30 Commits

Author SHA1 Message Date
Web Dev Cody
d37ced1c6e Merge pull request #841 from AutoMaker-Org/v1.0.0rc
V1.0.0rc
2026-03-15 12:58:24 -04:00
gsxdsm
0311130a8a fix: Update @github/copilot-sdk version and enhance version utility to locate package.json (#840)
- Changed @github/copilot-sdk dependency from "^0.1.16" to "0.1.16" in package.json and package-lock.json.
- Improved version utility in version.ts to check multiple candidate paths for package.json, ensuring better reliability in locating the file.
2026-03-12 19:30:17 -07:00
gsxdsm
7be8163b84 Preserve pipeline work by using waiting_approval on post-completion errors (#836)
* Changes from fix/pipeline-not-finishing

* fix: Prevent overwriting merge_conflict status in pipeline error handlers

* fix: Handle feature loading failures gracefully in error recovery

* ```
fix: Add logging when feature reload fails during status update
```
2026-03-04 20:22:44 -08:00
gsxdsm
26b73df097 Fix feature deep link with project path handling (#834)
* Changes from fix/feature-deeplink-worktree

* Update apps/ui/src/components/views/board-view.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-04 10:13:24 -08:00
gsxdsm
20e7c74b17 Fix event endpoint persistence (#831)
* Changes from fix/event-hook-endpoint

* fix: Allow empty eventHooks/ntfyEndpoints to reconcile from server

Remove the `length > 0` guards in fast-hydrate reconciliation that
prevented intentional empty-array clears from syncing across clients.
Server-side wipe protection (`__allowEmpty*` escape hatches) already
ensures empty arrays in the server are intentional.

Addresses PR #831 review feedback from CodeRabbit and Gemini.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:33:12 -08:00
gsxdsm
dd7108a7a0 Fixes critical React crash on the Kanban board view (#830)
* Changes from fix/board-react-crash

* fix: Prevent cascading re-renders and crashes from high-frequency WS events
2026-03-03 19:23:44 -08:00
gsxdsm
ae48065820 Fix dev server hang by reducing log spam and event frequency (#828)
* Changes from fix/dev-server-hang

* fix: Address PR #828 review feedback

- Reset RAF buffer on context changes (worktree switch, dev-server restart)
  to prevent stale output from flushing into new sessions
- Fix high-frequency WebSocket filter to catch auto-mode:event wrapping
  (auto_mode_progress is wrapped in auto-mode:event) and add feature:progress
- Reorder Vite aliases so explicit jsx-runtime entries aren't shadowed by
  the broad /^react(\/|$)/ regex (Vite uses first-match-wins)

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

* feat: Batch dev server logs and fix React module resolution order

* feat: Add fallback timer for flushing dev server logs in background tabs

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:52:44 -08:00
gsxdsm
b2915f4de1 refactor: Simplify click URL resolution logic 2026-03-02 21:36:33 -08:00
gsxdsm
cf3d312eef fix: Remove overly restrictive pattern from summary extraction regex 2026-03-02 21:36:33 -08:00
gsxdsm
341a6534e6 refactor: extract shared isBacklogLikeStatus helper and improve comments
Address PR #825 review feedback:
- Extract duplicated backlog-like status check into shared helper in constants.ts
- Improve spec-parser regex comment to clarify subsection preservation
- Add file path reference in row-actions.tsx comment for traceability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:36:33 -08:00
gsxdsm
4a128efbf4 Changes from fix/board-crash-new-feat 2026-03-02 21:36:33 -08:00
gsxdsm
54d69e907b fix: E2E test stability and UI performance improvements (#823) 2026-03-02 18:46:28 -08:00
gsxdsm
1c3d6434a8 fix: reduce excessive POST /api/auto-mode/context-exists requests
Two changes:
1. Server: Skip morgan logging for context-exists endpoint (like health check)
2. UI: Use a stable fingerprint (feature IDs + statuses) instead of the
   unstable features array reference as the useEffect dependency. This
   prevents re-checking context for all features on every React Query
   refetch when the actual feature set hasn't changed.
2026-03-02 11:14:46 -08:00
gsxdsm
8218c48e67 fix: use object destructuring in Playwright beforeEach callback
Playwright requires the first argument to use object destructuring pattern.
Changed `_` to `{}` in feature-deep-link.spec.ts beforeEach.
2026-03-02 07:50:05 -08:00
gsxdsm
59b100b5cc feat: Add conflict source branch tracking and fix auto-mode subscription cascade 2026-03-02 07:43:00 -08:00
gsxdsm
c11f390764 feat: Add conflict source branch detection and fix re-render cascade in BoardView 2026-03-02 07:20:11 -08:00
gsxdsm
33a2e04bf0 feat: Add settingsService integration for feature defaults and improve worktree handling 2026-03-02 03:28:37 -08:00
gsxdsm
34161ccc08 Changes from fix/bug-fixes-1rc 2026-03-01 21:59:02 -08:00
gsxdsm
57bcb2802d Improve auto-loop event emission and add ntfy notifications (#821) 2026-03-01 00:12:22 -08:00
gsxdsm
63b0a4fb38 Fix orphaned features when deleting worktrees (#820)
* Changes from fix/orphaned-features

* fix: Handle feature migration failures and improve UI accessibility

* feat: Add event emission for worktree deletion and feature migration

* fix: Handle OpenCode model errors and prevent duplicate model IDs

* feat: Add summary dialog and async verify with loading state

* fix: Add type attributes to buttons and improve OpenCode model selection

* fix: Add null checks for onVerify callback and opencode model selection
2026-02-28 15:42:10 -08:00
gsxdsm
1c0e460dd1 Add orphaned features management routes and UI integration (#819)
* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* refactor(auto-mode): enhance orphaned feature detection and improve project initialization

- Updated detectOrphanedFeatures method to accept preloaded features, reducing redundant disk reads.
- Improved project initialization by creating required directories and files in parallel for better performance.
- Adjusted planning mode handling in UI components to clarify approval requirements for different modes.
- Added refresh functionality for file editor tabs to ensure content consistency with disk state.

These changes enhance performance, maintainability, and user experience across the application.

* feat(orphaned-features): add orphaned features management routes and UI integration

- Introduced new routes for managing orphaned features, including listing, resolving, and bulk resolving.
- Updated the UI to include an Orphaned Features section in project settings and navigation.
- Enhanced the execution service to support new orphaned feature functionalities.

These changes improve the application's capability to handle orphaned features effectively, enhancing user experience and project management.

* fix: Normalize line endings and resolve stale dirty states in file editor

* chore: Update .gitignore and enhance orphaned feature handling

- Added a blank line in .gitignore for better readability.
- Introduced a hash to worktree paths in orphaned feature resolution to prevent conflicts.
- Added validation for target branch existence during orphaned feature resolution.
- Improved prompt formatting in execution service for clarity.
- Enhanced error handling in project selector for project initialization failures.
- Refactored orphaned features section to improve state management and UI responsiveness.

These changes improve code maintainability and user experience when managing orphaned features.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 22:14:41 -08:00
gsxdsm
0196911d59 Bug fixes and stability improvements (#815)
* fix(copilot): correct tool.execution_complete event handling

The CopilotProvider was using incorrect event type and data structure
for tool execution completion events from the @github/copilot-sdk,
causing tool call outputs to be empty.

Changes:
- Update event type from 'tool.execution_end' to 'tool.execution_complete'
- Fix data structure to use nested result.content instead of flat result
- Fix error structure to use error.message instead of flat error
- Add success field to match SDK event structure
- Add tests for empty and missing result handling

This aligns with the official @github/copilot-sdk v0.1.16 types
defined in session-events.d.ts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(copilot): improve error handling and code quality

Code review improvements:
- Extract magic string '[ERROR]' to TOOL_ERROR_PREFIX constant
- Add null-safe error handling with direct error variable assignment
- Include error codes in error messages for better debugging
- Add JSDoc documentation for tool.execution_complete handler
- Update tests to verify error codes are displayed
- Add missing tool_use_id assertion in error test

These changes improve:
- Code maintainability (no magic strings)
- Debugging experience (error codes now visible)
- Type safety (explicit null checks)
- Test coverage (verify error code formatting)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* fix: Handle detached HEAD state in worktree discovery and recovery

* fix: Remove unused isDevServerStarting prop and md: breakpoint classes

* fix: Add missing dependency and sanitize persisted cache data

* feat: Ensure NODE_ENV is set to test in vitest configs

* feat: Configure Playwright to run only E2E tests

* fix: Improve PR tracking and dev server lifecycle management

* feat: Add settings-based defaults for planning mode, model config, and custom providers. Fixes #816

* feat: Add worktree and branch selector to graph view

* fix: Add timeout and error handling for worktree HEAD ref resolution

* fix: use absolute icon path and place icon outside asar on Linux

The hicolor icon theme index only lists sizes up to 512x512, so an icon
installed only at 1024x1024 is invisible to GNOME/KDE's theme resolver,
causing both the app launcher and taskbar to show a generic icon.
Additionally, BrowserWindow.icon cannot be read by the window manager
when the file is inside app.asar.

- extraResources: copy logo_larger.png to resources/ (outside asar) so
  it lands at /opt/Automaker/resources/logo_larger.png on install
- linux.desktop.Icon: set to the absolute resources path, bypassing the
  hicolor theme lookup and its size constraints entirely
- icon-manager.ts: on Linux production use process.resourcesPath so
  BrowserWindow receives a real filesystem path the WM can read directly

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

* fix: use linux.desktop.entry for custom desktop Icon field

electron-builder v26 rejects arbitrary keys in linux.desktop — the
correct schema wraps custom .desktop overrides inside desktop.entry.

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

* fix: set desktop name on Linux so taskbar uses the correct app icon

Without app.setDesktopName(), the window manager cannot associate the
running Electron process with automaker.desktop. GNOME/KDE fall back to
_NET_WM_ICON which defaults to Electron's own bundled icon.

Calling app.setDesktopName('automaker.desktop') before any window is
created sets the _GTK_APPLICATION_ID hint and XDG app_id so the WM
picks up the desktop entry's Icon for the taskbar.

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

* Fix: memory and context views mobile friendly (#818)

* Changes from fix/memory-and-context-mobile-friendly

* fix: Improve file extension detection and add path traversal protection

* refactor: Extract file extension utilities and add path traversal guards

Code review improvements:
- Extract isMarkdownFilename and isImageFilename to shared image-utils.ts
- Remove duplicated code from context-view.tsx and memory-view.tsx
- Add path traversal guard for context fixture utilities (matching memory)
- Add 7 new tests for context fixture path traversal protection
- Total 61 tests pass

Addresses code review feedback from PR #813

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

* test: Add e2e tests for profiles crud and board background persistence

* Update apps/ui/playwright.config.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Add robust test navigation handling and file filtering

* fix: Format NODE_OPTIONS configuration on single line

* test: Update profiles and board background persistence tests

* test: Replace iPhone 13 Pro with Pixel 5 for mobile test consistency

* Update apps/ui/src/components/views/context-view.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore: Remove test project directory

* feat: Filter context files by type and improve mobile menu visibility

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Improve test reliability and localhost handling

* chore: Use explicit TEST_USE_EXTERNAL_BACKEND env var for server cleanup

* feat: Add E2E/CI mock mode for provider factory and auth verification

* feat: Add remoteBranch parameter to pull and rebase operations

* chore: Enhance E2E testing setup with worker isolation and auth state management

- Updated .gitignore to include worker-specific test fixtures.
- Modified e2e-tests.yml to implement test sharding for improved CI performance.
- Refactored global setup to authenticate once and save session state for reuse across tests.
- Introduced worker-isolated fixture paths to prevent conflicts during parallel test execution.
- Improved test navigation and loading handling for better reliability.
- Updated various test files to utilize new auth state management and fixture paths.

* fix: Update Playwright configuration and improve test reliability

- Increased the number of workers in Playwright configuration for better parallelism in CI environments.
- Enhanced the board background persistence test to ensure dropdown stability by waiting for the list to populate before interaction, improving test reliability.

* chore: Simplify E2E test configuration and enhance mock implementations

- Updated e2e-tests.yml to run tests in a single shard for streamlined CI execution.
- Enhanced unit tests for worktree list handling by introducing a mock for execGitCommand, improving test reliability and coverage.
- Refactored setup functions to better manage command mocks for git operations in tests.
- Improved error handling in mkdirSafe function to account for undefined stats in certain environments.

* refactor: Improve test configurations and enhance error handling

- Updated Playwright configuration to clear VITE_SERVER_URL, ensuring the frontend uses the Vite proxy and preventing cookie domain mismatches.
- Enhanced MergeRebaseDialog logic to normalize selectedBranch for better handling of various ref formats.
- Improved global setup with a more robust backend health check, throwing an error if the backend is not healthy after retries.
- Refactored project creation tests to handle file existence checks more reliably.
- Added error handling for missing E2E source fixtures to guide setup process.
- Enhanced memory navigation to handle sandbox dialog visibility more effectively.

* refactor: Enhance Git command execution and improve test configurations

- Updated Git command execution to merge environment paths correctly, ensuring proper command execution context.
- Refactored the Git initialization process to handle errors more gracefully and ensure user configuration is set before creating the initial commit.
- Improved test configurations by updating Playwright test identifiers for better clarity and consistency across different project states.
- Enhanced cleanup functions in tests to handle directory removal more robustly, preventing errors during test execution.

* fix: Resolve React hooks errors from duplicate instances in dependency tree

* style: Format alias configuration for improved readability

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: DhanushSantosh <dhanushsantoshs05@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-27 17:03:29 -08:00
gsxdsm
70d400793b Fix: memory and context views mobile friendly (#818)
* Changes from fix/memory-and-context-mobile-friendly

* fix: Improve file extension detection and add path traversal protection

* refactor: Extract file extension utilities and add path traversal guards

Code review improvements:
- Extract isMarkdownFilename and isImageFilename to shared image-utils.ts
- Remove duplicated code from context-view.tsx and memory-view.tsx
- Add path traversal guard for context fixture utilities (matching memory)
- Add 7 new tests for context fixture path traversal protection
- Total 61 tests pass

Addresses code review feedback from PR #813

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

* test: Add e2e tests for profiles crud and board background persistence

* Update apps/ui/playwright.config.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Add robust test navigation handling and file filtering

* fix: Format NODE_OPTIONS configuration on single line

* test: Update profiles and board background persistence tests

* test: Replace iPhone 13 Pro with Pixel 5 for mobile test consistency

* Update apps/ui/src/components/views/context-view.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore: Remove test project directory

* feat: Filter context files by type and improve mobile menu visibility

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-26 08:37:33 -08:00
DhanushSantosh
dd7654c254 fix: set desktop name on Linux so taskbar uses the correct app icon
Without app.setDesktopName(), the window manager cannot associate the
running Electron process with automaker.desktop. GNOME/KDE fall back to
_NET_WM_ICON which defaults to Electron's own bundled icon.

Calling app.setDesktopName('automaker.desktop') before any window is
created sets the _GTK_APPLICATION_ID hint and XDG app_id so the WM
picks up the desktop entry's Icon for the taskbar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 18:14:08 +05:30
DhanushSantosh
6a824a9ff0 fix: use linux.desktop.entry for custom desktop Icon field
electron-builder v26 rejects arbitrary keys in linux.desktop — the
correct schema wraps custom .desktop overrides inside desktop.entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 17:53:15 +05:30
DhanushSantosh
5e15f4120c Merge remote-tracking branch 'upstream/v1.0.0rc' into patchcraft 2026-02-26 17:49:45 +05:30
DhanushSantosh
82e9396cb8 fix: use absolute icon path and place icon outside asar on Linux
The hicolor icon theme index only lists sizes up to 512x512, so an icon
installed only at 1024x1024 is invisible to GNOME/KDE's theme resolver,
causing both the app launcher and taskbar to show a generic icon.
Additionally, BrowserWindow.icon cannot be read by the window manager
when the file is inside app.asar.

- extraResources: copy logo_larger.png to resources/ (outside asar) so
  it lands at /opt/Automaker/resources/logo_larger.png on install
- linux.desktop.Icon: set to the absolute resources path, bypassing the
  hicolor theme lookup and its size constraints entirely
- icon-manager.ts: on Linux production use process.resourcesPath so
  BrowserWindow receives a real filesystem path the WM can read directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 17:49:26 +05:30
gsxdsm
9747faf1b9 Fix agent output summary for pipeline steps (#812)
* Changes from fix/agent-output-summary-for-pipeline-steps

* feat: Optimize pipeline summary extraction and fix regex vulnerability

* fix: Use fallback summary for pipeline steps when extraction fails

* fix: Strip follow-up session scaffold from pipeline step fallback summaries
2026-02-25 22:13:38 -08:00
DhanushSantosh
70c9fd77f6 fix: correct production icon path and refresh icon cache on RPM install
- icon-manager.ts: fix production path from '../dist/public' to '../dist'
  Vite copies public/ assets to the root of dist/, not dist/public/, so
  the old path caused electronAppExists() to return null in packaged
  builds — falling through to Electron's default icon in the taskbar
- rpm-after-install.sh: add gtk-update-icon-cache and
  update-desktop-database so GNOME/KDE picks up the installed icon and
  desktop entry immediately without a session restart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 11:20:03 +05:30
DhanushSantosh
46ee34d499 fix: resolve desktop entry launch failure and hide Electron menu bar on Linux
- Add --ozone-platform-hint=auto before app.whenReady() so Electron
  auto-detects X11 vs Wayland when launched from a desktop entry where
  the display protocol is not guaranteed (fixes immediate crash on
  Fedora/GNOME Wayland sessions)
- Add autoHideMenuBar: true to BrowserWindow so the default Chromium
  File/Edit/View/Help menu bar is hidden by default (still accessible
  via Alt); removes visible Electron significance from the packaged app
- Add scripts/rpm-after-install.sh and wire it via afterInstall in the
  electron-builder RPM config to chmod 4755 chrome-sandbox, ensuring
  the setuid sandbox works on kernels with restricted user namespaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 10:53:58 +05:30
323 changed files with 33607 additions and 2173 deletions

14
.geminiignore Normal file
View File

@@ -0,0 +1,14 @@
# Auto-generated by Automaker to speed up Gemini CLI startup
# Prevents Gemini CLI from scanning large directories during context discovery
.git
node_modules
dist
build
.next
.nuxt
coverage
.automaker
.worktrees
.vscode
.idea
*.lock

View File

@@ -13,6 +13,13 @@ jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
# shardIndex: [1, 2, 3]
# shardTotal: [3]
shardIndex: [1]
shardTotal: [1]
steps:
- name: Checkout code
@@ -91,7 +98,7 @@ jobs:
curl -s http://localhost:3108/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3108/api/health 2>/dev/null || echo 'No response')"
exit 0
fi
# Check if server process is still running
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "ERROR: Server process died during wait!"
@@ -99,7 +106,7 @@ jobs:
cat backend.log
exit 1
fi
echo "Waiting... ($i/60)"
sleep 1
done
@@ -127,17 +134,23 @@ jobs:
exit 1
- name: Run E2E tests
- name: Run E2E tests (shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
# Playwright automatically starts the Vite frontend via webServer config
# (see apps/ui/playwright.config.ts) - no need to start it manually
run: npm run test --workspace=apps/ui
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
working-directory: apps/ui
env:
CI: true
VITE_SERVER_URL: http://localhost:3108
SERVER_URL: http://localhost:3108
VITE_SKIP_SETUP: 'true'
# Keep UI-side login/defaults consistent
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
# Backend is already started above - Playwright config sets
# AUTOMAKER_SERVER_PORT so the Vite proxy forwards /api/* to the backend.
# Do NOT set VITE_SERVER_URL here: it bypasses the Vite proxy and causes
# a cookie domain mismatch (cookies are bound to 127.0.0.1, but
# VITE_SERVER_URL=http://localhost:3108 makes the frontend call localhost).
TEST_USE_EXTERNAL_BACKEND: 'true'
TEST_SERVER_PORT: 3108
- name: Print backend logs on failure
if: failure()
@@ -155,7 +168,7 @@ jobs:
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
name: playwright-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
path: apps/ui/playwright-report/
retention-days: 7
@@ -163,12 +176,21 @@ jobs:
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
name: test-results-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
path: |
apps/ui/test-results/
retention-days: 7
if-no-files-found: ignore
- name: Upload blob report for merging
uses: actions/upload-artifact@v4
if: always()
with:
name: blob-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
path: apps/ui/blob-report/
retention-days: 1
if-no-files-found: ignore
- name: Cleanup - Kill backend server
if: always()
run: |

12
.gitignore vendored
View File

@@ -65,6 +65,17 @@ coverage/
*.lcov
playwright-report/
blob-report/
test/**/test-project-[0-9]*/
test/opus-thinking-*/
test/agent-session-test-*/
test/feature-backlog-test-*/
test/running-task-display-test-*/
test/agent-output-modal-responsive-*/
test/fixtures/
test/board-bg-test-*/
test/edit-feature-test-*/
test/open-project-test-*/
# Environment files (keep .example)
.env
@@ -102,3 +113,4 @@ data/
.planning/
.mcp.json
.planning
.bg-shell/

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/server",
"version": "0.15.0",
"version": "1.0.0",
"description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
@@ -32,7 +32,7 @@
"@automaker/prompts": "1.0.0",
"@automaker/types": "1.0.0",
"@automaker/utils": "1.0.0",
"@github/copilot-sdk": "^0.1.16",
"@github/copilot-sdk": "0.1.16",
"@modelcontextprotocol/sdk": "1.25.2",
"@openai/codex-sdk": "^0.98.0",
"cookie-parser": "1.4.7",

View File

@@ -261,7 +261,10 @@ morgan.token('status-colored', (_req, res) => {
app.use(
morgan(':method :url :status-colored', {
// Skip when request logging is disabled or for health check endpoints
skip: (req) => !requestLoggingEnabled || req.url === '/api/health',
skip: (req) =>
!requestLoggingEnabled ||
req.url === '/api/health' ||
req.url === '/api/auto-mode/context-exists',
})
);
// CORS configuration
@@ -349,7 +352,9 @@ const ideationService = new IdeationService(events, settingsService, featureLoad
// Initialize DevServerService with event emitter for real-time log streaming
const devServerService = getDevServerService();
devServerService.setEventEmitter(events);
devServerService.initialize(DATA_DIR, events).catch((err) => {
logger.error('Failed to initialize DevServerService:', err);
});
// Initialize Notification Service with event emitter for real-time updates
const notificationService = getNotificationService();
@@ -434,21 +439,18 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
logger.info('[STARTUP] Feature state reconciliation complete - no stale states found');
}
// Resume interrupted features in the background after reconciliation.
// This uses the saved execution state to identify features that were running
// before the restart (their statuses have been reset to ready/backlog by
// reconciliation above). Running in background so it doesn't block startup.
if (totalReconciled > 0) {
for (const project of globalSettings.projects) {
autoModeService.resumeInterruptedFeatures(project.path).catch((err) => {
logger.warn(
`[STARTUP] Failed to resume interrupted features for ${project.path}:`,
err
);
});
}
logger.info('[STARTUP] Initiated background resume of interrupted features');
// Resume interrupted features in the background for all projects.
// This handles features stuck in transient states (in_progress, pipeline_*)
// or explicitly marked as interrupted. Running in background so it doesn't block startup.
for (const project of globalSettings.projects) {
autoModeService.resumeInterruptedFeatures(project.path).catch((err) => {
logger.warn(
`[STARTUP] Failed to resume interrupted features for ${project.path}:`,
err
);
});
}
logger.info('[STARTUP] Initiated background resume of interrupted features');
}
} catch (err) {
logger.warn('[STARTUP] Failed to reconcile feature states:', err);
@@ -494,7 +496,7 @@ app.use(
);
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService, featureLoader));
app.use('/api/git', createGitRoutes());
app.use('/api/models', createModelsRoutes());
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
@@ -596,24 +598,23 @@ wss.on('connection', (ws: WebSocket) => {
// Subscribe to all events and forward to this client
const unsubscribe = events.subscribe((type, payload) => {
logger.info('Event received:', {
// Use debug level for high-frequency events to avoid log spam
// that causes progressive memory growth and server slowdown
const isHighFrequency =
type === 'dev-server:output' || type === 'test-runner:output' || type === 'feature:progress';
const log = isHighFrequency ? logger.debug.bind(logger) : logger.info.bind(logger);
log('Event received:', {
type,
hasPayload: !!payload,
payloadKeys: payload ? Object.keys(payload) : [],
wsReadyState: ws.readyState,
wsOpen: ws.readyState === WebSocket.OPEN,
});
if (ws.readyState === WebSocket.OPEN) {
const message = JSON.stringify({ type, payload });
logger.info('Sending event to client:', {
type,
messageLength: message.length,
sessionId: (payload as Record<string, unknown>)?.sessionId,
});
ws.send(message);
} else {
logger.info('WARNING: Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
logger.warn('Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
}
});

View File

@@ -13,6 +13,27 @@ import { createLogger } from '@automaker/utils';
const logger = createLogger('GitLib');
// Extended PATH so git is found when the process does not inherit a full shell PATH
// (e.g. Electron, some CI, or IDE-launched processes).
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const extraPaths: string[] =
process.platform === 'win32'
? ([
process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`,
process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}\\Git\\cmd`,
process.env['ProgramFiles(x86)'] && `${process.env['ProgramFiles(x86)']}\\Git\\cmd`,
].filter(Boolean) as string[])
: [
'/opt/homebrew/bin',
'/usr/local/bin',
'/usr/bin',
'/home/linuxbrew/.linuxbrew/bin',
process.env.HOME ? `${process.env.HOME}/.local/bin` : '',
].filter(Boolean);
const extendedPath = [process.env.PATH, ...extraPaths].filter(Boolean).join(pathSeparator);
const gitEnv = { ...process.env, PATH: extendedPath };
// ============================================================================
// Secure Command Execution
// ============================================================================
@@ -65,7 +86,14 @@ export async function execGitCommand(
command: 'git',
args,
cwd,
...(env !== undefined ? { env } : {}),
env:
env !== undefined
? {
...gitEnv,
...env,
PATH: [gitEnv.PATH, env.PATH].filter(Boolean).join(pathSeparator),
}
: gitEnv,
...(abortController !== undefined ? { abortController } : {}),
});

View File

@@ -689,6 +689,145 @@ export interface ProviderByModelIdResult {
resolvedModel: string | undefined;
}
/** Result from resolveProviderContext */
export interface ProviderContextResult {
/** The provider configuration */
provider: ClaudeCompatibleProvider | undefined;
/** Credentials for API key resolution */
credentials: Credentials | undefined;
/** The resolved Claude model ID for SDK configuration */
resolvedModel: string | undefined;
/** The original model config from the provider if found */
modelConfig: import('@automaker/types').ProviderModel | undefined;
}
/**
* Checks if a provider is enabled.
* Providers with enabled: undefined are treated as enabled (default state).
* Only explicitly set enabled: false means the provider is disabled.
*/
function isProviderEnabled(provider: ClaudeCompatibleProvider): boolean {
return provider.enabled !== false;
}
/**
* Finds a model config in a provider's models array by ID (case-insensitive).
*/
function findModelInProvider(
provider: ClaudeCompatibleProvider,
modelId: string
): import('@automaker/types').ProviderModel | undefined {
return provider.models?.find(
(m) => m.id === modelId || m.id.toLowerCase() === modelId.toLowerCase()
);
}
/**
* Resolves the provider and Claude-compatible model configuration.
*
* This is the central logic for resolving provider context, supporting:
* 1. Explicit lookup by providerId (most reliable for persistence)
* 2. Fallback lookup by modelId across all enabled providers
* 3. Resolution of mapsToClaudeModel for SDK configuration
*
* @param settingsService - Settings service instance
* @param modelId - The model ID to resolve
* @param providerId - Optional explicit provider ID
* @param logPrefix - Prefix for log messages
* @returns Promise resolving to the provider context
*/
export async function resolveProviderContext(
settingsService: SettingsService,
modelId: string,
providerId?: string,
logPrefix = '[SettingsHelper]'
): Promise<ProviderContextResult> {
try {
const globalSettings = await settingsService.getGlobalSettings();
const credentials = await settingsService.getCredentials();
const providers = globalSettings.claudeCompatibleProviders || [];
logger.debug(
`${logPrefix} Resolving provider context: modelId="${modelId}", providerId="${providerId ?? 'none'}", providers count=${providers.length}`
);
let provider: ClaudeCompatibleProvider | undefined;
let modelConfig: import('@automaker/types').ProviderModel | undefined;
// 1. Try resolving by explicit providerId first (most reliable)
if (providerId) {
provider = providers.find((p) => p.id === providerId);
if (provider) {
if (!isProviderEnabled(provider)) {
logger.warn(
`${logPrefix} Explicitly requested provider "${provider.name}" (${providerId}) is disabled (enabled=${provider.enabled})`
);
} else {
logger.debug(
`${logPrefix} Found provider "${provider.name}" (${providerId}), enabled=${provider.enabled ?? 'undefined (treated as enabled)'}`
);
// Find the model config within this provider to check for mappings
modelConfig = findModelInProvider(provider, modelId);
if (!modelConfig && provider.models && provider.models.length > 0) {
logger.debug(
`${logPrefix} Model "${modelId}" not found in provider "${provider.name}". Available models: ${provider.models.map((m) => m.id).join(', ')}`
);
}
}
} else {
logger.warn(
`${logPrefix} Explicitly requested provider "${providerId}" not found. Available providers: ${providers.map((p) => p.id).join(', ')}`
);
}
}
// 2. Fallback to model-based lookup across all providers if modelConfig not found
// Note: We still search even if provider was found, to get the modelConfig for mapping
if (!modelConfig) {
for (const p of providers) {
if (!isProviderEnabled(p) || p.id === providerId) continue; // Skip disabled or already checked
const config = findModelInProvider(p, modelId);
if (config) {
// Only override provider if we didn't find one by explicit ID
if (!provider) {
provider = p;
}
modelConfig = config;
logger.debug(`${logPrefix} Found model "${modelId}" in provider "${p.name}" (fallback)`);
break;
}
}
}
// 3. Resolve the mapped Claude model if specified
let resolvedModel: string | undefined;
if (modelConfig?.mapsToClaudeModel) {
const { resolveModelString } = await import('@automaker/model-resolver');
resolvedModel = resolveModelString(modelConfig.mapsToClaudeModel);
logger.debug(
`${logPrefix} Model "${modelId}" maps to Claude model "${modelConfig.mapsToClaudeModel}" -> "${resolvedModel}"`
);
}
// Log final result for debugging
logger.debug(
`${logPrefix} Provider context resolved: provider=${provider?.name ?? 'none'}, modelConfig=${modelConfig ? 'found' : 'not found'}, resolvedModel=${resolvedModel ?? modelId}`
);
return { provider, credentials, resolvedModel, modelConfig };
} catch (error) {
logger.error(`${logPrefix} Failed to resolve provider context:`, error);
return {
provider: undefined,
credentials: undefined,
resolvedModel: undefined,
modelConfig: undefined,
};
}
}
/**
* Find a ClaudeCompatibleProvider by one of its model IDs.
* Searches through all enabled providers to find one that contains the specified model.

View File

@@ -2,7 +2,7 @@
* Version utility - Reads version from package.json
*/
import { readFileSync } from 'fs';
import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { createLogger } from '@automaker/utils';
@@ -24,7 +24,20 @@ export function getVersion(): string {
}
try {
const packageJsonPath = join(__dirname, '..', '..', 'package.json');
const candidatePaths = [
// Development via tsx: src/lib -> project root
join(__dirname, '..', '..', 'package.json'),
// Packaged/build output: lib -> server bundle root
join(__dirname, '..', 'package.json'),
];
const packageJsonPath = candidatePaths.find((candidate) => existsSync(candidate));
if (!packageJsonPath) {
throw new Error(
`package.json not found in any expected location: ${candidatePaths.join(', ')}`
);
}
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
const version = packageJson.version || '0.0.0';
cachedVersion = version;

View File

@@ -188,6 +188,7 @@ export class ClaudeProvider extends BaseProvider {
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
// Validate that model doesn't have a provider prefix
// AgentService should strip prefixes before passing to providers
// Claude doesn't use a provider prefix, so we don't need to specify an expected provider
validateBareModelId(options.model, 'ClaudeProvider');
const {

View File

@@ -739,9 +739,9 @@ export class CodexProvider extends BaseProvider {
}
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
// Validate that model doesn't have a provider prefix
// Validate that model doesn't have a provider prefix (except codex- which should already be stripped)
// AgentService should strip prefixes before passing to providers
validateBareModelId(options.model, 'CodexProvider');
validateBareModelId(options.model, 'CodexProvider', 'codex');
try {
const mcpServers = options.mcpServers ?? {};

View File

@@ -76,13 +76,18 @@ interface SdkToolExecutionStartEvent extends SdkEvent {
};
}
interface SdkToolExecutionEndEvent extends SdkEvent {
type: 'tool.execution_end';
interface SdkToolExecutionCompleteEvent extends SdkEvent {
type: 'tool.execution_complete';
data: {
toolName: string;
toolCallId: string;
result?: string;
error?: string;
success: boolean;
result?: {
content: string;
};
error?: {
message: string;
code?: string;
};
};
}
@@ -94,6 +99,16 @@ interface SdkSessionErrorEvent extends SdkEvent {
};
}
// =============================================================================
// Constants
// =============================================================================
/**
* Prefix for error messages in tool results
* Consistent with GeminiProvider's error formatting
*/
const TOOL_ERROR_PREFIX = '[ERROR]' as const;
// =============================================================================
// Error Codes
// =============================================================================
@@ -357,12 +372,19 @@ export class CopilotProvider extends CliProvider {
};
}
case 'tool.execution_end': {
const toolResultEvent = sdkEvent as SdkToolExecutionEndEvent;
const isError = !!toolResultEvent.data.error;
const content = isError
? `[ERROR] ${toolResultEvent.data.error}`
: toolResultEvent.data.result || '';
/**
* Tool execution completed event
* Handles both successful results and errors from tool executions
* Error messages optionally include error codes for better debugging
*/
case 'tool.execution_complete': {
const toolResultEvent = sdkEvent as SdkToolExecutionCompleteEvent;
const error = toolResultEvent.data.error;
// Format error message with optional code for better debugging
const content = error
? `${TOOL_ERROR_PREFIX} ${error.message}${error.code ? ` (${error.code})` : ''}`
: toolResultEvent.data.result?.content || '';
return {
type: 'assistant',
@@ -628,7 +650,7 @@ export class CopilotProvider extends CliProvider {
sessionComplete = true;
pushEvent(event);
} else {
// Push all other events (tool.execution_start, tool.execution_end, assistant.message, etc.)
// Push all other events (tool.execution_start, tool.execution_complete, assistant.message, etc.)
pushEvent(event);
}
});

View File

@@ -843,9 +843,10 @@ export class CursorProvider extends CliProvider {
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected();
// Validate that model doesn't have a provider prefix
// Validate that model doesn't have a provider prefix (except cursor- which should already be stripped)
// AgentService should strip prefixes before passing to providers
validateBareModelId(options.model, 'CursorProvider');
// Note: Cursor's Gemini models (e.g., "gemini-3-pro") legitimately start with "gemini-"
validateBareModelId(options.model, 'CursorProvider', 'cursor');
if (!this.cliPath) {
throw this.createError(

View File

@@ -546,8 +546,8 @@ export class GeminiProvider extends CliProvider {
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected();
// Validate that model doesn't have a provider prefix
validateBareModelId(options.model, 'GeminiProvider');
// Validate that model doesn't have a provider prefix (except gemini- which should already be stripped)
validateBareModelId(options.model, 'GeminiProvider', 'gemini');
if (!this.cliPath) {
throw this.createError(

View File

@@ -0,0 +1,53 @@
/**
* Mock Provider - No-op AI provider for E2E and CI testing
*
* When AUTOMAKER_MOCK_AGENT=true, the server uses this provider instead of
* real backends (Claude, Codex, etc.) so tests never call external APIs.
*/
import type { ExecuteOptions } from '@automaker/types';
import { BaseProvider } from './base-provider.js';
import type { ProviderMessage, InstallationStatus, ModelDefinition } from './types.js';
const MOCK_TEXT = 'Mock agent output for testing.';
export class MockProvider extends BaseProvider {
getName(): string {
return 'mock';
}
async *executeQuery(_options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
yield {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: MOCK_TEXT }],
},
};
yield {
type: 'result',
subtype: 'success',
};
}
async detectInstallation(): Promise<InstallationStatus> {
return {
installed: true,
method: 'sdk',
hasApiKey: true,
authenticated: true,
};
}
getAvailableModels(): ModelDefinition[] {
return [
{
id: 'mock-model',
name: 'Mock Model',
modelString: 'mock-model',
provider: 'mock',
description: 'Mock model for testing',
},
];
}
}

View File

@@ -1189,8 +1189,26 @@ export class OpencodeProvider extends CliProvider {
* Format a display name for a model
*/
private formatModelDisplayName(model: OpenCodeModelInfo): string {
// Extract the last path segment for nested model IDs
// e.g., "arcee-ai/trinity-large-preview:free" → "trinity-large-preview:free"
let rawName = model.name;
if (rawName.includes('/')) {
rawName = rawName.split('/').pop()!;
}
// Strip tier/pricing suffixes like ":free", ":extended"
const colonIdx = rawName.indexOf(':');
let suffix = '';
if (colonIdx !== -1) {
const tierPart = rawName.slice(colonIdx + 1);
if (/^(free|extended|beta|preview)$/i.test(tierPart)) {
suffix = ` (${tierPart.charAt(0).toUpperCase() + tierPart.slice(1)})`;
}
rawName = rawName.slice(0, colonIdx);
}
// Capitalize and format the model name
const formattedName = model.name
const formattedName = rawName
.split('-')
.map((part) => {
// Handle version numbers like "4-5" -> "4.5"
@@ -1218,7 +1236,7 @@ export class OpencodeProvider extends CliProvider {
};
const providerDisplay = providerNames[model.provider] || model.provider;
return `${formattedName} (${providerDisplay})`;
return `${formattedName}${suffix} (${providerDisplay})`;
}
/**

View File

@@ -67,6 +67,16 @@ export function registerProvider(name: string, registration: ProviderRegistratio
providerRegistry.set(name.toLowerCase(), registration);
}
/** Cached mock provider instance when AUTOMAKER_MOCK_AGENT is set (E2E/CI). */
let mockProviderInstance: BaseProvider | null = null;
function getMockProvider(): BaseProvider {
if (!mockProviderInstance) {
mockProviderInstance = new MockProvider();
}
return mockProviderInstance;
}
export class ProviderFactory {
/**
* Determine which provider to use for a given model
@@ -75,6 +85,9 @@ export class ProviderFactory {
* @returns Provider name (ModelProvider type)
*/
static getProviderNameForModel(model: string): ModelProvider {
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
return 'claude' as ModelProvider; // Name only; getProviderForModel returns MockProvider
}
const lowerModel = model.toLowerCase();
// Get all registered providers sorted by priority (descending)
@@ -113,6 +126,9 @@ export class ProviderFactory {
modelId: string,
options: { throwOnDisconnected?: boolean } = {}
): BaseProvider {
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
return getMockProvider();
}
const { throwOnDisconnected = true } = options;
const providerName = this.getProviderForModelName(modelId);
@@ -142,6 +158,9 @@ export class ProviderFactory {
* Get the provider name for a given model ID (without creating provider instance)
*/
static getProviderForModelName(modelId: string): string {
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
return 'claude';
}
const lowerModel = modelId.toLowerCase();
// Get all registered providers sorted by priority (descending)
@@ -272,6 +291,7 @@ export class ProviderFactory {
// =============================================================================
// Import providers for registration side-effects
import { MockProvider } from './mock-provider.js';
import { ClaudeProvider } from './claude-provider.js';
import { CursorProvider } from './cursor-provider.js';
import { CodexProvider } from './codex-provider.js';

View File

@@ -323,7 +323,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
}
}
await parseAndCreateFeatures(projectPath, contentForParsing, events);
await parseAndCreateFeatures(projectPath, contentForParsing, events, settingsService);
logger.debug('========== generateFeaturesFromSpec() completed ==========');
}

View File

@@ -9,13 +9,16 @@ import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/
import { getFeaturesDir } from '@automaker/platform';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { getNotificationService } from '../../services/notification-service.js';
import type { SettingsService } from '../../services/settings-service.js';
import { resolvePhaseModel } from '@automaker/model-resolver';
const logger = createLogger('SpecRegeneration');
export async function parseAndCreateFeatures(
projectPath: string,
content: string,
events: EventEmitter
events: EventEmitter,
settingsService?: SettingsService
): Promise<void> {
logger.info('========== parseAndCreateFeatures() started ==========');
logger.info(`Content length: ${content.length} chars`);
@@ -23,6 +26,37 @@ export async function parseAndCreateFeatures(
logger.info(content);
logger.info('========== END CONTENT ==========');
// Load default model and planning settings from settingsService
let defaultModel: string | undefined;
let defaultPlanningMode: string = 'skip';
let defaultRequirePlanApproval = false;
if (settingsService) {
try {
const globalSettings = await settingsService.getGlobalSettings();
const projectSettings = await settingsService.getProjectSettings(projectPath);
const defaultModelEntry =
projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel;
if (defaultModelEntry) {
const resolved = resolvePhaseModel(defaultModelEntry);
defaultModel = resolved.model;
}
defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip';
defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false;
logger.info(
`[parseAndCreateFeatures] Using defaults: model=${defaultModel ?? 'none'}, planningMode=${defaultPlanningMode}, requirePlanApproval=${defaultRequirePlanApproval}`
);
} catch (settingsError) {
logger.warn(
'[parseAndCreateFeatures] Failed to load settings, using defaults:',
settingsError
);
}
}
try {
// Extract JSON from response using shared utility
logger.info('Extracting JSON from response using extractJsonWithArray...');
@@ -61,7 +95,7 @@ export async function parseAndCreateFeatures(
const featureDir = path.join(featuresDir, feature.id);
await secureFs.mkdir(featureDir, { recursive: true });
const featureData = {
const featureData: Record<string, unknown> = {
id: feature.id,
category: feature.category || 'Uncategorized',
title: feature.title,
@@ -70,10 +104,20 @@ export async function parseAndCreateFeatures(
priority: feature.priority || 2,
complexity: feature.complexity || 'moderate',
dependencies: feature.dependencies || [],
planningMode: defaultPlanningMode,
requirePlanApproval:
defaultPlanningMode === 'skip' || defaultPlanningMode === 'lite'
? false
: defaultRequirePlanApproval,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Apply default model if available from settings
if (defaultModel) {
featureData.model = defaultModel;
}
// Use atomic write with backup support for crash protection
await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, {
backupCount: DEFAULT_BACKUP_COUNT,

View File

@@ -25,7 +25,7 @@ export function createBacklogPlanRoutes(
);
router.post('/stop', createStopHandler());
router.get('/status', validatePathParams('projectPath'), createStatusHandler());
router.post('/apply', validatePathParams('projectPath'), createApplyHandler());
router.post('/apply', validatePathParams('projectPath'), createApplyHandler(settingsService));
router.post('/clear', validatePathParams('projectPath'), createClearHandler());
return router;

View File

@@ -3,13 +3,23 @@
*/
import type { Request, Response } from 'express';
import type { BacklogPlanResult } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import type { BacklogPlanResult, PhaseModelEntry, PlanningMode } from '@automaker/types';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
const featureLoader = new FeatureLoader();
export function createApplyHandler() {
function normalizePhaseModelEntry(
entry: PhaseModelEntry | string | undefined | null
): PhaseModelEntry | undefined {
if (!entry) return undefined;
if (typeof entry === 'string') return { model: entry };
return entry;
}
export function createApplyHandler(settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const {
@@ -38,6 +48,23 @@ export function createApplyHandler() {
return;
}
let defaultPlanningMode: PlanningMode = 'skip';
let defaultRequirePlanApproval = false;
let defaultModelEntry: PhaseModelEntry | undefined;
if (settingsService) {
const globalSettings = await settingsService.getGlobalSettings();
const projectSettings = await settingsService.getProjectSettings(projectPath);
defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip';
defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false;
defaultModelEntry = normalizePhaseModelEntry(
projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel
);
}
const resolvedDefaultModel = resolvePhaseModel(defaultModelEntry);
const appliedChanges: string[] = [];
// Load current features for dependency validation
@@ -88,6 +115,12 @@ export function createApplyHandler() {
if (!change.feature) continue;
try {
const effectivePlanningMode = change.feature.planningMode ?? defaultPlanningMode;
const effectiveRequirePlanApproval =
effectivePlanningMode === 'skip' || effectivePlanningMode === 'lite'
? false
: (change.feature.requirePlanApproval ?? defaultRequirePlanApproval);
// Create the new feature - use the AI-generated ID if provided
const newFeature = await featureLoader.create(projectPath, {
id: change.feature.id, // Use descriptive ID from AI if provided
@@ -97,6 +130,12 @@ export function createApplyHandler() {
dependencies: change.feature.dependencies,
priority: change.feature.priority,
status: 'backlog',
model: change.feature.model ?? resolvedDefaultModel.model,
thinkingLevel: change.feature.thinkingLevel ?? resolvedDefaultModel.thinkingLevel,
reasoningEffort: change.feature.reasoningEffort ?? resolvedDefaultModel.reasoningEffort,
providerId: change.feature.providerId ?? resolvedDefaultModel.providerId,
planningMode: effectivePlanningMode,
requirePlanApproval: effectiveRequirePlanApproval,
branchName,
});

View File

@@ -19,6 +19,11 @@ import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent
import { createGenerateTitleHandler } from './routes/generate-title.js';
import { createExportHandler } from './routes/export.js';
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
import {
createOrphanedListHandler,
createOrphanedResolveHandler,
createOrphanedBulkResolveHandler,
} from './routes/orphaned.js';
export function createFeaturesRoutes(
featureLoader: FeatureLoader,
@@ -70,6 +75,21 @@ export function createFeaturesRoutes(
validatePathParams('projectPath'),
createConflictCheckHandler(featureLoader)
);
router.post(
'/orphaned',
validatePathParams('projectPath'),
createOrphanedListHandler(featureLoader, autoModeService)
);
router.post(
'/orphaned/resolve',
validatePathParams('projectPath'),
createOrphanedResolveHandler(featureLoader, autoModeService)
);
router.post(
'/orphaned/bulk-resolve',
validatePathParams('projectPath'),
createOrphanedBulkResolveHandler(featureLoader)
);
return router;
}

View File

@@ -46,7 +46,7 @@ export function createListHandler(
// Note: detectOrphanedFeatures handles errors internally and always resolves
if (autoModeService) {
autoModeService
.detectOrphanedFeatures(projectPath)
.detectOrphanedFeatures(projectPath, features)
.then((orphanedFeatures) => {
if (orphanedFeatures.length > 0) {
logger.info(

View File

@@ -0,0 +1,287 @@
/**
* POST /orphaned endpoint - Detect orphaned features (features with missing branches)
* POST /orphaned/resolve endpoint - Resolve an orphaned feature (delete, create-worktree, or move-to-branch)
* POST /orphaned/bulk-resolve endpoint - Resolve multiple orphaned features at once
*/
import crypto from 'crypto';
import path from 'path';
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import { deleteWorktreeMetadata } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('OrphanedFeatures');
type ResolveAction = 'delete' | 'create-worktree' | 'move-to-branch';
const VALID_ACTIONS: ResolveAction[] = ['delete', 'create-worktree', 'move-to-branch'];
export function createOrphanedListHandler(
featureLoader: FeatureLoader,
autoModeService?: AutoModeServiceCompat
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!autoModeService) {
res.status(500).json({ success: false, error: 'Auto-mode service not available' });
return;
}
const orphanedFeatures = await autoModeService.detectOrphanedFeatures(projectPath);
res.json({ success: true, orphanedFeatures });
} catch (error) {
logError(error, 'Detect orphaned features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
export function createOrphanedResolveHandler(
featureLoader: FeatureLoader,
_autoModeService?: AutoModeServiceCompat
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, action, targetBranch } = req.body as {
projectPath: string;
featureId: string;
action: ResolveAction;
targetBranch?: string | null;
};
if (!projectPath || !featureId || !action) {
res.status(400).json({
success: false,
error: 'projectPath, featureId, and action are required',
});
return;
}
if (!VALID_ACTIONS.includes(action)) {
res.status(400).json({
success: false,
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`,
});
return;
}
const result = await resolveOrphanedFeature(
featureLoader,
projectPath,
featureId,
action,
targetBranch
);
if (!result.success) {
res.status(result.error === 'Feature not found' ? 404 : 500).json(result);
return;
}
res.json(result);
} catch (error) {
logError(error, 'Resolve orphaned feature failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
interface BulkResolveResult {
featureId: string;
success: boolean;
action?: string;
error?: string;
}
async function resolveOrphanedFeature(
featureLoader: FeatureLoader,
projectPath: string,
featureId: string,
action: ResolveAction,
targetBranch?: string | null
): Promise<BulkResolveResult> {
try {
const feature = await featureLoader.get(projectPath, featureId);
if (!feature) {
return { featureId, success: false, error: 'Feature not found' };
}
const missingBranch = feature.branchName;
switch (action) {
case 'delete': {
if (missingBranch) {
try {
await deleteWorktreeMetadata(projectPath, missingBranch);
} catch {
// Non-fatal
}
}
const success = await featureLoader.delete(projectPath, featureId);
if (!success) {
return { featureId, success: false, error: 'Deletion failed' };
}
logger.info(`Deleted orphaned feature ${featureId} (branch: ${missingBranch})`);
return { featureId, success: true, action: 'deleted' };
}
case 'create-worktree': {
if (!missingBranch) {
return { featureId, success: false, error: 'Feature has no branch name to recreate' };
}
const sanitizedName = missingBranch.replace(/[^a-zA-Z0-9_-]/g, '-');
const hash = crypto.createHash('sha1').update(missingBranch).digest('hex').slice(0, 8);
const worktreesDir = path.join(projectPath, '.worktrees');
const worktreePath = path.join(worktreesDir, `${sanitizedName}-${hash}`);
try {
await execGitCommand(['worktree', 'add', '-b', missingBranch, worktreePath], projectPath);
} catch (error) {
const msg = getErrorMessage(error);
if (msg.includes('already exists')) {
try {
await execGitCommand(['worktree', 'add', worktreePath, missingBranch], projectPath);
} catch (innerError) {
return {
featureId,
success: false,
error: `Failed to create worktree: ${getErrorMessage(innerError)}`,
};
}
} else {
return { featureId, success: false, error: `Failed to create worktree: ${msg}` };
}
}
logger.info(
`Created worktree for orphaned feature ${featureId} at ${worktreePath} (branch: ${missingBranch})`
);
return { featureId, success: true, action: 'worktree-created' };
}
case 'move-to-branch': {
// Move the feature to a different branch (or clear branch to use main worktree)
const newBranch = targetBranch || null;
// Validate that the target branch exists if one is specified
if (newBranch) {
try {
await execGitCommand(['rev-parse', '--verify', newBranch], projectPath);
} catch {
return {
featureId,
success: false,
error: `Target branch "${newBranch}" does not exist`,
};
}
}
await featureLoader.update(projectPath, featureId, {
branchName: newBranch,
status: 'pending',
});
// Clean up old worktree metadata
if (missingBranch) {
try {
await deleteWorktreeMetadata(projectPath, missingBranch);
} catch {
// Non-fatal
}
}
const destination = newBranch ?? 'main worktree';
logger.info(
`Moved orphaned feature ${featureId} to ${destination} (was: ${missingBranch})`
);
return { featureId, success: true, action: 'moved' };
}
}
} catch (error) {
return { featureId, success: false, error: getErrorMessage(error) };
}
}
export function createOrphanedBulkResolveHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureIds, action, targetBranch } = req.body as {
projectPath: string;
featureIds: string[];
action: ResolveAction;
targetBranch?: string | null;
};
if (
!projectPath ||
!featureIds ||
!Array.isArray(featureIds) ||
featureIds.length === 0 ||
!action
) {
res.status(400).json({
success: false,
error: 'projectPath, featureIds (non-empty array), and action are required',
});
return;
}
if (!VALID_ACTIONS.includes(action)) {
res.status(400).json({
success: false,
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`,
});
return;
}
// Process sequentially for worktree creation (git operations shouldn't race),
// in parallel for delete/move-to-branch
const results: BulkResolveResult[] = [];
if (action === 'create-worktree') {
for (const featureId of featureIds) {
const result = await resolveOrphanedFeature(
featureLoader,
projectPath,
featureId,
action,
targetBranch
);
results.push(result);
}
} else {
const batchResults = await Promise.all(
featureIds.map((featureId) =>
resolveOrphanedFeature(featureLoader, projectPath, featureId, action, targetBranch)
)
);
results.push(...batchResults);
}
const successCount = results.filter((r) => r.success).length;
const failedCount = results.length - successCount;
res.json({
success: failedCount === 0,
resolvedCount: successCount,
failedCount,
results,
});
} catch (error) {
logError(error, 'Bulk resolve orphaned features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -43,7 +43,11 @@ export function createUpdateHandler(featureLoader: FeatureLoader, events?: Event
// Get the current feature to detect status changes
const currentFeature = await featureLoader.get(projectPath, featureId);
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
if (!currentFeature) {
res.status(404).json({ success: false, error: `Feature ${featureId} not found` });
return;
}
const previousStatus = currentFeature.status as FeatureStatus;
const newStatus = updates.status as FeatureStatus | undefined;
const updated = await featureLoader.update(

View File

@@ -3,16 +3,29 @@
*/
import type { Request, Response } from 'express';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
// Optional files that are expected to not exist in new projects
// Don't log ENOENT errors for these to reduce noise
const OPTIONAL_FILES = ['categories.json', 'app_spec.txt'];
const OPTIONAL_FILES = ['categories.json', 'app_spec.txt', 'context-metadata.json'];
function isOptionalFile(filePath: string): boolean {
return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile));
const basename = path.basename(filePath);
if (OPTIONAL_FILES.some((optionalFile) => basename === optionalFile)) {
return true;
}
// Context and memory files may not exist yet during create/delete or test races
if (filePath.includes('.automaker/context/') || filePath.includes('.automaker/memory/')) {
const name = path.basename(filePath);
const lower = name.toLowerCase();
if (lower.endsWith('.md') || lower.endsWith('.txt') || lower.endsWith('.markdown')) {
return true;
}
}
return false;
}
function isENOENT(error: unknown): boolean {
@@ -39,12 +52,14 @@ export function createReadHandler() {
return;
}
// Don't log ENOENT errors for optional files (expected to be missing in new projects)
const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || ''));
if (shouldLog) {
const filePath = req.body?.filePath || '';
const optionalMissing = isENOENT(error) && isOptionalFile(filePath);
if (!optionalMissing) {
logError(error, 'Read file failed');
}
res.status(500).json({ success: false, error: getErrorMessage(error) });
// Return 404 for missing optional files so clients can handle "not found"
const status = optionalMissing ? 404 : 500;
res.status(status).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -35,6 +35,16 @@ export function createStatHandler() {
return;
}
// File or directory does not exist - return 404 so UI can handle missing paths
const code =
error && typeof error === 'object' && 'code' in error
? (error as { code: string }).code
: '';
if (code === 'ENOENT') {
res.status(404).json({ success: false, error: 'File or directory not found' });
return;
}
logError(error, 'Get file stats failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -38,7 +38,7 @@ import {
import {
getPromptCustomization,
getAutoLoadClaudeMdSetting,
getProviderByModelId,
resolveProviderContext,
} from '../../../lib/settings-helpers.js';
import {
trySetValidationRunning,
@@ -64,6 +64,8 @@ interface ValidateIssueRequestBody {
thinkingLevel?: ThinkingLevel;
/** Reasoning effort for Codex models (ignored for non-Codex models) */
reasoningEffort?: ReasoningEffort;
/** Optional Claude-compatible provider ID for custom providers (e.g., GLM, MiniMax) */
providerId?: string;
/** Comments to include in validation analysis */
comments?: GitHubComment[];
/** Linked pull requests for this issue */
@@ -87,6 +89,7 @@ async function runValidation(
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService,
providerId?: string,
comments?: ValidationComment[],
linkedPRs?: ValidationLinkedPR[],
thinkingLevel?: ThinkingLevel,
@@ -176,7 +179,12 @@ ${basePrompt}`;
let credentials = await settingsService?.getCredentials();
if (settingsService) {
const providerResult = await getProviderByModelId(model, settingsService, '[ValidateIssue]');
const providerResult = await resolveProviderContext(
settingsService,
model,
providerId,
'[ValidateIssue]'
);
if (providerResult.provider) {
claudeCompatibleProvider = providerResult.provider;
providerResolvedModel = providerResult.resolvedModel;
@@ -312,10 +320,16 @@ export function createValidateIssueHandler(
model = 'opus',
thinkingLevel,
reasoningEffort,
providerId,
comments: rawComments,
linkedPRs: rawLinkedPRs,
} = req.body as ValidateIssueRequestBody;
const normalizedProviderId =
typeof providerId === 'string' && providerId.trim().length > 0
? providerId.trim()
: undefined;
// Transform GitHubComment[] to ValidationComment[] if provided
const validationComments: ValidationComment[] | undefined = rawComments?.map((c) => ({
author: c.author?.login || 'ghost',
@@ -364,12 +378,14 @@ export function createValidateIssueHandler(
isClaudeModel(model) ||
isCursorModel(model) ||
isCodexModel(model) ||
isOpencodeModel(model);
isOpencodeModel(model) ||
!!normalizedProviderId;
if (!isValidModel) {
res.status(400).json({
success: false,
error: 'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias).',
error:
'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias), or provide a valid providerId for custom Claude-compatible models.',
});
return;
}
@@ -398,6 +414,7 @@ export function createValidateIssueHandler(
events,
abortController,
settingsService,
normalizedProviderId,
validationComments,
validationLinkedPRs,
thinkingLevel,

View File

@@ -80,6 +80,12 @@ function containsAuthError(text: string): boolean {
export function createVerifyClaudeAuthHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
// In E2E/CI mock mode, skip real API calls
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
res.json({ success: true, authenticated: true });
return;
}
// Get the auth method and optional API key from the request body
const { authMethod, apiKey } = req.body as {
authMethod?: 'cli' | 'api_key';

View File

@@ -82,6 +82,12 @@ function isRateLimitError(text: string): boolean {
export function createVerifyCodexAuthHandler() {
return async (req: Request, res: Response): Promise<void> => {
// In E2E/CI mock mode, skip real API calls
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
res.json({ success: true, authenticated: true });
return;
}
const { authMethod, apiKey } = req.body as {
authMethod?: 'cli' | 'api_key';
apiKey?: string;

View File

@@ -71,10 +71,12 @@ import { createSetTrackingHandler } from './routes/set-tracking.js';
import { createSyncHandler } from './routes/sync.js';
import { createUpdatePRNumberHandler } from './routes/update-pr-number.js';
import type { SettingsService } from '../../services/settings-service.js';
import type { FeatureLoader } from '../../services/feature-loader.js';
export function createWorktreeRoutes(
events: EventEmitter,
settingsService?: SettingsService
settingsService?: SettingsService,
featureLoader?: FeatureLoader
): Router {
const router = Router();
@@ -94,7 +96,11 @@ export function createWorktreeRoutes(
validatePathParams('projectPath'),
createCreateHandler(events, settingsService)
);
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
router.post(
'/delete',
validatePathParams('projectPath', 'worktreePath'),
createDeleteHandler(events, featureLoader)
);
router.post('/create-pr', createCreatePRHandler());
router.post('/pr-info', createPRInfoHandler());
router.post(

View File

@@ -10,11 +10,13 @@ import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import { createLogger } from '@automaker/utils';
import type { FeatureLoader } from '../../../services/feature-loader.js';
import type { EventEmitter } from '../../../lib/events.js';
const execAsync = promisify(exec);
const logger = createLogger('Worktree');
export function createDeleteHandler() {
export function createDeleteHandler(events: EventEmitter, featureLoader?: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, worktreePath, deleteBranch } = req.body as {
@@ -134,12 +136,65 @@ export function createDeleteHandler() {
}
}
// Emit worktree:deleted event after successful deletion
events.emit('worktree:deleted', {
worktreePath,
projectPath,
branchName,
branchDeleted,
});
// Move features associated with the deleted branch to the main worktree
// This prevents features from being orphaned when a worktree is deleted
let featuresMovedToMain = 0;
if (featureLoader && branchName) {
try {
const allFeatures = await featureLoader.getAll(projectPath);
const affectedFeatures = allFeatures.filter((f) => f.branchName === branchName);
for (const feature of affectedFeatures) {
try {
await featureLoader.update(projectPath, feature.id, {
branchName: null,
});
featuresMovedToMain++;
// Emit feature:migrated event for each successfully migrated feature
events.emit('feature:migrated', {
featureId: feature.id,
status: 'migrated',
fromBranch: branchName,
toWorktreeId: null, // migrated to main worktree (no specific worktree)
projectPath,
});
} catch (featureUpdateError) {
// Non-fatal: log per-feature failure but continue migrating others
logger.warn('Failed to move feature to main worktree after deletion', {
error: getErrorMessage(featureUpdateError),
featureId: feature.id,
branchName,
});
}
}
if (featuresMovedToMain > 0) {
logger.info(
`Moved ${featuresMovedToMain} feature(s) to main worktree after deleting worktree with branch: ${branchName}`
);
}
} catch (featureError) {
// Non-fatal: log but don't fail the deletion (getAll failed)
logger.warn('Failed to load features for migration to main worktree after deletion', {
error: getErrorMessage(featureError),
branchName,
});
}
}
res.json({
success: true,
deleted: {
worktreePath,
branch: branchDeleted ? branchName : null,
branchDeleted,
featuresMovedToMain,
},
});
} catch (error) {

View File

@@ -44,13 +44,79 @@ export function createInitGitHandler() {
}
// Initialize git with 'main' as the default branch (matching GitHub's standard since 2020)
// and create an initial empty commit
await execAsync(
`git init --initial-branch=main && git commit --allow-empty -m "Initial commit"`,
{
cwd: projectPath,
// Run commands sequentially so failures can be handled and partial state cleaned up.
let gitDirCreated = false;
try {
// Step 1: initialize the repository
try {
await execAsync(`git init --initial-branch=main`, { cwd: projectPath });
} catch (initError: unknown) {
const stderr =
initError && typeof initError === 'object' && 'stderr' in initError
? String((initError as { stderr?: string }).stderr)
: '';
// Idempotent: if .git was created by a concurrent request or a stale lock exists,
// treat as "repo already exists" instead of failing
if (
/could not lock config file.*File exists|fatal: could not set 'core\.repositoryformatversion'/.test(
stderr
)
) {
try {
await secureFs.access(gitDirPath);
res.json({
success: true,
result: {
initialized: false,
message: 'Git repository already exists',
},
});
return;
} catch {
// .git still missing, rethrow original error
}
}
throw initError;
}
);
gitDirCreated = true;
// Step 2: ensure user.name and user.email are set so the commit can succeed.
// Check the global/system config first; only set locally if missing.
let userName = '';
let userEmail = '';
try {
({ stdout: userName } = await execAsync(`git config user.name`, { cwd: projectPath }));
} catch {
// not set globally will configure locally below
}
try {
({ stdout: userEmail } = await execAsync(`git config user.email`, {
cwd: projectPath,
}));
} catch {
// not set globally will configure locally below
}
if (!userName.trim()) {
await execAsync(`git config user.name "Automaker"`, { cwd: projectPath });
}
if (!userEmail.trim()) {
await execAsync(`git config user.email "automaker@localhost"`, { cwd: projectPath });
}
// Step 3: create the initial empty commit
await execAsync(`git commit --allow-empty -m "Initial commit"`, { cwd: projectPath });
} catch (error: unknown) {
// Clean up the partial .git directory so subsequent runs behave deterministically
if (gitDirCreated) {
try {
await secureFs.rm(gitDirPath, { recursive: true, force: true });
} catch {
// best-effort cleanup; ignore errors
}
}
throw error;
}
res.json({
success: true,

View File

@@ -6,12 +6,11 @@
*/
import type { Request, Response } from 'express';
import { exec, execFile } from 'child_process';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logWorktreeError } from '../common.js';
import { getErrorMessage, logWorktreeError, execGitCommand } from '../common.js';
import { getRemotesWithBranch } from '../../../services/worktree-service.js';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
interface BranchInfo {
@@ -36,18 +35,18 @@ export function createListBranchesHandler() {
return;
}
// Get current branch
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
// Get current branch (execGitCommand avoids spawning /bin/sh; works in sandboxed CI)
const currentBranchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
worktreePath
);
const currentBranch = currentBranchOutput.trim();
// List all local branches
// Use double quotes around the format string for cross-platform compatibility
// Single quotes are preserved literally on Windows; double quotes work on both
const { stdout: branchesOutput } = await execAsync('git branch --format="%(refname:short)"', {
cwd: worktreePath,
});
const branchesOutput = await execGitCommand(
['branch', '--format=%(refname:short)'],
worktreePath
);
const branches: BranchInfo[] = branchesOutput
.trim()
@@ -68,18 +67,15 @@ export function createListBranchesHandler() {
try {
// Fetch latest remote refs (silently, don't fail if offline)
try {
await execAsync('git fetch --all --quiet', {
cwd: worktreePath,
timeout: 10000, // 10 second timeout
});
await execGitCommand(['fetch', '--all', '--quiet'], worktreePath);
} catch {
// Ignore fetch errors - we'll use cached remote refs
}
// List remote branches
const { stdout: remoteBranchesOutput } = await execAsync(
'git branch -r --format="%(refname:short)"',
{ cwd: worktreePath }
const remoteBranchesOutput = await execGitCommand(
['branch', '-r', '--format=%(refname:short)'],
worktreePath
);
const localBranchNames = new Set(branches.map((b) => b.name));
@@ -118,9 +114,7 @@ export function createListBranchesHandler() {
// Check if any remotes are configured for this repository
let hasAnyRemotes = false;
try {
const { stdout: remotesOutput } = await execAsync('git remote', {
cwd: worktreePath,
});
const remotesOutput = await execGitCommand(['remote'], worktreePath);
hasAnyRemotes = remotesOutput.trim().length > 0;
} catch {
// If git remote fails, assume no remotes

View File

@@ -13,7 +13,14 @@ import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js';
import {
getErrorMessage,
logError,
normalizePath,
execEnv,
isGhCliAvailable,
execGitCommand,
} from '../common.js';
import {
readAllWorktreeMetadata,
updateWorktreePRInfo,
@@ -29,6 +36,22 @@ import {
const execAsync = promisify(exec);
const logger = createLogger('Worktree');
/** True when git (or shell) could not be spawned (e.g. ENOENT in sandboxed CI). */
function isSpawnENOENT(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const e = error as { code?: string; errno?: number; syscall?: string };
// Accept ENOENT with or without syscall so wrapped/reexported errors are handled.
// Node may set syscall to 'spawn' or 'spawn git' (or other command name).
if (e.code === 'ENOENT' || e.errno === -2) {
return (
e.syscall === 'spawn' ||
(typeof e.syscall === 'string' && e.syscall.startsWith('spawn')) ||
e.syscall === undefined
);
}
return false;
}
/**
* Cache for GitHub remote status per project path.
* This prevents repeated "no git remotes found" warnings when polling
@@ -64,6 +87,8 @@ interface WorktreeInfo {
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
/** List of files with conflicts */
conflictFiles?: string[];
/** Source branch involved in merge/rebase/cherry-pick, when resolvable */
conflictSourceBranch?: string;
}
/**
@@ -75,13 +100,11 @@ async function detectConflictState(worktreePath: string): Promise<{
hasConflicts: boolean;
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
conflictFiles?: string[];
conflictSourceBranch?: string;
}> {
try {
// Find the canonical .git directory for this worktree
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
cwd: worktreePath,
timeout: 15000,
});
// Find the canonical .git directory for this worktree (execGitCommand avoids /bin/sh in CI)
const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
// Check for merge, rebase, and cherry-pick state files/directories
@@ -121,10 +144,10 @@ async function detectConflictState(worktreePath: string): Promise<{
// Get list of conflicted files using machine-readable git status
let conflictFiles: string[] = [];
try {
const { stdout: statusOutput } = await execAsync('git diff --name-only --diff-filter=U', {
cwd: worktreePath,
timeout: 15000,
});
const statusOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
worktreePath
);
conflictFiles = statusOutput
.trim()
.split('\n')
@@ -133,10 +156,84 @@ async function detectConflictState(worktreePath: string): Promise<{
// Fall back to empty list if diff fails
}
// Detect the source branch involved in the conflict
let conflictSourceBranch: string | undefined;
try {
if (conflictType === 'merge' && mergeHeadExists) {
// For merges, resolve MERGE_HEAD to a branch name
const mergeHead = (
(await secureFs.readFile(path.join(gitDir, 'MERGE_HEAD'), 'utf-8')) as string
).trim();
try {
const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', mergeHead],
worktreePath
);
const cleaned = branchName.trim().replace(/~\d+$/, '');
if (cleaned && cleaned !== 'undefined') {
conflictSourceBranch = cleaned;
}
} catch {
// Could not resolve to branch name
}
} else if (conflictType === 'rebase') {
// For rebases, read the onto branch from rebase-merge/head-name or rebase-apply/head-name
const headNamePath = rebaseMergeExists
? path.join(gitDir, 'rebase-merge', 'onto-name')
: path.join(gitDir, 'rebase-apply', 'onto-name');
try {
const ontoName = ((await secureFs.readFile(headNamePath, 'utf-8')) as string).trim();
if (ontoName) {
conflictSourceBranch = ontoName.replace(/^refs\/heads\//, '');
}
} catch {
// onto-name may not exist; try to resolve the onto commit
try {
const ontoPath = rebaseMergeExists
? path.join(gitDir, 'rebase-merge', 'onto')
: path.join(gitDir, 'rebase-apply', 'onto');
const ontoCommit = ((await secureFs.readFile(ontoPath, 'utf-8')) as string).trim();
if (ontoCommit) {
const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', ontoCommit],
worktreePath
);
const cleaned = branchName.trim().replace(/~\d+$/, '');
if (cleaned && cleaned !== 'undefined') {
conflictSourceBranch = cleaned;
}
}
} catch {
// Could not resolve onto commit
}
}
} else if (conflictType === 'cherry-pick' && cherryPickHeadExists) {
// For cherry-picks, try to resolve CHERRY_PICK_HEAD to a branch name
const cherryPickHead = (
(await secureFs.readFile(path.join(gitDir, 'CHERRY_PICK_HEAD'), 'utf-8')) as string
).trim();
try {
const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', cherryPickHead],
worktreePath
);
const cleaned = branchName.trim().replace(/~\d+$/, '');
if (cleaned && cleaned !== 'undefined') {
conflictSourceBranch = cleaned;
}
} catch {
// Could not resolve to branch name
}
}
} catch {
// Ignore source branch detection errors
}
return {
hasConflicts: conflictFiles.length > 0,
conflictType,
conflictFiles,
conflictSourceBranch,
};
} catch {
// If anything fails, assume no conflicts
@@ -146,13 +243,69 @@ async function detectConflictState(worktreePath: string): Promise<{
async function getCurrentBranch(cwd: string): Promise<string> {
try {
const { stdout } = await execAsync('git branch --show-current', { cwd });
const stdout = await execGitCommand(['branch', '--show-current'], cwd);
return stdout.trim();
} catch {
return '';
}
}
function normalizeBranchFromHeadRef(headRef: string): string | null {
let normalized = headRef.trim();
const prefixes = ['refs/heads/', 'refs/remotes/origin/', 'refs/remotes/', 'refs/'];
for (const prefix of prefixes) {
if (normalized.startsWith(prefix)) {
normalized = normalized.slice(prefix.length);
break;
}
}
// Return the full branch name, including any slashes (e.g., "feature/my-branch")
return normalized || null;
}
/**
* Attempt to recover the branch name for a worktree in detached HEAD state.
* This happens during rebase operations where git detaches HEAD from the branch.
* We look at git state files (rebase-merge/head-name, rebase-apply/head-name)
* to determine which branch the operation is targeting.
*
* Note: merge conflicts do NOT detach HEAD, so `git worktree list --porcelain`
* still includes the `branch` line for merge conflicts. This recovery is
* specifically for rebase and cherry-pick operations.
*/
async function recoverBranchForDetachedWorktree(worktreePath: string): Promise<string | null> {
try {
const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
// During a rebase, the original branch is stored in rebase-merge/head-name
try {
const headNamePath = path.join(gitDir, 'rebase-merge', 'head-name');
const headName = (await secureFs.readFile(headNamePath, 'utf-8')) as string;
const branch = normalizeBranchFromHeadRef(headName);
if (branch) return branch;
} catch {
// Not a rebase-merge
}
// rebase-apply also stores the original branch in head-name
try {
const headNamePath = path.join(gitDir, 'rebase-apply', 'head-name');
const headName = (await secureFs.readFile(headNamePath, 'utf-8')) as string;
const branch = normalizeBranchFromHeadRef(headName);
if (branch) return branch;
} catch {
// Not a rebase-apply
}
return null;
} catch {
return null;
}
}
/**
* Scan the .worktrees directory to discover worktrees that may exist on disk
* but are not registered with git (e.g., created externally or corrupted state).
@@ -204,22 +357,36 @@ async function scanWorktreesDirectory(
});
} else {
// Try to get branch from HEAD if branch --show-current fails (detached HEAD)
let headBranch: string | null = null;
try {
const { stdout: headRef } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const headBranch = headRef.trim();
if (headBranch && headBranch !== 'HEAD') {
logger.info(
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})`
);
discovered.push({
path: normalizedPath,
branch: headBranch,
});
const headRef = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
worktreePath
);
const ref = headRef.trim();
if (ref && ref !== 'HEAD') {
headBranch = ref;
}
} catch {
// Can't determine branch, skip this directory
} catch (error) {
// Can't determine branch from HEAD ref (including timeout) - fall back to detached HEAD recovery
logger.debug(
`Failed to resolve HEAD ref for ${worktreePath}: ${getErrorMessage(error)}`
);
}
// If HEAD is detached (rebase/merge in progress), try recovery from git state files
if (!headBranch) {
headBranch = await recoverBranchForDetachedWorktree(worktreePath);
}
if (headBranch) {
logger.info(
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})`
);
discovered.push({
path: normalizedPath,
branch: headBranch,
});
}
}
}
@@ -378,15 +545,14 @@ export function createListHandler() {
// Get current branch in main directory
const currentBranch = await getCurrentBranch(projectPath);
// Get actual worktrees from git
const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: projectPath,
});
// Get actual worktrees from git (execGitCommand avoids /bin/sh in sandboxed CI)
const stdout = await execGitCommand(['worktree', 'list', '--porcelain'], projectPath);
const worktrees: WorktreeInfo[] = [];
const removedWorktrees: Array<{ path: string; branch: string }> = [];
let hasMissingWorktree = false;
const lines = stdout.split('\n');
let current: { path?: string; branch?: string } = {};
let current: { path?: string; branch?: string; isDetached?: boolean } = {};
let isFirst = true;
// First pass: detect removed worktrees
@@ -395,8 +561,11 @@ export function createListHandler() {
current.path = normalizePath(line.slice(9));
} else if (line.startsWith('branch ')) {
current.branch = line.slice(7).replace('refs/heads/', '');
} else if (line.startsWith('detached')) {
// Worktree is in detached HEAD state (e.g., during rebase)
current.isDetached = true;
} else if (line === '') {
if (current.path && current.branch) {
if (current.path) {
const isMainWorktree = isFirst;
// Check if the worktree directory actually exists
// Skip checking/pruning the main worktree (projectPath itself)
@@ -407,14 +576,19 @@ export function createListHandler() {
} catch {
worktreeExists = false;
}
if (!isMainWorktree && !worktreeExists) {
hasMissingWorktree = true;
// Worktree directory doesn't exist - it was manually deleted
removedWorktrees.push({
path: current.path,
branch: current.branch,
});
} else {
// Worktree exists (or is main worktree), add it to the list
// Only add to removed list if we know the branch name
if (current.branch) {
removedWorktrees.push({
path: current.path,
branch: current.branch,
});
}
} else if (current.branch) {
// Normal case: worktree with a known branch
worktrees.push({
path: current.path,
branch: current.branch,
@@ -423,16 +597,29 @@ export function createListHandler() {
hasWorktree: true,
});
isFirst = false;
} else if (current.isDetached && worktreeExists) {
// Detached HEAD (e.g., rebase in progress) - try to recover branch name.
// This is critical: without this, worktrees undergoing rebase/merge
// operations would silently disappear from the UI.
const recoveredBranch = await recoverBranchForDetachedWorktree(current.path);
worktrees.push({
path: current.path,
branch: recoveredBranch || `(detached)`,
isMain: isMainWorktree,
isCurrent: false,
hasWorktree: true,
});
isFirst = false;
}
}
current = {};
}
}
// Prune removed worktrees from git (only if any were detected)
if (removedWorktrees.length > 0) {
// Prune removed worktrees from git (only if any missing worktrees were detected)
if (hasMissingWorktree) {
try {
await execAsync('git worktree prune', { cwd: projectPath });
await execGitCommand(['worktree', 'prune'], projectPath);
} catch {
// Prune failed, but we'll still report the removed worktrees
}
@@ -461,9 +648,7 @@ export function createListHandler() {
if (includeDetails) {
for (const worktree of worktrees) {
try {
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
cwd: worktree.path,
});
const statusOutput = await execGitCommand(['status', '--porcelain'], worktree.path);
const changedFiles = statusOutput
.trim()
.split('\n')
@@ -486,13 +671,14 @@ export function createListHandler() {
// hasConflicts is true only when there are actual unresolved files
worktree.hasConflicts = conflictState.hasConflicts;
worktree.conflictFiles = conflictState.conflictFiles;
worktree.conflictSourceBranch = conflictState.conflictSourceBranch;
} catch {
// Ignore conflict detection errors
}
}
}
// Assign PR info to each worktree, preferring fresh GitHub data over cached metadata.
// Assign PR info to each worktree.
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
const githubPRs = includeDetails
@@ -510,14 +696,27 @@ export function createListHandler() {
const metadata = allMetadata.get(worktree.branch);
const githubPR = githubPRs.get(worktree.branch);
if (githubPR) {
// Prefer fresh GitHub data (it has the current state)
const metadataPR = metadata?.pr;
// Preserve explicit user-selected PR tracking from metadata when it differs
// from branch-derived GitHub PR lookup. This allows "Change PR Number" to
// persist instead of being overwritten by gh pr list for the branch.
const hasManualOverride =
!!metadataPR && !!githubPR && metadataPR.number !== githubPR.number;
if (hasManualOverride) {
worktree.pr = metadataPR;
} else if (githubPR) {
// Use fresh GitHub data when there is no explicit override.
worktree.pr = githubPR;
// Sync metadata with GitHub state when:
// 1. No metadata exists for this PR (PR created externally)
// 2. State has changed (e.g., merged/closed on GitHub)
const needsSync = !metadata?.pr || metadata.pr.state !== githubPR.state;
// Sync metadata when missing or stale so fallback data stays current.
const needsSync =
!metadataPR ||
metadataPR.number !== githubPR.number ||
metadataPR.state !== githubPR.state ||
metadataPR.title !== githubPR.title ||
metadataPR.url !== githubPR.url ||
metadataPR.createdAt !== githubPR.createdAt;
if (needsSync) {
// Fire and forget - don't block the response
updateWorktreePRInfo(projectPath, worktree.branch, githubPR).catch((err) => {
@@ -526,9 +725,9 @@ export function createListHandler() {
);
});
}
} else if (metadata?.pr && metadata.pr.state === 'OPEN') {
} else if (metadataPR && metadataPR.state === 'OPEN') {
// Fall back to stored metadata only if the PR is still OPEN
worktree.pr = metadata.pr;
worktree.pr = metadataPR;
}
}
@@ -538,6 +737,26 @@ export function createListHandler() {
removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined,
});
} catch (error) {
// When git is unavailable (e.g. sandboxed E2E, PATH without git), return minimal list so UI still loads
if (isSpawnENOENT(error)) {
const projectPathFromBody = (req.body as { projectPath?: string })?.projectPath;
const mainPath = projectPathFromBody ? normalizePath(projectPathFromBody) : undefined;
if (mainPath) {
res.json({
success: true,
worktrees: [
{
path: mainPath,
branch: 'main',
isMain: true,
isCurrent: true,
hasWorktree: true,
},
],
});
return;
}
}
logError(error, 'List worktrees failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -23,9 +23,11 @@ import type { PullResult } from '../../../services/pull-service.js';
export function createPullHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, remote, stashIfNeeded } = req.body as {
const { worktreePath, remote, remoteBranch, stashIfNeeded } = req.body as {
worktreePath: string;
remote?: string;
/** Specific remote branch to pull (e.g. 'main'). When provided, pulls this branch from the remote regardless of tracking config. */
remoteBranch?: string;
/** When true, automatically stash local changes before pulling and reapply after */
stashIfNeeded?: boolean;
};
@@ -39,7 +41,7 @@ export function createPullHandler() {
}
// Execute the pull via the service
const result = await performPull(worktreePath, { remote, stashIfNeeded });
const result = await performPull(worktreePath, { remote, remoteBranch, stashIfNeeded });
// Map service result to HTTP response
mapResultToResponse(res, result);

View File

@@ -44,6 +44,8 @@ export interface AgentExecutionOptions {
specAlreadyDetected?: boolean;
existingApprovedPlanContent?: string;
persistedTasks?: ParsedTask[];
/** Feature status - used to check if pipeline summary extraction is required */
status?: string;
}
export interface AgentExecutionResult {

View File

@@ -4,6 +4,7 @@
import path from 'path';
import type { ExecuteOptions, ParsedTask } from '@automaker/types';
import { isPipelineStatus } from '@automaker/types';
import { buildPromptWithImages, createLogger, isAuthenticationError } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform';
import * as secureFs from '../lib/secure-fs.js';
@@ -91,6 +92,7 @@ export class AgentExecutor {
existingApprovedPlanContent,
persistedTasks,
credentials,
status, // Feature status for pipeline summary check
claudeCompatibleProvider,
mcpServers,
sdkSessionId,
@@ -207,6 +209,17 @@ export class AgentExecutor {
if (writeTimeout) clearTimeout(writeTimeout);
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
await writeToFile();
// Extract and save summary from the new content generated in this session
await this.extractAndSaveSessionSummary(
projectPath,
featureId,
result.responseText,
previousContent,
callbacks,
status
);
return {
responseText: result.responseText,
specDetected: true,
@@ -340,9 +353,78 @@ export class AgentExecutor {
}
}
}
// Capture summary if it hasn't been captured by handleSpecGenerated or executeTasksLoop
// or if we're in a simple execution mode (planningMode='skip')
await this.extractAndSaveSessionSummary(
projectPath,
featureId,
responseText,
previousContent,
callbacks,
status
);
return { responseText, specDetected, tasksCompleted, aborted };
}
/**
* Strip the follow-up session scaffold marker from content.
* The scaffold is added when resuming a session with previous content:
* "\n\n---\n\n## Follow-up Session\n\n"
* This ensures fallback summaries don't include the scaffold header.
*
* The regex pattern handles variations in whitespace while matching the
* scaffold structure: dashes followed by "## Follow-up Session" at the
* start of the content.
*/
private static stripFollowUpScaffold(content: string): string {
// Pattern matches: ^\s*---\s*##\s*Follow-up Session\s*
// - ^ = start of content (scaffold is always at the beginning of sessionContent)
// - \s* = any whitespace (handles \n\n before ---, spaces/tabs between markers)
// - --- = literal dashes
// - \s* = whitespace between dashes and heading
// - ## = heading marker
// - \s* = whitespace before "Follow-up"
// - Follow-up Session = literal heading text
// - \s* = trailing whitespace/newlines after heading
const scaffoldPattern = /^\s*---\s*##\s*Follow-up Session\s*/;
return content.replace(scaffoldPattern, '');
}
/**
* Extract summary ONLY from the new content generated in this session
* and save it via the provided callback.
*/
private async extractAndSaveSessionSummary(
projectPath: string,
featureId: string,
responseText: string,
previousContent: string | undefined,
callbacks: AgentExecutorCallbacks,
status?: string
): Promise<void> {
const sessionContent = responseText.substring(previousContent ? previousContent.length : 0);
const summary = extractSummary(sessionContent);
if (summary) {
await callbacks.saveFeatureSummary(projectPath, featureId, summary);
return;
}
// If we're in a pipeline step, a summary is expected. Use a fallback if extraction fails.
if (isPipelineStatus(status)) {
// Strip any follow-up session scaffold before using as fallback
const cleanSessionContent = AgentExecutor.stripFollowUpScaffold(sessionContent);
const fallback = cleanSessionContent.trim();
if (fallback) {
await callbacks.saveFeatureSummary(projectPath, featureId, fallback);
}
logger.warn(
`[AgentExecutor] Mandatory summary extraction failed for pipeline feature ${featureId} (status="${status}")`
);
}
}
private async executeTasksLoop(
options: AgentExecutionOptions,
tasks: ParsedTask[],
@@ -439,14 +521,15 @@ export class AgentExecutor {
}
}
if (!taskCompleteDetected) {
const cid = detectTaskCompleteMarker(taskOutput);
if (cid) {
const completeMarker = detectTaskCompleteMarker(taskOutput);
if (completeMarker) {
taskCompleteDetected = true;
await this.featureStateManager.updateTaskStatus(
projectPath,
featureId,
cid,
'completed'
completeMarker.id,
'completed',
completeMarker.summary
);
}
}
@@ -524,8 +607,6 @@ export class AgentExecutor {
}
}
}
const summary = extractSummary(responseText);
if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary);
return { responseText, tasksCompleted, aborted: false };
}
@@ -722,8 +803,6 @@ export class AgentExecutor {
);
responseText = r.responseText;
}
const summary = extractSummary(responseText);
if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary);
return { responseText, tasksCompleted };
}

View File

@@ -15,6 +15,12 @@ const logger = createLogger('AutoLoopCoordinator');
const CONSECUTIVE_FAILURE_THRESHOLD = 3;
const FAILURE_WINDOW_MS = 60000;
// Sleep intervals for the auto-loop (in milliseconds)
const SLEEP_INTERVAL_CAPACITY_MS = 5000;
const SLEEP_INTERVAL_IDLE_MS = 10000;
const SLEEP_INTERVAL_NORMAL_MS = 2000;
const SLEEP_INTERVAL_ERROR_MS = 5000;
export interface AutoModeConfig {
maxConcurrency: number;
useWorktrees: boolean;
@@ -169,20 +175,32 @@ export class AutoLoopCoordinator {
// presence is accounted for when deciding whether to dispatch new auto-mode tasks.
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
if (runningCount >= projectState.config.maxConcurrency) {
await this.sleep(5000, projectState.abortController.signal);
await this.sleep(SLEEP_INTERVAL_CAPACITY_MS, projectState.abortController.signal);
continue;
}
const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName);
if (pendingFeatures.length === 0) {
if (runningCount === 0 && !projectState.hasEmittedIdleEvent) {
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
message: 'No pending features - auto mode idle',
// Double-check that we have no features in 'in_progress' state that might
// have been released from the concurrency manager but not yet updated to
// their final status. This prevents auto_mode_idle from firing prematurely
// when features are transitioning states (e.g., during status update).
const hasInProgressFeatures = await this.hasInProgressFeaturesForWorktree(
projectPath,
branchName,
});
projectState.hasEmittedIdleEvent = true;
branchName
);
// Only emit auto_mode_idle if we're truly done with all features
if (!hasInProgressFeatures) {
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath,
branchName,
});
projectState.hasEmittedIdleEvent = true;
}
}
await this.sleep(10000, projectState.abortController.signal);
await this.sleep(SLEEP_INTERVAL_IDLE_MS, projectState.abortController.signal);
continue;
}
@@ -228,10 +246,10 @@ export class AutoLoopCoordinator {
}
});
}
await this.sleep(2000, projectState.abortController.signal);
await this.sleep(SLEEP_INTERVAL_NORMAL_MS, projectState.abortController.signal);
} catch {
if (projectState.abortController.signal.aborted) break;
await this.sleep(5000, projectState.abortController.signal);
await this.sleep(SLEEP_INTERVAL_ERROR_MS, projectState.abortController.signal);
}
}
projectState.isRunning = false;
@@ -462,4 +480,48 @@ export class AutoLoopCoordinator {
signal?.addEventListener('abort', onAbort);
});
}
/**
* Check if a feature belongs to the current worktree based on branch name.
* For main worktree (branchName === null or 'main'): includes features with no branchName or branchName === 'main'.
* For feature worktrees (branchName !== null and !== 'main'): only includes features with matching branchName.
*/
private featureBelongsToWorktree(feature: Feature, branchName: string | null): boolean {
const isMainWorktree = branchName === null || branchName === 'main';
if (isMainWorktree) {
// Main worktree: include features with no branchName or branchName === 'main'
return !feature.branchName || feature.branchName === 'main';
} else {
// Feature worktree: only include exact branch match
return feature.branchName === branchName;
}
}
/**
* Check if there are features in 'in_progress' status for the current worktree.
* This prevents auto_mode_idle from firing prematurely when features are
* transitioning states (e.g., during status update from in_progress to completed).
*/
private async hasInProgressFeaturesForWorktree(
projectPath: string,
branchName: string | null
): Promise<boolean> {
if (!this.loadAllFeaturesFn) {
return false;
}
try {
const allFeatures = await this.loadAllFeaturesFn(projectPath);
return allFeatures.some(
(f) => f.status === 'in_progress' && this.featureBelongsToWorktree(f, branchName)
);
} catch (error) {
const errorInfo = classifyError(error);
logger.warn(
`Failed to load all features for idle check (projectPath=${projectPath}, branchName=${branchName}): ${errorInfo.message}`,
error
);
return false;
}
}
}

View File

@@ -232,9 +232,10 @@ export class AutoModeServiceCompat {
}
async detectOrphanedFeatures(
projectPath: string
projectPath: string,
preloadedFeatures?: Feature[]
): Promise<Array<{ feature: Feature; missingBranch: string }>> {
const facade = this.createFacade(projectPath);
return facade.detectOrphanedFeatures();
return facade.detectOrphanedFeatures(preloadedFeatures);
}
}

View File

@@ -15,7 +15,12 @@ import path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types';
import { DEFAULT_MAX_CONCURRENCY, DEFAULT_MODELS, stripProviderPrefix } from '@automaker/types';
import {
DEFAULT_MAX_CONCURRENCY,
DEFAULT_MODELS,
stripProviderPrefix,
isPipelineStatus,
} from '@automaker/types';
import { resolveModelString } from '@automaker/model-resolver';
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform';
@@ -23,7 +28,7 @@ import * as secureFs from '../../lib/secure-fs.js';
import { validateWorkingDirectory, createAutoModeOptions } from '../../lib/sdk-options.js';
import {
getPromptCustomization,
getProviderByModelId,
resolveProviderContext,
getMCPServersFromSettings,
getDefaultMaxTurnsSetting,
} from '../../lib/settings-helpers.js';
@@ -79,6 +84,37 @@ export class AutoModeServiceFacade {
private readonly settingsService: SettingsService | null
) {}
/**
* Determine if a feature is eligible to be picked up by the auto-mode loop.
*
* @param feature - The feature to check
* @param branchName - The current worktree branch name (null for main)
* @param primaryBranch - The resolved primary branch name for the project
* @returns True if the feature is eligible for auto-dispatch
*/
public static isFeatureEligibleForAutoMode(
feature: Feature,
branchName: string | null,
primaryBranch: string | null
): boolean {
const isEligibleStatus =
feature.status === 'backlog' ||
feature.status === 'ready' ||
feature.status === 'interrupted' ||
isPipelineStatus(feature.status);
if (!isEligibleStatus) return false;
// Filter by branch/worktree alignment
if (branchName === null) {
// For main worktree, include features with no branch or matching primary branch
return !feature.branchName || (primaryBranch != null && feature.branchName === primaryBranch);
} else {
// For named worktrees, only include features matching that branch
return feature.branchName === branchName;
}
}
/**
* Classify and log an error at the facade boundary.
* Emits an error event to the UI so failures are surfaced to the user.
@@ -190,8 +226,7 @@ export class AutoModeServiceFacade {
/**
* Shared agent-run helper used by both PipelineOrchestrator and ExecutionService.
*
* Resolves the model string, looks up the custom provider/credentials via
* getProviderByModelId, then delegates to agentExecutor.execute with the
* Resolves provider/model context, then delegates to agentExecutor.execute with the
* full payload. The opts parameter uses an index-signature union so it
* accepts both the typed ExecutionService opts object and the looser
* Record<string, unknown> used by PipelineOrchestrator without requiring
@@ -217,6 +252,7 @@ export class AutoModeServiceFacade {
thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
branchName?: string | null;
status?: string; // Feature status for pipeline summary check
[key: string]: unknown;
}
): Promise<void> => {
@@ -229,16 +265,19 @@ export class AutoModeServiceFacade {
| import('@automaker/types').ClaudeCompatibleProvider
| undefined;
let credentials: import('@automaker/types').Credentials | undefined;
let providerResolvedModel: string | undefined;
if (settingsService) {
const providerResult = await getProviderByModelId(
resolvedModel,
const providerId = opts?.providerId as string | undefined;
const result = await resolveProviderContext(
settingsService,
resolvedModel,
providerId,
'[AutoModeFacade]'
);
if (providerResult.provider) {
claudeCompatibleProvider = providerResult.provider;
credentials = providerResult.credentials;
}
claudeCompatibleProvider = result.provider;
credentials = result.credentials;
providerResolvedModel = result.resolvedModel;
}
// Build sdkOptions with proper maxTurns and allowedTools for auto-mode.
@@ -264,7 +303,7 @@ export class AutoModeServiceFacade {
const sdkOpts = createAutoModeOptions({
cwd: workDir,
model: resolvedModel,
model: providerResolvedModel || resolvedModel,
systemPrompt: opts?.systemPrompt,
abortController,
autoLoadClaudeMd,
@@ -276,8 +315,14 @@ export class AutoModeServiceFacade {
| undefined,
});
if (!sdkOpts) {
logger.error(
`[createRunAgentFn] sdkOpts is UNDEFINED! createAutoModeOptions type: ${typeof createAutoModeOptions}`
);
}
logger.info(
`[createRunAgentFn] Feature ${featureId}: model=${resolvedModel}, ` +
`[createRunAgentFn] Feature ${featureId}: model=${resolvedModel} (resolved=${providerResolvedModel || resolvedModel}), ` +
`maxTurns=${sdkOpts.maxTurns}, allowedTools=${(sdkOpts.allowedTools as string[])?.length ?? 'default'}, ` +
`provider=${provider.getName()}`
);
@@ -300,6 +345,7 @@ export class AutoModeServiceFacade {
thinkingLevel: opts?.thinkingLevel as ThinkingLevel | undefined,
reasoningEffort: opts?.reasoningEffort as ReasoningEffort | undefined,
branchName: opts?.branchName as string | null | undefined,
status: opts?.status as string | undefined,
provider,
effectiveBareModel,
credentials,
@@ -373,12 +419,8 @@ export class AutoModeServiceFacade {
if (branchName === null) {
primaryBranch = await worktreeResolver.getCurrentBranch(pPath);
}
return features.filter(
(f) =>
(f.status === 'backlog' || f.status === 'ready') &&
(branchName === null
? !f.branchName || (primaryBranch && f.branchName === primaryBranch)
: f.branchName === branchName)
return features.filter((f) =>
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f, branchName, primaryBranch)
);
},
(pPath, branchName, maxConcurrency) =>
@@ -421,9 +463,25 @@ export class AutoModeServiceFacade {
(pPath, featureId, status) =>
featureStateManager.updateFeatureStatus(pPath, featureId, status),
(pPath, featureId) => featureStateManager.loadFeature(pPath, featureId),
async (_feature) => {
// getPlanningPromptPrefixFn - planning prompts handled by AutoModeService
return '';
async (feature) => {
// getPlanningPromptPrefixFn - select appropriate planning prompt based on feature's planningMode
if (!feature.planningMode || feature.planningMode === 'skip') {
return '';
}
const prompts = await getPromptCustomization(settingsService, '[PlanningPromptPrefix]');
const autoModePrompts = prompts.autoMode;
switch (feature.planningMode) {
case 'lite':
return feature.requirePlanApproval
? autoModePrompts.planningLiteWithApproval + '\n\n'
: autoModePrompts.planningLite + '\n\n';
case 'spec':
return autoModePrompts.planningSpec + '\n\n';
case 'full':
return autoModePrompts.planningFull + '\n\n';
default:
return '';
}
},
(pPath, featureId, summary) =>
featureStateManager.saveFeatureSummary(pPath, featureId, summary),
@@ -1075,12 +1133,13 @@ export class AutoModeServiceFacade {
/**
* Detect orphaned features (features with missing branches)
* @param preloadedFeatures - Optional pre-loaded features to avoid redundant disk reads
*/
async detectOrphanedFeatures(): Promise<OrphanedFeatureInfo[]> {
async detectOrphanedFeatures(preloadedFeatures?: Feature[]): Promise<OrphanedFeatureInfo[]> {
const orphanedFeatures: OrphanedFeatureInfo[] = [];
try {
const allFeatures = await this.featureLoader.getAll(this.projectPath);
const allFeatures = preloadedFeatures ?? (await this.featureLoader.getAll(this.projectPath));
const featuresWithBranches = allFeatures.filter(
(f) => f.branchName && f.branchName.trim() !== ''
);

View File

@@ -193,7 +193,11 @@ export class CodexModelCacheService {
* Infer tier from model ID
*/
private inferTier(modelId: string): 'premium' | 'standard' | 'basic' {
if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) {
if (
modelId.includes('max') ||
modelId.includes('gpt-5.2-codex') ||
modelId.includes('gpt-5.3-codex')
) {
return 'premium';
}
if (modelId.includes('mini')) {

View File

@@ -13,6 +13,8 @@ import path from 'path';
import net from 'net';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js';
import fs from 'fs/promises';
import { constants } from 'fs';
const logger = createLogger('DevServerService');
@@ -86,9 +88,13 @@ const PORT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
},
];
// Throttle output to prevent overwhelming WebSocket under heavy load
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
// Throttle output to prevent overwhelming WebSocket under heavy load.
// 100ms (~10fps) is sufficient for readable log streaming while keeping
// WebSocket traffic manageable. The previous 4ms rate (~250fps) generated
// up to 250 events/sec which caused progressive browser slowdown from
// accumulated console logs, JSON serialization overhead, and React re-renders.
const OUTPUT_THROTTLE_MS = 100; // ~10fps max update rate
const OUTPUT_BATCH_SIZE = 8192; // Larger batches to compensate for lower frequency
export interface DevServerInfo {
worktreePath: string;
@@ -110,6 +116,21 @@ export interface DevServerInfo {
urlDetected: boolean;
// Timer for URL detection timeout fallback
urlDetectionTimeout: NodeJS.Timeout | null;
// Custom command used to start the server
customCommand?: string;
}
/**
* Persistable subset of DevServerInfo for survival across server restarts
*/
interface PersistedDevServerInfo {
worktreePath: string;
allocatedPort: number;
port: number;
url: string;
startedAt: string;
urlDetected: boolean;
customCommand?: string;
}
// Port allocation starts at 3001 to avoid conflicts with common dev ports
@@ -121,8 +142,20 @@ const LIVERELOAD_PORTS = [35729, 35730, 35731] as const;
class DevServerService {
private runningServers: Map<string, DevServerInfo> = new Map();
private startingServers: Set<string> = new Set();
private allocatedPorts: Set<number> = new Set();
private emitter: EventEmitter | null = null;
private dataDir: string | null = null;
private saveQueue: Promise<void> = Promise.resolve();
/**
* Initialize the service with data directory for persistence
*/
async initialize(dataDir: string, emitter: EventEmitter): Promise<void> {
this.dataDir = dataDir;
this.emitter = emitter;
await this.loadState();
}
/**
* Set the event emitter for streaming log events
@@ -132,6 +165,131 @@ class DevServerService {
this.emitter = emitter;
}
/**
* Save the current state of running servers to disk
*/
private async saveState(): Promise<void> {
if (!this.dataDir) return;
// Queue the save operation to prevent concurrent writes
this.saveQueue = this.saveQueue
.then(async () => {
if (!this.dataDir) return;
try {
const statePath = path.join(this.dataDir, 'dev-servers.json');
const persistedInfo: PersistedDevServerInfo[] = Array.from(
this.runningServers.values()
).map((s) => ({
worktreePath: s.worktreePath,
allocatedPort: s.allocatedPort,
port: s.port,
url: s.url,
startedAt: s.startedAt.toISOString(),
urlDetected: s.urlDetected,
customCommand: s.customCommand,
}));
await fs.writeFile(statePath, JSON.stringify(persistedInfo, null, 2));
logger.debug(`Saved dev server state to ${statePath}`);
} catch (error) {
logger.error('Failed to save dev server state:', error);
}
})
.catch((error) => {
logger.error('Error in save queue:', error);
});
return this.saveQueue;
}
/**
* Load the state of running servers from disk
*/
private async loadState(): Promise<void> {
if (!this.dataDir) return;
try {
const statePath = path.join(this.dataDir, 'dev-servers.json');
try {
await fs.access(statePath, constants.F_OK);
} catch {
// File doesn't exist, which is fine
return;
}
const content = await fs.readFile(statePath, 'utf-8');
const rawParsed: unknown = JSON.parse(content);
if (!Array.isArray(rawParsed)) {
logger.warn('Dev server state file is not an array, skipping load');
return;
}
const persistedInfo: PersistedDevServerInfo[] = rawParsed.filter((entry: unknown) => {
if (entry === null || typeof entry !== 'object') {
logger.warn('Dropping invalid dev server entry (not an object):', entry);
return false;
}
const e = entry as Record<string, unknown>;
const valid =
typeof e.worktreePath === 'string' &&
e.worktreePath.length > 0 &&
typeof e.allocatedPort === 'number' &&
Number.isInteger(e.allocatedPort) &&
e.allocatedPort >= 1 &&
e.allocatedPort <= 65535 &&
typeof e.port === 'number' &&
Number.isInteger(e.port) &&
e.port >= 1 &&
e.port <= 65535 &&
typeof e.url === 'string' &&
typeof e.startedAt === 'string' &&
typeof e.urlDetected === 'boolean' &&
(e.customCommand === undefined || typeof e.customCommand === 'string');
if (!valid) {
logger.warn('Dropping malformed dev server entry:', e);
}
return valid;
}) as PersistedDevServerInfo[];
logger.info(`Loading ${persistedInfo.length} dev servers from state`);
for (const info of persistedInfo) {
// Check if the process is still running on the port
// Since we can't reliably re-attach to the process for output,
// we'll just check if the port is in use.
const portInUse = !(await this.isPortAvailable(info.port));
if (portInUse) {
logger.info(`Re-attached to dev server on port ${info.port} for ${info.worktreePath}`);
const serverInfo: DevServerInfo = {
...info,
startedAt: new Date(info.startedAt),
process: null, // Process object is lost, but we know it's running
scrollbackBuffer: '',
outputBuffer: '',
flushTimeout: null,
stopping: false,
urlDetectionTimeout: null,
};
this.runningServers.set(info.worktreePath, serverInfo);
this.allocatedPorts.add(info.allocatedPort);
} else {
logger.info(
`Dev server on port ${info.port} for ${info.worktreePath} is no longer running`
);
}
}
// Cleanup stale entries from the file if any
if (this.runningServers.size !== persistedInfo.length) {
await this.saveState();
}
} catch (error) {
logger.error('Failed to load dev server state:', error);
}
}
/**
* Prune a stale server entry whose process has exited without cleanup.
* Clears any pending timers, removes the port from allocatedPorts, deletes
@@ -148,6 +306,10 @@ class DevServerService {
// been mutated by detectUrlFromOutput to reflect the actual detected port.
this.allocatedPorts.delete(server.allocatedPort);
this.runningServers.delete(worktreePath);
// Persist state change
this.saveState().catch((err) => logger.error('Failed to save state in pruneStaleServer:', err));
if (this.emitter) {
this.emitter.emit('dev-server:stopped', {
worktreePath,
@@ -249,7 +411,7 @@ class DevServerService {
* - PHP: "Development Server (http://localhost:8000) started"
* - Generic: Any localhost URL with a port
*/
private detectUrlFromOutput(server: DevServerInfo, content: string): void {
private async detectUrlFromOutput(server: DevServerInfo, content: string): Promise<void> {
// Skip if URL already detected
if (server.urlDetected) {
return;
@@ -304,6 +466,11 @@ class DevServerService {
logger.info(`Detected server URL via ${description}: ${detectedUrl}`);
// Persist state change
await this.saveState().catch((err) =>
logger.error('Failed to save state in detectUrlFromOutput:', err)
);
// Emit URL update event
if (this.emitter) {
this.emitter.emit('dev-server:url-detected', {
@@ -346,6 +513,11 @@ class DevServerService {
logger.info(`Detected server port via ${description}: ${detectedPort}${detectedUrl}`);
// Persist state change
await this.saveState().catch((err) =>
logger.error('Failed to save state in detectUrlFromOutput Phase 2:', err)
);
// Emit URL update event
if (this.emitter) {
this.emitter.emit('dev-server:url-detected', {
@@ -365,7 +537,7 @@ class DevServerService {
* Handle incoming stdout/stderr data from dev server process
* Buffers data for scrollback replay and schedules throttled emission
*/
private handleProcessOutput(server: DevServerInfo, data: Buffer): void {
private async handleProcessOutput(server: DevServerInfo, data: Buffer): Promise<void> {
// Skip output if server is stopping
if (server.stopping) {
return;
@@ -374,7 +546,7 @@ class DevServerService {
const content = data.toString();
// Try to detect actual server URL from output
this.detectUrlFromOutput(server, content);
await this.detectUrlFromOutput(server, content);
// Append to scrollback buffer for replay on reconnect
this.appendToScrollback(server, content);
@@ -594,261 +766,305 @@ class DevServerService {
};
error?: string;
}> {
// Check if already running
if (this.runningServers.has(worktreePath)) {
const existing = this.runningServers.get(worktreePath)!;
return {
success: true,
result: {
worktreePath: existing.worktreePath,
port: existing.port,
url: existing.url,
message: `Dev server already running on port ${existing.port}`,
},
};
}
// Verify the worktree exists
if (!(await this.fileExists(worktreePath))) {
// Check if already running or starting
if (this.runningServers.has(worktreePath) || this.startingServers.has(worktreePath)) {
const existing = this.runningServers.get(worktreePath);
if (existing) {
return {
success: true,
result: {
worktreePath: existing.worktreePath,
port: existing.port,
url: existing.url,
message: `Dev server already running on port ${existing.port}`,
},
};
}
return {
success: false,
error: `Worktree path does not exist: ${worktreePath}`,
error: 'Dev server is already starting',
};
}
// Determine the dev command to use
let devCommand: { cmd: string; args: string[] };
this.startingServers.add(worktreePath);
// Normalize custom command: trim whitespace and treat empty strings as undefined
const normalizedCustomCommand = customCommand?.trim();
if (normalizedCustomCommand) {
// Use the provided custom command
devCommand = this.parseCustomCommand(normalizedCustomCommand);
if (!devCommand.cmd) {
return {
success: false,
error: 'Invalid custom command: command cannot be empty',
};
}
logger.debug(`Using custom command: ${normalizedCustomCommand}`);
} else {
// Check for package.json when auto-detecting
const packageJsonPath = path.join(worktreePath, 'package.json');
if (!(await this.fileExists(packageJsonPath))) {
return {
success: false,
error: `No package.json found in: ${worktreePath}`,
};
}
// Get dev command from package manager detection
const detectedCommand = await this.getDevCommand(worktreePath);
if (!detectedCommand) {
return {
success: false,
error: `Could not determine dev command for: ${worktreePath}`,
};
}
devCommand = detectedCommand;
}
// Find available port
let port: number;
try {
port = await this.findAvailablePort();
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Port allocation failed',
};
}
// Reserve the port (port was already force-killed in findAvailablePort)
this.allocatedPorts.add(port);
// Also kill common related ports (livereload, etc.)
// Some dev servers use fixed ports for HMR/livereload regardless of main port
for (const relatedPort of LIVERELOAD_PORTS) {
this.killProcessOnPort(relatedPort);
}
// Small delay to ensure related ports are freed
await new Promise((resolve) => setTimeout(resolve, 100));
logger.info(`Starting dev server on port ${port}`);
logger.debug(`Working directory (cwd): ${worktreePath}`);
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
// Spawn the dev process with PORT environment variable
// FORCE_COLOR enables colored output even when not running in a TTY
const env = {
...process.env,
PORT: String(port),
FORCE_COLOR: '1',
// Some tools use these additional env vars for color detection
COLORTERM: 'truecolor',
TERM: 'xterm-256color',
};
const devProcess = spawn(devCommand.cmd, devCommand.args, {
cwd: worktreePath,
env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
// Track if process failed early using object to work around TypeScript narrowing
const status = { error: null as string | null, exited: false };
// Create server info early so we can reference it in handlers
// We'll add it to runningServers after verifying the process started successfully
const hostname = process.env.HOSTNAME || 'localhost';
const serverInfo: DevServerInfo = {
worktreePath,
allocatedPort: port, // Immutable: records which port we reserved; never changed after this point
port,
url: `http://${hostname}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
process: devProcess,
startedAt: new Date(),
scrollbackBuffer: '',
outputBuffer: '',
flushTimeout: null,
stopping: false,
urlDetected: false, // Will be set to true when actual URL is detected from output
urlDetectionTimeout: null, // Will be set after server starts successfully
};
// Capture stdout with buffer management and event emission
if (devProcess.stdout) {
devProcess.stdout.on('data', (data: Buffer) => {
this.handleProcessOutput(serverInfo, data);
});
}
// Capture stderr with buffer management and event emission
if (devProcess.stderr) {
devProcess.stderr.on('data', (data: Buffer) => {
this.handleProcessOutput(serverInfo, data);
});
}
// Helper to clean up resources and emit stop event
const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => {
if (serverInfo.flushTimeout) {
clearTimeout(serverInfo.flushTimeout);
serverInfo.flushTimeout = null;
// Verify the worktree exists
if (!(await this.fileExists(worktreePath))) {
return {
success: false,
error: `Worktree path does not exist: ${worktreePath}`,
};
}
// Clear URL detection timeout to prevent stale fallback emission
if (serverInfo.urlDetectionTimeout) {
clearTimeout(serverInfo.urlDetectionTimeout);
serverInfo.urlDetectionTimeout = null;
// Determine the dev command to use
let devCommand: { cmd: string; args: string[] };
// Normalize custom command: trim whitespace and treat empty strings as undefined
const normalizedCustomCommand = customCommand?.trim();
if (normalizedCustomCommand) {
// Use the provided custom command
devCommand = this.parseCustomCommand(normalizedCustomCommand);
if (!devCommand.cmd) {
return {
success: false,
error: 'Invalid custom command: command cannot be empty',
};
}
logger.debug(`Using custom command: ${normalizedCustomCommand}`);
} else {
// Check for package.json when auto-detecting
const packageJsonPath = path.join(worktreePath, 'package.json');
if (!(await this.fileExists(packageJsonPath))) {
return {
success: false,
error: `No package.json found in: ${worktreePath}`,
};
}
// Get dev command from package manager detection
const detectedCommand = await this.getDevCommand(worktreePath);
if (!detectedCommand) {
return {
success: false,
error: `Could not determine dev command for: ${worktreePath}`,
};
}
devCommand = detectedCommand;
}
// Emit stopped event (only if not already stopping - prevents duplicate events)
if (this.emitter && !serverInfo.stopping) {
this.emitter.emit('dev-server:stopped', {
// Find available port
let port: number;
try {
port = await this.findAvailablePort();
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Port allocation failed',
};
}
// Reserve the port (port was already force-killed in findAvailablePort)
this.allocatedPorts.add(port);
// Also kill common related ports (livereload, etc.)
// Some dev servers use fixed ports for HMR/livereload regardless of main port
for (const relatedPort of LIVERELOAD_PORTS) {
this.killProcessOnPort(relatedPort);
}
// Small delay to ensure related ports are freed
await new Promise((resolve) => setTimeout(resolve, 100));
logger.info(`Starting dev server on port ${port}`);
logger.debug(`Working directory (cwd): ${worktreePath}`);
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
// Emit starting only after preflight checks pass to avoid dangling starting state.
if (this.emitter) {
this.emitter.emit('dev-server:starting', {
worktreePath,
port: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it)
exitCode,
error: errorMessage,
timestamp: new Date().toISOString(),
});
}
this.allocatedPorts.delete(serverInfo.allocatedPort);
this.runningServers.delete(worktreePath);
};
devProcess.on('error', (error) => {
logger.error(`Process error:`, error);
status.error = error.message;
cleanupAndEmitStop(null, error.message);
});
devProcess.on('exit', (code) => {
logger.info(`Process for ${worktreePath} exited with code ${code}`);
status.exited = true;
cleanupAndEmitStop(code);
});
// Wait a moment to see if the process fails immediately
await new Promise((resolve) => setTimeout(resolve, 500));
if (status.error) {
return {
success: false,
error: `Failed to start dev server: ${status.error}`,
// Spawn the dev process with PORT environment variable
// FORCE_COLOR enables colored output even when not running in a TTY
const env = {
...process.env,
PORT: String(port),
FORCE_COLOR: '1',
// Some tools use these additional env vars for color detection
COLORTERM: 'truecolor',
TERM: 'xterm-256color',
};
}
if (status.exited) {
return {
success: false,
error: `Dev server process exited immediately. Check server logs for details.`,
};
}
// Server started successfully - add to running servers map
this.runningServers.set(worktreePath, serverInfo);
// Emit started event for WebSocket subscribers
if (this.emitter) {
this.emitter.emit('dev-server:started', {
worktreePath,
port,
url: serverInfo.url,
timestamp: new Date().toISOString(),
const devProcess = spawn(devCommand.cmd, devCommand.args, {
cwd: worktreePath,
env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
}
// Set up URL detection timeout fallback.
// If URL detection hasn't succeeded after URL_DETECTION_TIMEOUT_MS, check if
// the allocated port is actually in use (server probably started successfully)
// and emit a url-detected event with the allocated port as fallback.
// Also re-scan the scrollback buffer in case the URL was printed before
// our patterns could match (e.g., it was split across multiple data chunks).
serverInfo.urlDetectionTimeout = setTimeout(() => {
serverInfo.urlDetectionTimeout = null;
// Track if process failed early using object to work around TypeScript narrowing
const status = { error: null as string | null, exited: false };
// Only run fallback if server is still running and URL wasn't detected
if (serverInfo.stopping || serverInfo.urlDetected || !this.runningServers.has(worktreePath)) {
return;
// Create server info early so we can reference it in handlers
// We'll add it to runningServers after verifying the process started successfully
const fallbackHost = 'localhost';
const serverInfo: DevServerInfo = {
worktreePath,
allocatedPort: port, // Immutable: records which port we reserved; never changed after this point
port,
url: `http://${fallbackHost}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
process: devProcess,
startedAt: new Date(),
scrollbackBuffer: '',
outputBuffer: '',
flushTimeout: null,
stopping: false,
urlDetected: false, // Will be set to true when actual URL is detected from output
urlDetectionTimeout: null, // Will be set after server starts successfully
customCommand: normalizedCustomCommand,
};
// Capture stdout with buffer management and event emission
if (devProcess.stdout) {
devProcess.stdout.on('data', (data: Buffer) => {
this.handleProcessOutput(serverInfo, data).catch((error: unknown) => {
logger.error('Failed to handle dev server stdout output:', error);
});
});
}
// Re-scan the entire scrollback buffer for URL patterns
// This catches cases where the URL was split across multiple output chunks
logger.info(`URL detection timeout for ${worktreePath}, re-scanning scrollback buffer`);
this.detectUrlFromOutput(serverInfo, serverInfo.scrollbackBuffer);
// Capture stderr with buffer management and event emission
if (devProcess.stderr) {
devProcess.stderr.on('data', (data: Buffer) => {
this.handleProcessOutput(serverInfo, data).catch((error: unknown) => {
logger.error('Failed to handle dev server stderr output:', error);
});
});
}
// If still not detected after full rescan, use the allocated port as fallback
if (!serverInfo.urlDetected) {
logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`);
const fallbackUrl = `http://${hostname}:${port}`;
serverInfo.url = fallbackUrl;
serverInfo.urlDetected = true;
// Helper to clean up resources and emit stop event
const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => {
if (serverInfo.flushTimeout) {
clearTimeout(serverInfo.flushTimeout);
serverInfo.flushTimeout = null;
}
if (this.emitter) {
this.emitter.emit('dev-server:url-detected', {
// Clear URL detection timeout to prevent stale fallback emission
if (serverInfo.urlDetectionTimeout) {
clearTimeout(serverInfo.urlDetectionTimeout);
serverInfo.urlDetectionTimeout = null;
}
// Emit stopped event (only if not already stopping - prevents duplicate events)
if (this.emitter && !serverInfo.stopping) {
this.emitter.emit('dev-server:stopped', {
worktreePath,
url: fallbackUrl,
port,
port: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it)
exitCode,
error: errorMessage,
timestamp: new Date().toISOString(),
});
}
}
}, URL_DETECTION_TIMEOUT_MS);
return {
success: true,
result: {
worktreePath,
port,
url: `http://${hostname}:${port}`,
message: `Dev server started on port ${port}`,
},
};
this.allocatedPorts.delete(serverInfo.allocatedPort);
this.runningServers.delete(worktreePath);
// Persist state change
this.saveState().catch((err) => logger.error('Failed to save state in cleanup:', err));
};
devProcess.on('error', (error) => {
logger.error(`Process error:`, error);
status.error = error.message;
cleanupAndEmitStop(null, error.message);
});
devProcess.on('exit', (code) => {
logger.info(`Process for ${worktreePath} exited with code ${code}`);
status.exited = true;
cleanupAndEmitStop(code);
});
// Wait a moment to see if the process fails immediately
await new Promise((resolve) => setTimeout(resolve, 500));
if (status.error) {
return {
success: false,
error: `Failed to start dev server: ${status.error}`,
};
}
if (status.exited) {
return {
success: false,
error: `Dev server process exited immediately. Check server logs for details.`,
};
}
// Server started successfully - add to running servers map
this.runningServers.set(worktreePath, serverInfo);
// Persist state change
await this.saveState().catch((err) =>
logger.error('Failed to save state in startDevServer:', err)
);
// Emit started event for WebSocket subscribers
if (this.emitter) {
this.emitter.emit('dev-server:started', {
worktreePath,
port,
url: serverInfo.url,
timestamp: new Date().toISOString(),
});
}
// Set up URL detection timeout fallback.
// If URL detection hasn't succeeded after URL_DETECTION_TIMEOUT_MS, check if
// the allocated port is actually in use (server probably started successfully)
// and emit a url-detected event with the allocated port as fallback.
// Also re-scan the scrollback buffer in case the URL was printed before
// our patterns could match (e.g., it was split across multiple data chunks).
serverInfo.urlDetectionTimeout = setTimeout(async () => {
serverInfo.urlDetectionTimeout = null;
// Only run fallback if server is still running and URL wasn't detected
if (
serverInfo.stopping ||
serverInfo.urlDetected ||
!this.runningServers.has(worktreePath)
) {
return;
}
// Re-scan the entire scrollback buffer for URL patterns
// This catches cases where the URL was split across multiple output chunks
logger.info(`URL detection timeout for ${worktreePath}, re-scanning scrollback buffer`);
await this.detectUrlFromOutput(serverInfo, serverInfo.scrollbackBuffer).catch((err) =>
logger.error('Failed to re-scan scrollback buffer:', err)
);
// If still not detected after full rescan, use the allocated port as fallback
if (!serverInfo.urlDetected) {
logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`);
const fallbackUrl = `http://${fallbackHost}:${port}`;
serverInfo.url = fallbackUrl;
serverInfo.urlDetected = true;
// Persist state change
await this.saveState().catch((err) =>
logger.error('Failed to save state in URL detection fallback:', err)
);
if (this.emitter) {
this.emitter.emit('dev-server:url-detected', {
worktreePath: serverInfo.worktreePath,
url: fallbackUrl,
port,
timestamp: new Date().toISOString(),
});
}
}
}, URL_DETECTION_TIMEOUT_MS);
return {
success: true,
result: {
worktreePath: serverInfo.worktreePath,
port: serverInfo.port,
url: serverInfo.url,
message: `Dev server started on port ${port}`,
},
};
} finally {
this.startingServers.delete(worktreePath);
}
}
/**
@@ -904,9 +1120,11 @@ class DevServerService {
});
}
// Kill the process
// Kill the process; persisted/re-attached entries may not have a process handle.
if (server.process && !server.process.killed) {
server.process.kill('SIGTERM');
} else {
this.killProcessOnPort(server.port);
}
// Free the originally-reserved port slot (allocatedPort is immutable and always
@@ -915,6 +1133,11 @@ class DevServerService {
this.allocatedPorts.delete(server.allocatedPort);
this.runningServers.delete(worktreePath);
// Persist state change
await this.saveState().catch((err) =>
logger.error('Failed to save state in stopDevServer:', err)
);
return {
success: true,
result: {

View File

@@ -27,7 +27,11 @@ import type {
EventHookTrigger,
EventHookShellAction,
EventHookHttpAction,
EventHookNtfyAction,
NtfyEndpointConfig,
EventHookContext,
} from '@automaker/types';
import { ntfyService, type NtfyContext } from './ntfy-service.js';
const execAsync = promisify(exec);
const logger = createLogger('EventHooks');
@@ -38,19 +42,8 @@ const DEFAULT_SHELL_TIMEOUT = 30000;
/** Default timeout for HTTP requests (10 seconds) */
const DEFAULT_HTTP_TIMEOUT = 10000;
/**
* Context available for variable substitution in hooks
*/
interface HookContext {
featureId?: string;
featureName?: string;
projectPath?: string;
projectName?: string;
error?: string;
errorType?: string;
timestamp: string;
eventType: EventHookTrigger;
}
// Use the shared EventHookContext type (aliased locally as HookContext for clarity)
type HookContext = EventHookContext;
/**
* Auto-mode event payload structure
@@ -451,6 +444,8 @@ export class EventHookService {
await this.executeShellHook(hook.action, context, hookName);
} else if (hook.action.type === 'http') {
await this.executeHttpHook(hook.action, context, hookName);
} else if (hook.action.type === 'ntfy') {
await this.executeNtfyHook(hook.action, context, hookName);
}
} catch (error) {
logger.error(`Hook "${hookName}" failed:`, error);
@@ -558,6 +553,86 @@ export class EventHookService {
}
}
/**
* Execute an ntfy.sh notification hook
*/
private async executeNtfyHook(
action: EventHookNtfyAction,
context: HookContext,
hookName: string
): Promise<void> {
if (!this.settingsService) {
logger.warn('Settings service not available for ntfy hook');
return;
}
// Get the endpoint configuration
const settings = await this.settingsService.getGlobalSettings();
const endpoints = settings.ntfyEndpoints || [];
const endpoint = endpoints.find((e) => e.id === action.endpointId);
if (!endpoint) {
logger.error(`Ntfy hook "${hookName}" references unknown endpoint: ${action.endpointId}`);
return;
}
// Convert HookContext to NtfyContext
const ntfyContext: NtfyContext = {
featureId: context.featureId,
featureName: context.featureName,
projectPath: context.projectPath,
projectName: context.projectName,
error: context.error,
errorType: context.errorType,
timestamp: context.timestamp,
eventType: context.eventType,
};
// Resolve click URL: action-level overrides endpoint default
let clickUrl = action.clickUrl || endpoint.defaultClickUrl;
// Apply deep-link parameters to the resolved click URL
if (clickUrl && context.projectPath) {
try {
const url = new URL(clickUrl);
url.pathname = '/board';
// Add projectPath so the UI can switch to the correct project
url.searchParams.set('projectPath', context.projectPath);
// Add featureId as query param for deep linking to board with feature output modal
if (context.featureId) {
url.searchParams.set('featureId', context.featureId);
}
clickUrl = url.toString();
} catch (error) {
// If URL parsing fails, log warning and use as-is
logger.warn(
`Failed to parse click URL "${clickUrl}" for deep linking: ${error instanceof Error ? error.message : String(error)}`
);
}
}
logger.info(`Executing ntfy hook "${hookName}" to endpoint "${endpoint.name}"`);
const result = await ntfyService.sendNotification(
endpoint,
{
title: action.title,
body: action.body,
tags: action.tags,
emoji: action.emoji,
clickUrl,
priority: action.priority,
},
ntfyContext
);
if (!result.success) {
logger.warn(`Ntfy hook "${hookName}" failed: ${result.error}`);
} else {
logger.info(`Ntfy hook "${hookName}" completed successfully`);
}
}
/**
* Substitute {{variable}} placeholders in a string
*/

View File

@@ -108,16 +108,14 @@ export class ExecutionService {
return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...';
}
buildFeaturePrompt(
feature: Feature,
taskExecutionPrompts: {
implementationInstructions: string;
playwrightVerificationInstructions: string;
}
): string {
/**
* Build feature description section (without implementation instructions).
* Used when planning mode is active — the planning prompt provides its own instructions.
*/
buildFeatureDescription(feature: Feature): string {
const title = this.extractTitleFromDescription(feature.description);
let prompt = `## Feature Implementation Task
let prompt = `## Feature Task
**Feature ID:** ${feature.id}
**Title:** ${title}
@@ -146,6 +144,18 @@ ${feature.spec}
prompt += `\n**Context Images Attached:**\n${feature.imagePaths.length} image(s) attached:\n${imagesList}\n`;
}
return prompt;
}
buildFeaturePrompt(
feature: Feature,
taskExecutionPrompts: {
implementationInstructions: string;
playwrightVerificationInstructions: string;
}
): string {
let prompt = this.buildFeatureDescription(feature);
prompt += feature.skipTests
? `\n${taskExecutionPrompts.implementationInstructions}`
: `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`;
@@ -169,6 +179,7 @@ ${feature.spec}
const abortController = tempRunningFeature.abortController;
if (isAutoMode) await this.saveExecutionStateFn(projectPath);
let feature: Feature | null = null;
let pipelineCompleted = false;
try {
validateWorkingDirectory(projectPath);
@@ -214,7 +225,12 @@ ${feature.spec}
const branchName = feature.branchName;
if (!worktreePath && useWorktrees && branchName) {
worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
if (!worktreePath) {
throw new Error(
`Worktree enabled but no worktree found for feature branch "${branchName}".`
);
}
logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
}
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
validateWorkingDirectory(workDir);
@@ -268,9 +284,15 @@ ${feature.spec}
if (options?.continuationPrompt) {
prompt = options.continuationPrompt;
} else {
prompt =
(await this.getPlanningPromptPrefixFn(feature)) +
this.buildFeaturePrompt(feature, prompts.taskExecution);
const planningPrefix = await this.getPlanningPromptPrefixFn(feature);
if (planningPrefix) {
// Planning mode active: use planning instructions + feature description only.
// Do NOT include implementationInstructions — they conflict with the planning
// prompt's "DO NOT proceed with implementation until approval" directive.
prompt = planningPrefix + '\n\n' + this.buildFeatureDescription(feature);
} else {
prompt = this.buildFeaturePrompt(feature, prompts.taskExecution);
}
if (feature.planningMode && feature.planningMode !== 'skip') {
this.eventBus.emitAutoModeEvent('planning_started', {
featureId: feature.id,
@@ -304,6 +326,7 @@ ${feature.spec}
useClaudeCodeSystemPrompt,
thinkingLevel: feature.thinkingLevel,
reasoningEffort: feature.reasoningEffort,
providerId: feature.providerId,
branchName: feature.branchName ?? null,
}
);
@@ -370,6 +393,7 @@ Please continue from where you left off and complete all remaining tasks. Use th
useClaudeCodeSystemPrompt,
thinkingLevel: feature.thinkingLevel,
reasoningEffort: feature.reasoningEffort,
providerId: feature.providerId,
branchName: feature.branchName ?? null,
}
);
@@ -408,6 +432,7 @@ Please continue from where you left off and complete all remaining tasks. Use th
testAttempts: 0,
maxTestAttempts: 5,
});
pipelineCompleted = true;
// Check if pipeline set a terminal status (e.g., merge_conflict) — don't overwrite it
const refreshed = await this.loadFeatureFn(projectPath, featureId);
if (refreshed?.status === 'merge_conflict') {
@@ -461,7 +486,10 @@ Please continue from where you left off and complete all remaining tasks. Use th
const hasIncompleteTasks = totalTasks > 0 && completedTasks < totalTasks;
try {
if (agentOutput) {
// Only save summary if feature doesn't already have one (e.g., accumulated from pipeline steps)
// This prevents overwriting accumulated summaries with just the last step's output
// The agent-executor already extracts and saves summaries during execution
if (agentOutput && !completedFeature?.summary) {
const summary = extractSummary(agentOutput);
if (summary) await this.saveFeatureSummaryFn(projectPath, featureId, summary);
}
@@ -515,7 +543,30 @@ Please continue from where you left off and complete all remaining tasks. Use th
}
} else {
logger.error(`Feature ${featureId} failed:`, error);
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
// If pipeline steps completed successfully, don't send the feature back to backlog.
// The pipeline work is done — set to waiting_approval so the user can review.
const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog';
if (pipelineCompleted) {
logger.info(
`[executeFeature] Feature ${featureId} failed after pipeline completed. ` +
`Setting status to waiting_approval instead of backlog to preserve pipeline work.`
);
}
// Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution
let currentStatus: string | undefined;
try {
const currentFeature = await this.loadFeatureFn(projectPath, featureId);
currentStatus = currentFeature?.status;
} catch (loadErr) {
// If loading fails, log it and proceed with the status update anyway
logger.warn(
`[executeFeature] Failed to reload feature ${featureId} for status check:`,
loadErr
);
}
if (currentStatus !== 'merge_conflict') {
await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus);
}
this.eventBus.emitAutoModeEvent('auto_mode_error', {
featureId,
featureName: feature?.title,

View File

@@ -34,6 +34,7 @@ export type RunAgentFn = (
useClaudeCodeSystemPrompt?: boolean;
thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
providerId?: string;
branchName?: string | null;
}
) => Promise<void>;

View File

@@ -378,6 +378,7 @@ export class FeatureLoader {
description: featureData.description || '',
...featureData,
id: featureId,
createdAt: featureData.createdAt || new Date().toISOString(),
imagePaths: migratedImagePaths,
descriptionHistory: initialHistory,
};

View File

@@ -14,7 +14,8 @@
*/
import path from 'path';
import type { Feature, ParsedTask, PlanSpec } from '@automaker/types';
import type { Feature, FeatureStatusWithPipeline, ParsedTask, PlanSpec } from '@automaker/types';
import { isPipelineStatus } from '@automaker/types';
import {
atomicWriteJson,
readJsonWithRecovery,
@@ -28,9 +29,40 @@ import type { EventEmitter } from '../lib/events.js';
import type { AutoModeEventType } from './typed-event-bus.js';
import { getNotificationService } from './notification-service.js';
import { FeatureLoader } from './feature-loader.js';
import { pipelineService } from './pipeline-service.js';
const logger = createLogger('FeatureStateManager');
// Notification type constants
const NOTIFICATION_TYPE_WAITING_APPROVAL = 'feature_waiting_approval';
const NOTIFICATION_TYPE_VERIFIED = 'feature_verified';
const NOTIFICATION_TYPE_FEATURE_ERROR = 'feature_error';
const NOTIFICATION_TYPE_AUTO_MODE_ERROR = 'auto_mode_error';
// Notification title constants
const NOTIFICATION_TITLE_WAITING_APPROVAL = 'Feature Ready for Review';
const NOTIFICATION_TITLE_VERIFIED = 'Feature Verified';
const NOTIFICATION_TITLE_FEATURE_ERROR = 'Feature Failed';
const NOTIFICATION_TITLE_AUTO_MODE_ERROR = 'Auto Mode Error';
/**
* Auto-mode event payload structure
* This is the payload that comes with 'auto-mode:event' events
*/
interface AutoModeEventPayload {
type?: string;
featureId?: string;
featureName?: string;
passes?: boolean;
executionMode?: 'auto' | 'manual';
message?: string;
error?: string;
errorType?: string;
projectPath?: string;
/** Status field present when type === 'feature_status_changed' */
status?: string;
}
/**
* FeatureStateManager handles feature status updates with persistence guarantees.
*
@@ -43,10 +75,28 @@ const logger = createLogger('FeatureStateManager');
export class FeatureStateManager {
private events: EventEmitter;
private featureLoader: FeatureLoader;
private unsubscribe: (() => void) | null = null;
constructor(events: EventEmitter, featureLoader: FeatureLoader) {
this.events = events;
this.featureLoader = featureLoader;
// Subscribe to error events to create notifications
this.unsubscribe = events.subscribe((type, payload) => {
if (type === 'auto-mode:event') {
this.handleAutoModeEventError(payload as AutoModeEventPayload);
}
});
}
/**
* Cleanup subscriptions
*/
destroy(): void {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
}
/**
@@ -104,77 +154,18 @@ export class FeatureStateManager {
feature.status = status;
feature.updatedAt = new Date().toISOString();
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
// Badge will show for 2 minutes after this timestamp
if (status === 'waiting_approval') {
// Handle justFinishedAt timestamp based on status
const shouldSetJustFinishedAt = status === 'waiting_approval';
const shouldClearJustFinishedAt = status !== 'waiting_approval';
if (shouldSetJustFinishedAt) {
feature.justFinishedAt = new Date().toISOString();
} else if (shouldClearJustFinishedAt) {
feature.justFinishedAt = undefined;
}
// Finalize task statuses when feature is done:
// - Mark any in_progress tasks as completed (agent finished but didn't explicitly complete them)
// - Do NOT mark pending tasks as completed (they were never started)
// - Clear currentTaskId since no task is actively running
// This prevents cards in "waiting for review" from appearing to still have running tasks
if (feature.planSpec?.tasks) {
let tasksFinalized = 0;
let tasksPending = 0;
for (const task of feature.planSpec.tasks) {
if (task.status === 'in_progress') {
task.status = 'completed';
tasksFinalized++;
} else if (task.status === 'pending') {
tasksPending++;
}
}
if (tasksFinalized > 0) {
logger.info(
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to waiting_approval`
);
}
if (tasksPending > 0) {
logger.warn(
`[updateFeatureStatus] Feature ${featureId} moving to waiting_approval with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
);
}
// Update tasksCompleted count to reflect actual completed tasks
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
(t) => t.status === 'completed'
).length;
feature.planSpec.currentTaskId = undefined;
}
} else if (status === 'verified') {
// Also finalize in_progress tasks when moving directly to verified (skipTests=false)
// Do NOT mark pending tasks as completed - they were never started
if (feature.planSpec?.tasks) {
let tasksFinalized = 0;
let tasksPending = 0;
for (const task of feature.planSpec.tasks) {
if (task.status === 'in_progress') {
task.status = 'completed';
tasksFinalized++;
} else if (task.status === 'pending') {
tasksPending++;
}
}
if (tasksFinalized > 0) {
logger.info(
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to verified`
);
}
if (tasksPending > 0) {
logger.warn(
`[updateFeatureStatus] Feature ${featureId} moving to verified with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
);
}
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
(t) => t.status === 'completed'
).length;
feature.planSpec.currentTaskId = undefined;
}
// Clear the timestamp when moving to other statuses
feature.justFinishedAt = undefined;
} else {
// Clear the timestamp when moving to other statuses
feature.justFinishedAt = undefined;
// Finalize in-progress tasks when reaching terminal states (waiting_approval or verified)
if (status === 'waiting_approval' || status === 'verified') {
this.finalizeInProgressTasks(feature, featureId, status);
}
// PERSIST BEFORE EMIT (Pitfall 2)
@@ -191,19 +182,21 @@ export class FeatureStateManager {
// Wrapped in try-catch so failures don't block syncFeatureToAppSpec below
try {
const notificationService = getNotificationService();
const displayName = this.getFeatureDisplayName(feature, featureId);
if (status === 'waiting_approval') {
await notificationService.createNotification({
type: 'feature_waiting_approval',
title: 'Feature Ready for Review',
message: `"${feature.name || featureId}" is ready for your review and approval.`,
type: NOTIFICATION_TYPE_WAITING_APPROVAL,
title: displayName,
message: NOTIFICATION_TITLE_WAITING_APPROVAL,
featureId,
projectPath,
});
} else if (status === 'verified') {
await notificationService.createNotification({
type: 'feature_verified',
title: 'Feature Verified',
message: `"${feature.name || featureId}" has been verified and is complete.`,
type: NOTIFICATION_TYPE_VERIFIED,
title: displayName,
message: NOTIFICATION_TITLE_VERIFIED,
featureId,
projectPath,
});
@@ -252,7 +245,7 @@ export class FeatureStateManager {
const currentStatus = feature?.status;
// Preserve pipeline_* statuses so resumePipelineFeature can resume from the correct step
if (currentStatus && currentStatus.startsWith('pipeline_')) {
if (isPipelineStatus(currentStatus)) {
logger.info(
`Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume`
);
@@ -270,7 +263,8 @@ export class FeatureStateManager {
/**
* Shared helper that scans features in a project directory and resets any stuck
* in transient states (in_progress, interrupted, pipeline_*) back to resting states.
* in transient states (in_progress, interrupted) back to resting states.
* Pipeline_* statuses are preserved so they can be resumed.
*
* Also resets:
* - generating planSpec status back to pending
@@ -324,10 +318,7 @@ export class FeatureStateManager {
// Reset features in active execution states back to a resting state
// After a server restart, no processes are actually running
const isActiveState =
originalStatus === 'in_progress' ||
originalStatus === 'interrupted' ||
(originalStatus != null && originalStatus.startsWith('pipeline_'));
const isActiveState = originalStatus === 'in_progress' || originalStatus === 'interrupted';
if (isActiveState) {
const hasApprovedPlan = feature.planSpec?.status === 'approved';
@@ -338,6 +329,17 @@ export class FeatureStateManager {
);
}
// Handle pipeline_* statuses separately: preserve them so they can be resumed
// but still count them as needing attention if they were stuck.
if (isPipelineStatus(originalStatus)) {
// We don't change the status, but we still want to reset planSpec/task states
// if they were stuck in transient generation/execution modes.
// No feature.status change here.
logger.debug(
`[${callerLabel}] Preserving pipeline status for feature ${feature.id}: ${originalStatus}`
);
}
// Reset generating planSpec status back to pending (spec generation was interrupted)
if (feature.planSpec?.status === 'generating') {
feature.planSpec.status = 'pending';
@@ -396,10 +398,12 @@ export class FeatureStateManager {
* Resets:
* - in_progress features back to ready (if has plan) or backlog (if no plan)
* - interrupted features back to ready (if has plan) or backlog (if no plan)
* - pipeline_* features back to ready (if has plan) or backlog (if no plan)
* - generating planSpec status back to pending
* - in_progress tasks back to pending
*
* Preserves:
* - pipeline_* statuses (so resumePipelineFeature can resume from correct step)
*
* @param projectPath - The project path to reset features for
*/
async resetStuckFeatures(projectPath: string): Promise<void> {
@@ -530,6 +534,10 @@ export class FeatureStateManager {
* This is called after agent execution completes to save a summary
* extracted from the agent's output using <summary> tags.
*
* For pipeline features (status starts with pipeline_), summaries are accumulated
* across steps with a header identifying each step. For non-pipeline features,
* the summary is replaced entirely.
*
* @param projectPath - The project path
* @param featureId - The feature ID
* @param summary - The summary text to save
@@ -537,6 +545,7 @@ export class FeatureStateManager {
async saveFeatureSummary(projectPath: string, featureId: string, summary: string): Promise<void> {
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json');
const normalizedSummary = summary.trim();
try {
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
@@ -552,7 +561,63 @@ export class FeatureStateManager {
return;
}
feature.summary = summary;
if (!normalizedSummary) {
logger.debug(
`[saveFeatureSummary] Skipping empty summary for feature ${featureId} (status="${feature.status}")`
);
return;
}
// For pipeline features, accumulate summaries across steps
if (isPipelineStatus(feature.status)) {
// If we already have a non-phase summary (typically the initial implementation
// summary from in_progress), normalize it into a named phase before appending
// pipeline step summaries. This keeps the format consistent for UI phase parsing.
const implementationHeader = '### Implementation';
if (feature.summary && !feature.summary.trimStart().startsWith('### ')) {
feature.summary = `${implementationHeader}\n\n${feature.summary.trim()}`;
}
const stepName = await this.getPipelineStepName(projectPath, feature.status);
const stepHeader = `### ${stepName}`;
const stepSection = `${stepHeader}\n\n${normalizedSummary}`;
if (feature.summary) {
// Check if this step already exists in the summary (e.g., if retried)
// Use section splitting to only match real section boundaries, not text in body content
const separator = '\n\n---\n\n';
const sections = feature.summary.split(separator);
let replaced = false;
const updatedSections = sections.map((section) => {
if (section.startsWith(`${stepHeader}\n\n`)) {
replaced = true;
return stepSection;
}
return section;
});
if (replaced) {
feature.summary = updatedSections.join(separator);
logger.info(
`[saveFeatureSummary] Updated existing pipeline step summary for feature ${featureId}: step="${stepName}"`
);
} else {
// Append as a new section
feature.summary = `${feature.summary}${separator}${stepSection}`;
logger.info(
`[saveFeatureSummary] Appended new pipeline step summary for feature ${featureId}: step="${stepName}"`
);
}
} else {
feature.summary = stepSection;
logger.info(
`[saveFeatureSummary] Initialized pipeline summary for feature ${featureId}: step="${stepName}"`
);
}
} else {
feature.summary = normalizedSummary;
}
feature.updatedAt = new Date().toISOString();
// PERSIST BEFORE EMIT
@@ -562,13 +627,42 @@ export class FeatureStateManager {
this.emitAutoModeEvent('auto_mode_summary', {
featureId,
projectPath,
summary,
summary: feature.summary,
});
} catch (error) {
logger.error(`Failed to save summary for ${featureId}:`, error);
}
}
/**
* Look up the pipeline step name from the current pipeline status.
*
* @param projectPath - The project path
* @param status - The current pipeline status (e.g., 'pipeline_abc123')
* @returns The step name, or a fallback based on the step ID
*/
private async getPipelineStepName(projectPath: string, status: string): Promise<string> {
try {
const stepId = pipelineService.getStepIdFromStatus(status as FeatureStatusWithPipeline);
if (stepId) {
const step = await pipelineService.getStep(projectPath, stepId);
if (step) return step.name;
}
} catch (error) {
logger.debug(
`[getPipelineStepName] Failed to look up step name for status "${status}", using fallback:`,
error
);
}
// Fallback: derive a human-readable name from the status suffix
// e.g., 'pipeline_code_review' → 'Code Review'
const suffix = status.replace('pipeline_', '');
return suffix
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Update the status of a specific task within planSpec.tasks
*
@@ -581,7 +675,8 @@ export class FeatureStateManager {
projectPath: string,
featureId: string,
taskId: string,
status: ParsedTask['status']
status: ParsedTask['status'],
summary?: string
): Promise<void> {
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json');
@@ -604,6 +699,9 @@ export class FeatureStateManager {
const task = feature.planSpec.tasks.find((t) => t.id === taskId);
if (task) {
task.status = status;
if (summary) {
task.summary = summary;
}
feature.updatedAt = new Date().toISOString();
// PERSIST BEFORE EMIT
@@ -615,6 +713,7 @@ export class FeatureStateManager {
projectPath,
taskId,
status,
summary,
tasks: feature.planSpec.tasks,
});
} else {
@@ -628,6 +727,137 @@ export class FeatureStateManager {
}
}
/**
* Get the display name for a feature, preferring title over feature ID.
* Empty string titles are treated as missing and fallback to featureId.
*
* @param feature - The feature to get the display name for
* @param featureId - The feature ID to use as fallback
* @returns The display name (title or feature ID)
*/
private getFeatureDisplayName(feature: Feature, featureId: string): string {
// Use title if it's a non-empty string, otherwise fallback to featureId
return feature.title && feature.title.trim() ? feature.title : featureId;
}
/**
* Handle auto-mode events to create error notifications.
* This listens for error events and creates notifications to alert users.
*/
private async handleAutoModeEventError(payload: AutoModeEventPayload): Promise<void> {
if (!payload.type) return;
// Only handle error events
if (payload.type !== 'auto_mode_error' && payload.type !== 'auto_mode_feature_complete') {
return;
}
// For auto_mode_feature_complete, only notify on failures (passes === false)
if (payload.type === 'auto_mode_feature_complete' && payload.passes !== false) {
return;
}
// Get project path - handle different event formats
const projectPath = payload.projectPath;
if (!projectPath) return;
try {
const notificationService = getNotificationService();
// Determine notification type and title based on event type
// Only auto_mode_feature_complete events should create feature_error notifications
const isFeatureError = payload.type === 'auto_mode_feature_complete';
const notificationType = isFeatureError
? NOTIFICATION_TYPE_FEATURE_ERROR
: NOTIFICATION_TYPE_AUTO_MODE_ERROR;
const notificationTitle = isFeatureError
? NOTIFICATION_TITLE_FEATURE_ERROR
: NOTIFICATION_TITLE_AUTO_MODE_ERROR;
// Build error message
let errorMessage = payload.message || 'An error occurred';
if (payload.error) {
errorMessage = payload.error;
}
// Use feature title as notification title when available, fall back to gesture name
let title = notificationTitle;
if (payload.featureId) {
const displayName = await this.getFeatureDisplayNameById(projectPath, payload.featureId);
if (displayName) {
title = displayName;
errorMessage = `${notificationTitle}: ${errorMessage}`;
}
}
await notificationService.createNotification({
type: notificationType,
title,
message: errorMessage,
featureId: payload.featureId,
projectPath,
});
} catch (notificationError) {
logger.warn(`Failed to create error notification:`, notificationError);
}
}
/**
* Get feature display name by loading the feature directly.
*/
private async getFeatureDisplayNameById(
projectPath: string,
featureId: string
): Promise<string | null> {
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) return null;
return this.getFeatureDisplayName(feature, featureId);
}
/**
* Finalize in-progress tasks when a feature reaches a terminal state.
* Marks in_progress tasks as completed but leaves pending tasks untouched.
*
* @param feature - The feature whose tasks should be finalized
* @param featureId - The feature ID for logging
* @param targetStatus - The status the feature is transitioning to
*/
private finalizeInProgressTasks(feature: Feature, featureId: string, targetStatus: string): void {
if (!feature.planSpec?.tasks) {
return;
}
let tasksFinalized = 0;
let tasksPending = 0;
for (const task of feature.planSpec.tasks) {
if (task.status === 'in_progress') {
task.status = 'completed';
tasksFinalized++;
} else if (task.status === 'pending') {
tasksPending++;
}
}
// Update tasksCompleted count to reflect actual completed tasks
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
(t) => t.status === 'completed'
).length;
feature.planSpec.currentTaskId = undefined;
if (tasksFinalized > 0) {
logger.info(
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to ${targetStatus}`
);
}
if (tasksPending > 0) {
logger.warn(
`[updateFeatureStatus] Feature ${featureId} moving to ${targetStatus} with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
);
}
}
/**
* Emit an auto-mode event via the event emitter
*

View File

@@ -0,0 +1,282 @@
/**
* Ntfy Service - Sends push notifications via ntfy.sh
*
* Provides integration with ntfy.sh for push notifications.
* Supports custom servers, authentication, tags, emojis, and click actions.
*
* @see https://docs.ntfy.sh/publish/
*/
import { createLogger } from '@automaker/utils';
import type { NtfyEndpointConfig, EventHookContext } from '@automaker/types';
const logger = createLogger('Ntfy');
/** Default timeout for ntfy HTTP requests (10 seconds) */
const DEFAULT_NTFY_TIMEOUT = 10000;
// Re-export EventHookContext as NtfyContext for backward compatibility
export type NtfyContext = EventHookContext;
/**
* Ntfy Service
*
* Handles sending notifications to ntfy.sh endpoints.
*/
export class NtfyService {
/**
* Send a notification to a ntfy.sh endpoint
*
* @param endpoint The ntfy.sh endpoint configuration
* @param options Notification options (title, body, tags, etc.)
* @param context Context for variable substitution
*/
async sendNotification(
endpoint: NtfyEndpointConfig,
options: {
title?: string;
body?: string;
tags?: string;
emoji?: string;
clickUrl?: string;
priority?: 1 | 2 | 3 | 4 | 5;
},
context: NtfyContext
): Promise<{ success: boolean; error?: string }> {
if (!endpoint.enabled) {
logger.warn(`Ntfy endpoint "${endpoint.name}" is disabled, skipping notification`);
return { success: false, error: 'Endpoint is disabled' };
}
// Validate endpoint configuration
const validationError = this.validateEndpoint(endpoint);
if (validationError) {
logger.error(`Invalid ntfy endpoint configuration: ${validationError}`);
return { success: false, error: validationError };
}
// Build URL
const serverUrl = endpoint.serverUrl.replace(/\/$/, ''); // Remove trailing slash
const url = `${serverUrl}/${encodeURIComponent(endpoint.topic)}`;
// Build headers
const headers: Record<string, string> = {
'Content-Type': 'text/plain; charset=utf-8',
};
// Title (with variable substitution)
const title = this.substituteVariables(options.title || this.getDefaultTitle(context), context);
if (title) {
headers['Title'] = title;
}
// Priority
const priority = options.priority || 3;
headers['Priority'] = String(priority);
// Tags and emoji
const tags = this.buildTags(
options.tags || endpoint.defaultTags,
options.emoji || endpoint.defaultEmoji
);
if (tags) {
headers['Tags'] = tags;
}
// Click action URL
const clickUrl = this.substituteVariables(
options.clickUrl || endpoint.defaultClickUrl || '',
context
);
if (clickUrl) {
headers['Click'] = clickUrl;
}
// Authentication
this.addAuthHeaders(headers, endpoint);
// Message body (with variable substitution)
const body = this.substituteVariables(options.body || this.getDefaultBody(context), context);
logger.info(`Sending ntfy notification to ${endpoint.name}: ${title}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_NTFY_TIMEOUT);
try {
const response = await fetch(url, {
method: 'POST',
headers,
body,
signal: controller.signal,
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
logger.error(`Ntfy notification failed with status ${response.status}: ${errorText}`);
return {
success: false,
error: `HTTP ${response.status}: ${errorText}`,
};
}
logger.info(`Ntfy notification sent successfully to ${endpoint.name}`);
return { success: true };
} catch (error) {
if ((error as Error).name === 'AbortError') {
logger.error(`Ntfy notification timed out after ${DEFAULT_NTFY_TIMEOUT}ms`);
return { success: false, error: 'Request timed out' };
}
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Ntfy notification failed: ${errorMessage}`);
return { success: false, error: errorMessage };
} finally {
clearTimeout(timeoutId);
}
}
/**
* Validate an ntfy endpoint configuration
*/
validateEndpoint(endpoint: NtfyEndpointConfig): string | null {
// Validate server URL
if (!endpoint.serverUrl) {
return 'Server URL is required';
}
try {
new URL(endpoint.serverUrl);
} catch {
return 'Invalid server URL format';
}
// Validate topic
if (!endpoint.topic) {
return 'Topic is required';
}
if (endpoint.topic.includes(' ') || endpoint.topic.includes('\t')) {
return 'Topic cannot contain spaces';
}
// Validate authentication
if (endpoint.authType === 'basic') {
if (!endpoint.username || !endpoint.password) {
return 'Username and password are required for basic authentication';
}
} else if (endpoint.authType === 'token') {
if (!endpoint.token) {
return 'Access token is required for token authentication';
}
}
return null;
}
/**
* Build tags string from tags and emoji
*/
private buildTags(tags?: string, emoji?: string): string {
const tagList: string[] = [];
if (tags) {
// Split by comma and trim whitespace
const parsedTags = tags
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
tagList.push(...parsedTags);
}
if (emoji) {
// Add emoji as first tag if it looks like a shortcode
if (emoji.startsWith(':') && emoji.endsWith(':')) {
tagList.unshift(emoji.slice(1, -1));
} else if (!emoji.includes(' ')) {
// If it's a single emoji or shortcode without colons, add as-is
tagList.unshift(emoji);
}
}
return tagList.join(',');
}
/**
* Add authentication headers based on auth type
*/
private addAuthHeaders(headers: Record<string, string>, endpoint: NtfyEndpointConfig): void {
if (endpoint.authType === 'basic' && endpoint.username && endpoint.password) {
const credentials = Buffer.from(`${endpoint.username}:${endpoint.password}`).toString(
'base64'
);
headers['Authorization'] = `Basic ${credentials}`;
} else if (endpoint.authType === 'token' && endpoint.token) {
headers['Authorization'] = `Bearer ${endpoint.token}`;
}
}
/**
* Get default title based on event context
*/
private getDefaultTitle(context: NtfyContext): string {
const eventName = this.formatEventName(context.eventType);
if (context.featureName) {
return `${eventName}: ${context.featureName}`;
}
return eventName;
}
/**
* Get default body based on event context
*/
private getDefaultBody(context: NtfyContext): string {
const lines: string[] = [];
if (context.featureName) {
lines.push(`Feature: ${context.featureName}`);
}
if (context.featureId) {
lines.push(`ID: ${context.featureId}`);
}
if (context.projectName) {
lines.push(`Project: ${context.projectName}`);
}
if (context.error) {
lines.push(`Error: ${context.error}`);
}
lines.push(`Time: ${context.timestamp}`);
return lines.join('\n');
}
/**
* Format event type to human-readable name
*/
private formatEventName(eventType: string): string {
const eventNames: Record<string, string> = {
feature_created: 'Feature Created',
feature_success: 'Feature Completed',
feature_error: 'Feature Failed',
auto_mode_complete: 'Auto Mode Complete',
auto_mode_error: 'Auto Mode Error',
};
return eventNames[eventType] || eventType;
}
/**
* Substitute {{variable}} placeholders in a string
*/
private substituteVariables(template: string, context: NtfyContext): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
const value = context[variable as keyof NtfyContext];
if (value === undefined || value === null) {
return '';
}
return String(value);
});
}
}
// Singleton instance
export const ntfyService = new NtfyService();

View File

@@ -115,6 +115,7 @@ export class PipelineOrchestrator {
projectPath,
});
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
const currentStatus = `pipeline_${step.id}`;
await this.runAgentFn(
workDir,
featureId,
@@ -133,6 +134,8 @@ export class PipelineOrchestrator {
useClaudeCodeSystemPrompt,
thinkingLevel: feature.thinkingLevel,
reasoningEffort: feature.reasoningEffort,
status: currentStatus,
providerId: feature.providerId,
}
);
try {
@@ -165,7 +168,18 @@ export class PipelineOrchestrator {
if (previousContext) prompt += `### Previous Work\n${previousContext}\n\n`;
return (
prompt +
`### Pipeline Step Instructions\n${step.instructions}\n\n### Task\nComplete the pipeline step instructions above.`
`### Pipeline Step Instructions\n${step.instructions}\n\n### Task\nComplete the pipeline step instructions above.\n\n` +
`**CRITICAL: After completing the instructions, you MUST output a summary using this EXACT format:**\n\n` +
`<summary>\n` +
`## Summary: ${step.name}\n\n` +
`### Changes Implemented\n` +
`- [List all changes made in this step]\n\n` +
`### Files Modified\n` +
`- [List all files modified in this step]\n\n` +
`### Outcome\n` +
`- [Describe the result of this step]\n` +
`</summary>\n\n` +
`The <summary> and </summary> tags MUST be on their own lines. This is REQUIRED.`
);
}
@@ -336,6 +350,7 @@ export class PipelineOrchestrator {
});
const abortController = runningEntry.abortController;
runningEntry.branchName = feature.branchName ?? null;
let pipelineCompleted = false;
try {
validateWorkingDirectory(projectPath);
@@ -389,6 +404,7 @@ export class PipelineOrchestrator {
};
await this.executePipeline(context);
pipelineCompleted = true;
// Re-fetch feature to check if executePipeline set a terminal status (e.g., merge_conflict)
const reloadedFeature = await this.featureStateManager.loadFeature(projectPath, featureId);
@@ -425,8 +441,21 @@ export class PipelineOrchestrator {
});
}
} else {
// If pipeline steps completed successfully, don't send the feature back to backlog.
// The pipeline work is done — set to waiting_approval so the user can review.
const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog';
if (pipelineCompleted) {
logger.info(
`[resumeFromStep] Feature ${featureId} failed after pipeline completed. ` +
`Setting status to waiting_approval instead of backlog to preserve pipeline work.`
);
}
logger.error(`Pipeline resume failed for ${featureId}:`, error);
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
// Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution
const currentFeature = await this.featureStateManager.loadFeature(projectPath, featureId);
if (currentFeature?.status !== 'merge_conflict') {
await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus);
}
this.eventBus.emitAutoModeEvent('auto_mode_error', {
featureId,
featureName: feature.title,
@@ -490,7 +519,10 @@ export class PipelineOrchestrator {
requirePlanApproval: false,
useClaudeCodeSystemPrompt: context.useClaudeCodeSystemPrompt,
autoLoadClaudeMd: context.autoLoadClaudeMd,
thinkingLevel: context.feature.thinkingLevel,
reasoningEffort: context.feature.reasoningEffort,
status: context.feature.status,
providerId: context.feature.providerId,
}
);
}

View File

@@ -28,6 +28,8 @@ const logger = createLogger('PullService');
export interface PullOptions {
/** Remote name to pull from (defaults to 'origin') */
remote?: string;
/** Specific remote branch to pull (e.g. 'main'). When provided, overrides the tracking branch and fetches this branch from the remote. */
remoteBranch?: string;
/** When true, automatically stash local changes before pulling and reapply after */
stashIfNeeded?: boolean;
}
@@ -243,6 +245,7 @@ export async function performPull(
): Promise<PullResult> {
const targetRemote = options?.remote || 'origin';
const stashIfNeeded = options?.stashIfNeeded ?? false;
const targetRemoteBranch = options?.remoteBranch;
// 1. Get current branch name
let branchName: string;
@@ -313,24 +316,34 @@ export async function performPull(
}
// 7. Verify upstream tracking or remote branch exists
const upstreamStatus = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote);
if (upstreamStatus === 'none') {
let stashRecoveryFailed = false;
if (didStash) {
const stashPopped = await tryPopStash(worktreePath);
stashRecoveryFailed = !stashPopped;
// Skip this check when a specific remote branch is provided - we always use
// explicit 'git pull <remote> <branch>' args in that case.
let upstreamStatus: UpstreamStatus = 'tracking';
if (!targetRemoteBranch) {
upstreamStatus = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote);
if (upstreamStatus === 'none') {
let stashRecoveryFailed = false;
if (didStash) {
const stashPopped = await tryPopStash(worktreePath);
stashRecoveryFailed = !stashPopped;
}
return {
success: false,
error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`,
stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined,
};
}
return {
success: false,
error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`,
stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined,
};
}
// 8. Pull latest changes
// When a specific remote branch is requested, always use explicit remote + branch args.
// When the branch has a configured upstream tracking ref, let Git use it automatically.
// When only the remote branch exists (no tracking ref), explicitly specify remote and branch.
const pullArgs = upstreamStatus === 'tracking' ? ['pull'] : ['pull', targetRemote, branchName];
const pullArgs = targetRemoteBranch
? ['pull', targetRemote, targetRemoteBranch]
: upstreamStatus === 'tracking'
? ['pull']
: ['pull', targetRemote, branchName];
let pullConflict = false;
let pullConflictFiles: string[] = [];

View File

@@ -618,6 +618,36 @@ export class SettingsService {
ignoreEmptyArrayOverwrite('eventHooks');
}
// Guard ntfyEndpoints against accidental wipe
// (similar to eventHooks, these are user-configured and shouldn't be lost)
// Check for explicit permission to clear ntfyEndpoints (escape hatch for intentional clearing)
const allowEmptyNtfyEndpoints =
(sanitizedUpdates as Record<string, unknown>).__allowEmptyNtfyEndpoints === true;
// Remove the flag so it doesn't get persisted
delete (sanitizedUpdates as Record<string, unknown>).__allowEmptyNtfyEndpoints;
if (!allowEmptyNtfyEndpoints) {
const currentNtfyLen = Array.isArray(current.ntfyEndpoints)
? current.ntfyEndpoints.length
: 0;
const newNtfyLen = Array.isArray(sanitizedUpdates.ntfyEndpoints)
? sanitizedUpdates.ntfyEndpoints.length
: currentNtfyLen;
if (Array.isArray(sanitizedUpdates.ntfyEndpoints) && newNtfyLen === 0 && currentNtfyLen > 0) {
logger.warn(
'[WIPE_PROTECTION] Attempted to set ntfyEndpoints to empty array! Ignoring update.',
{
currentNtfyLen,
newNtfyLen,
}
);
delete sanitizedUpdates.ntfyEndpoints;
}
} else {
logger.info('[INTENTIONAL_CLEAR] Clearing ntfyEndpoints via escape hatch');
}
// Empty object overwrite guard
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
const nextVal = sanitizedUpdates[key] as unknown;
@@ -1023,6 +1053,8 @@ export class SettingsService {
keyboardShortcuts:
(appState.keyboardShortcuts as KeyboardShortcuts) ||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
eventHooks: (appState.eventHooks as GlobalSettings['eventHooks']) || [],
ntfyEndpoints: (appState.ntfyEndpoints as GlobalSettings['ntfyEndpoints']) || [],
projects: (appState.projects as ProjectRef[]) || [],
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
projectHistory: (appState.projectHistory as string[]) || [],

View File

@@ -101,12 +101,32 @@ export function detectTaskStartMarker(text: string): string | null {
}
/**
* Detect [TASK_COMPLETE] marker in text and extract task ID
* Detect [TASK_COMPLETE] marker in text and extract task ID and summary
* Format: [TASK_COMPLETE] T###: Brief summary
*/
export function detectTaskCompleteMarker(text: string): string | null {
const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})/);
return match ? match[1] : null;
export function detectTaskCompleteMarker(text: string): { id: string; summary?: string } | null {
// Use a regex that captures the summary until newline or next task marker
// Allow brackets in summary content (e.g., "supports array[index] access")
// Pattern breakdown:
// - \[TASK_COMPLETE\]\s* - Match the marker
// - (T\d{3}) - Capture task ID
// - (?::\s*([^\n\[]+))? - Optionally capture summary (stops at newline or bracket)
// - But we want to allow brackets in summary, so we use a different approach:
// - Match summary until newline, then trim any trailing markers in post-processing
const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})(?::\s*(.+?))?(?=\n|$)/i);
if (!match) return null;
// Post-process: remove trailing task markers from summary if present
let summary = match[2]?.trim();
if (summary) {
// Remove trailing content that looks like another marker
summary = summary.replace(/\s*\[TASK_[A-Z_]+\].*$/i, '').trim();
}
return {
id: match[1],
summary: summary || undefined,
};
}
/**
@@ -194,10 +214,14 @@ export function extractSummary(text: string): string | null {
}
// Check for ## Summary section (use last match)
const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n##|\n\*\*|$)/gi);
// Stop at \n## [^#] (same-level headers like "## Changes") but preserve ### subsections
// (like "### Root Cause", "### Fix Applied") that belong to the summary content.
const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n## [^#]|$)/gi);
const sectionMatch = getLastMatch(sectionMatches);
if (sectionMatch) {
return truncate(sectionMatch[1].trim(), 500);
const content = sectionMatch[1].trim();
// Keep full content (including ### subsections) up to max length
return content.length > 500 ? `${content.substring(0, 500)}...` : content;
}
// Check for **Goal**: section (lite mode, use last match)

View File

@@ -39,6 +39,18 @@ export interface WorktreeInfo {
* 3. Listing all worktrees with normalized paths
*/
export class WorktreeResolver {
private normalizeBranchName(branchName: string | null | undefined): string | null {
if (!branchName) return null;
let normalized = branchName.trim();
if (!normalized) return null;
normalized = normalized.replace(/^refs\/heads\//, '');
normalized = normalized.replace(/^refs\/remotes\/[^/]+\//, '');
normalized = normalized.replace(/^(origin|upstream)\//, '');
return normalized || null;
}
/**
* Get the current branch name for a git repository
*
@@ -64,6 +76,9 @@ export class WorktreeResolver {
*/
async findWorktreeForBranch(projectPath: string, branchName: string): Promise<string | null> {
try {
const normalizedTargetBranch = this.normalizeBranchName(branchName);
if (!normalizedTargetBranch) return null;
const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: projectPath,
});
@@ -76,10 +91,10 @@ export class WorktreeResolver {
if (line.startsWith('worktree ')) {
currentPath = line.slice(9);
} else if (line.startsWith('branch ')) {
currentBranch = line.slice(7).replace('refs/heads/', '');
currentBranch = this.normalizeBranchName(line.slice(7));
} else if (line === '' && currentPath && currentBranch) {
// End of a worktree entry
if (currentBranch === branchName) {
if (currentBranch === normalizedTargetBranch) {
// Resolve to absolute path - git may return relative paths
// On Windows, this is critical for cwd to work correctly
// On all platforms, absolute paths ensure consistent behavior
@@ -91,7 +106,7 @@ export class WorktreeResolver {
}
// Check the last entry (if file doesn't end with newline)
if (currentPath && currentBranch && currentBranch === branchName) {
if (currentPath && currentBranch && currentBranch === normalizedTargetBranch) {
return this.resolvePath(projectPath, currentPath);
}
@@ -123,7 +138,7 @@ export class WorktreeResolver {
if (line.startsWith('worktree ')) {
currentPath = line.slice(9);
} else if (line.startsWith('branch ')) {
currentBranch = line.slice(7).replace('refs/heads/', '');
currentBranch = this.normalizeBranchName(line.slice(7));
} else if (line.startsWith('detached')) {
// Detached HEAD - branch is null
currentBranch = null;

View File

@@ -0,0 +1,333 @@
import { describe, it, expect } from 'vitest';
import {
computeIsDirty,
updateTabWithContent as updateTabContent,
markTabAsSaved as markTabSaved,
} from '../../../../ui/src/components/views/file-editor-view/file-editor-dirty-utils.ts';
/**
* Unit tests for the file editor store logic, focusing on the unsaved indicator fix.
*
* The bug was: File unsaved indicators weren't working reliably - editing a file
* and saving it would sometimes leave the dirty indicator (dot) visible.
*
* Root causes:
* 1. Stale closure in handleSave - captured activeTab could have old content
* 2. Editor buffer not synced - CodeMirror might have buffered changes not yet in store
*
* Fix:
* - handleSave now gets fresh state from store using getState()
* - handleSave gets current content from editor via getValue()
* - Content is synced to store before saving if it differs
*
* Since we can't easily test the React/zustand store in node environment,
* we test the pure logic that the store uses for dirty state tracking.
*/
describe('File editor dirty state logic', () => {
describe('updateTabContent', () => {
it('should set isDirty to true when content differs from originalContent', () => {
const tab = {
content: 'original content',
originalContent: 'original content',
isDirty: false,
};
const updated = updateTabContent(tab, 'modified content');
expect(updated.isDirty).toBe(true);
expect(updated.content).toBe('modified content');
expect(updated.originalContent).toBe('original content');
});
it('should set isDirty to false when content matches originalContent', () => {
const tab = {
content: 'original content',
originalContent: 'original content',
isDirty: false,
};
// First modify it
let updated = updateTabContent(tab, 'modified content');
expect(updated.isDirty).toBe(true);
// Now update back to original
updated = updateTabContent(updated, 'original content');
expect(updated.isDirty).toBe(false);
});
it('should handle empty content correctly', () => {
const tab = {
content: '',
originalContent: '',
isDirty: false,
};
const updated = updateTabContent(tab, 'new content');
expect(updated.isDirty).toBe(true);
});
});
describe('markTabSaved', () => {
it('should set isDirty to false and update both content and originalContent', () => {
const tab = {
content: 'original content',
originalContent: 'original content',
isDirty: false,
};
// First modify
let updated = updateTabContent(tab, 'modified content');
expect(updated.isDirty).toBe(true);
// Then save
updated = markTabSaved(updated, 'modified content');
expect(updated.isDirty).toBe(false);
expect(updated.content).toBe('modified content');
expect(updated.originalContent).toBe('modified content');
});
it('should correctly clear dirty state when save is triggered after edit', () => {
// This test simulates the bug scenario:
// 1. User edits file -> isDirty = true
// 2. User saves -> markTabSaved should set isDirty = false
let tab = {
content: 'initial',
originalContent: 'initial',
isDirty: false,
};
// Simulate user editing
tab = updateTabContent(tab, 'initial\nnew line');
// Should be dirty
expect(tab.isDirty).toBe(true);
// Simulate save (with the content that was saved)
tab = markTabSaved(tab, 'initial\nnew line');
// Should NOT be dirty anymore
expect(tab.isDirty).toBe(false);
});
});
describe('race condition handling', () => {
it('should correctly handle updateTabContent after markTabSaved with same content', () => {
// This tests the scenario where:
// 1. CodeMirror has a pending onChange with content "B"
// 2. User presses save when editor shows "B"
// 3. markTabSaved is called with "B"
// 4. CodeMirror's pending onChange fires with "B" (same content)
// Result: isDirty should remain false
let tab = {
content: 'A',
originalContent: 'A',
isDirty: false,
};
// User edits to "B"
tab = updateTabContent(tab, 'B');
// Save with "B"
tab = markTabSaved(tab, 'B');
// Late onChange with same content "B"
tab = updateTabContent(tab, 'B');
expect(tab.isDirty).toBe(false);
expect(tab.content).toBe('B');
});
it('should correctly handle updateTabContent after markTabSaved with different content', () => {
// This tests the scenario where:
// 1. CodeMirror has a pending onChange with content "C"
// 2. User presses save when store has "B"
// 3. markTabSaved is called with "B"
// 4. CodeMirror's pending onChange fires with "C" (different content)
// Result: isDirty should be true (file changed after save)
let tab = {
content: 'A',
originalContent: 'A',
isDirty: false,
};
// User edits to "B"
tab = updateTabContent(tab, 'B');
// Save with "B"
tab = markTabSaved(tab, 'B');
// Late onChange with different content "C"
tab = updateTabContent(tab, 'C');
// File changed after save, so it should be dirty
expect(tab.isDirty).toBe(true);
expect(tab.content).toBe('C');
expect(tab.originalContent).toBe('B');
});
it('should handle rapid edit-save-edit cycle correctly', () => {
// Simulate rapid user actions
let tab = {
content: 'v1',
originalContent: 'v1',
isDirty: false,
};
// Edit 1
tab = updateTabContent(tab, 'v2');
expect(tab.isDirty).toBe(true);
// Save 1
tab = markTabSaved(tab, 'v2');
expect(tab.isDirty).toBe(false);
// Edit 2
tab = updateTabContent(tab, 'v3');
expect(tab.isDirty).toBe(true);
// Save 2
tab = markTabSaved(tab, 'v3');
expect(tab.isDirty).toBe(false);
// Edit 3 (back to v2)
tab = updateTabContent(tab, 'v2');
expect(tab.isDirty).toBe(true);
// Save 3
tab = markTabSaved(tab, 'v2');
expect(tab.isDirty).toBe(false);
});
});
describe('handleSave stale closure fix simulation', () => {
it('demonstrates the fix: using fresh content instead of closure content', () => {
// This test demonstrates why the fix was necessary.
// The old handleSave captured activeTab in closure, which could be stale.
// The fix gets fresh state from getState() and uses editor.getValue().
// Simulate store state
let storeState = {
tabs: [
{
id: 'tab-1',
content: 'A',
originalContent: 'A',
isDirty: false,
},
],
activeTabId: 'tab-1',
};
// Simulate a "stale closure" capturing the tab state
const staleClosureTab = storeState.tabs[0];
// User edits - store state updates
storeState = {
...storeState,
tabs: [
{
id: 'tab-1',
content: 'B',
originalContent: 'A',
isDirty: true,
},
],
};
// OLD BUG: Using stale closure tab would save "A" (old content)
const oldBugSavedContent = staleClosureTab!.content;
expect(oldBugSavedContent).toBe('A'); // Wrong! Should be "B"
// FIX: Using fresh state from getState() gets correct content
const freshTab = storeState.tabs[0];
const fixedSavedContent = freshTab!.content;
expect(fixedSavedContent).toBe('B'); // Correct!
});
it('demonstrates syncing editor content before save', () => {
// This test demonstrates why we need to get content from editor directly.
// The store might have stale content if onChange hasn't fired yet.
// Simulate store state (has old content because onChange hasn't fired)
let storeContent = 'A';
// Editor has newer content (not yet synced to store)
const editorContent = 'B';
// FIX: Use editor content if available, fall back to store content
const contentToSave = editorContent ?? storeContent;
expect(contentToSave).toBe('B'); // Correctly saves editor content
// Simulate syncing to store before save
if (editorContent !== null && editorContent !== storeContent) {
storeContent = editorContent;
}
// Now store is synced
expect(storeContent).toBe('B');
// After save, markTabSaved would set originalContent = savedContent
// and isDirty = false (if no more changes come in)
});
});
describe('edge cases', () => {
it('should handle whitespace-only changes as dirty', () => {
let tab = {
content: 'hello',
originalContent: 'hello',
isDirty: false,
};
tab = updateTabContent(tab, 'hello ');
expect(tab.isDirty).toBe(true);
});
it('should treat CRLF and LF line endings as equivalent (not dirty)', () => {
let tab = {
content: 'line1\nline2',
originalContent: 'line1\nline2',
isDirty: false,
};
// CodeMirror normalizes \r\n to \n internally, so content that only
// differs by line endings should NOT be considered dirty.
tab = updateTabContent(tab, 'line1\r\nline2');
expect(tab.isDirty).toBe(false);
});
it('should handle unicode content correctly', () => {
let tab = {
content: '你好世界',
originalContent: '你好世界',
isDirty: false,
};
tab = updateTabContent(tab, '你好宇宙');
expect(tab.isDirty).toBe(true);
tab = markTabSaved(tab, '你好宇宙');
expect(tab.isDirty).toBe(false);
});
it('should handle very large content efficiently', () => {
// Generate a large string (1MB)
const largeOriginal = 'x'.repeat(1024 * 1024);
const largeModified = largeOriginal + 'y';
let tab = {
content: largeOriginal,
originalContent: largeOriginal,
isDirty: false,
};
tab = updateTabContent(tab, largeModified);
expect(tab.isDirty).toBe(true);
});
});
});

View File

@@ -1,5 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getMCPServersFromSettings } from '@/lib/settings-helpers.js';
import {
getMCPServersFromSettings,
getProviderById,
getProviderByModelId,
resolveProviderContext,
getAllProviderModels,
} from '@/lib/settings-helpers.js';
import type { SettingsService } from '@/services/settings-service.js';
// Mock the logger
@@ -286,4 +292,691 @@ describe('settings-helpers.ts', () => {
});
});
});
describe('getProviderById', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return provider when found by ID', async () => {
const mockProvider = { id: 'zai-1', name: 'Zai', enabled: true };
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderById('zai-1', mockSettingsService);
expect(result.provider).toEqual(mockProvider);
});
it('should return undefined when provider not found', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderById('unknown', mockSettingsService);
expect(result.provider).toBeUndefined();
});
it('should return provider even if disabled (caller handles enabled state)', async () => {
const mockProvider = { id: 'disabled-1', name: 'Disabled', enabled: false };
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderById('disabled-1', mockSettingsService);
expect(result.provider).toEqual(mockProvider);
});
});
describe('getProviderByModelId', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return provider and modelConfig when found by model ID', async () => {
const mockModel = { id: 'custom-model-1', name: 'Custom Model' };
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [mockModel],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderByModelId('custom-model-1', mockSettingsService);
expect(result.provider).toEqual(mockProvider);
expect(result.modelConfig).toEqual(mockModel);
});
it('should resolve mapped Claude model when mapsToClaudeModel is present', async () => {
const mockModel = {
id: 'custom-model-1',
name: 'Custom Model',
mapsToClaudeModel: 'sonnet-3-5',
};
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [mockModel],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderByModelId('custom-model-1', mockSettingsService);
expect(result.resolvedModel).toBeDefined();
// resolveModelString('sonnet-3-5') usually returns 'claude-3-5-sonnet-20240620' or similar
});
it('should ignore disabled providers', async () => {
const mockModel = { id: 'custom-model-1', name: 'Custom Model' };
const mockProvider = {
id: 'disabled-1',
name: 'Disabled Provider',
enabled: false,
models: [mockModel],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderByModelId('custom-model-1', mockSettingsService);
expect(result.provider).toBeUndefined();
});
});
describe('resolveProviderContext', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should resolve provider by explicit providerId', async () => {
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [{ id: 'custom-model-1', name: 'Custom Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
} as unknown as SettingsService;
const result = await resolveProviderContext(
mockSettingsService,
'custom-model-1',
'provider-1'
);
expect(result.provider).toEqual(mockProvider);
expect(result.credentials).toEqual({ anthropicApiKey: 'test-key' });
});
it('should return undefined provider when explicit providerId not found', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(
mockSettingsService,
'some-model',
'unknown-provider'
);
expect(result.provider).toBeUndefined();
});
it('should fallback to model-based lookup when providerId not provided', async () => {
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [{ id: 'custom-model-1', name: 'Custom Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'custom-model-1');
expect(result.provider).toEqual(mockProvider);
expect(result.modelConfig?.id).toBe('custom-model-1');
});
it('should resolve mapsToClaudeModel to actual Claude model', async () => {
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [
{
id: 'custom-model-1',
name: 'Custom Model',
mapsToClaudeModel: 'sonnet',
},
],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'custom-model-1');
// resolveModelString('sonnet') should return a valid Claude model ID
expect(result.resolvedModel).toBeDefined();
expect(result.resolvedModel).toContain('claude');
});
it('should handle empty providers list', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'some-model');
expect(result.provider).toBeUndefined();
expect(result.resolvedModel).toBeUndefined();
expect(result.modelConfig).toBeUndefined();
});
it('should handle missing claudeCompatibleProviders field', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'some-model');
expect(result.provider).toBeUndefined();
});
it('should skip disabled providers during fallback lookup', async () => {
const disabledProvider = {
id: 'disabled-1',
name: 'Disabled Provider',
enabled: false,
models: [{ id: 'model-in-disabled', name: 'Model' }],
};
const enabledProvider = {
id: 'enabled-1',
name: 'Enabled Provider',
enabled: true,
models: [{ id: 'model-in-enabled', name: 'Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [disabledProvider, enabledProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
// Should skip the disabled provider and find the model in the enabled one
const result = await resolveProviderContext(mockSettingsService, 'model-in-enabled');
expect(result.provider?.id).toBe('enabled-1');
// Should not find model that only exists in disabled provider
const result2 = await resolveProviderContext(mockSettingsService, 'model-in-disabled');
expect(result2.provider).toBeUndefined();
});
it('should perform case-insensitive model ID matching', async () => {
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [{ id: 'Custom-Model-1', name: 'Custom Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'custom-model-1');
expect(result.provider).toEqual(mockProvider);
expect(result.modelConfig?.id).toBe('Custom-Model-1');
});
it('should return error result on exception', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'some-model');
expect(result.provider).toBeUndefined();
expect(result.credentials).toBeUndefined();
expect(result.resolvedModel).toBeUndefined();
expect(result.modelConfig).toBeUndefined();
});
it('should persist and load provider config from server settings', async () => {
// This test verifies the main bug fix: providers are loaded from server settings
const savedProvider = {
id: 'saved-provider-1',
name: 'Saved Provider',
enabled: true,
apiKeySource: 'credentials' as const,
models: [
{
id: 'saved-model-1',
name: 'Saved Model',
mapsToClaudeModel: 'sonnet',
},
],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [savedProvider],
}),
getCredentials: vi.fn().mockResolvedValue({
anthropicApiKey: 'saved-api-key',
}),
} as unknown as SettingsService;
// Simulate loading saved provider config
const result = await resolveProviderContext(
mockSettingsService,
'saved-model-1',
'saved-provider-1'
);
// Verify the provider is loaded from server settings
expect(result.provider).toEqual(savedProvider);
expect(result.provider?.id).toBe('saved-provider-1');
expect(result.provider?.models).toHaveLength(1);
expect(result.credentials?.anthropicApiKey).toBe('saved-api-key');
// Verify model mapping is resolved
expect(result.resolvedModel).toContain('claude');
});
it('should accept custom logPrefix parameter', async () => {
// Verify that the logPrefix parameter is accepted (used by facade.ts)
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [{ id: 'model-1', name: 'Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
// Call with custom logPrefix (as facade.ts does)
const result = await resolveProviderContext(
mockSettingsService,
'model-1',
undefined,
'[CustomPrefix]'
);
// Function should work the same with custom prefix
expect(result.provider).toEqual(mockProvider);
});
// Session restore scenarios - provider.enabled: undefined should be treated as enabled
describe('session restore scenarios (enabled: undefined)', () => {
it('should treat provider with enabled: undefined as enabled', async () => {
// This is the main bug fix: when providers are loaded from settings on session restore,
// enabled might be undefined (not explicitly set) and should be treated as enabled
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: undefined, // Not explicitly set - should be treated as enabled
models: [{ id: 'model-1', name: 'Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'model-1');
// Provider should be found and used even though enabled is undefined
expect(result.provider).toEqual(mockProvider);
expect(result.modelConfig?.id).toBe('model-1');
});
it('should use provider by ID when enabled is undefined', async () => {
// This tests the explicit providerId lookup with undefined enabled
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: undefined, // Not explicitly set - should be treated as enabled
models: [{ id: 'custom-model', name: 'Custom Model', mapsToClaudeModel: 'sonnet' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
} as unknown as SettingsService;
const result = await resolveProviderContext(
mockSettingsService,
'custom-model',
'provider-1'
);
// Provider should be found and used even though enabled is undefined
expect(result.provider).toEqual(mockProvider);
expect(result.credentials?.anthropicApiKey).toBe('test-key');
expect(result.resolvedModel).toContain('claude');
});
it('should find model via fallback in provider with enabled: undefined', async () => {
// Test fallback model lookup when provider has undefined enabled
const providerWithUndefinedEnabled = {
id: 'provider-1',
name: 'Provider 1',
// enabled is not set (undefined)
models: [{ id: 'model-1', name: 'Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [providerWithUndefinedEnabled],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'model-1');
expect(result.provider).toEqual(providerWithUndefinedEnabled);
expect(result.modelConfig?.id).toBe('model-1');
});
it('should still use provider for connection when model not found in its models array', async () => {
// This tests the fix: when providerId is explicitly set and provider is found,
// but the model isn't in that provider's models array, we still use that provider
// for connection settings (baseUrl, credentials)
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
baseUrl: 'https://custom-api.example.com',
models: [{ id: 'other-model', name: 'Other Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
} as unknown as SettingsService;
const result = await resolveProviderContext(
mockSettingsService,
'unknown-model', // Model not in provider's models array
'provider-1'
);
// Provider should still be returned for connection settings
expect(result.provider).toEqual(mockProvider);
// modelConfig should be undefined since the model wasn't found
expect(result.modelConfig).toBeUndefined();
// resolvedModel should be undefined since no mapping was found
expect(result.resolvedModel).toBeUndefined();
});
it('should fallback to find modelConfig in other providers when not in explicit providerId provider', async () => {
// When providerId is set and provider is found, but model isn't there,
// we should still search for modelConfig in other providers
const provider1 = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
baseUrl: 'https://provider1.example.com',
models: [{ id: 'provider1-model', name: 'Provider 1 Model' }],
};
const provider2 = {
id: 'provider-2',
name: 'Provider 2',
enabled: true,
baseUrl: 'https://provider2.example.com',
models: [
{
id: 'shared-model',
name: 'Shared Model',
mapsToClaudeModel: 'sonnet',
},
],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [provider1, provider2],
}),
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
} as unknown as SettingsService;
const result = await resolveProviderContext(
mockSettingsService,
'shared-model', // This model is in provider-2, not provider-1
'provider-1' // But we explicitly want to use provider-1
);
// Provider should still be provider-1 (for connection settings)
expect(result.provider).toEqual(provider1);
// But modelConfig should be found from provider-2
expect(result.modelConfig?.id).toBe('shared-model');
// And the model mapping should be resolved
expect(result.resolvedModel).toContain('claude');
});
it('should handle multiple providers with mixed enabled states', async () => {
// Test the full session restore scenario with multiple providers
const providers = [
{
id: 'provider-1',
name: 'First Provider',
enabled: undefined, // Undefined after restore
models: [{ id: 'model-a', name: 'Model A' }],
},
{
id: 'provider-2',
name: 'Second Provider',
// enabled field missing entirely
models: [{ id: 'model-b', name: 'Model B', mapsToClaudeModel: 'opus' }],
},
{
id: 'provider-3',
name: 'Disabled Provider',
enabled: false, // Explicitly disabled
models: [{ id: 'model-c', name: 'Model C' }],
},
];
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: providers,
}),
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
} as unknown as SettingsService;
// Provider 1 should work (enabled: undefined)
const result1 = await resolveProviderContext(mockSettingsService, 'model-a', 'provider-1');
expect(result1.provider?.id).toBe('provider-1');
expect(result1.modelConfig?.id).toBe('model-a');
// Provider 2 should work (enabled field missing)
const result2 = await resolveProviderContext(mockSettingsService, 'model-b', 'provider-2');
expect(result2.provider?.id).toBe('provider-2');
expect(result2.modelConfig?.id).toBe('model-b');
expect(result2.resolvedModel).toContain('claude');
// Provider 3 with explicit providerId IS returned even if disabled
// (caller handles enabled state check)
const result3 = await resolveProviderContext(mockSettingsService, 'model-c', 'provider-3');
// Provider is found but modelConfig won't be found since disabled providers
// skip model lookup in their models array
expect(result3.provider).toEqual(providers[2]);
expect(result3.modelConfig).toBeUndefined();
});
});
});
describe('getAllProviderModels', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return all models from enabled providers', async () => {
const mockProviders = [
{
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [
{ id: 'model-1', name: 'Model 1' },
{ id: 'model-2', name: 'Model 2' },
],
},
{
id: 'provider-2',
name: 'Provider 2',
enabled: true,
models: [{ id: 'model-3', name: 'Model 3' }],
},
];
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: mockProviders,
}),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toHaveLength(3);
expect(result[0].providerId).toBe('provider-1');
expect(result[0].model.id).toBe('model-1');
expect(result[2].providerId).toBe('provider-2');
});
it('should filter out disabled providers', async () => {
const mockProviders = [
{
id: 'enabled-1',
name: 'Enabled Provider',
enabled: true,
models: [{ id: 'model-1', name: 'Model 1' }],
},
{
id: 'disabled-1',
name: 'Disabled Provider',
enabled: false,
models: [{ id: 'model-2', name: 'Model 2' }],
},
];
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: mockProviders,
}),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toHaveLength(1);
expect(result[0].providerId).toBe('enabled-1');
});
it('should return empty array when no providers configured', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [],
}),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toEqual([]);
});
it('should handle missing claudeCompatibleProviders field', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toEqual([]);
});
it('should handle provider with no models', async () => {
const mockProviders = [
{
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [],
},
{
id: 'provider-2',
name: 'Provider 2',
enabled: true,
// no models field
},
];
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: mockProviders,
}),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toEqual([]);
});
it('should return empty array on exception', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toEqual([]);
});
});
});

View File

@@ -15,6 +15,7 @@ import {
calculateReasoningTimeout,
REASONING_TIMEOUT_MULTIPLIERS,
DEFAULT_TIMEOUT_MS,
validateBareModelId,
} from '@automaker/types';
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
@@ -455,4 +456,19 @@ describe('codex-provider.ts', () => {
expect(calculateReasoningTimeout('xhigh')).toBe(120000);
});
});
describe('validateBareModelId integration', () => {
it('should allow codex- prefixed models for Codex provider with expectedProvider="codex"', () => {
expect(() => validateBareModelId('codex-gpt-4', 'CodexProvider', 'codex')).not.toThrow();
expect(() =>
validateBareModelId('codex-gpt-5.1-codex-max', 'CodexProvider', 'codex')
).not.toThrow();
});
it('should reject other provider prefixes for Codex provider', () => {
expect(() => validateBareModelId('cursor-gpt-4', 'CodexProvider', 'codex')).toThrow();
expect(() => validateBareModelId('gemini-2.5-flash', 'CodexProvider', 'codex')).toThrow();
expect(() => validateBareModelId('copilot-gpt-4', 'CodexProvider', 'codex')).toThrow();
});
});
});

View File

@@ -331,13 +331,15 @@ describe('copilot-provider.ts', () => {
});
});
it('should normalize tool.execution_end event', () => {
it('should normalize tool.execution_complete event', () => {
const event = {
type: 'tool.execution_end',
type: 'tool.execution_complete',
data: {
toolName: 'read_file',
toolCallId: 'call-123',
result: 'file content',
success: true,
result: {
content: 'file content',
},
},
};
@@ -357,23 +359,85 @@ describe('copilot-provider.ts', () => {
});
});
it('should handle tool.execution_end with error', () => {
it('should handle tool.execution_complete with error', () => {
const event = {
type: 'tool.execution_end',
type: 'tool.execution_complete',
data: {
toolName: 'bash',
toolCallId: 'call-456',
error: 'Command failed',
success: false,
error: {
message: 'Command failed',
},
},
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({
type: 'tool_result',
tool_use_id: 'call-456',
content: '[ERROR] Command failed',
});
});
it('should handle tool.execution_complete with empty result', () => {
const event = {
type: 'tool.execution_complete',
data: {
toolCallId: 'call-789',
success: true,
result: {
content: '',
},
},
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({
type: 'tool_result',
tool_use_id: 'call-789',
content: '',
});
});
it('should handle tool.execution_complete with missing result', () => {
const event = {
type: 'tool.execution_complete',
data: {
toolCallId: 'call-999',
success: true,
// No result field
},
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({
type: 'tool_result',
tool_use_id: 'call-999',
content: '',
});
});
it('should handle tool.execution_complete with error code', () => {
const event = {
type: 'tool.execution_complete',
data: {
toolCallId: 'call-567',
success: false,
error: {
message: 'Permission denied',
code: 'EACCES',
},
},
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({
type: 'tool_result',
tool_use_id: 'call-567',
content: '[ERROR] Permission denied (EACCES)',
});
});
it('should normalize session.idle to success result', () => {
const event = { type: 'session.idle' };

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { CursorProvider } from '@/providers/cursor-provider.js';
import { validateBareModelId } from '@automaker/types';
describe('cursor-provider.ts', () => {
describe('buildCliArgs', () => {
@@ -154,4 +155,81 @@ describe('cursor-provider.ts', () => {
expect(msg!.subtype).toBe('success');
});
});
describe('Cursor Gemini models support', () => {
let provider: CursorProvider;
beforeEach(() => {
provider = Object.create(CursorProvider.prototype) as CursorProvider & {
cliPath?: string;
};
provider.cliPath = '/usr/local/bin/cursor-agent';
});
describe('buildCliArgs with Cursor Gemini models', () => {
it('should handle cursor-gemini-3-pro model', () => {
const args = provider.buildCliArgs({
prompt: 'Write a function',
model: 'gemini-3-pro', // Bare model ID after stripping cursor- prefix
cwd: '/tmp/project',
});
const modelIndex = args.indexOf('--model');
expect(modelIndex).toBeGreaterThan(-1);
expect(args[modelIndex + 1]).toBe('gemini-3-pro');
});
it('should handle cursor-gemini-3-flash model', () => {
const args = provider.buildCliArgs({
prompt: 'Quick task',
model: 'gemini-3-flash', // Bare model ID after stripping cursor- prefix
cwd: '/tmp/project',
});
const modelIndex = args.indexOf('--model');
expect(modelIndex).toBeGreaterThan(-1);
expect(args[modelIndex + 1]).toBe('gemini-3-flash');
});
it('should include --resume with Cursor Gemini models when sdkSessionId is provided', () => {
const args = provider.buildCliArgs({
prompt: 'Continue task',
model: 'gemini-3-pro',
cwd: '/tmp/project',
sdkSessionId: 'cursor-gemini-session-123',
});
const resumeIndex = args.indexOf('--resume');
expect(resumeIndex).toBeGreaterThan(-1);
expect(args[resumeIndex + 1]).toBe('cursor-gemini-session-123');
});
});
describe('validateBareModelId with Cursor Gemini models', () => {
it('should allow gemini- prefixed models for Cursor provider with expectedProvider="cursor"', () => {
// This is the key fix - Cursor Gemini models have bare IDs like "gemini-3-pro"
expect(() => validateBareModelId('gemini-3-pro', 'CursorProvider', 'cursor')).not.toThrow();
expect(() =>
validateBareModelId('gemini-3-flash', 'CursorProvider', 'cursor')
).not.toThrow();
});
it('should still reject other provider prefixes for Cursor provider', () => {
expect(() => validateBareModelId('codex-gpt-4', 'CursorProvider', 'cursor')).toThrow();
expect(() => validateBareModelId('copilot-gpt-4', 'CursorProvider', 'cursor')).toThrow();
expect(() => validateBareModelId('opencode-gpt-4', 'CursorProvider', 'cursor')).toThrow();
});
it('should accept cursor- prefixed models when expectedProvider is "cursor" (for double-prefix validation)', () => {
// Note: When expectedProvider="cursor", we skip the cursor- prefix check
// This is intentional because the validation happens AFTER prefix stripping
// So if cursor-gemini-3-pro reaches validateBareModelId with expectedProvider="cursor",
// it means the prefix was NOT properly stripped, but we skip it anyway
// since we're checking if the Cursor provider itself can receive cursor- prefixed models
expect(() =>
validateBareModelId('cursor-gemini-3-pro', 'CursorProvider', 'cursor')
).not.toThrow();
});
});
});
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GeminiProvider } from '@/providers/gemini-provider.js';
import type { ProviderMessage } from '@automaker/types';
import { validateBareModelId } from '@automaker/types';
describe('gemini-provider.ts', () => {
let provider: GeminiProvider;
@@ -253,4 +254,19 @@ describe('gemini-provider.ts', () => {
expect(msg.subtype).toBe('success');
});
});
describe('validateBareModelId integration', () => {
it('should allow gemini- prefixed models for Gemini provider with expectedProvider="gemini"', () => {
expect(() =>
validateBareModelId('gemini-2.5-flash', 'GeminiProvider', 'gemini')
).not.toThrow();
expect(() => validateBareModelId('gemini-2.5-pro', 'GeminiProvider', 'gemini')).not.toThrow();
});
it('should reject other provider prefixes for Gemini provider', () => {
expect(() => validateBareModelId('cursor-gpt-4', 'GeminiProvider', 'gemini')).toThrow();
expect(() => validateBareModelId('codex-gpt-4', 'GeminiProvider', 'gemini')).toThrow();
expect(() => validateBareModelId('copilot-gpt-4', 'GeminiProvider', 'gemini')).toThrow();
});
});
});

View File

@@ -0,0 +1,270 @@
/**
* Tests for default fields applied to features created by parseAndCreateFeatures
*
* Verifies that auto-created features include planningMode: 'skip',
* requirePlanApproval: false, and dependencies: [].
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import path from 'path';
// Use vi.hoisted to create mock functions that can be referenced in vi.mock factories
const { mockMkdir, mockAtomicWriteJson, mockExtractJsonWithArray, mockCreateNotification } =
vi.hoisted(() => ({
mockMkdir: vi.fn().mockResolvedValue(undefined),
mockAtomicWriteJson: vi.fn().mockResolvedValue(undefined),
mockExtractJsonWithArray: vi.fn(),
mockCreateNotification: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/lib/secure-fs.js', () => ({
mkdir: mockMkdir,
}));
vi.mock('@automaker/utils', () => ({
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
atomicWriteJson: mockAtomicWriteJson,
DEFAULT_BACKUP_COUNT: 3,
}));
vi.mock('@automaker/platform', () => ({
getFeaturesDir: vi.fn((projectPath: string) => path.join(projectPath, '.automaker', 'features')),
}));
vi.mock('@/lib/json-extractor.js', () => ({
extractJsonWithArray: mockExtractJsonWithArray,
}));
vi.mock('@/services/notification-service.js', () => ({
getNotificationService: vi.fn(() => ({
createNotification: mockCreateNotification,
})),
}));
// Import after mocks are set up
import { parseAndCreateFeatures } from '../../../../src/routes/app-spec/parse-and-create-features.js';
describe('parseAndCreateFeatures - default fields', () => {
const mockEvents = {
emit: vi.fn(),
} as any;
const projectPath = '/test/project';
beforeEach(() => {
vi.clearAllMocks();
});
it('should set planningMode to "skip" on created features', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
priority: 1,
complexity: 'simple',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
expect(mockAtomicWriteJson).toHaveBeenCalledTimes(1);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.planningMode).toBe('skip');
});
it('should set requirePlanApproval to false on created features', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.requirePlanApproval).toBe(false);
});
it('should set dependencies to empty array when not provided', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.dependencies).toEqual([]);
});
it('should preserve dependencies when provided by the parser', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
dependencies: ['feature-0'],
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.dependencies).toEqual(['feature-0']);
});
it('should apply all default fields consistently across multiple features', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Feature 1',
description: 'First feature',
},
{
id: 'feature-2',
title: 'Feature 2',
description: 'Second feature',
dependencies: ['feature-1'],
},
{
id: 'feature-3',
title: 'Feature 3',
description: 'Third feature',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
expect(mockAtomicWriteJson).toHaveBeenCalledTimes(3);
for (let i = 0; i < 3; i++) {
const writtenData = mockAtomicWriteJson.mock.calls[i][1];
expect(writtenData.planningMode, `feature ${i + 1} planningMode`).toBe('skip');
expect(writtenData.requirePlanApproval, `feature ${i + 1} requirePlanApproval`).toBe(false);
expect(Array.isArray(writtenData.dependencies), `feature ${i + 1} dependencies`).toBe(true);
}
// Feature 2 should have its explicit dependency preserved
expect(mockAtomicWriteJson.mock.calls[1][1].dependencies).toEqual(['feature-1']);
// Features 1 and 3 should have empty arrays
expect(mockAtomicWriteJson.mock.calls[0][1].dependencies).toEqual([]);
expect(mockAtomicWriteJson.mock.calls[2][1].dependencies).toEqual([]);
});
it('should set status to "backlog" on all created features', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.status).toBe('backlog');
});
it('should include createdAt and updatedAt timestamps', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.createdAt).toBeDefined();
expect(writtenData.updatedAt).toBeDefined();
// Should be valid ISO date strings
expect(new Date(writtenData.createdAt).toISOString()).toBe(writtenData.createdAt);
expect(new Date(writtenData.updatedAt).toISOString()).toBe(writtenData.updatedAt);
});
it('should use default values for optional fields not provided', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-minimal',
title: 'Minimal Feature',
description: 'Only required fields',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.category).toBe('Uncategorized');
expect(writtenData.priority).toBe(2);
expect(writtenData.complexity).toBe('moderate');
expect(writtenData.dependencies).toEqual([]);
expect(writtenData.planningMode).toBe('skip');
expect(writtenData.requirePlanApproval).toBe(false);
});
it('should emit success event after creating features', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Feature 1',
description: 'First',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
expect(mockEvents.emit).toHaveBeenCalledWith(
'spec-regeneration:event',
expect.objectContaining({
type: 'spec_regeneration_complete',
projectPath,
})
);
});
it('should emit error event when no valid JSON is found', async () => {
mockExtractJsonWithArray.mockReturnValue(null);
await parseAndCreateFeatures(projectPath, 'invalid content', mockEvents);
expect(mockEvents.emit).toHaveBeenCalledWith(
'spec-regeneration:event',
expect.objectContaining({
type: 'spec_regeneration_error',
projectPath,
})
);
});
});

View File

@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockGetAll, mockCreate, mockUpdate, mockDelete, mockClearBacklogPlan } = vi.hoisted(() => ({
mockGetAll: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
mockClearBacklogPlan: vi.fn(),
}));
vi.mock('@/services/feature-loader.js', () => ({
FeatureLoader: class {
getAll = mockGetAll;
create = mockCreate;
update = mockUpdate;
delete = mockDelete;
},
}));
vi.mock('@/routes/backlog-plan/common.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
clearBacklogPlan: mockClearBacklogPlan,
getErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)),
logError: vi.fn(),
}));
import { createApplyHandler } from '@/routes/backlog-plan/routes/apply.js';
function createMockRes() {
const res: {
status: ReturnType<typeof vi.fn>;
json: ReturnType<typeof vi.fn>;
} = {
status: vi.fn(),
json: vi.fn(),
};
res.status.mockReturnValue(res);
return res;
}
describe('createApplyHandler', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetAll.mockResolvedValue([]);
mockCreate.mockResolvedValue({ id: 'feature-created' });
mockUpdate.mockResolvedValue({});
mockDelete.mockResolvedValue(true);
mockClearBacklogPlan.mockResolvedValue(undefined);
});
it('applies default feature model and planning settings when backlog plan additions omit them', async () => {
const settingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
defaultFeatureModel: { model: 'codex-gpt-5.2-codex', reasoningEffort: 'high' },
defaultPlanningMode: 'spec',
defaultRequirePlanApproval: true,
}),
getProjectSettings: vi.fn().mockResolvedValue({}),
} as any;
const req = {
body: {
projectPath: '/tmp/project',
plan: {
changes: [
{
type: 'add',
feature: {
id: 'feature-from-plan',
title: 'Created from plan',
description: 'desc',
},
},
],
},
},
} as any;
const res = createMockRes();
await createApplyHandler(settingsService)(req, res as any);
expect(mockCreate).toHaveBeenCalledWith(
'/tmp/project',
expect.objectContaining({
model: 'codex-gpt-5.2-codex',
reasoningEffort: 'high',
planningMode: 'spec',
requirePlanApproval: true,
})
);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
})
);
});
it('uses project default feature model override and enforces no approval for skip mode', async () => {
const settingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
defaultFeatureModel: { model: 'claude-opus' },
defaultPlanningMode: 'skip',
defaultRequirePlanApproval: true,
}),
getProjectSettings: vi.fn().mockResolvedValue({
defaultFeatureModel: {
model: 'GLM-4.7',
providerId: 'provider-glm',
thinkingLevel: 'adaptive',
},
}),
} as any;
const req = {
body: {
projectPath: '/tmp/project',
plan: {
changes: [
{
type: 'add',
feature: {
id: 'feature-from-plan',
title: 'Created from plan',
},
},
],
},
},
} as any;
const res = createMockRes();
await createApplyHandler(settingsService)(req, res as any);
expect(mockCreate).toHaveBeenCalledWith(
'/tmp/project',
expect.objectContaining({
model: 'GLM-4.7',
providerId: 'provider-glm',
thinkingLevel: 'adaptive',
planningMode: 'skip',
requirePlanApproval: false,
})
);
});
});

View File

@@ -47,6 +47,8 @@ describe('running-agents routes', () => {
projectPath: '/home/user/project',
projectName: 'project',
isAutoMode: true,
model: 'claude-sonnet-4-20250514',
provider: 'claude',
title: 'Implement login feature',
description: 'Add user authentication with OAuth',
},
@@ -55,6 +57,8 @@ describe('running-agents routes', () => {
projectPath: '/home/user/other-project',
projectName: 'other-project',
isAutoMode: false,
model: 'codex-gpt-5.1',
provider: 'codex',
title: 'Fix navigation bug',
description: undefined,
},
@@ -82,6 +86,8 @@ describe('running-agents routes', () => {
projectPath: '/project',
projectName: 'project',
isAutoMode: true,
model: undefined,
provider: undefined,
title: undefined,
description: undefined,
},
@@ -141,6 +147,8 @@ describe('running-agents routes', () => {
projectPath: `/project-${i}`,
projectName: `project-${i}`,
isAutoMode: i % 2 === 0,
model: i % 3 === 0 ? 'claude-sonnet-4-20250514' : 'claude-haiku-4-5',
provider: 'claude',
title: `Feature ${i}`,
description: `Description ${i}`,
}));
@@ -167,6 +175,8 @@ describe('running-agents routes', () => {
projectPath: '/workspace/project-alpha',
projectName: 'project-alpha',
isAutoMode: true,
model: 'claude-sonnet-4-20250514',
provider: 'claude',
title: 'Feature A',
description: 'In project alpha',
},
@@ -175,6 +185,8 @@ describe('running-agents routes', () => {
projectPath: '/workspace/project-beta',
projectName: 'project-beta',
isAutoMode: false,
model: 'codex-gpt-5.1',
provider: 'codex',
title: 'Feature B',
description: 'In project beta',
},
@@ -191,5 +203,56 @@ describe('running-agents routes', () => {
expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha');
expect(response.runningAgents[1].projectPath).toBe('/workspace/project-beta');
});
it('should include model and provider information for running agents', async () => {
// Arrange
const runningAgents = [
{
featureId: 'feature-claude',
projectPath: '/project',
projectName: 'project',
isAutoMode: true,
model: 'claude-sonnet-4-20250514',
provider: 'claude',
title: 'Claude Feature',
description: 'Using Claude model',
},
{
featureId: 'feature-codex',
projectPath: '/project',
projectName: 'project',
isAutoMode: false,
model: 'codex-gpt-5.1',
provider: 'codex',
title: 'Codex Feature',
description: 'Using Codex model',
},
{
featureId: 'feature-cursor',
projectPath: '/project',
projectName: 'project',
isAutoMode: false,
model: 'cursor-auto',
provider: 'cursor',
title: 'Cursor Feature',
description: 'Using Cursor model',
},
];
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
const response = vi.mocked(res.json).mock.calls[0][0];
expect(response.runningAgents[0].model).toBe('claude-sonnet-4-20250514');
expect(response.runningAgents[0].provider).toBe('claude');
expect(response.runningAgents[1].model).toBe('codex-gpt-5.1');
expect(response.runningAgents[1].provider).toBe('codex');
expect(response.runningAgents[2].model).toBe('cursor-auto');
expect(response.runningAgents[2].provider).toBe('cursor');
});
});
});

View File

@@ -0,0 +1,930 @@
/**
* Tests for worktree list endpoint handling of detached HEAD state.
*
* When a worktree is in detached HEAD state (e.g., during a rebase),
* `git worktree list --porcelain` outputs "detached" instead of
* "branch refs/heads/...". Previously, these worktrees were silently
* dropped from the response because the parser required both path AND branch.
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { createMockExpressContext } from '../../../utils/mocks.js';
// Mock all external dependencies before importing the module under test
vi.mock('child_process', () => ({
exec: vi.fn(),
}));
vi.mock('@/lib/git.js', () => ({
execGitCommand: vi.fn(),
}));
vi.mock('@automaker/git-utils', () => ({
isGitRepo: vi.fn(async () => true),
}));
vi.mock('@automaker/utils', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
vi.mock('@automaker/types', () => ({
validatePRState: vi.fn((state: string) => state),
}));
vi.mock('@/lib/secure-fs.js', () => ({
access: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn(),
readdir: vi.fn().mockResolvedValue([]),
stat: vi.fn(),
}));
vi.mock('@/lib/worktree-metadata.js', () => ({
readAllWorktreeMetadata: vi.fn(async () => new Map()),
updateWorktreePRInfo: vi.fn(async () => undefined),
}));
vi.mock('@/routes/worktree/common.js', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
getErrorMessage: vi.fn((e: Error) => e?.message || 'Unknown error'),
logError: vi.fn(),
normalizePath: vi.fn((p: string) => p),
execEnv: {},
isGhCliAvailable: vi.fn().mockResolvedValue(false),
};
});
vi.mock('@/routes/github/routes/check-github-remote.js', () => ({
checkGitHubRemote: vi.fn().mockResolvedValue({ hasGitHubRemote: false }),
}));
import { createListHandler } from '@/routes/worktree/routes/list.js';
import * as secureFs from '@/lib/secure-fs.js';
import { execGitCommand } from '@/lib/git.js';
import { readAllWorktreeMetadata, updateWorktreePRInfo } from '@/lib/worktree-metadata.js';
import { isGitRepo } from '@automaker/git-utils';
import { isGhCliAvailable, normalizePath, getErrorMessage } from '@/routes/worktree/common.js';
import { checkGitHubRemote } from '@/routes/github/routes/check-github-remote.js';
/**
* Set up execGitCommand mock (list handler uses this via lib/git.js, not child_process.exec).
*/
function setupExecGitCommandMock(options: {
porcelainOutput: string;
projectBranch?: string;
gitDirs?: Record<string, string>;
worktreeBranches?: Record<string, string>;
}) {
const { porcelainOutput, projectBranch = 'main', gitDirs = {}, worktreeBranches = {} } = options;
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
if (args[0] === 'worktree' && args[1] === 'list' && args[2] === '--porcelain') {
return porcelainOutput;
}
if (args[0] === 'branch' && args[1] === '--show-current') {
if (worktreeBranches[cwd] !== undefined) {
return worktreeBranches[cwd] + '\n';
}
return projectBranch + '\n';
}
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
if (cwd && gitDirs[cwd]) {
return gitDirs[cwd] + '\n';
}
throw new Error('not a git directory');
}
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') {
return 'HEAD\n';
}
if (args[0] === 'worktree' && args[1] === 'prune') {
return '';
}
if (args[0] === 'status' && args[1] === '--porcelain') {
return '';
}
if (args[0] === 'diff' && args[1] === '--name-only' && args[2] === '--diff-filter=U') {
return '';
}
return '';
});
}
describe('worktree list - detached HEAD handling', () => {
let req: Request;
let res: Response;
beforeEach(() => {
vi.clearAllMocks();
const context = createMockExpressContext();
req = context.req;
res = context.res;
// Re-establish mock implementations cleared by mockReset/clearAllMocks
vi.mocked(isGitRepo).mockResolvedValue(true);
vi.mocked(readAllWorktreeMetadata).mockResolvedValue(new Map());
vi.mocked(isGhCliAvailable).mockResolvedValue(false);
vi.mocked(checkGitHubRemote).mockResolvedValue({ hasGitHubRemote: false });
vi.mocked(normalizePath).mockImplementation((p: string) => p);
vi.mocked(getErrorMessage).mockImplementation(
(e: unknown) => (e as Error)?.message || 'Unknown error'
);
// Default: all paths exist
vi.mocked(secureFs.access).mockResolvedValue(undefined);
// Default: .worktrees directory doesn't exist (no scan via readdir)
vi.mocked(secureFs.readdir).mockRejectedValue(new Error('ENOENT'));
// Default: readFile fails
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
// Default execGitCommand so list handler gets valid porcelain/branch output (vitest clearMocks resets implementations)
setupExecGitCommandMock({
porcelainOutput: 'worktree /project\nbranch refs/heads/main\n\n',
projectBranch: 'main',
});
});
/**
* Helper: set up execGitCommand mock for the list handler.
* Worktree-specific behavior can be customized via the options parameter.
*/
function setupStandardExec(options: {
porcelainOutput: string;
projectBranch?: string;
/** Map of worktree path -> git-dir path */
gitDirs?: Record<string, string>;
/** Map of worktree cwd -> branch for `git branch --show-current` */
worktreeBranches?: Record<string, string>;
}) {
setupExecGitCommandMock(options);
}
/** Suppress .worktrees dir scan by making access throw for the .worktrees dir. */
function disableWorktreesScan() {
vi.mocked(secureFs.access).mockImplementation(async (p) => {
const pathStr = String(p);
// Block only the .worktrees dir access check in scanWorktreesDirectory
if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) {
throw new Error('ENOENT');
}
// All other paths exist
return undefined;
});
}
describe('porcelain parser', () => {
it('should include normal worktrees with branch lines', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/feature-a',
'branch refs/heads/feature-a',
'',
].join('\n'),
});
disableWorktreesScan();
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
success: boolean;
worktrees: Array<{ branch: string; path: string; isMain: boolean; hasWorktree: boolean }>;
};
expect(response.success).toBe(true);
expect(response.worktrees).toHaveLength(2);
expect(response.worktrees[0]).toEqual(
expect.objectContaining({
path: '/project',
branch: 'main',
isMain: true,
hasWorktree: true,
})
);
expect(response.worktrees[1]).toEqual(
expect.objectContaining({
path: '/project/.worktrees/feature-a',
branch: 'feature-a',
isMain: false,
hasWorktree: true,
})
);
});
it('should include worktrees with detached HEAD and recover branch from rebase-merge state', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/rebasing-wt',
'detached',
'',
].join('\n'),
gitDirs: {
'/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git',
},
});
disableWorktreesScan();
// rebase-merge/head-name returns the branch being rebased
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-merge/head-name')) {
return 'refs/heads/feature/my-rebasing-branch\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string; isCurrent: boolean }>;
};
expect(response.worktrees).toHaveLength(2);
expect(response.worktrees[1]).toEqual(
expect.objectContaining({
path: '/project/.worktrees/rebasing-wt',
branch: 'feature/my-rebasing-branch',
isMain: false,
isCurrent: false,
hasWorktree: true,
})
);
});
it('should include worktrees with detached HEAD and recover branch from rebase-apply state', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/apply-wt',
'detached',
'',
].join('\n'),
gitDirs: {
'/project/.worktrees/apply-wt': '/project/.worktrees/apply-wt/.git',
},
});
disableWorktreesScan();
// rebase-merge doesn't exist, but rebase-apply does
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-apply/head-name')) {
return 'refs/heads/feature/apply-branch\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
const detachedWt = response.worktrees.find((w) => w.path === '/project/.worktrees/apply-wt');
expect(detachedWt).toBeDefined();
expect(detachedWt!.branch).toBe('feature/apply-branch');
});
it('should show merge conflict worktrees normally since merge does not detach HEAD', async () => {
// During a merge conflict, HEAD stays on the branch, so `git worktree list --porcelain`
// still outputs `branch refs/heads/...`. This test verifies merge conflicts don't
// trigger the detached HEAD recovery path.
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/merge-wt',
'branch refs/heads/feature/merge-branch',
'',
].join('\n'),
});
disableWorktreesScan();
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
const mergeWt = response.worktrees.find((w) => w.path === '/project/.worktrees/merge-wt');
expect(mergeWt).toBeDefined();
expect(mergeWt!.branch).toBe('feature/merge-branch');
});
it('should fall back to (detached) when all branch recovery methods fail', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/unknown-wt',
'detached',
'',
].join('\n'),
worktreeBranches: {
'/project/.worktrees/unknown-wt': '', // empty = no branch
},
});
disableWorktreesScan();
// All readFile calls fail (no gitDirs so rev-parse --git-dir will throw)
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
const detachedWt = response.worktrees.find(
(w) => w.path === '/project/.worktrees/unknown-wt'
);
expect(detachedWt).toBeDefined();
expect(detachedWt!.branch).toBe('(detached)');
});
it('should not include detached worktree when directory does not exist on disk', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/deleted-wt',
'detached',
'',
].join('\n'),
});
// The deleted worktree doesn't exist on disk
vi.mocked(secureFs.access).mockImplementation(async (p) => {
const pathStr = String(p);
if (pathStr.includes('deleted-wt')) {
throw new Error('ENOENT');
}
if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) {
throw new Error('ENOENT');
}
return undefined;
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
// Only the main worktree should be present
expect(response.worktrees).toHaveLength(1);
expect(response.worktrees[0].path).toBe('/project');
});
it('should set isCurrent to false for detached worktrees even if recovered branch matches current branch', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/rebasing-wt',
'detached',
'',
].join('\n'),
// currentBranch for project is 'feature/my-branch'
projectBranch: 'feature/my-branch',
gitDirs: {
'/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git',
},
});
disableWorktreesScan();
// Recovery returns the same branch as currentBranch
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-merge/head-name')) {
return 'refs/heads/feature/my-branch\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; isCurrent: boolean; path: string }>;
};
const detachedWt = response.worktrees.find(
(w) => w.path === '/project/.worktrees/rebasing-wt'
);
expect(detachedWt).toBeDefined();
// Detached worktrees should always have isCurrent=false
expect(detachedWt!.isCurrent).toBe(false);
});
it('should handle mixed normal and detached worktrees', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/normal-wt',
'branch refs/heads/feature-normal',
'',
'worktree /project/.worktrees/rebasing-wt',
'detached',
'',
'worktree /project/.worktrees/another-normal',
'branch refs/heads/feature-other',
'',
].join('\n'),
gitDirs: {
'/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git',
},
});
disableWorktreesScan();
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-merge/head-name')) {
return 'refs/heads/feature/rebasing\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string; isMain: boolean }>;
};
expect(response.worktrees).toHaveLength(4);
expect(response.worktrees[0]).toEqual(
expect.objectContaining({ path: '/project', branch: 'main', isMain: true })
);
expect(response.worktrees[1]).toEqual(
expect.objectContaining({
path: '/project/.worktrees/normal-wt',
branch: 'feature-normal',
isMain: false,
})
);
expect(response.worktrees[2]).toEqual(
expect.objectContaining({
path: '/project/.worktrees/rebasing-wt',
branch: 'feature/rebasing',
isMain: false,
})
);
expect(response.worktrees[3]).toEqual(
expect.objectContaining({
path: '/project/.worktrees/another-normal',
branch: 'feature-other',
isMain: false,
})
);
});
it('should correctly advance isFirst flag past detached worktrees', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/detached-wt',
'detached',
'',
'worktree /project/.worktrees/normal-wt',
'branch refs/heads/feature-x',
'',
].join('\n'),
});
disableWorktreesScan();
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; isMain: boolean }>;
};
expect(response.worktrees).toHaveLength(3);
expect(response.worktrees[0].isMain).toBe(true); // main
expect(response.worktrees[1].isMain).toBe(false); // detached
expect(response.worktrees[2].isMain).toBe(false); // normal
});
it('should not add removed detached worktrees to removedWorktrees list', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/gone-wt',
'detached',
'',
].join('\n'),
});
// The detached worktree doesn't exist on disk
vi.mocked(secureFs.access).mockImplementation(async (p) => {
const pathStr = String(p);
if (pathStr.includes('gone-wt')) {
throw new Error('ENOENT');
}
if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) {
throw new Error('ENOENT');
}
return undefined;
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string }>;
removedWorktrees?: Array<{ path: string; branch: string }>;
};
// Should not be in removed list since we don't know the branch
expect(response.removedWorktrees).toBeUndefined();
});
it('should strip refs/heads/ prefix from recovered branch name', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/wt1',
'detached',
'',
].join('\n'),
gitDirs: {
'/project/.worktrees/wt1': '/project/.worktrees/wt1/.git',
},
});
disableWorktreesScan();
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-merge/head-name')) {
return 'refs/heads/my-branch\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
const wt = response.worktrees.find((w) => w.path === '/project/.worktrees/wt1');
expect(wt).toBeDefined();
// Should be 'my-branch', not 'refs/heads/my-branch'
expect(wt!.branch).toBe('my-branch');
});
});
describe('scanWorktreesDirectory with detached HEAD recovery', () => {
it('should recover branch for discovered worktrees with detached HEAD', async () => {
req.body = { projectPath: '/project' };
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
if (args[0] === 'worktree' && args[1] === 'list') {
return 'worktree /project\nbranch refs/heads/main\n\n';
}
if (args[0] === 'branch' && args[1] === '--show-current') {
return cwd === '/project' ? 'main\n' : '\n';
}
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
return 'HEAD\n';
}
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
return '/project/.worktrees/orphan-wt/.git\n';
}
return '';
});
// .worktrees directory exists and has an orphan worktree
vi.mocked(secureFs.access).mockResolvedValue(undefined);
vi.mocked(secureFs.readdir).mockResolvedValue([
{ name: 'orphan-wt', isDirectory: () => true, isFile: () => false } as any,
]);
vi.mocked(secureFs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
} as any);
// readFile returns branch from rebase-merge/head-name
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-merge/head-name')) {
return 'refs/heads/feature/orphan-branch\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
const orphanWt = response.worktrees.find((w) => w.path === '/project/.worktrees/orphan-wt');
expect(orphanWt).toBeDefined();
expect(orphanWt!.branch).toBe('feature/orphan-branch');
});
it('should skip discovered worktrees when all branch detection fails', async () => {
req.body = { projectPath: '/project' };
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
if (args[0] === 'worktree' && args[1] === 'list') {
return 'worktree /project\nbranch refs/heads/main\n\n';
}
if (args[0] === 'branch' && args[1] === '--show-current') {
return cwd === '/project' ? 'main\n' : '\n';
}
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
return 'HEAD\n';
}
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
throw new Error('not a git dir');
}
return '';
});
vi.mocked(secureFs.access).mockResolvedValue(undefined);
vi.mocked(secureFs.readdir).mockResolvedValue([
{ name: 'broken-wt', isDirectory: () => true, isFile: () => false } as any,
]);
vi.mocked(secureFs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
} as any);
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
// Only main worktree should be present
expect(response.worktrees).toHaveLength(1);
expect(response.worktrees[0].branch).toBe('main');
});
});
describe('PR tracking precedence', () => {
it('should keep manually tracked PR from metadata when branch PR differs', async () => {
req.body = { projectPath: '/project', includeDetails: true };
vi.mocked(readAllWorktreeMetadata).mockResolvedValue(
new Map([
[
'feature-a',
{
branch: 'feature-a',
createdAt: '2026-01-01T00:00:00.000Z',
pr: {
number: 99,
url: 'https://github.com/org/repo/pull/99',
title: 'Manual override PR',
state: 'OPEN',
createdAt: '2026-01-01T00:00:00.000Z',
},
},
],
])
);
vi.mocked(isGhCliAvailable).mockResolvedValue(true);
vi.mocked(checkGitHubRemote).mockResolvedValue({
hasGitHubRemote: true,
owner: 'org',
repo: 'repo',
});
vi.mocked(secureFs.access).mockImplementation(async (p) => {
const pathStr = String(p);
if (
pathStr.includes('MERGE_HEAD') ||
pathStr.includes('rebase-merge') ||
pathStr.includes('rebase-apply') ||
pathStr.includes('CHERRY_PICK_HEAD')
) {
throw new Error('ENOENT');
}
return undefined;
});
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
throw new Error('no git dir');
}
if (args[0] === 'worktree' && args[1] === 'list') {
return [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/feature-a',
'branch refs/heads/feature-a',
'',
].join('\n');
}
if (args[0] === 'branch' && args[1] === '--show-current') {
return cwd === '/project' ? 'main\n' : 'feature-a\n';
}
if (args[0] === 'status' && args[1] === '--porcelain') {
return '';
}
return '';
});
(exec as unknown as Mock).mockImplementation(
(
cmd: string,
_opts: unknown,
callback?: (err: Error | null, out: { stdout: string; stderr: string }) => void
) => {
const cb = typeof _opts === 'function' ? _opts : callback!;
if (cmd.includes('gh pr list')) {
cb(null, {
stdout: JSON.stringify([
{
number: 42,
title: 'Branch PR',
url: 'https://github.com/org/repo/pull/42',
state: 'OPEN',
headRefName: 'feature-a',
createdAt: '2026-01-02T00:00:00.000Z',
},
]),
stderr: '',
});
} else {
cb(null, { stdout: '', stderr: '' });
}
}
);
disableWorktreesScan();
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; pr?: { number: number; title: string } }>;
};
const featureWorktree = response.worktrees.find((w) => w.branch === 'feature-a');
expect(featureWorktree?.pr?.number).toBe(99);
expect(featureWorktree?.pr?.title).toBe('Manual override PR');
});
it('should prefer GitHub PR when it matches metadata number and sync updated fields', async () => {
req.body = { projectPath: '/project-2', includeDetails: true };
vi.mocked(readAllWorktreeMetadata).mockResolvedValue(
new Map([
[
'feature-a',
{
branch: 'feature-a',
createdAt: '2026-01-01T00:00:00.000Z',
pr: {
number: 42,
url: 'https://github.com/org/repo/pull/42',
title: 'Old title',
state: 'OPEN',
createdAt: '2026-01-01T00:00:00.000Z',
},
},
],
])
);
vi.mocked(isGhCliAvailable).mockResolvedValue(true);
vi.mocked(checkGitHubRemote).mockResolvedValue({
hasGitHubRemote: true,
owner: 'org',
repo: 'repo',
});
vi.mocked(secureFs.access).mockImplementation(async (p) => {
const pathStr = String(p);
if (
pathStr.includes('MERGE_HEAD') ||
pathStr.includes('rebase-merge') ||
pathStr.includes('rebase-apply') ||
pathStr.includes('CHERRY_PICK_HEAD')
) {
throw new Error('ENOENT');
}
return undefined;
});
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
throw new Error('no git dir');
}
if (args[0] === 'worktree' && args[1] === 'list') {
return [
'worktree /project-2',
'branch refs/heads/main',
'',
'worktree /project-2/.worktrees/feature-a',
'branch refs/heads/feature-a',
'',
].join('\n');
}
if (args[0] === 'branch' && args[1] === '--show-current') {
return cwd === '/project-2' ? 'main\n' : 'feature-a\n';
}
if (args[0] === 'status' && args[1] === '--porcelain') {
return '';
}
return '';
});
(exec as unknown as Mock).mockImplementation(
(
cmd: string,
_opts: unknown,
callback?: (err: Error | null, out: { stdout: string; stderr: string }) => void
) => {
const cb = typeof _opts === 'function' ? _opts : callback!;
if (cmd.includes('gh pr list')) {
cb(null, {
stdout: JSON.stringify([
{
number: 42,
title: 'New title from GitHub',
url: 'https://github.com/org/repo/pull/42',
state: 'MERGED',
headRefName: 'feature-a',
createdAt: '2026-01-02T00:00:00.000Z',
},
]),
stderr: '',
});
} else {
cb(null, { stdout: '', stderr: '' });
}
}
);
disableWorktreesScan();
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; pr?: { number: number; title: string; state: string } }>;
};
const featureWorktree = response.worktrees.find((w) => w.branch === 'feature-a');
expect(featureWorktree?.pr?.number).toBe(42);
expect(featureWorktree?.pr?.title).toBe('New title from GitHub');
expect(featureWorktree?.pr?.state).toBe('MERGED');
expect(vi.mocked(updateWorktreePRInfo)).toHaveBeenCalledWith(
'/project-2',
'feature-a',
expect.objectContaining({
number: 42,
title: 'New title from GitHub',
state: 'MERGED',
})
);
});
});
});

View File

@@ -0,0 +1,446 @@
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { AgentExecutor } from '../../../src/services/agent-executor.js';
import type { TypedEventBus } from '../../../src/services/typed-event-bus.js';
import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js';
import type { PlanApprovalService } from '../../../src/services/plan-approval-service.js';
import type { BaseProvider } from '../../../src/providers/base-provider.js';
import * as secureFs from '../../../src/lib/secure-fs.js';
import { getFeatureDir } from '@automaker/platform';
import { buildPromptWithImages } from '@automaker/utils';
vi.mock('../../../src/lib/secure-fs.js', () => ({
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
appendFile: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue(''),
}));
vi.mock('@automaker/platform', () => ({
getFeatureDir: vi.fn(),
}));
vi.mock('@automaker/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@automaker/utils')>();
return {
...actual,
buildPromptWithImages: vi.fn(),
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
};
});
describe('AgentExecutor Summary Extraction', () => {
let mockEventBus: TypedEventBus;
let mockFeatureStateManager: FeatureStateManager;
let mockPlanApprovalService: PlanApprovalService;
beforeEach(() => {
vi.clearAllMocks();
mockEventBus = {
emitAutoModeEvent: vi.fn(),
} as unknown as TypedEventBus;
mockFeatureStateManager = {
updateTaskStatus: vi.fn().mockResolvedValue(undefined),
updateFeaturePlanSpec: vi.fn().mockResolvedValue(undefined),
saveFeatureSummary: vi.fn().mockResolvedValue(undefined),
} as unknown as FeatureStateManager;
mockPlanApprovalService = {
waitForApproval: vi.fn(),
} as unknown as PlanApprovalService;
(getFeatureDir as Mock).mockReturnValue('/mock/feature/dir');
(buildPromptWithImages as Mock).mockResolvedValue({ content: 'mocked prompt' });
});
it('should extract summary from new session content only', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
const previousContent = `Some previous work.
<summary>Old summary</summary>`;
const newWork = `New implementation work.
<summary>New summary</summary>`;
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
previousContent,
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify it called saveFeatureSummary with the NEW summary
expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith(
'/project',
'test-feature',
'New summary'
);
// Ensure it didn't call it with Old summary
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalledWith(
'/project',
'test-feature',
'Old summary'
);
});
it('should not save summary if no summary in NEW session content', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
const previousContent = `Some previous work.
<summary>Old summary</summary>`;
const newWork = `New implementation work without a summary tag.`;
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
previousContent,
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify it NEVER called saveFeatureSummary because there was no NEW summary
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled();
});
it('should extract task summary and update task status during streaming', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: 'Working... ' }],
},
};
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: '[TASK_COMPLETE] T001: Task finished successfully' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
// We trigger executeTasksLoop by providing persistedTasks
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
existingApprovedPlanContent: 'Some plan',
persistedTasks: [{ id: 'T001', description: 'Task 1', status: 'pending' as const }],
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
// Verify it updated task status with summary
expect(mockFeatureStateManager.updateTaskStatus).toHaveBeenCalledWith(
'/project',
'test-feature',
'T001',
'completed',
'Task finished successfully'
);
});
describe('Pipeline step summary fallback', () => {
it('should save fallback summary when extraction fails for pipeline step', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
// Content without a summary tag (extraction will fail)
const newWork = 'Implementation completed without summary tag.';
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
status: 'pipeline_step1' as const, // Pipeline status triggers fallback
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify fallback summary was saved with trimmed content
expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith(
'/project',
'test-feature',
'Implementation completed without summary tag.'
);
});
it('should not save fallback for non-pipeline status when extraction fails', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
// Content without a summary tag
const newWork = 'Implementation completed without summary tag.';
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
status: 'in_progress' as const, // Non-pipeline status
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify no fallback was saved for non-pipeline status
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled();
});
it('should not save empty fallback for pipeline step', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
// Empty/whitespace-only content
const newWork = ' \n\t ';
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
status: 'pipeline_step1' as const,
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify no fallback was saved since content was empty/whitespace
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled();
});
it('should prefer extracted summary over fallback for pipeline step', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
// Content WITH a summary tag
const newWork = `Implementation details here.
<summary>Proper summary from extraction</summary>`;
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
status: 'pipeline_step1' as const,
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify extracted summary was saved, not the full content
expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith(
'/project',
'test-feature',
'Proper summary from extraction'
);
// Ensure it didn't save the full content as fallback
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalledWith(
'/project',
'test-feature',
expect.stringContaining('Implementation details here')
);
});
});
});

View File

@@ -1181,6 +1181,50 @@ describe('AgentExecutor', () => {
);
});
it('should pass claudeCompatibleProvider to executeQuery options', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const mockClaudeProvider = { id: 'zai-1', name: 'Zai' } as any;
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
claudeCompatibleProvider: mockClaudeProvider,
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
expect(mockProvider.executeQuery).toHaveBeenCalledWith(
expect.objectContaining({
claudeCompatibleProvider: mockClaudeProvider,
})
);
});
it('should return correct result structure', async () => {
const executor = new AgentExecutor(
mockEventBus,
@@ -1235,4 +1279,471 @@ describe('AgentExecutor', () => {
expect(typeof result.aborted).toBe('boolean');
});
});
describe('pipeline summary fallback with scaffold stripping', () => {
it('should strip follow-up scaffold from fallback summary when extraction fails', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: 'Some agent output without summary markers' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: 'Previous session content',
status: 'pipeline_step1', // Pipeline status to trigger fallback
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
// The fallback summary should be called without the scaffold header
expect(saveFeatureSummary).toHaveBeenCalled();
const savedSummary = saveFeatureSummary.mock.calls[0][2];
// Should not contain the scaffold header
expect(savedSummary).not.toContain('---');
expect(savedSummary).not.toContain('Follow-up Session');
// Should contain the actual content
expect(savedSummary).toContain('Some agent output without summary markers');
});
it('should not save fallback when scaffold is the only content after stripping', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
// Provider yields no content - only scaffold will be present
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
// Empty stream - no actual content
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: 'Previous session content',
status: 'pipeline_step1', // Pipeline status
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
// Should not save an empty fallback (after scaffold is stripped)
expect(saveFeatureSummary).not.toHaveBeenCalled();
});
it('should save extracted summary when available, not fallback', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [
{
type: 'text',
text: 'Some content\n\n<summary>Extracted summary here</summary>\n\nMore content',
},
],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: 'Previous session content',
status: 'pipeline_step1', // Pipeline status
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
// Should save the extracted summary, not the full content
expect(saveFeatureSummary).toHaveBeenCalledTimes(1);
expect(saveFeatureSummary).toHaveBeenCalledWith(
'/project',
'test-feature',
'Extracted summary here'
);
});
it('should handle scaffold with various whitespace patterns', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: 'Agent response here' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: 'Previous session content',
status: 'pipeline_step1',
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
// Should strip scaffold and save actual content
expect(saveFeatureSummary).toHaveBeenCalled();
const savedSummary = saveFeatureSummary.mock.calls[0][2];
expect(savedSummary.trim()).toBe('Agent response here');
});
it('should handle scaffold with extra newlines between markers', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: 'Actual content after scaffold' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
// Set up with previous content to trigger scaffold insertion
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: 'Previous session content',
status: 'pipeline_step1',
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
expect(saveFeatureSummary).toHaveBeenCalled();
const savedSummary = saveFeatureSummary.mock.calls[0][2];
// Verify the scaffold is stripped
expect(savedSummary).not.toMatch(/---\s*##\s*Follow-up Session/);
});
it('should handle content without any scaffold (first session)', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: 'First session output without summary' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
// No previousContent means no scaffold
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: undefined, // No previous content
status: 'pipeline_step1',
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
expect(saveFeatureSummary).toHaveBeenCalled();
const savedSummary = saveFeatureSummary.mock.calls[0][2];
expect(savedSummary).toBe('First session output without summary');
});
it('should handle non-pipeline status without saving fallback', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: 'Output without summary' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: 'Previous content',
status: 'implementing', // Non-pipeline status
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
// Should NOT save fallback for non-pipeline status
expect(saveFeatureSummary).not.toHaveBeenCalled();
});
it('should correctly handle content that starts with dashes but is not scaffold', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
// Content that looks like it might have dashes but is actual content
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: '---This is a code comment or separator---' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: undefined,
status: 'pipeline_step1',
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
expect(saveFeatureSummary).toHaveBeenCalled();
const savedSummary = saveFeatureSummary.mock.calls[0][2];
// Content should be preserved since it's not the scaffold pattern
expect(savedSummary).toContain('---This is a code comment or separator---');
});
it('should handle scaffold at different positions in content', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
mockSettingsService
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: 'Content after scaffold marker' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
// With previousContent, scaffold will be at the start of sessionContent
const options: AgentExecutionOptions = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
previousContent: 'Previous content',
status: 'pipeline_step1',
};
const callbacks = {
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
saveFeatureSummary,
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
expect(saveFeatureSummary).toHaveBeenCalled();
const savedSummary = saveFeatureSummary.mock.calls[0][2];
// Scaffold should be stripped, only actual content remains
expect(savedSummary).toBe('Content after scaffold marker');
});
});
});

View File

@@ -1050,4 +1050,383 @@ describe('auto-loop-coordinator.ts', () => {
);
});
});
describe('auto_mode_idle emission timing (idle check fix)', () => {
it('emits auto_mode_idle when no features in any state (empty project)', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
vi.mocked(mockLoadAllFeatures).mockResolvedValue([]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration and idle event
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('does NOT emit auto_mode_idle when features are in in_progress status', async () => {
// No pending features (backlog/ready)
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
// But there are features in in_progress status
const inProgressFeature: Feature = {
...testFeature,
id: 'feature-1',
status: 'in_progress',
title: 'In Progress Feature',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([inProgressFeature]);
// No running features in concurrency manager (they were released during status update)
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should NOT emit auto_mode_idle because there's an in_progress feature
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('emits auto_mode_idle after in_progress feature completes', async () => {
const completedFeature: Feature = {
...testFeature,
id: 'feature-1',
status: 'completed',
title: 'Completed Feature',
};
// Initially has in_progress feature
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
vi.mocked(mockLoadAllFeatures).mockResolvedValue([completedFeature]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should emit auto_mode_idle because all features are completed
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('does NOT emit auto_mode_idle for in_progress features in main worktree (no branchName)', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
// Feature in main worktree has no branchName
const mainWorktreeFeature: Feature = {
...testFeature,
id: 'feature-main',
status: 'in_progress',
title: 'Main Worktree Feature',
branchName: undefined, // Main worktree feature
};
// Feature in branch worktree has branchName
const branchFeature: Feature = {
...testFeature,
id: 'feature-branch',
status: 'in_progress',
title: 'Branch Feature',
branchName: 'feature/some-branch',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([mainWorktreeFeature, branchFeature]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
// Start auto mode for main worktree
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should NOT emit auto_mode_idle because there's an in_progress feature in main worktree
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith(
'auto_mode_idle',
expect.objectContaining({
projectPath: '/test/project',
branchName: null,
})
);
});
it('does NOT emit auto_mode_idle for in_progress features with matching branchName', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
// Feature in matching branch
const matchingBranchFeature: Feature = {
...testFeature,
id: 'feature-matching',
status: 'in_progress',
title: 'Matching Branch Feature',
branchName: 'feature/test-branch',
};
// Feature in different branch
const differentBranchFeature: Feature = {
...testFeature,
id: 'feature-different',
status: 'in_progress',
title: 'Different Branch Feature',
branchName: 'feature/other-branch',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([
matchingBranchFeature,
differentBranchFeature,
]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
// Start auto mode for feature/test-branch
await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch');
// Should NOT emit auto_mode_idle because there's an in_progress feature with matching branch
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith(
'auto_mode_idle',
expect.objectContaining({
projectPath: '/test/project',
branchName: 'feature/test-branch',
})
);
});
it('emits auto_mode_idle when in_progress feature has different branchName', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
// Only feature is in a different branch
const differentBranchFeature: Feature = {
...testFeature,
id: 'feature-different',
status: 'in_progress',
title: 'Different Branch Feature',
branchName: 'feature/other-branch',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([differentBranchFeature]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
// Start auto mode for feature/test-branch
await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch');
// Should emit auto_mode_idle because the in_progress feature is in a different branch
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: 'feature/test-branch',
});
});
it('emits auto_mode_idle when only backlog/ready features exist and no running/in_progress features', async () => {
// backlog/ready features should be in loadPendingFeatures, not loadAllFeatures for idle check
// But this test verifies the idle check doesn't incorrectly block on backlog/ready
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No pending (for current iteration check)
const backlogFeature: Feature = {
...testFeature,
id: 'feature-1',
status: 'backlog',
title: 'Backlog Feature',
};
const readyFeature: Feature = {
...testFeature,
id: 'feature-2',
status: 'ready',
title: 'Ready Feature',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([backlogFeature, readyFeature]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should NOT emit auto_mode_idle because there are backlog/ready features
// (even though they're not in_progress, the idle check only looks at in_progress status)
// Actually, backlog/ready would be caught by loadPendingFeatures on next iteration,
// so this should emit idle since runningCount=0 and no in_progress features
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('handles loadAllFeaturesFn error gracefully (falls back to emitting idle)', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
vi.mocked(mockLoadAllFeatures).mockRejectedValue(new Error('Failed to load features'));
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should still emit auto_mode_idle when loadAllFeatures fails (defensive behavior)
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('handles missing loadAllFeaturesFn gracefully (falls back to emitting idle)', async () => {
// Create coordinator without loadAllFeaturesFn
const coordWithoutLoadAll = new AutoLoopCoordinator(
mockEventBus,
mockConcurrencyManager,
mockSettingsService,
mockExecuteFeature,
mockLoadPendingFeatures,
mockSaveExecutionState,
mockClearExecutionState,
mockResetStuckFeatures,
mockIsFeatureFinished,
mockIsFeatureRunning
// loadAllFeaturesFn omitted
);
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordWithoutLoadAll.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordWithoutLoadAll.stopAutoLoopForProject('/test/project', null);
// Should emit auto_mode_idle when loadAllFeaturesFn is missing (defensive behavior)
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('only emits auto_mode_idle once per idle period (hasEmittedIdleEvent flag)', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
vi.mocked(mockLoadAllFeatures).mockResolvedValue([]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time multiple times to trigger multiple loop iterations
await vi.advanceTimersByTimeAsync(11000); // First idle check
await vi.advanceTimersByTimeAsync(11000); // Second idle check
await vi.advanceTimersByTimeAsync(11000); // Third idle check
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should only emit auto_mode_idle once despite multiple iterations
const idleCalls = vi
.mocked(mockEventBus.emitAutoModeEvent)
.mock.calls.filter((call) => call[0] === 'auto_mode_idle');
expect(idleCalls.length).toBe(1);
});
it('premature auto_mode_idle bug scenario: runningCount=0 but feature still in_progress', async () => {
// This test reproduces the exact bug scenario described in the feature:
// When a feature completes, there's a brief window where:
// 1. The feature has been released from runningFeatures (so runningCount = 0)
// 2. The feature's status is still 'in_progress' during the status update transition
// 3. pendingFeatures returns empty (only checks 'backlog'/'ready' statuses)
// The fix ensures auto_mode_idle is NOT emitted in this window
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No backlog/ready features
// Feature is still in in_progress status (during status update transition)
const transitioningFeature: Feature = {
...testFeature,
id: 'feature-1',
status: 'in_progress',
title: 'Transitioning Feature',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([transitioningFeature]);
// Feature has been released from concurrency manager (runningCount = 0)
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// The fix prevents auto_mode_idle from being emitted in this scenario
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
});
});

View File

@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import { AutoModeServiceFacade } from '@/services/auto-mode/facade.js';
import type { Feature } from '@automaker/types';
describe('AutoModeServiceFacade', () => {
describe('isFeatureEligibleForAutoMode', () => {
it('should include features with pipeline_* status', () => {
const features: Partial<Feature>[] = [
{ id: '1', status: 'ready', branchName: 'main' },
{ id: '2', status: 'pipeline_testing', branchName: 'main' },
{ id: '3', status: 'in_progress', branchName: 'main' },
{ id: '4', status: 'interrupted', branchName: 'main' },
{ id: '5', status: 'backlog', branchName: 'main' },
];
const branchName = 'main';
const primaryBranch = 'main';
const filtered = features.filter((f) =>
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, branchName, primaryBranch)
);
expect(filtered.map((f) => f.id)).toContain('1'); // ready
expect(filtered.map((f) => f.id)).toContain('2'); // pipeline_testing
expect(filtered.map((f) => f.id)).toContain('4'); // interrupted
expect(filtered.map((f) => f.id)).toContain('5'); // backlog
expect(filtered.map((f) => f.id)).not.toContain('3'); // in_progress
});
it('should correctly handle main worktree alignment', () => {
const features: Partial<Feature>[] = [
{ id: '1', status: 'ready', branchName: undefined },
{ id: '2', status: 'ready', branchName: 'main' },
{ id: '3', status: 'ready', branchName: 'other' },
];
const branchName = null; // main worktree
const primaryBranch = 'main';
const filtered = features.filter((f) =>
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, branchName, primaryBranch)
);
expect(filtered.map((f) => f.id)).toContain('1'); // no branch
expect(filtered.map((f) => f.id)).toContain('2'); // matching primary branch
expect(filtered.map((f) => f.id)).not.toContain('3'); // mismatching branch
});
it('should exclude completed, verified, and waiting_approval statuses', () => {
const features: Partial<Feature>[] = [
{ id: '1', status: 'completed', branchName: 'main' },
{ id: '2', status: 'verified', branchName: 'main' },
{ id: '3', status: 'waiting_approval', branchName: 'main' },
];
const filtered = features.filter((f) =>
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, 'main', 'main')
);
expect(filtered).toHaveLength(0);
});
it('should include pipeline_complete as eligible (still a pipeline status)', () => {
const feature: Partial<Feature> = {
id: '1',
status: 'pipeline_complete',
branchName: 'main',
};
const result = AutoModeServiceFacade.isFeatureEligibleForAutoMode(
feature as Feature,
'main',
'main'
);
expect(result).toBe(true);
});
it('should filter pipeline features by branch in named worktrees', () => {
const features: Partial<Feature>[] = [
{ id: '1', status: 'pipeline_testing', branchName: 'feature-branch' },
{ id: '2', status: 'pipeline_review', branchName: 'other-branch' },
{ id: '3', status: 'pipeline_deploy', branchName: undefined },
];
const filtered = features.filter((f) =>
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, 'feature-branch', null)
);
expect(filtered.map((f) => f.id)).toEqual(['1']);
});
it('should handle null primaryBranch for main worktree', () => {
const features: Partial<Feature>[] = [
{ id: '1', status: 'ready', branchName: undefined },
{ id: '2', status: 'ready', branchName: 'main' },
];
const filtered = features.filter((f) =>
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, null, null)
);
// When primaryBranch is null, only features with no branchName are included
expect(filtered.map((f) => f.id)).toEqual(['1']);
});
it('should include various pipeline_* step IDs as eligible', () => {
const statuses = [
'pipeline_step_abc_123',
'pipeline_code_review',
'pipeline_step1',
'pipeline_testing',
'pipeline_deploy',
];
for (const status of statuses) {
const feature: Partial<Feature> = { id: '1', status, branchName: 'main' };
const result = AutoModeServiceFacade.isFeatureEligibleForAutoMode(
feature as Feature,
'main',
'main'
);
expect(result).toBe(true);
}
});
});
});

View File

@@ -0,0 +1,207 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock dependencies (hoisted)
vi.mock('../../../../src/services/agent-executor.js');
vi.mock('../../../../src/lib/settings-helpers.js');
vi.mock('../../../../src/providers/provider-factory.js');
vi.mock('../../../../src/lib/sdk-options.js');
vi.mock('@automaker/model-resolver', () => ({
resolveModelString: vi.fn((model, fallback) => model || fallback),
DEFAULT_MODELS: { claude: 'claude-3-5-sonnet' },
}));
import { AutoModeServiceFacade } from '../../../../src/services/auto-mode/facade.js';
import { AgentExecutor } from '../../../../src/services/agent-executor.js';
import * as settingsHelpers from '../../../../src/lib/settings-helpers.js';
import { ProviderFactory } from '../../../../src/providers/provider-factory.js';
import * as sdkOptions from '../../../../src/lib/sdk-options.js';
describe('AutoModeServiceFacade Agent Runner', () => {
let mockAgentExecutor: MockAgentExecutor;
let mockSettingsService: MockSettingsService;
let facade: AutoModeServiceFacade;
// Type definitions for mocks
interface MockAgentExecutor {
execute: ReturnType<typeof vi.fn>;
}
interface MockSettingsService {
getGlobalSettings: ReturnType<typeof vi.fn>;
getCredentials: ReturnType<typeof vi.fn>;
getProjectSettings: ReturnType<typeof vi.fn>;
}
beforeEach(() => {
vi.clearAllMocks();
// Set up the mock for createAutoModeOptions
// Note: Using 'as any' because Options type from SDK is complex and we only need
// the specific fields that are verified in tests (maxTurns, allowedTools, etc.)
vi.mocked(sdkOptions.createAutoModeOptions).mockReturnValue({
maxTurns: 123,
allowedTools: ['tool1'],
systemPrompt: 'system-prompt',
} as any);
mockAgentExecutor = {
execute: vi.fn().mockResolvedValue(undefined),
};
(AgentExecutor as any).mockImplementation(function (this: MockAgentExecutor) {
return mockAgentExecutor;
});
mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({}),
getCredentials: vi.fn().mockResolvedValue({}),
getProjectSettings: vi.fn().mockResolvedValue({}),
};
// Helper to access the private createRunAgentFn via factory creation
facade = AutoModeServiceFacade.create('/project', {
events: { on: vi.fn(), emit: vi.fn(), subscribe: vi.fn().mockReturnValue(vi.fn()) } as any,
settingsService: mockSettingsService,
sharedServices: {
eventBus: { emitAutoModeEvent: vi.fn() } as any,
worktreeResolver: { getCurrentBranch: vi.fn().mockResolvedValue('main') } as any,
concurrencyManager: {
isRunning: vi.fn().mockReturnValue(false),
getRunningFeature: vi.fn().mockReturnValue(null),
} as any,
} as any,
});
});
it('should resolve provider by providerId and pass to AgentExecutor', async () => {
// 1. Setup mocks
const mockProvider = { getName: () => 'mock-provider' };
(ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider);
const mockClaudeProvider = { id: 'zai-1', name: 'Zai' };
const mockCredentials = { apiKey: 'test-key' };
(settingsHelpers.resolveProviderContext as any).mockResolvedValue({
provider: mockClaudeProvider,
credentials: mockCredentials,
resolvedModel: undefined,
});
const runAgentFn = (facade as any).executionService.runAgentFn;
// 2. Execute
await runAgentFn(
'/workdir',
'feature-1',
'prompt',
new AbortController(),
'/project',
[],
'model-1',
{
providerId: 'zai-1',
}
);
// 3. Verify
expect(settingsHelpers.resolveProviderContext).toHaveBeenCalledWith(
mockSettingsService,
'model-1',
'zai-1',
'[AutoModeFacade]'
);
expect(mockAgentExecutor.execute).toHaveBeenCalledWith(
expect.objectContaining({
claudeCompatibleProvider: mockClaudeProvider,
credentials: mockCredentials,
model: 'model-1', // Original model ID
}),
expect.any(Object)
);
});
it('should fallback to model-based lookup if providerId is not provided', async () => {
const mockProvider = { getName: () => 'mock-provider' };
(ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider);
const mockClaudeProvider = { id: 'zai-model', name: 'Zai Model' };
(settingsHelpers.resolveProviderContext as any).mockResolvedValue({
provider: mockClaudeProvider,
credentials: { apiKey: 'model-key' },
resolvedModel: 'resolved-model-1',
});
const runAgentFn = (facade as any).executionService.runAgentFn;
await runAgentFn(
'/workdir',
'feature-1',
'prompt',
new AbortController(),
'/project',
[],
'model-1',
{
// no providerId
}
);
expect(settingsHelpers.resolveProviderContext).toHaveBeenCalledWith(
mockSettingsService,
'model-1',
undefined,
'[AutoModeFacade]'
);
expect(mockAgentExecutor.execute).toHaveBeenCalledWith(
expect.objectContaining({
claudeCompatibleProvider: mockClaudeProvider,
}),
expect.any(Object)
);
});
it('should use resolvedModel from provider config for createAutoModeOptions if it maps to a Claude model', async () => {
const mockProvider = { getName: () => 'mock-provider' };
(ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider);
const mockClaudeProvider = {
id: 'zai-1',
name: 'Zai',
models: [{ id: 'custom-model-1', mapsToClaudeModel: 'claude-3-opus' }],
};
(settingsHelpers.resolveProviderContext as any).mockResolvedValue({
provider: mockClaudeProvider,
credentials: { apiKey: 'test-key' },
resolvedModel: 'claude-3-5-opus',
});
const runAgentFn = (facade as any).executionService.runAgentFn;
await runAgentFn(
'/workdir',
'feature-1',
'prompt',
new AbortController(),
'/project',
[],
'custom-model-1',
{
providerId: 'zai-1',
}
);
// Verify createAutoModeOptions was called with the mapped model
expect(sdkOptions.createAutoModeOptions).toHaveBeenCalledWith(
expect.objectContaining({
model: 'claude-3-5-opus',
})
);
// Verify AgentExecutor.execute still gets the original custom model ID
expect(mockAgentExecutor.execute).toHaveBeenCalledWith(
expect.objectContaining({
model: 'custom-model-1',
}),
expect.any(Object)
);
});
});

View File

@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import path from 'path';
import os from 'os';
import fs from 'fs/promises';
import { spawn } from 'child_process';
// Mock child_process
vi.mock('child_process', () => ({
spawn: vi.fn(),
execSync: vi.fn(),
execFile: vi.fn(),
}));
// Mock secure-fs
vi.mock('@/lib/secure-fs.js', () => ({
access: vi.fn(),
}));
// Mock net
vi.mock('net', () => ({
default: {
createServer: vi.fn(),
},
createServer: vi.fn(),
}));
import * as secureFs from '@/lib/secure-fs.js';
import net from 'net';
describe('DevServerService Event Types', () => {
let testDataDir: string;
let worktreeDir: string;
let mockEmitter: EventEmitter;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
testDataDir = path.join(os.tmpdir(), `dev-server-events-test-${Date.now()}`);
worktreeDir = path.join(os.tmpdir(), `dev-server-worktree-events-test-${Date.now()}`);
await fs.mkdir(testDataDir, { recursive: true });
await fs.mkdir(worktreeDir, { recursive: true });
mockEmitter = new EventEmitter();
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockServer = new EventEmitter() as any;
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
process.nextTick(() => mockServer.emit('listening'));
});
mockServer.close = vi.fn();
vi.mocked(net.createServer).mockReturnValue(mockServer);
});
afterEach(async () => {
try {
await fs.rm(testDataDir, { recursive: true, force: true });
await fs.rm(worktreeDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
it('should emit all required event types during dev server lifecycle', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.initialize(testDataDir, mockEmitter as any);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const emittedEvents: Record<string, any[]> = {
'dev-server:starting': [],
'dev-server:started': [],
'dev-server:url-detected': [],
'dev-server:output': [],
'dev-server:stopped': [],
};
Object.keys(emittedEvents).forEach((type) => {
mockEmitter.on(type, (payload) => emittedEvents[type].push(payload));
});
// 1. Starting & Started
await service.startDevServer(worktreeDir, worktreeDir);
expect(emittedEvents['dev-server:starting'].length).toBe(1);
expect(emittedEvents['dev-server:started'].length).toBe(1);
// 2. Output & URL Detected
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n'));
// Throttled output needs a bit of time (OUTPUT_THROTTLE_MS is 100ms)
await new Promise((resolve) => setTimeout(resolve, 250));
expect(emittedEvents['dev-server:output'].length).toBeGreaterThanOrEqual(1);
expect(emittedEvents['dev-server:url-detected'].length).toBe(1);
expect(emittedEvents['dev-server:url-detected'][0].url).toBe('http://localhost:5173/');
// 3. Stopped
await service.stopDevServer(worktreeDir);
expect(emittedEvents['dev-server:stopped'].length).toBe(1);
});
});
// Helper to create a mock child process
function createMockProcess() {
const mockProcess = new EventEmitter() as any;
mockProcess.stdout = new EventEmitter();
mockProcess.stderr = new EventEmitter();
mockProcess.kill = vi.fn();
mockProcess.killed = false;
mockProcess.pid = 12345;
mockProcess.unref = vi.fn();
return mockProcess;
}

View File

@@ -0,0 +1,240 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import path from 'path';
import os from 'os';
import fs from 'fs/promises';
import { spawn, execSync } from 'child_process';
// Mock child_process
vi.mock('child_process', () => ({
spawn: vi.fn(),
execSync: vi.fn(),
execFile: vi.fn(),
}));
// Mock secure-fs
vi.mock('@/lib/secure-fs.js', () => ({
access: vi.fn(),
}));
// Mock net
vi.mock('net', () => ({
default: {
createServer: vi.fn(),
},
createServer: vi.fn(),
}));
import * as secureFs from '@/lib/secure-fs.js';
import net from 'net';
describe('DevServerService Persistence & Sync', () => {
let testDataDir: string;
let worktreeDir: string;
let mockEmitter: EventEmitter;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
testDataDir = path.join(os.tmpdir(), `dev-server-persistence-test-${Date.now()}`);
worktreeDir = path.join(os.tmpdir(), `dev-server-worktree-test-${Date.now()}`);
await fs.mkdir(testDataDir, { recursive: true });
await fs.mkdir(worktreeDir, { recursive: true });
mockEmitter = new EventEmitter();
// Default mock for secureFs.access - return resolved (file exists)
vi.mocked(secureFs.access).mockResolvedValue(undefined);
// Default mock for net.createServer - port available
const mockServer = new EventEmitter() as any;
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
process.nextTick(() => mockServer.emit('listening'));
});
mockServer.close = vi.fn();
vi.mocked(net.createServer).mockReturnValue(mockServer);
// Default mock for execSync - no process on port
vi.mocked(execSync).mockImplementation(() => {
throw new Error('No process found');
});
});
afterEach(async () => {
try {
await fs.rm(testDataDir, { recursive: true, force: true });
await fs.rm(worktreeDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
it('should emit dev-server:starting when startDevServer is called', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.initialize(testDataDir, mockEmitter as any);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const events: any[] = [];
mockEmitter.on('dev-server:starting', (payload) => events.push(payload));
await service.startDevServer(worktreeDir, worktreeDir);
expect(events.length).toBe(1);
expect(events[0].worktreePath).toBe(worktreeDir);
});
it('should prevent concurrent starts for the same worktree', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.initialize(testDataDir, mockEmitter as any);
// Delay spawn to simulate long starting time
vi.mocked(spawn).mockImplementation(() => {
const p = createMockProcess();
// Don't return immediately, simulate some work
return p as any;
});
// Start first one (don't await yet if we want to test concurrency)
const promise1 = service.startDevServer(worktreeDir, worktreeDir);
// Try to start second one immediately
const result2 = await service.startDevServer(worktreeDir, worktreeDir);
expect(result2.success).toBe(false);
expect(result2.error).toContain('already starting');
await promise1;
});
it('should persist state to dev-servers.json when started', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.initialize(testDataDir, mockEmitter as any);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
await service.startDevServer(worktreeDir, worktreeDir);
const statePath = path.join(testDataDir, 'dev-servers.json');
const exists = await fs
.access(statePath)
.then(() => true)
.catch(() => false);
expect(exists).toBe(true);
const content = await fs.readFile(statePath, 'utf-8');
const state = JSON.parse(content);
expect(state.length).toBe(1);
expect(state[0].worktreePath).toBe(worktreeDir);
});
it('should load state from dev-servers.json on initialize', async () => {
// 1. Create a fake state file
const persistedInfo = [
{
worktreePath: worktreeDir,
allocatedPort: 3005,
port: 3005,
url: 'http://localhost:3005',
startedAt: new Date().toISOString(),
urlDetected: true,
customCommand: 'npm run dev',
},
];
await fs.writeFile(path.join(testDataDir, 'dev-servers.json'), JSON.stringify(persistedInfo));
// 2. Mock port as IN USE (so it re-attaches)
const mockServer = new EventEmitter() as any;
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
// Fail to listen = port in use
process.nextTick(() => mockServer.emit('error', new Error('EADDRINUSE')));
});
vi.mocked(net.createServer).mockReturnValue(mockServer);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.initialize(testDataDir, mockEmitter as any);
expect(service.isRunning(worktreeDir)).toBe(true);
const info = service.getServerInfo(worktreeDir);
expect(info?.port).toBe(3005);
});
it('should prune stale servers from state on initialize if port is available', async () => {
// 1. Create a fake state file
const persistedInfo = [
{
worktreePath: worktreeDir,
allocatedPort: 3005,
port: 3005,
url: 'http://localhost:3005',
startedAt: new Date().toISOString(),
urlDetected: true,
},
];
await fs.writeFile(path.join(testDataDir, 'dev-servers.json'), JSON.stringify(persistedInfo));
// 2. Mock port as AVAILABLE (so it prunes)
const mockServer = new EventEmitter() as any;
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
process.nextTick(() => mockServer.emit('listening'));
});
mockServer.close = vi.fn();
vi.mocked(net.createServer).mockReturnValue(mockServer);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.initialize(testDataDir, mockEmitter as any);
expect(service.isRunning(worktreeDir)).toBe(false);
// Give it a moment to complete the pruning saveState
await new Promise((resolve) => setTimeout(resolve, 100));
// Check if file was updated
const content = await fs.readFile(path.join(testDataDir, 'dev-servers.json'), 'utf-8');
const state = JSON.parse(content);
expect(state.length).toBe(0);
});
it('should update persisted state when URL is detected', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.initialize(testDataDir, mockEmitter as any);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
await service.startDevServer(worktreeDir, worktreeDir);
// Simulate output with URL
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5555/\n'));
// Give it a moment to process and save (needs to wait for saveQueue)
await new Promise((resolve) => setTimeout(resolve, 300));
const content = await fs.readFile(path.join(testDataDir, 'dev-servers.json'), 'utf-8');
const state = JSON.parse(content);
expect(state[0].url).toBe('http://localhost:5555/');
expect(state[0].port).toBe(5555);
expect(state[0].urlDetected).toBe(true);
});
});
// Helper to create a mock child process
function createMockProcess() {
const mockProcess = new EventEmitter() as any;
mockProcess.stdout = new EventEmitter();
mockProcess.stderr = new EventEmitter();
mockProcess.kill = vi.fn();
mockProcess.killed = false;
mockProcess.pid = 12345;
mockProcess.unref = vi.fn();
return mockProcess;
}

View File

@@ -5,6 +5,9 @@ import type { SettingsService } from '../../../src/services/settings-service.js'
import type { EventHistoryService } from '../../../src/services/event-history-service.js';
import type { FeatureLoader } from '../../../src/services/feature-loader.js';
// Mock global fetch for ntfy tests
const originalFetch = global.fetch;
/**
* Create a mock EventEmitter for testing
*/
@@ -38,9 +41,15 @@ function createMockEventEmitter(): EventEmitter & {
/**
* Create a mock SettingsService
*/
function createMockSettingsService(hooks: unknown[] = []): SettingsService {
function createMockSettingsService(
hooks: unknown[] = [],
ntfyEndpoints: unknown[] = []
): SettingsService {
return {
getGlobalSettings: vi.fn().mockResolvedValue({ eventHooks: hooks }),
getGlobalSettings: vi.fn().mockResolvedValue({
eventHooks: hooks,
ntfyEndpoints: ntfyEndpoints,
}),
} as unknown as SettingsService;
}
@@ -70,6 +79,7 @@ describe('EventHookService', () => {
let mockSettingsService: ReturnType<typeof createMockSettingsService>;
let mockEventHistoryService: ReturnType<typeof createMockEventHistoryService>;
let mockFeatureLoader: ReturnType<typeof createMockFeatureLoader>;
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
service = new EventHookService();
@@ -77,10 +87,14 @@ describe('EventHookService', () => {
mockSettingsService = createMockSettingsService();
mockEventHistoryService = createMockEventHistoryService();
mockFeatureLoader = createMockFeatureLoader();
// Set up mock fetch for ntfy tests
mockFetch = vi.fn();
global.fetch = mockFetch;
});
afterEach(() => {
service.destroy();
global.fetch = originalFetch;
});
describe('initialize', () => {
@@ -832,4 +846,631 @@ describe('EventHookService', () => {
expect(storeCall.error).toBe('Feature stopped by user');
});
});
describe('ntfy hook execution', () => {
const mockNtfyEndpoint = {
id: 'endpoint-1',
name: 'Test Endpoint',
serverUrl: 'https://ntfy.sh',
topic: 'test-topic',
authType: 'none' as const,
enabled: true,
};
it('should execute ntfy hook when endpoint is configured', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Success Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
title: 'Feature {{featureName}} completed!',
priority: 3,
},
},
];
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('https://ntfy.sh/test-topic');
expect(options.method).toBe('POST');
expect(options.headers['Title']).toBe('Feature Test Feature completed!');
});
it('should NOT execute ntfy hook when endpoint is not found', async () => {
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook with Missing Endpoint',
action: {
type: 'ntfy',
endpointId: 'non-existent-endpoint',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
});
// Fetch should NOT have been called since endpoint doesn't exist
expect(mockFetch).not.toHaveBeenCalled();
});
it('should use ntfy endpoint default values when hook does not override', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaults = {
...mockNtfyEndpoint,
defaultTags: 'default-tag',
defaultEmoji: 'tada',
defaultClickUrl: 'https://default.example.com',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_error',
name: 'Ntfy Error Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
// No title, tags, or emoji - should use endpoint defaults
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaults]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Failed Feature',
passes: false,
message: 'Something went wrong',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
// Should use default tags and emoji from endpoint
expect(options.headers['Tags']).toBe('tada,default-tag');
// Click URL gets deep-link query param when feature context is available
expect(options.headers['Click']).toContain('https://default.example.com/board');
expect(options.headers['Click']).toContain('featureId=feat-1');
});
it('should send ntfy notification with authentication', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithAuth = {
...mockNtfyEndpoint,
authType: 'token' as const,
token: 'tk_test_token',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Authenticated Ntfy Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithAuth]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Authorization']).toBe('Bearer tk_test_token');
});
it('should handle ntfy notification failure gracefully', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook That Will Fail',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
// Should not throw - error should be caught gracefully
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
// Event should still be stored even if ntfy hook fails
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
});
it('should substitute variables in ntfy title and body', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook with Variables',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
title: '[{{projectName}}] {{featureName}}',
body: 'Feature {{featureId}} completed at {{timestamp}}',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-123',
featureName: 'Cool Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/my-project',
projectName: 'my-project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('[my-project] Cool Feature');
expect(options.body).toContain('feat-123');
});
it('should NOT execute ntfy hook when endpoint is disabled', async () => {
const disabledEndpoint = {
...mockNtfyEndpoint,
enabled: false,
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook with Disabled Endpoint',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [disabledEndpoint]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
});
// Fetch should not be called because endpoint is disabled
expect(mockFetch).not.toHaveBeenCalled();
});
it('should use hook-specific values over endpoint defaults', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaults = {
...mockNtfyEndpoint,
defaultTags: 'default-tag',
defaultEmoji: 'default-emoji',
defaultClickUrl: 'https://default.example.com',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook with Overrides',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
tags: 'override-tag',
emoji: 'override-emoji',
clickUrl: 'https://override.example.com',
priority: 5,
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaults]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
// Hook values should override endpoint defaults
expect(options.headers['Tags']).toBe('override-emoji,override-tag');
// Click URL uses hook-specific base URL with deep link params applied
expect(options.headers['Click']).toContain('https://override.example.com/board');
expect(options.headers['Click']).toContain('featureId=feat-1');
expect(options.headers['Priority']).toBe('5');
});
describe('click URL deep linking', () => {
it('should generate board URL with featureId query param when feature context is available', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaultClickUrl = {
...mockNtfyEndpoint,
defaultClickUrl: 'https://app.example.com',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'test-feature-123',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
const clickUrl = options.headers['Click'];
// Should use /board path with featureId query param
expect(clickUrl).toContain('/board');
expect(clickUrl).toContain('featureId=test-feature-123');
// Should NOT use the old path-based format
expect(clickUrl).not.toContain('/feature/');
});
it('should generate board URL without featureId when no feature context', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaultClickUrl = {
...mockNtfyEndpoint,
defaultClickUrl: 'https://app.example.com',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'auto_mode_complete',
name: 'Auto Mode Complete Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
// Event without featureId but with projectPath (auto_mode_idle triggers auto_mode_complete)
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_idle',
executionMode: 'auto',
projectPath: '/test/project',
totalFeatures: 5,
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
const clickUrl = options.headers['Click'];
// Should navigate to board without featureId
expect(clickUrl).toContain('/board');
expect(clickUrl).not.toContain('featureId=');
});
it('should apply deep link params to hook-specific click URL', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaultClickUrl = {
...mockNtfyEndpoint,
defaultClickUrl: 'https://default.example.com',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook with Custom Click URL',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
clickUrl: 'https://custom.example.com/custom-page',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-789',
featureName: 'Custom URL Test',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
const clickUrl = options.headers['Click'];
// Should use the hook-specific click URL with deep link params applied
expect(clickUrl).toContain('https://custom.example.com/board');
expect(clickUrl).toContain('featureId=feat-789');
});
it('should preserve existing query params when adding featureId', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaultClickUrl = {
...mockNtfyEndpoint,
defaultClickUrl: 'https://app.example.com/board?view=list',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-456',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
const clickUrl = options.headers['Click'];
// Should preserve existing query params and add featureId
expect(clickUrl).toContain('view=list');
expect(clickUrl).toContain('featureId=feat-456');
// Should be properly formatted URL
expect(clickUrl).toMatch(/^https:\/\/app\.example\.com\/board\?.+$/);
});
});
});
});

View File

@@ -451,13 +451,28 @@ describe('execution-service.ts', () => {
const callArgs = mockRunAgentFn.mock.calls[0];
expect(callArgs[0]).toMatch(/test.*project/); // workDir contains project
expect(callArgs[1]).toBe('feature-1');
expect(callArgs[2]).toContain('Feature Implementation Task');
expect(callArgs[2]).toContain('Feature Task');
expect(callArgs[3]).toBeInstanceOf(AbortController);
expect(callArgs[4]).toBe('/test/project');
// Model (index 6) should be resolved
expect(callArgs[6]).toBe('claude-sonnet-4');
});
it('passes providerId to runAgentFn when present on feature', async () => {
const featureWithProvider: Feature = {
...testFeature,
providerId: 'zai-provider-1',
};
vi.mocked(mockLoadFeatureFn).mockResolvedValue(featureWithProvider);
await service.executeFeature('/test/project', 'feature-1');
expect(mockRunAgentFn).toHaveBeenCalled();
const callArgs = mockRunAgentFn.mock.calls[0];
const options = callArgs[7];
expect(options.providerId).toBe('zai-provider-1');
});
it('executes pipeline after agent completes', async () => {
const pipelineSteps = [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }];
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
@@ -1316,16 +1331,19 @@ describe('execution-service.ts', () => {
);
});
it('falls back to project path when worktree not found', async () => {
it('emits error and does not execute agent when worktree is not found in worktree mode', async () => {
vi.mocked(mockWorktreeResolver.findWorktreeForBranch).mockResolvedValue(null);
await service.executeFeature('/test/project', 'feature-1', true);
// Should still run agent, just with project path
expect(mockRunAgentFn).toHaveBeenCalled();
const callArgs = mockRunAgentFn.mock.calls[0];
// First argument is workDir - should be normalized path to /test/project
expect(callArgs[0]).toBe(normalizePath('/test/project'));
expect(mockRunAgentFn).not.toHaveBeenCalled();
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
'auto_mode_error',
expect.objectContaining({
featureId: 'feature-1',
error: 'Worktree enabled but no worktree found for feature branch "feature/test-1".',
})
);
});
it('skips worktree resolution when useWorktrees is false', async () => {
@@ -1439,6 +1457,114 @@ describe('execution-service.ts', () => {
expect.objectContaining({ passes: true })
);
});
// Helper to create ExecutionService with a custom loadFeatureFn that returns
// different features on first load (initial) vs subsequent loads (after completion)
const createServiceWithCustomLoad = (completedFeature: Feature): ExecutionService => {
let loadCallCount = 0;
mockLoadFeatureFn = vi.fn().mockImplementation(() => {
loadCallCount++;
return loadCallCount === 1 ? testFeature : completedFeature;
});
return new ExecutionService(
mockEventBus,
mockConcurrencyManager,
mockWorktreeResolver,
mockSettingsService,
mockRunAgentFn,
mockExecutePipelineFn,
mockUpdateFeatureStatusFn,
mockLoadFeatureFn,
mockGetPlanningPromptPrefixFn,
mockSaveFeatureSummaryFn,
mockRecordLearningsFn,
mockContextExistsFn,
mockResumeFeatureFn,
mockTrackFailureFn,
mockSignalPauseFn,
mockRecordSuccessFn,
mockSaveExecutionStateFn,
mockLoadContextFilesFn
);
};
it('does not overwrite accumulated summary when feature already has one', async () => {
const featureWithAccumulatedSummary: Feature = {
...testFeature,
summary:
'### Implementation\n\nFirst step output\n\n---\n\n### Code Review\n\nReview findings',
};
const svc = createServiceWithCustomLoad(featureWithAccumulatedSummary);
await svc.executeFeature('/test/project', 'feature-1');
// saveFeatureSummaryFn should NOT be called because feature already has a summary
// This prevents overwriting accumulated pipeline summaries with just the last step's output
expect(mockSaveFeatureSummaryFn).not.toHaveBeenCalled();
});
it('saves summary when feature has no existing summary', async () => {
const featureWithoutSummary: Feature = {
...testFeature,
summary: undefined,
};
vi.mocked(secureFs.readFile).mockResolvedValue(
'🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts"}\n\n<summary>New summary</summary>'
);
const svc = createServiceWithCustomLoad(featureWithoutSummary);
await svc.executeFeature('/test/project', 'feature-1');
// Should save the extracted summary since feature has none
expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith(
'/test/project',
'feature-1',
'Test summary'
);
});
it('does not overwrite summary when feature has empty string summary (treats as no summary)', async () => {
// Empty string is falsy, so it should be treated as "no summary" and a new one should be saved
const featureWithEmptySummary: Feature = {
...testFeature,
summary: '',
};
vi.mocked(secureFs.readFile).mockResolvedValue(
'🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts"}\n\n<summary>New summary</summary>'
);
const svc = createServiceWithCustomLoad(featureWithEmptySummary);
await svc.executeFeature('/test/project', 'feature-1');
// Empty string is falsy, so it should save a new summary
expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith(
'/test/project',
'feature-1',
'Test summary'
);
});
it('preserves accumulated summary when feature is transitioned from pipeline to verified', async () => {
// This is the key scenario: feature went through pipeline steps, accumulated a summary,
// then status changed to 'verified' - we should NOT overwrite the accumulated summary
const featureWithAccumulatedSummary: Feature = {
...testFeature,
status: 'verified',
summary:
'### Implementation\n\nCreated auth module\n\n---\n\n### Code Review\n\nApproved\n\n---\n\n### Testing\n\nAll tests pass',
};
vi.mocked(secureFs.readFile).mockResolvedValue('Agent output with summary');
const svc = createServiceWithCustomLoad(featureWithAccumulatedSummary);
await svc.executeFeature('/test/project', 'feature-1');
// The accumulated summary should be preserved
expect(mockSaveFeatureSummaryFn).not.toHaveBeenCalled();
});
});
describe('executeFeature - agent output validation', () => {
@@ -1874,5 +2000,60 @@ describe('execution-service.ts', () => {
// The only non-in_progress status call should be absent since merge_conflict returns early
expect(statusCalls.length).toBe(0);
});
it('sets waiting_approval instead of backlog when error occurs after pipeline completes', async () => {
// Set up pipeline with steps
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
version: 1,
steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any,
});
// Pipeline succeeds, but reading agent output throws after pipeline completes
mockExecutePipelineFn = vi.fn().mockResolvedValue(undefined);
// Simulate an error after pipeline completes by making loadFeature throw
// on the post-pipeline refresh call
let loadCallCount = 0;
mockLoadFeatureFn = vi.fn().mockImplementation(() => {
loadCallCount++;
if (loadCallCount === 1) return testFeature; // initial load
// Second call is the task-retry check, third is the pipeline refresh
if (loadCallCount <= 2) return testFeature;
throw new Error('Unexpected post-pipeline error');
});
const svc = createServiceWithMocks();
await svc.executeFeature('/test/project', 'feature-1');
// Should set to waiting_approval, NOT backlog, since pipeline completed
const backlogCalls = vi
.mocked(mockUpdateFeatureStatusFn)
.mock.calls.filter((call) => call[2] === 'backlog');
expect(backlogCalls.length).toBe(0);
const waitingCalls = vi
.mocked(mockUpdateFeatureStatusFn)
.mock.calls.filter((call) => call[2] === 'waiting_approval');
expect(waitingCalls.length).toBeGreaterThan(0);
});
it('still sets backlog when error occurs before pipeline completes', async () => {
// Set up pipeline with steps
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
version: 1,
steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any,
});
// Pipeline itself throws (e.g., agent error during pipeline step)
mockExecutePipelineFn = vi.fn().mockRejectedValue(new Error('Agent execution failed'));
const svc = createServiceWithMocks();
await svc.executeFeature('/test/project', 'feature-1');
// Should still set to backlog since pipeline did NOT complete
const backlogCalls = vi
.mocked(mockUpdateFeatureStatusFn)
.mock.calls.filter((call) => call[2] === 'backlog');
expect(backlogCalls.length).toBe(1);
});
});
});

View File

@@ -2,12 +2,17 @@ import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import path from 'path';
import { FeatureStateManager } from '@/services/feature-state-manager.js';
import type { Feature } from '@automaker/types';
import { isPipelineStatus } from '@automaker/types';
const PIPELINE_SUMMARY_SEPARATOR = '\n\n---\n\n';
const PIPELINE_SUMMARY_HEADER_PREFIX = '### ';
import type { EventEmitter } from '@/lib/events.js';
import type { FeatureLoader } from '@/services/feature-loader.js';
import * as secureFs from '@/lib/secure-fs.js';
import { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils';
import { getFeatureDir, getFeaturesDir } from '@automaker/platform';
import { getNotificationService } from '@/services/notification-service.js';
import { pipelineService } from '@/services/pipeline-service.js';
/**
* Helper to normalize paths for cross-platform test compatibility.
@@ -42,6 +47,16 @@ vi.mock('@/services/notification-service.js', () => ({
})),
}));
vi.mock('@/services/pipeline-service.js', () => ({
pipelineService: {
getStepIdFromStatus: vi.fn((status: string) => {
if (status.startsWith('pipeline_')) return status.replace('pipeline_', '');
return null;
}),
getStep: vi.fn(),
},
}));
describe('FeatureStateManager', () => {
let manager: FeatureStateManager;
let mockEvents: EventEmitter;
@@ -264,6 +279,81 @@ describe('FeatureStateManager', () => {
);
});
it('should use feature.title as notification title for waiting_approval status', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithTitle: Feature = {
...mockFeature,
title: 'My Awesome Feature Title',
name: 'old-name-property', // name property exists but should not be used
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_waiting_approval',
title: 'My Awesome Feature Title',
message: 'Feature Ready for Review',
})
);
});
it('should fallback to featureId as notification title when feature.title is undefined in waiting_approval notification', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithoutTitle: Feature = {
...mockFeature,
title: undefined,
name: 'old-name-property',
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithoutTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_waiting_approval',
title: 'feature-123',
message: 'Feature Ready for Review',
})
);
});
it('should handle empty string title by using featureId as notification title in waiting_approval notification', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithEmptyTitle: Feature = {
...mockFeature,
title: '',
name: 'old-name-property',
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithEmptyTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_waiting_approval',
title: 'feature-123',
message: 'Feature Ready for Review',
})
);
});
it('should create notification for verified status', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
@@ -283,6 +373,81 @@ describe('FeatureStateManager', () => {
);
});
it('should use feature.title as notification title for verified status', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithTitle: Feature = {
...mockFeature,
title: 'My Awesome Feature Title',
name: 'old-name-property', // name property exists but should not be used
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_verified',
title: 'My Awesome Feature Title',
message: 'Feature Verified',
})
);
});
it('should fallback to featureId as notification title when feature.title is undefined in verified notification', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithoutTitle: Feature = {
...mockFeature,
title: undefined,
name: 'old-name-property',
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithoutTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_verified',
title: 'feature-123',
message: 'Feature Verified',
})
);
});
it('should handle empty string title by using featureId as notification title in verified notification', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithEmptyTitle: Feature = {
...mockFeature,
title: '',
name: 'old-name-property',
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithEmptyTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_verified',
title: 'feature-123',
message: 'Feature Verified',
})
);
});
it('should sync to app_spec for completed status', async () => {
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature },
@@ -341,9 +506,6 @@ describe('FeatureStateManager', () => {
describe('markFeatureInterrupted', () => {
it('should mark feature as interrupted', async () => {
(secureFs.readFile as Mock).mockResolvedValue(
JSON.stringify({ ...mockFeature, status: 'in_progress' })
);
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'in_progress' },
recovered: false,
@@ -358,20 +520,25 @@ describe('FeatureStateManager', () => {
});
it('should preserve pipeline_* statuses', async () => {
(secureFs.readFile as Mock).mockResolvedValue(
JSON.stringify({ ...mockFeature, status: 'pipeline_step_1' })
);
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step_1' },
recovered: false,
source: 'main',
});
await manager.markFeatureInterrupted('/project', 'feature-123', 'server shutdown');
// Should NOT call atomicWriteJson because pipeline status is preserved
expect(atomicWriteJson).not.toHaveBeenCalled();
expect(isPipelineStatus('pipeline_step_1')).toBe(true);
});
it('should preserve pipeline_complete status', async () => {
(secureFs.readFile as Mock).mockResolvedValue(
JSON.stringify({ ...mockFeature, status: 'pipeline_complete' })
);
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_complete' },
recovered: false,
source: 'main',
});
await manager.markFeatureInterrupted('/project', 'feature-123');
@@ -379,7 +546,6 @@ describe('FeatureStateManager', () => {
});
it('should handle feature not found', async () => {
(secureFs.readFile as Mock).mockRejectedValue(new Error('ENOENT'));
(readJsonWithRecovery as Mock).mockResolvedValue({
data: null,
recovered: true,
@@ -439,6 +605,29 @@ describe('FeatureStateManager', () => {
expect(savedFeature.status).toBe('backlog');
});
it('should preserve pipeline_* statuses during reset', async () => {
const pipelineFeature: Feature = {
...mockFeature,
status: 'pipeline_testing',
planSpec: { status: 'approved', version: 1, reviewedByUser: true },
};
(secureFs.readdir as Mock).mockResolvedValue([
{ name: 'feature-123', isDirectory: () => true },
]);
(readJsonWithRecovery as Mock).mockResolvedValue({
data: pipelineFeature,
recovered: false,
source: 'main',
});
await manager.resetStuckFeatures('/project');
// Status should NOT be changed, but needsUpdate might be true if other things reset
// In this case, nothing else should be reset, so atomicWriteJson shouldn't be called
expect(atomicWriteJson).not.toHaveBeenCalled();
});
it('should reset generating planSpec status to pending', async () => {
const stuckFeature: Feature = {
...mockFeature,
@@ -628,6 +817,379 @@ describe('FeatureStateManager', () => {
expect(atomicWriteJson).not.toHaveBeenCalled();
expect(mockEvents.emit).not.toHaveBeenCalled();
});
it('should accumulate summary with step header for pipeline features', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'First step output');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output`
);
});
it('should append subsequent pipeline step summaries with separator', async () => {
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output`;
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Second step output');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nSecond step output`
);
});
it('should normalize existing non-phase summary before appending pipeline step summary', async () => {
const existingSummary = 'Implemented authentication and settings management.';
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Reviewed and approved changes');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nImplemented authentication and settings management.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nReviewed and approved changes`
);
});
it('should use fallback step name when pipeline step not found', async () => {
(pipelineService.getStep as Mock).mockResolvedValue(null);
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_unknown_step', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Step output');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Unknown Step\n\nStep output`
);
});
it('should overwrite summary for non-pipeline features', async () => {
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'in_progress', summary: 'Old summary' },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'New summary');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe('New summary');
});
it('should emit full accumulated summary for pipeline features', async () => {
const existingSummary = '### Code Review\n\nFirst step output';
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Refinement', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Refinement output');
const expectedSummary =
'### Code Review\n\nFirst step output\n\n---\n\n### Refinement\n\nRefinement output';
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_summary',
featureId: 'feature-123',
projectPath: '/project',
summary: expectedSummary,
});
});
it('should skip accumulation for pipeline features when summary is empty', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: '' },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Test output');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Empty string is falsy, so should start fresh
expect(savedFeature.summary).toBe('### Testing\n\nTest output');
});
it('should skip persistence when incoming summary is only whitespace', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: '### Existing\n\nValue' },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', ' \n\t ');
expect(atomicWriteJson).not.toHaveBeenCalled();
expect(mockEvents.emit).not.toHaveBeenCalled();
});
it('should accumulate three pipeline steps in chronological order', async () => {
// Step 1: Code Review
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Review findings');
const afterStep1 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(afterStep1.summary).toBe('### Code Review\n\nReview findings');
// Step 2: Testing (summary from step 1 exists)
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/feature-123');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step2', summary: afterStep1.summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'All tests pass');
const afterStep2 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Step 3: Refinement (summaries from steps 1+2 exist)
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/feature-123');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Refinement', id: 'step3' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step3', summary: afterStep2.summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Code polished');
const afterStep3 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Verify the full accumulated summary has all three steps in order
expect(afterStep3.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nReview findings${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Refinement\n\nCode polished`
);
});
it('should replace existing step summary if called again for the same step', async () => {
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst review attempt`;
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'feature-123',
'Second review attempt (success)'
);
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Should REPLACE "First review attempt" with "Second review attempt (success)"
// and NOT append it as a new section
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nSecond review attempt (success)`
);
// Ensure it didn't duplicate the separator or header
expect(
savedFeature.summary.match(new RegExp(PIPELINE_SUMMARY_HEADER_PREFIX + 'Code Review', 'g'))
?.length
).toBe(1);
expect(
savedFeature.summary.match(new RegExp(PIPELINE_SUMMARY_SEPARATOR.trim(), 'g'))?.length
).toBe(1);
});
it('should replace last step summary without trailing separator', async () => {
// Test case: replacing the last step which has no separator after it
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nFirst test run`;
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'All tests pass');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass`
);
});
it('should replace first step summary with separator after it', async () => {
// Test case: replacing the first step which has a separator after it
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nFirst attempt${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass`;
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nSecond attempt${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass`
);
});
it('should not match step header appearing in body text, only at section boundaries', async () => {
// Test case: body text contains "### Testing" which should NOT be matched
// Only headers at actual section boundaries should be replaced
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nThis step covers the Testing module.\n\n### Testing\n\nThe above is just markdown in body, not a section header.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nReal test section`;
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Updated test results');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// The section replacement should only replace the actual Testing section at the boundary
// NOT the "### Testing" that appears in the body text
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nThis step covers the Testing module.\n\n### Testing\n\nThe above is just markdown in body, not a section header.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nUpdated test results`
);
});
it('should handle step name with special regex characters safely', async () => {
// Test case: step name contains characters that would break regex
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Code (Review)\n\nFirst attempt`;
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code (Review)', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code (Review)\n\nSecond attempt`
);
});
it('should handle step name with brackets safely', async () => {
// Test case: step name contains array-like syntax [0]
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Step [0]\n\nFirst attempt`;
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Step [0]', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Step [0]\n\nSecond attempt`
);
});
it('should handle pipelineService.getStepIdFromStatus throwing an error gracefully', async () => {
(pipelineService.getStepIdFromStatus as Mock).mockImplementation(() => {
throw new Error('Config not found');
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_my_step', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Step output');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Should use fallback: capitalize each word in the status suffix
expect(savedFeature.summary).toBe(`${PIPELINE_SUMMARY_HEADER_PREFIX}My Step\n\nStep output`);
});
it('should handle pipelineService.getStep throwing an error gracefully', async () => {
(pipelineService.getStep as Mock).mockRejectedValue(new Error('Disk read error'));
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_code_review', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Step output');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Should use fallback: capitalize each word in the status suffix
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nStep output`
);
});
it('should handle summary content with markdown formatting', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
const markdownSummary =
'## Changes Made\n- Fixed **bug** in `parser.ts`\n- Added `validateInput()` function\n\n```typescript\nconst x = 1;\n```';
await manager.saveFeatureSummary('/project', 'feature-123', markdownSummary);
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\n${markdownSummary}`
);
});
it('should persist before emitting event for pipeline summary accumulation', async () => {
const callOrder: string[] = [];
const existingSummary = '### Code Review\n\nFirst step output';
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
recovered: false,
source: 'main',
});
(atomicWriteJson as Mock).mockImplementation(async () => {
callOrder.push('persist');
});
(mockEvents.emit as Mock).mockImplementation(() => {
callOrder.push('emit');
});
await manager.saveFeatureSummary('/project', 'feature-123', 'Test results');
expect(callOrder).toEqual(['persist', 'emit']);
});
});
describe('updateTaskStatus', () => {
@@ -668,6 +1230,48 @@ describe('FeatureStateManager', () => {
});
});
it('should update task status and summary and emit event', async () => {
const featureWithTasks: Feature = {
...mockFeature,
planSpec: {
status: 'approved',
version: 1,
reviewedByUser: true,
tasks: [{ id: 'task-1', title: 'Task 1', status: 'pending', description: '' }],
},
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithTasks,
recovered: false,
source: 'main',
});
await manager.updateTaskStatus(
'/project',
'feature-123',
'task-1',
'completed',
'Task finished successfully'
);
// Verify persisted
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed');
expect(savedFeature.planSpec?.tasks?.[0].summary).toBe('Task finished successfully');
// Verify event emitted
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_task_status',
featureId: 'feature-123',
projectPath: '/project',
taskId: 'task-1',
status: 'completed',
summary: 'Task finished successfully',
tasks: expect.any(Array),
});
});
it('should handle task not found', async () => {
const featureWithTasks: Feature = {
...mockFeature,
@@ -757,4 +1361,179 @@ describe('FeatureStateManager', () => {
expect(callOrder).toEqual(['persist', 'emit']);
});
});
describe('handleAutoModeEventError', () => {
let subscribeCallback: (type: string, payload: unknown) => void;
beforeEach(() => {
// Get the subscribe callback from the mock - the callback passed TO subscribe is at index [0]
// subscribe is called like: events.subscribe(callback), so callback is at mock.calls[0][0]
const mockCalls = (mockEvents.subscribe as Mock).mock.calls;
if (mockCalls.length > 0 && mockCalls[0].length > 0) {
subscribeCallback = mockCalls[0][0] as typeof subscribeCallback;
}
});
it('should ignore events with no type', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {});
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
});
it('should ignore non-error events', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {
type: 'auto_mode_feature_complete',
passes: true,
projectPath: '/project',
});
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
});
it('should create auto_mode_error notification with gesture name as title when no featureId', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {
type: 'auto_mode_error',
message: 'Something went wrong',
projectPath: '/project',
});
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'auto_mode_error',
title: 'Auto Mode Error',
message: 'Something went wrong',
projectPath: '/project',
})
);
});
it('should use error field instead of message when available', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {
type: 'auto_mode_error',
message: 'Some message',
error: 'The actual error',
projectPath: '/project',
});
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'auto_mode_error',
message: 'The actual error',
})
);
});
it('should use feature title as notification title for feature error with featureId', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, title: 'Login Page Feature' },
recovered: false,
source: 'main',
});
subscribeCallback('auto-mode:event', {
type: 'auto_mode_feature_complete',
passes: false,
featureId: 'feature-123',
error: 'Build failed',
projectPath: '/project',
});
// Wait for async handleAutoModeEventError to complete
await vi.waitFor(() => {
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_error',
title: 'Login Page Feature',
message: 'Feature Failed: Build failed',
featureId: 'feature-123',
})
);
});
});
it('should ignore auto_mode_feature_complete without passes=false', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {
type: 'auto_mode_feature_complete',
passes: true,
projectPath: '/project',
});
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
});
it('should handle missing projectPath gracefully', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {
type: 'auto_mode_error',
message: 'Error occurred',
});
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
});
it('should handle notification service failures gracefully', async () => {
(getNotificationService as Mock).mockImplementation(() => {
throw new Error('Service unavailable');
});
// Should not throw - the callback returns void so we just call it and wait for async work
subscribeCallback('auto-mode:event', {
type: 'auto_mode_error',
message: 'Error',
projectPath: '/project',
});
// Give async handleAutoModeEventError time to complete
await new Promise((resolve) => setTimeout(resolve, 0));
});
});
describe('destroy', () => {
it('should unsubscribe from event subscription', () => {
const unsubscribeFn = vi.fn();
(mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn);
// Create a new manager to get a fresh subscription
const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader);
// Call destroy
newManager.destroy();
// Verify unsubscribe was called
expect(unsubscribeFn).toHaveBeenCalled();
});
it('should handle destroy being called multiple times', () => {
const unsubscribeFn = vi.fn();
(mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn);
const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader);
// Call destroy multiple times
newManager.destroy();
newManager.destroy();
// Should only unsubscribe once
expect(unsubscribeFn).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,642 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NtfyService } from '../../../src/services/ntfy-service.js';
import type { NtfyEndpointConfig } from '@automaker/types';
// Mock global fetch
const originalFetch = global.fetch;
describe('NtfyService', () => {
let service: NtfyService;
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
service = new NtfyService();
mockFetch = vi.fn();
global.fetch = mockFetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
/**
* Create a valid endpoint config for testing
*/
function createEndpoint(overrides: Partial<NtfyEndpointConfig> = {}): NtfyEndpointConfig {
return {
id: 'test-endpoint-id',
name: 'Test Endpoint',
serverUrl: 'https://ntfy.sh',
topic: 'test-topic',
authType: 'none',
enabled: true,
...overrides,
};
}
/**
* Create a basic context for testing
*/
function createContext() {
return {
featureId: 'feat-123',
featureName: 'Test Feature',
projectPath: '/test/project',
projectName: 'test-project',
timestamp: '2024-01-15T10:30:00.000Z',
eventType: 'feature_success',
};
}
describe('validateEndpoint', () => {
it('should return null for valid endpoint with no auth', () => {
const endpoint = createEndpoint();
const result = service.validateEndpoint(endpoint);
expect(result).toBeNull();
});
it('should return null for valid endpoint with basic auth', () => {
const endpoint = createEndpoint({
authType: 'basic',
username: 'user',
password: 'pass',
});
const result = service.validateEndpoint(endpoint);
expect(result).toBeNull();
});
it('should return null for valid endpoint with token auth', () => {
const endpoint = createEndpoint({
authType: 'token',
token: 'tk_123456',
});
const result = service.validateEndpoint(endpoint);
expect(result).toBeNull();
});
it('should return error when serverUrl is missing', () => {
const endpoint = createEndpoint({ serverUrl: '' });
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Server URL is required');
});
it('should return error when serverUrl is invalid', () => {
const endpoint = createEndpoint({ serverUrl: 'not-a-valid-url' });
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Invalid server URL format');
});
it('should return error when topic is missing', () => {
const endpoint = createEndpoint({ topic: '' });
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Topic is required');
});
it('should return error when topic contains spaces', () => {
const endpoint = createEndpoint({ topic: 'invalid topic' });
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Topic cannot contain spaces');
});
it('should return error when topic contains tabs', () => {
const endpoint = createEndpoint({ topic: 'invalid\ttopic' });
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Topic cannot contain spaces');
});
it('should return error when basic auth is missing username', () => {
const endpoint = createEndpoint({
authType: 'basic',
username: '',
password: 'pass',
});
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Username and password are required for basic authentication');
});
it('should return error when basic auth is missing password', () => {
const endpoint = createEndpoint({
authType: 'basic',
username: 'user',
password: '',
});
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Username and password are required for basic authentication');
});
it('should return error when token auth is missing token', () => {
const endpoint = createEndpoint({
authType: 'token',
token: '',
});
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Access token is required for token authentication');
});
});
describe('sendNotification', () => {
it('should return error when endpoint is disabled', async () => {
const endpoint = createEndpoint({ enabled: false });
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(false);
expect(result.error).toBe('Endpoint is disabled');
expect(mockFetch).not.toHaveBeenCalled();
});
it('should return error when endpoint validation fails', async () => {
const endpoint = createEndpoint({ serverUrl: '' });
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(false);
expect(result.error).toBe('Server URL is required');
expect(mockFetch).not.toHaveBeenCalled();
});
it('should send notification with default values', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint();
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('https://ntfy.sh/test-topic');
expect(options.method).toBe('POST');
expect(options.headers['Content-Type']).toBe('text/plain; charset=utf-8');
expect(options.headers['Title']).toContain('Feature Completed');
expect(options.headers['Priority']).toBe('3');
});
it('should send notification with custom title and body', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint();
const result = await service.sendNotification(
endpoint,
{
title: 'Custom Title',
body: 'Custom body message',
},
createContext()
);
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Custom Title');
expect(options.body).toBe('Custom body message');
});
it('should send notification with tags and emoji', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint();
const result = await service.sendNotification(
endpoint,
{
tags: 'warning,skull',
emoji: 'tada',
},
createContext()
);
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Tags']).toBe('tada,warning,skull');
});
it('should send notification with priority', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint();
const result = await service.sendNotification(endpoint, { priority: 5 }, createContext());
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Priority']).toBe('5');
});
it('should send notification with click URL', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint();
const result = await service.sendNotification(
endpoint,
{ clickUrl: 'https://example.com/feature/123' },
createContext()
);
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Click']).toBe('https://example.com/feature/123');
});
it('should use endpoint default tags and emoji when not specified', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({
defaultTags: 'default-tag',
defaultEmoji: 'rocket',
});
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Tags']).toBe('rocket,default-tag');
});
it('should use endpoint default click URL when not specified', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({
defaultClickUrl: 'https://default.example.com',
});
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Click']).toBe('https://default.example.com');
});
it('should send notification with basic authentication', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({
authType: 'basic',
username: 'testuser',
password: 'testpass',
});
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
// Basic auth should be base64 encoded
const expectedAuth = Buffer.from('testuser:testpass').toString('base64');
expect(options.headers['Authorization']).toBe(`Basic ${expectedAuth}`);
});
it('should send notification with token authentication', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({
authType: 'token',
token: 'tk_test_token_123',
});
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Authorization']).toBe('Bearer tk_test_token_123');
});
it('should return error on HTTP error response', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
text: () => Promise.resolve('Forbidden - invalid token'),
});
const endpoint = createEndpoint();
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(false);
expect(result.error).toContain('403');
expect(result.error).toContain('Forbidden');
});
it('should return error on timeout', async () => {
mockFetch.mockImplementationOnce(() => {
const error = new Error('Aborted');
error.name = 'AbortError';
throw error;
});
const endpoint = createEndpoint();
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(false);
expect(result.error).toBe('Request timed out');
});
it('should return error on network error', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const endpoint = createEndpoint();
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(false);
expect(result.error).toBe('Network error');
});
it('should handle server URL with trailing slash', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({ serverUrl: 'https://ntfy.sh/' });
await service.sendNotification(endpoint, {}, createContext());
const url = mockFetch.mock.calls[0][0];
expect(url).toBe('https://ntfy.sh/test-topic');
});
it('should URL encode the topic', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({ topic: 'test/topic#special' });
await service.sendNotification(endpoint, {}, createContext());
const url = mockFetch.mock.calls[0][0];
expect(url).toContain('test%2Ftopic%23special');
});
});
describe('variable substitution', () => {
it('should substitute {{featureId}} in title', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ title: 'Feature {{featureId}} completed' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Feature feat-123 completed');
});
it('should substitute {{featureName}} in body', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ body: 'The feature "{{featureName}}" is done!' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.body).toBe('The feature "Test Feature" is done!');
});
it('should substitute {{projectName}} in title', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ title: '[{{projectName}}] Event: {{eventType}}' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('[test-project] Event: feature_success');
});
it('should substitute {{timestamp}} in body', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ body: 'Completed at: {{timestamp}}' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.body).toBe('Completed at: 2024-01-15T10:30:00.000Z');
});
it('should substitute {{error}} in body for error events', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = {
...createContext(),
eventType: 'feature_error',
error: 'Something went wrong',
};
await service.sendNotification(endpoint, { title: 'Error: {{error}}' }, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Error: Something went wrong');
});
it('should substitute multiple variables', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{
title: '[{{projectName}}] {{featureName}}',
body: 'Feature {{featureId}} completed at {{timestamp}}',
},
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('[test-project] Test Feature');
expect(options.body).toBe('Feature feat-123 completed at 2024-01-15T10:30:00.000Z');
});
it('should replace unknown variables with empty string', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ title: 'Value: {{unknownVariable}}' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Value: ');
});
});
describe('default title generation', () => {
it('should generate title with feature name for feature_success', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(endpoint, {}, createContext());
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Feature Completed: Test Feature');
});
it('should generate title without feature name when missing', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = { ...createContext(), featureName: undefined };
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Feature Completed');
});
it('should generate correct title for feature_created', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = { ...createContext(), eventType: 'feature_created' };
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Feature Created: Test Feature');
});
it('should generate correct title for feature_error', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = { ...createContext(), eventType: 'feature_error' };
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Feature Failed: Test Feature');
});
it('should generate correct title for auto_mode_complete', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = {
...createContext(),
eventType: 'auto_mode_complete',
featureName: undefined,
};
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Auto Mode Complete');
});
it('should generate correct title for auto_mode_error', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = { ...createContext(), eventType: 'auto_mode_error', featureName: undefined };
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Auto Mode Error');
});
});
describe('default body generation', () => {
it('should generate body with feature info', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(endpoint, {}, createContext());
const options = mockFetch.mock.calls[0][1];
expect(options.body).toContain('Feature: Test Feature');
expect(options.body).toContain('ID: feat-123');
expect(options.body).toContain('Project: test-project');
expect(options.body).toContain('Time: 2024-01-15T10:30:00.000Z');
});
it('should include error in body for error events', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = {
...createContext(),
eventType: 'feature_error',
error: 'Build failed',
};
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.body).toContain('Error: Build failed');
});
});
describe('emoji and tags handling', () => {
it('should handle emoji shortcode with colons', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(endpoint, { emoji: ':tada:' }, createContext());
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Tags']).toBe('tada');
});
it('should handle emoji without colons', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(endpoint, { emoji: 'warning' }, createContext());
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Tags']).toBe('warning');
});
it('should combine emoji and tags correctly', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ emoji: 'rotating_light', tags: 'urgent,alert' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
// Emoji comes first, then tags
expect(options.headers['Tags']).toBe('rotating_light,urgent,alert');
});
it('should ignore emoji with spaces', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ emoji: 'multi word emoji', tags: 'test' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Tags']).toBe('test');
});
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from 'vitest';
import { PipelineOrchestrator } from '../../../src/services/pipeline-orchestrator.js';
import type { Feature } from '@automaker/types';
describe('PipelineOrchestrator Prompts', () => {
const mockFeature: Feature = {
id: 'feature-123',
title: 'Test Feature',
description: 'A test feature',
status: 'in_progress',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tasks: [],
};
const mockBuildFeaturePrompt = (feature: Feature) => `Feature: ${feature.title}`;
it('should include mandatory summary requirement in pipeline step prompt', () => {
const orchestrator = new PipelineOrchestrator(
null as any, // eventBus
null as any, // featureStateManager
null as any, // agentExecutor
null as any, // testRunnerService
null as any, // worktreeResolver
null as any, // concurrencyManager
null as any, // settingsService
null as any, // updateFeatureStatusFn
null as any, // loadContextFilesFn
mockBuildFeaturePrompt,
null as any, // executeFeatureFn
null as any // runAgentFn
);
const step = {
id: 'step1',
name: 'Code Review',
instructions: 'Review the code for quality.',
};
const prompt = orchestrator.buildPipelineStepPrompt(
step as any,
mockFeature,
'Previous work context',
{ implementationInstructions: '', playwrightVerificationInstructions: '' }
);
expect(prompt).toContain('## Pipeline Step: Code Review');
expect(prompt).toContain('Review the code for quality.');
expect(prompt).toContain(
'**CRITICAL: After completing the instructions, you MUST output a summary using this EXACT format:**'
);
expect(prompt).toContain('<summary>');
expect(prompt).toContain('## Summary: Code Review');
expect(prompt).toContain('</summary>');
expect(prompt).toContain('The <summary> and </summary> tags MUST be on their own lines.');
});
});

View File

@@ -0,0 +1,356 @@
/**
* Tests for providerId passthrough in PipelineOrchestrator
* Verifies that feature.providerId is forwarded to runAgentFn in both
* executePipeline (step execution) and executeTestStep (test fix) contexts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Feature, PipelineStep } from '@automaker/types';
import {
PipelineOrchestrator,
type PipelineContext,
type UpdateFeatureStatusFn,
type BuildFeaturePromptFn,
type ExecuteFeatureFn,
type RunAgentFn,
} from '../../../src/services/pipeline-orchestrator.js';
import type { TypedEventBus } from '../../../src/services/typed-event-bus.js';
import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js';
import type { AgentExecutor } from '../../../src/services/agent-executor.js';
import type { WorktreeResolver } from '../../../src/services/worktree-resolver.js';
import type { SettingsService } from '../../../src/services/settings-service.js';
import type { ConcurrencyManager } from '../../../src/services/concurrency-manager.js';
import type { TestRunnerService } from '../../../src/services/test-runner-service.js';
import * as secureFs from '../../../src/lib/secure-fs.js';
import { getFeatureDir } from '@automaker/platform';
import {
getPromptCustomization,
getAutoLoadClaudeMdSetting,
filterClaudeMdFromContext,
} from '../../../src/lib/settings-helpers.js';
// Mock pipelineService
vi.mock('../../../src/services/pipeline-service.js', () => ({
pipelineService: {
isPipelineStatus: vi.fn(),
getStepIdFromStatus: vi.fn(),
getPipelineConfig: vi.fn(),
getNextStatus: vi.fn(),
},
}));
// Mock merge-service
vi.mock('../../../src/services/merge-service.js', () => ({
performMerge: vi.fn().mockResolvedValue({ success: true }),
}));
// Mock secureFs
vi.mock('../../../src/lib/secure-fs.js', () => ({
readFile: vi.fn(),
access: vi.fn(),
}));
// Mock settings helpers
vi.mock('../../../src/lib/settings-helpers.js', () => ({
getPromptCustomization: vi.fn().mockResolvedValue({
taskExecution: {
implementationInstructions: 'test instructions',
playwrightVerificationInstructions: 'test playwright',
},
}),
getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true),
getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true),
filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'),
}));
// Mock validateWorkingDirectory
vi.mock('../../../src/lib/sdk-options.js', () => ({
validateWorkingDirectory: vi.fn(),
}));
// Mock platform
vi.mock('@automaker/platform', () => ({
getFeatureDir: vi
.fn()
.mockImplementation(
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
),
}));
// Mock model-resolver
vi.mock('@automaker/model-resolver', () => ({
resolveModelString: vi.fn().mockReturnValue('claude-sonnet-4'),
DEFAULT_MODELS: { claude: 'claude-sonnet-4' },
}));
describe('PipelineOrchestrator - providerId passthrough', () => {
let mockEventBus: TypedEventBus;
let mockFeatureStateManager: FeatureStateManager;
let mockAgentExecutor: AgentExecutor;
let mockTestRunnerService: TestRunnerService;
let mockWorktreeResolver: WorktreeResolver;
let mockConcurrencyManager: ConcurrencyManager;
let mockUpdateFeatureStatusFn: UpdateFeatureStatusFn;
let mockLoadContextFilesFn: vi.Mock;
let mockBuildFeaturePromptFn: BuildFeaturePromptFn;
let mockExecuteFeatureFn: ExecuteFeatureFn;
let mockRunAgentFn: RunAgentFn;
let orchestrator: PipelineOrchestrator;
const testSteps: PipelineStep[] = [
{
id: 'step-1',
name: 'Step 1',
order: 1,
instructions: 'Do step 1',
colorClass: 'blue',
createdAt: '',
updatedAt: '',
},
];
const createFeatureWithProvider = (providerId?: string): Feature => ({
id: 'feature-1',
title: 'Test Feature',
category: 'test',
description: 'Test description',
status: 'pipeline_step-1',
branchName: 'feature/test-1',
providerId,
});
beforeEach(() => {
vi.clearAllMocks();
mockEventBus = {
emitAutoModeEvent: vi.fn(),
getUnderlyingEmitter: vi.fn().mockReturnValue({}),
} as unknown as TypedEventBus;
mockFeatureStateManager = {
updateFeatureStatus: vi.fn().mockResolvedValue(undefined),
loadFeature: vi.fn().mockResolvedValue(createFeatureWithProvider()),
} as unknown as FeatureStateManager;
mockAgentExecutor = {
execute: vi.fn().mockResolvedValue({ success: true }),
} as unknown as AgentExecutor;
mockTestRunnerService = {
startTests: vi
.fn()
.mockResolvedValue({ success: true, result: { sessionId: 'test-session-1' } }),
getSession: vi.fn().mockReturnValue({
status: 'passed',
exitCode: 0,
startedAt: new Date(),
finishedAt: new Date(),
}),
getSessionOutput: vi
.fn()
.mockReturnValue({ success: true, result: { output: 'All tests passed' } }),
} as unknown as TestRunnerService;
mockWorktreeResolver = {
findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'),
getCurrentBranch: vi.fn().mockResolvedValue('main'),
} as unknown as WorktreeResolver;
mockConcurrencyManager = {
acquire: vi.fn().mockImplementation(({ featureId, isAutoMode }) => ({
featureId,
projectPath: '/test/project',
abortController: new AbortController(),
branchName: null,
worktreePath: null,
isAutoMode: isAutoMode ?? false,
})),
release: vi.fn(),
getRunningFeature: vi.fn().mockReturnValue(undefined),
} as unknown as ConcurrencyManager;
mockUpdateFeatureStatusFn = vi.fn().mockResolvedValue(undefined);
mockLoadContextFilesFn = vi.fn().mockResolvedValue({ contextPrompt: 'test context' });
mockBuildFeaturePromptFn = vi.fn().mockReturnValue('Feature prompt content');
mockExecuteFeatureFn = vi.fn().mockResolvedValue(undefined);
mockRunAgentFn = vi.fn().mockResolvedValue(undefined);
vi.mocked(secureFs.readFile).mockResolvedValue('Previous context');
vi.mocked(secureFs.access).mockResolvedValue(undefined);
vi.mocked(getFeatureDir).mockImplementation(
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
);
vi.mocked(getPromptCustomization).mockResolvedValue({
taskExecution: {
implementationInstructions: 'test instructions',
playwrightVerificationInstructions: 'test playwright',
},
} as any);
vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true);
vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt');
orchestrator = new PipelineOrchestrator(
mockEventBus,
mockFeatureStateManager,
mockAgentExecutor,
mockTestRunnerService,
mockWorktreeResolver,
mockConcurrencyManager,
null,
mockUpdateFeatureStatusFn,
mockLoadContextFilesFn,
mockBuildFeaturePromptFn,
mockExecuteFeatureFn,
mockRunAgentFn
);
});
describe('executePipeline', () => {
it('should pass providerId to runAgentFn options when feature has providerId', async () => {
const feature = createFeatureWithProvider('moonshot-ai');
const context: PipelineContext = {
projectPath: '/test/project',
featureId: 'feature-1',
feature,
steps: testSteps,
workDir: '/test/project',
worktreePath: null,
branchName: 'feature/test-1',
abortController: new AbortController(),
autoLoadClaudeMd: true,
testAttempts: 0,
maxTestAttempts: 5,
};
await orchestrator.executePipeline(context);
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('providerId', 'moonshot-ai');
});
it('should pass undefined providerId when feature has no providerId', async () => {
const feature = createFeatureWithProvider(undefined);
const context: PipelineContext = {
projectPath: '/test/project',
featureId: 'feature-1',
feature,
steps: testSteps,
workDir: '/test/project',
worktreePath: null,
branchName: 'feature/test-1',
abortController: new AbortController(),
autoLoadClaudeMd: true,
testAttempts: 0,
maxTestAttempts: 5,
};
await orchestrator.executePipeline(context);
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('providerId', undefined);
});
it('should pass status alongside providerId in options', async () => {
const feature = createFeatureWithProvider('zhipu');
const context: PipelineContext = {
projectPath: '/test/project',
featureId: 'feature-1',
feature,
steps: testSteps,
workDir: '/test/project',
worktreePath: null,
branchName: 'feature/test-1',
abortController: new AbortController(),
autoLoadClaudeMd: true,
testAttempts: 0,
maxTestAttempts: 5,
};
await orchestrator.executePipeline(context);
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('providerId', 'zhipu');
expect(options).toHaveProperty('status');
});
});
describe('executeTestStep', () => {
it('should pass providerId in test fix agent options when tests fail', async () => {
vi.mocked(mockTestRunnerService.getSession)
.mockReturnValueOnce({
status: 'failed',
exitCode: 1,
startedAt: new Date(),
finishedAt: new Date(),
} as never)
.mockReturnValueOnce({
status: 'passed',
exitCode: 0,
startedAt: new Date(),
finishedAt: new Date(),
} as never);
const feature = createFeatureWithProvider('custom-provider');
const context: PipelineContext = {
projectPath: '/test/project',
featureId: 'feature-1',
feature,
steps: testSteps,
workDir: '/test/project',
worktreePath: null,
branchName: 'feature/test-1',
abortController: new AbortController(),
autoLoadClaudeMd: true,
testAttempts: 0,
maxTestAttempts: 5,
};
await orchestrator.executeTestStep(context, 'npm test');
// The fix agent should receive providerId
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('providerId', 'custom-provider');
}, 15000);
it('should pass thinkingLevel in test fix agent options', async () => {
vi.mocked(mockTestRunnerService.getSession)
.mockReturnValueOnce({
status: 'failed',
exitCode: 1,
startedAt: new Date(),
finishedAt: new Date(),
} as never)
.mockReturnValueOnce({
status: 'passed',
exitCode: 0,
startedAt: new Date(),
finishedAt: new Date(),
} as never);
const feature = createFeatureWithProvider('moonshot-ai');
feature.thinkingLevel = 'high';
const context: PipelineContext = {
projectPath: '/test/project',
featureId: 'feature-1',
feature,
steps: testSteps,
workDir: '/test/project',
worktreePath: null,
branchName: 'feature/test-1',
abortController: new AbortController(),
autoLoadClaudeMd: true,
testAttempts: 0,
maxTestAttempts: 5,
};
await orchestrator.executeTestStep(context, 'npm test');
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('thinkingLevel', 'high');
expect(options).toHaveProperty('providerId', 'moonshot-ai');
}, 15000);
});
});

View File

@@ -0,0 +1,302 @@
/**
* Tests for status + providerId coexistence in PipelineOrchestrator options.
*
* During rebase onto upstream/v1.0.0rc, a merge conflict arose where
* upstream added `status: currentStatus` and the incoming branch added
* `providerId: feature.providerId`. The conflict resolution kept BOTH fields.
*
* This test validates that both fields coexist correctly in the options
* object passed to runAgentFn in both executePipeline and executeTestStep.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Feature, PipelineStep } from '@automaker/types';
import {
PipelineOrchestrator,
type PipelineContext,
type UpdateFeatureStatusFn,
type BuildFeaturePromptFn,
type ExecuteFeatureFn,
type RunAgentFn,
} from '../../../src/services/pipeline-orchestrator.js';
import type { TypedEventBus } from '../../../src/services/typed-event-bus.js';
import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js';
import type { AgentExecutor } from '../../../src/services/agent-executor.js';
import type { WorktreeResolver } from '../../../src/services/worktree-resolver.js';
import type { ConcurrencyManager } from '../../../src/services/concurrency-manager.js';
import type { TestRunnerService } from '../../../src/services/test-runner-service.js';
import * as secureFs from '../../../src/lib/secure-fs.js';
import { getFeatureDir } from '@automaker/platform';
import {
getPromptCustomization,
getAutoLoadClaudeMdSetting,
filterClaudeMdFromContext,
} from '../../../src/lib/settings-helpers.js';
vi.mock('../../../src/services/pipeline-service.js', () => ({
pipelineService: {
isPipelineStatus: vi.fn(),
getStepIdFromStatus: vi.fn(),
getPipelineConfig: vi.fn(),
getNextStatus: vi.fn(),
},
}));
vi.mock('../../../src/services/merge-service.js', () => ({
performMerge: vi.fn().mockResolvedValue({ success: true }),
}));
vi.mock('../../../src/lib/secure-fs.js', () => ({
readFile: vi.fn(),
access: vi.fn(),
}));
vi.mock('../../../src/lib/settings-helpers.js', () => ({
getPromptCustomization: vi.fn().mockResolvedValue({
taskExecution: {
implementationInstructions: 'test instructions',
playwrightVerificationInstructions: 'test playwright',
},
}),
getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true),
getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true),
filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'),
}));
vi.mock('../../../src/lib/sdk-options.js', () => ({
validateWorkingDirectory: vi.fn(),
}));
vi.mock('@automaker/platform', () => ({
getFeatureDir: vi
.fn()
.mockImplementation(
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
),
}));
vi.mock('@automaker/model-resolver', () => ({
resolveModelString: vi.fn().mockReturnValue('claude-sonnet-4'),
DEFAULT_MODELS: { claude: 'claude-sonnet-4' },
}));
describe('PipelineOrchestrator - status and providerId coexistence', () => {
let mockRunAgentFn: RunAgentFn;
let orchestrator: PipelineOrchestrator;
const testSteps: PipelineStep[] = [
{
id: 'implement',
name: 'Implement Feature',
order: 1,
instructions: 'Implement the feature',
colorClass: 'blue',
createdAt: '',
updatedAt: '',
},
];
const createFeature = (overrides: Partial<Feature> = {}): Feature => ({
id: 'feature-1',
title: 'Test Feature',
category: 'test',
description: 'Test description',
status: 'pipeline_implement',
branchName: 'feature/test-1',
providerId: 'moonshot-ai',
thinkingLevel: 'medium',
reasoningEffort: 'high',
...overrides,
});
const createContext = (feature: Feature): PipelineContext => ({
projectPath: '/test/project',
featureId: feature.id,
feature,
steps: testSteps,
workDir: '/test/project',
worktreePath: null,
branchName: feature.branchName ?? 'main',
abortController: new AbortController(),
autoLoadClaudeMd: true,
testAttempts: 0,
maxTestAttempts: 5,
});
beforeEach(() => {
vi.clearAllMocks();
mockRunAgentFn = vi.fn().mockResolvedValue(undefined);
vi.mocked(secureFs.readFile).mockResolvedValue('Previous context');
vi.mocked(secureFs.access).mockResolvedValue(undefined);
vi.mocked(getFeatureDir).mockImplementation(
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
);
vi.mocked(getPromptCustomization).mockResolvedValue({
taskExecution: {
implementationInstructions: 'test instructions',
playwrightVerificationInstructions: 'test playwright',
},
} as any);
vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true);
vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt');
const mockEventBus = {
emitAutoModeEvent: vi.fn(),
getUnderlyingEmitter: vi.fn().mockReturnValue({}),
} as unknown as TypedEventBus;
const mockFeatureStateManager = {
updateFeatureStatus: vi.fn().mockResolvedValue(undefined),
loadFeature: vi.fn().mockResolvedValue(createFeature()),
} as unknown as FeatureStateManager;
const mockTestRunnerService = {
startTests: vi
.fn()
.mockResolvedValue({ success: true, result: { sessionId: 'test-session-1' } }),
getSession: vi.fn().mockReturnValue({
status: 'passed',
exitCode: 0,
startedAt: new Date(),
finishedAt: new Date(),
}),
getSessionOutput: vi
.fn()
.mockReturnValue({ success: true, result: { output: 'All tests passed' } }),
} as unknown as TestRunnerService;
orchestrator = new PipelineOrchestrator(
mockEventBus,
mockFeatureStateManager,
{} as AgentExecutor,
mockTestRunnerService,
{
findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'),
getCurrentBranch: vi.fn().mockResolvedValue('main'),
} as unknown as WorktreeResolver,
{
acquire: vi.fn().mockImplementation(({ featureId }) => ({
featureId,
projectPath: '/test/project',
abortController: new AbortController(),
branchName: null,
worktreePath: null,
isAutoMode: false,
})),
release: vi.fn(),
getRunningFeature: vi.fn().mockReturnValue(undefined),
} as unknown as ConcurrencyManager,
null,
vi.fn().mockResolvedValue(undefined),
vi.fn().mockResolvedValue({ contextPrompt: 'test context' }),
vi.fn().mockReturnValue('Feature prompt content'),
vi.fn().mockResolvedValue(undefined),
mockRunAgentFn
);
});
describe('executePipeline - options object', () => {
it('should pass both status and providerId in options', async () => {
const feature = createFeature({ providerId: 'moonshot-ai' });
const context = createContext(feature);
await orchestrator.executePipeline(context);
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('status', 'pipeline_implement');
expect(options).toHaveProperty('providerId', 'moonshot-ai');
});
it('should pass status even when providerId is undefined', async () => {
const feature = createFeature({ providerId: undefined });
const context = createContext(feature);
await orchestrator.executePipeline(context);
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('status', 'pipeline_implement');
expect(options).toHaveProperty('providerId', undefined);
});
it('should pass thinkingLevel and reasoningEffort alongside status and providerId', async () => {
const feature = createFeature({
providerId: 'zhipu',
thinkingLevel: 'high',
reasoningEffort: 'medium',
});
const context = createContext(feature);
await orchestrator.executePipeline(context);
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('status', 'pipeline_implement');
expect(options).toHaveProperty('providerId', 'zhipu');
expect(options).toHaveProperty('thinkingLevel', 'high');
expect(options).toHaveProperty('reasoningEffort', 'medium');
});
});
describe('executeTestStep - options object', () => {
it('should pass both status and providerId in test fix agent options', async () => {
const feature = createFeature({
status: 'running',
providerId: 'custom-provider',
});
const context = createContext(feature);
const mockTestRunner = orchestrator['testRunnerService'] as any;
vi.mocked(mockTestRunner.getSession)
.mockReturnValueOnce({
status: 'failed',
exitCode: 1,
startedAt: new Date(),
finishedAt: new Date(),
})
.mockReturnValueOnce({
status: 'passed',
exitCode: 0,
startedAt: new Date(),
finishedAt: new Date(),
});
await orchestrator.executeTestStep(context, 'npm test');
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
const options = mockRunAgentFn.mock.calls[0][7];
expect(options).toHaveProperty('status', 'running');
expect(options).toHaveProperty('providerId', 'custom-provider');
}, 15000);
it('should pass feature.status (not currentStatus) in test fix context', async () => {
const feature = createFeature({
status: 'pipeline_test',
providerId: 'moonshot-ai',
});
const context = createContext(feature);
const mockTestRunner = orchestrator['testRunnerService'] as any;
vi.mocked(mockTestRunner.getSession)
.mockReturnValueOnce({
status: 'failed',
exitCode: 1,
startedAt: new Date(),
finishedAt: new Date(),
})
.mockReturnValueOnce({
status: 'passed',
exitCode: 0,
startedAt: new Date(),
finishedAt: new Date(),
});
await orchestrator.executeTestStep(context, 'npm test');
const options = mockRunAgentFn.mock.calls[0][7];
// In test fix context, status should come from context.feature.status
expect(options).toHaveProperty('status', 'pipeline_test');
expect(options).toHaveProperty('providerId', 'moonshot-ai');
}, 15000);
});
});

View File

@@ -0,0 +1,598 @@
/**
* Integration tests for pipeline summary accumulation across multiple steps.
*
* These tests verify the end-to-end behavior where:
* 1. Each pipeline step produces a summary via agent-executor → callbacks.saveFeatureSummary()
* 2. FeatureStateManager.saveFeatureSummary() accumulates summaries with step headers
* 3. The emitted auto_mode_summary event contains the full accumulated summary
* 4. The UI can use feature.summary (accumulated) instead of extractSummary() (last-only)
*/
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import { FeatureStateManager } from '@/services/feature-state-manager.js';
import type { Feature } from '@automaker/types';
import type { EventEmitter } from '@/lib/events.js';
import type { FeatureLoader } from '@/services/feature-loader.js';
import { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform';
import { pipelineService } from '@/services/pipeline-service.js';
// Mock dependencies
vi.mock('@/lib/secure-fs.js', () => ({
readFile: vi.fn(),
readdir: vi.fn(),
}));
vi.mock('@automaker/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@automaker/utils')>();
return {
...actual,
atomicWriteJson: vi.fn(),
readJsonWithRecovery: vi.fn(),
logRecoveryWarning: vi.fn(),
};
});
vi.mock('@automaker/platform', () => ({
getFeatureDir: vi.fn(),
getFeaturesDir: vi.fn(),
}));
vi.mock('@/services/notification-service.js', () => ({
getNotificationService: vi.fn(() => ({
createNotification: vi.fn(),
})),
}));
vi.mock('@/services/pipeline-service.js', () => ({
pipelineService: {
getStepIdFromStatus: vi.fn((status: string) => {
if (status.startsWith('pipeline_')) return status.replace('pipeline_', '');
return null;
}),
getStep: vi.fn(),
},
}));
describe('Pipeline Summary Accumulation (Integration)', () => {
let manager: FeatureStateManager;
let mockEvents: EventEmitter;
const baseFeature: Feature = {
id: 'pipeline-feature-1',
name: 'Pipeline Feature',
title: 'Pipeline Feature Title',
description: 'A feature going through pipeline steps',
status: 'pipeline_step1',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
mockEvents = {
emit: vi.fn(),
subscribe: vi.fn(() => vi.fn()),
};
const mockFeatureLoader = {
syncFeatureToAppSpec: vi.fn(),
} as unknown as FeatureLoader;
manager = new FeatureStateManager(mockEvents, mockFeatureLoader);
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
});
describe('multi-step pipeline summary accumulation', () => {
it('should accumulate summaries across three pipeline steps in chronological order', async () => {
// --- Step 1: Implementation ---
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'pipeline-feature-1',
'## Changes\n- Added auth module\n- Created user service'
);
const step1Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(step1Feature.summary).toBe(
'### Implementation\n\n## Changes\n- Added auth module\n- Created user service'
);
// --- Step 2: Code Review ---
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step2', summary: step1Feature.summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'pipeline-feature-1',
'## Review Findings\n- Style issues fixed\n- Added error handling'
);
const step2Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// --- Step 3: Testing ---
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step3' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step3', summary: step2Feature.summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'pipeline-feature-1',
'## Test Results\n- 42 tests pass\n- 98% coverage'
);
const finalFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Verify the full accumulated summary has all three steps separated by ---
const expectedSummary = [
'### Implementation',
'',
'## Changes',
'- Added auth module',
'- Created user service',
'',
'---',
'',
'### Code Review',
'',
'## Review Findings',
'- Style issues fixed',
'- Added error handling',
'',
'---',
'',
'### Testing',
'',
'## Test Results',
'- 42 tests pass',
'- 98% coverage',
].join('\n');
expect(finalFeature.summary).toBe(expectedSummary);
});
it('should emit the full accumulated summary in auto_mode_summary event', async () => {
// Step 1
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Step 1 output');
// Verify the event was emitted with correct data
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_summary',
featureId: 'pipeline-feature-1',
projectPath: '/project',
summary: '### Implementation\n\nStep 1 output',
});
// Step 2 (with accumulated summary from step 1)
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: {
...baseFeature,
status: 'pipeline_step2',
summary: '### Implementation\n\nStep 1 output',
},
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Step 2 output');
// The event should contain the FULL accumulated summary, not just step 2
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_summary',
featureId: 'pipeline-feature-1',
projectPath: '/project',
summary: '### Implementation\n\nStep 1 output\n\n---\n\n### Testing\n\nStep 2 output',
});
});
});
describe('edge cases in pipeline accumulation', () => {
it('should normalize a legacy implementation summary before appending pipeline output', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: {
...baseFeature,
status: 'pipeline_step2',
summary: 'Implemented authentication and settings updates.',
},
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Reviewed and approved');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(
'### Implementation\n\nImplemented authentication and settings updates.\n\n---\n\n### Code Review\n\nReviewed and approved'
);
});
it('should skip persistence when a pipeline step summary is empty', async () => {
const existingSummary = '### Step 1\n\nFirst step output';
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Step 2', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step2', summary: existingSummary },
recovered: false,
source: 'main',
});
// Empty summary should be ignored to avoid persisting blank sections.
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', '');
expect(atomicWriteJson).not.toHaveBeenCalled();
expect(mockEvents.emit).not.toHaveBeenCalled();
});
it('should handle pipeline step name lookup failure with fallback', async () => {
(pipelineService.getStepIdFromStatus as Mock).mockImplementation(() => {
throw new Error('Pipeline config not loaded');
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_code_review', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Review output');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Fallback: capitalize words from status suffix
expect(savedFeature.summary).toBe('### Code Review\n\nReview output');
});
it('should handle summary with special markdown characters in pipeline mode', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
const markdownSummary = [
'## Changes Made',
'- Fixed **critical bug** in `parser.ts`',
'- Added `validateInput()` function',
'',
'```typescript',
'const x = 1;',
'```',
'',
'| Column | Value |',
'|--------|-------|',
'| Tests | Pass |',
].join('\n');
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', markdownSummary);
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe(`### Implementation\n\n${markdownSummary}`);
// Verify markdown is preserved
expect(savedFeature.summary).toContain('```typescript');
expect(savedFeature.summary).toContain('| Column | Value |');
});
it('should correctly handle rapid sequential pipeline steps without data loss', async () => {
// Simulate 5 rapid pipeline steps
const stepConfigs = [
{ name: 'Planning', status: 'pipeline_step1', content: 'Plan created' },
{ name: 'Implementation', status: 'pipeline_step2', content: 'Code written' },
{ name: 'Code Review', status: 'pipeline_step3', content: 'Review complete' },
{ name: 'Testing', status: 'pipeline_step4', content: 'All tests pass' },
{ name: 'Refinement', status: 'pipeline_step5', content: 'Code polished' },
];
let currentSummary: string | undefined = undefined;
for (const step of stepConfigs) {
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({
name: step.name,
id: step.status.replace('pipeline_', ''),
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: step.status, summary: currentSummary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', step.content);
currentSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
}
// Final summary should contain all 5 steps
expect(currentSummary).toContain('### Planning');
expect(currentSummary).toContain('Plan created');
expect(currentSummary).toContain('### Implementation');
expect(currentSummary).toContain('Code written');
expect(currentSummary).toContain('### Code Review');
expect(currentSummary).toContain('Review complete');
expect(currentSummary).toContain('### Testing');
expect(currentSummary).toContain('All tests pass');
expect(currentSummary).toContain('### Refinement');
expect(currentSummary).toContain('Code polished');
// Verify there are exactly 4 separators (between 5 steps)
const separatorCount = (currentSummary!.match(/\n\n---\n\n/g) || []).length;
expect(separatorCount).toBe(4);
});
});
describe('UI summary display logic', () => {
it('should emit accumulated summary that UI can display directly (no extractSummary needed)', async () => {
// This test verifies the UI can use feature.summary directly
// without needing to call extractSummary() which only returns the last entry
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'First step');
const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
// Step 2
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step2', summary: step1Summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Second step');
const emittedEvent = (mockEvents.emit as Mock).mock.calls[0][1];
const accumulatedSummary = emittedEvent.summary;
// The accumulated summary should contain BOTH steps
expect(accumulatedSummary).toContain('### Implementation');
expect(accumulatedSummary).toContain('First step');
expect(accumulatedSummary).toContain('### Testing');
expect(accumulatedSummary).toContain('Second step');
});
it('should handle single-step pipeline (no accumulation needed)', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Single step output');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe('### Implementation\n\nSingle step output');
// No separator should be present for single step
expect(savedFeature.summary).not.toContain('---');
});
it('should preserve chronological order of summaries', async () => {
// Step 1
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Alpha', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'First');
const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
// Step 2
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Beta', id: 'step2' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step2', summary: step1Summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Second');
const finalSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
// Verify order: Alpha should come before Beta
const alphaIndex = finalSummary!.indexOf('### Alpha');
const betaIndex = finalSummary!.indexOf('### Beta');
expect(alphaIndex).toBeLessThan(betaIndex);
});
});
describe('non-pipeline features', () => {
it('should overwrite summary for non-pipeline features', async () => {
(readJsonWithRecovery as Mock).mockResolvedValue({
data: {
...baseFeature,
status: 'in_progress', // Non-pipeline status
summary: 'Old summary',
},
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'New summary');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe('New summary');
});
it('should not add step headers for non-pipeline features', async () => {
(readJsonWithRecovery as Mock).mockResolvedValue({
data: {
...baseFeature,
status: 'in_progress', // Non-pipeline status
summary: undefined,
},
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Simple summary');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toBe('Simple summary');
expect(savedFeature.summary).not.toContain('###');
});
});
describe('summary content edge cases', () => {
it('should handle summary with unicode characters', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
const unicodeSummary = 'Test results: ✅ 42 passed, ❌ 0 failed, 🎉 100% coverage';
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', unicodeSummary);
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toContain('✅');
expect(savedFeature.summary).toContain('❌');
expect(savedFeature.summary).toContain('🎉');
});
it('should handle very long summary content', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
// Generate a very long summary (10KB+)
const longContent = 'This is a line of content.\n'.repeat(500);
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', longContent);
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary!.length).toBeGreaterThan(10000);
});
it('should handle summary with markdown tables', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
const tableSummary = `
## Test Results
| Test Suite | Passed | Failed | Skipped |
|------------|--------|--------|---------|
| Unit | 42 | 0 | 2 |
| Integration| 15 | 0 | 0 |
| E2E | 8 | 1 | 0 |
`;
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', tableSummary);
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toContain('| Test Suite |');
expect(savedFeature.summary).toContain('| Unit | 42 |');
});
it('should handle summary with nested markdown headers', async () => {
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
const nestedSummary = `
## Main Changes
### Backend
- Added API endpoints
### Frontend
- Created components
#### Deep nesting
- Minor fix
`;
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', nestedSummary);
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.summary).toContain('### Backend');
expect(savedFeature.summary).toContain('### Frontend');
expect(savedFeature.summary).toContain('#### Deep nesting');
});
});
describe('persistence and event ordering', () => {
it('should persist summary BEFORE emitting event', async () => {
const callOrder: string[] = [];
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
(atomicWriteJson as Mock).mockImplementation(async () => {
callOrder.push('persist');
});
(mockEvents.emit as Mock).mockImplementation(() => {
callOrder.push('emit');
});
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Summary');
expect(callOrder).toEqual(['persist', 'emit']);
});
it('should not emit event if persistence fails (error is caught silently)', async () => {
// Note: saveFeatureSummary catches errors internally and logs them
// It does NOT re-throw, so the method completes successfully even on error
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
recovered: false,
source: 'main',
});
(atomicWriteJson as Mock).mockRejectedValue(new Error('Disk full'));
// Method completes without throwing (error is logged internally)
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Summary');
// Event should NOT be emitted since persistence failed
expect(mockEvents.emit).not.toHaveBeenCalled();
});
});
});

View File

@@ -14,12 +14,28 @@ import {
type Credentials,
type ProjectSettings,
} from '@/types/settings.js';
import type { NtfyEndpointConfig } from '@automaker/types';
describe('settings-service.ts', () => {
let testDataDir: string;
let testProjectDir: string;
let settingsService: SettingsService;
/**
* Helper to create a test ntfy endpoint with sensible defaults
*/
function createTestNtfyEndpoint(overrides: Partial<NtfyEndpointConfig> = {}): NtfyEndpointConfig {
return {
id: `endpoint-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
name: 'Test Endpoint',
serverUrl: 'https://ntfy.sh',
topic: 'test-topic',
authType: 'none',
enabled: true,
...overrides,
};
}
beforeEach(async () => {
testDataDir = path.join(os.tmpdir(), `settings-test-${Date.now()}`);
testProjectDir = path.join(os.tmpdir(), `project-test-${Date.now()}`);
@@ -171,6 +187,150 @@ describe('settings-service.ts', () => {
expect(updated.theme).toBe('solarized');
});
it('should not overwrite non-empty ntfyEndpoints with an empty array (data loss guard)', async () => {
const endpoint1 = createTestNtfyEndpoint({
id: 'endpoint-1',
name: 'My Ntfy',
topic: 'my-topic',
});
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
ntfyEndpoints: [endpoint1] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updated = await settingsService.updateGlobalSettings({
ntfyEndpoints: [],
} as any);
// The empty array should be ignored - existing endpoints should be preserved
expect(updated.ntfyEndpoints?.length).toBe(1);
expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
});
it('should allow adding new ntfyEndpoints to existing list', async () => {
const endpoint1 = createTestNtfyEndpoint({
id: 'endpoint-1',
name: 'First Endpoint',
topic: 'first-topic',
});
const endpoint2 = createTestNtfyEndpoint({
id: 'endpoint-2',
name: 'Second Endpoint',
serverUrl: 'https://ntfy.example.com',
topic: 'second-topic',
authType: 'token',
token: 'test-token',
});
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
ntfyEndpoints: [endpoint1] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updated = await settingsService.updateGlobalSettings({
ntfyEndpoints: [endpoint1, endpoint2] as any,
});
// Both endpoints should be present
expect(updated.ntfyEndpoints?.length).toBe(2);
expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
expect((updated.ntfyEndpoints as any)?.[1]?.id).toBe('endpoint-2');
});
it('should allow updating ntfyEndpoints with non-empty array', async () => {
const originalEndpoint = createTestNtfyEndpoint({
id: 'endpoint-1',
name: 'Original Name',
topic: 'original-topic',
});
const updatedEndpoint = createTestNtfyEndpoint({
id: 'endpoint-1',
name: 'Updated Name',
topic: 'updated-topic',
enabled: false,
});
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
ntfyEndpoints: [originalEndpoint] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updated = await settingsService.updateGlobalSettings({
ntfyEndpoints: [updatedEndpoint] as any,
});
// The update should go through with the new values
expect(updated.ntfyEndpoints?.length).toBe(1);
expect((updated.ntfyEndpoints as any)?.[0]?.name).toBe('Updated Name');
expect((updated.ntfyEndpoints as any)?.[0]?.topic).toBe('updated-topic');
expect((updated.ntfyEndpoints as any)?.[0]?.enabled).toBe(false);
});
it('should allow empty ntfyEndpoints when no existing endpoints exist', async () => {
// Start with no endpoints (default state)
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(DEFAULT_GLOBAL_SETTINGS, null, 2));
// Trying to set empty array should be fine when there are no existing endpoints
const updated = await settingsService.updateGlobalSettings({
ntfyEndpoints: [],
} as any);
// Empty array should be set (no data loss because there was nothing to lose)
expect(updated.ntfyEndpoints?.length ?? 0).toBe(0);
});
it('should preserve ntfyEndpoints while updating other settings', async () => {
const endpoint = createTestNtfyEndpoint({
id: 'endpoint-1',
name: 'My Endpoint',
topic: 'my-topic',
});
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
theme: 'dark',
ntfyEndpoints: [endpoint] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
// Update theme without sending ntfyEndpoints
const updated = await settingsService.updateGlobalSettings({
theme: 'light',
});
// Theme should be updated
expect(updated.theme).toBe('light');
// ntfyEndpoints should be preserved from existing settings
expect(updated.ntfyEndpoints?.length).toBe(1);
expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
});
it('should allow clearing ntfyEndpoints with escape hatch flag', async () => {
const endpoint = createTestNtfyEndpoint({ id: 'endpoint-1' });
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
ntfyEndpoints: [endpoint] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
// Use escape hatch to intentionally clear ntfyEndpoints
const updated = await settingsService.updateGlobalSettings({
ntfyEndpoints: [],
__allowEmptyNtfyEndpoints: true,
} as any);
// The empty array should be applied because escape hatch was used
expect(updated.ntfyEndpoints?.length ?? 0).toBe(0);
});
it('should create data directory if it does not exist', async () => {
const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`);
const newService = new SettingsService(newDataDir);
@@ -562,6 +722,73 @@ describe('settings-service.ts', () => {
expect(projectSettings.boardBackground?.imagePath).toBe('/path/to/image.jpg');
});
it('should migrate ntfyEndpoints from localStorage data', async () => {
const localStorageData = {
'automaker-storage': JSON.stringify({
state: {
ntfyEndpoints: [
{
id: 'endpoint-1',
name: 'My Ntfy Server',
serverUrl: 'https://ntfy.sh',
topic: 'my-topic',
authType: 'none',
enabled: true,
},
],
},
}),
};
const result = await settingsService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(true);
expect(result.migratedGlobalSettings).toBe(true);
const settings = await settingsService.getGlobalSettings();
expect(settings.ntfyEndpoints?.length).toBe(1);
expect((settings.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
expect((settings.ntfyEndpoints as any)?.[0]?.name).toBe('My Ntfy Server');
expect((settings.ntfyEndpoints as any)?.[0]?.topic).toBe('my-topic');
});
it('should migrate eventHooks and ntfyEndpoints together from localStorage data', async () => {
const localStorageData = {
'automaker-storage': JSON.stringify({
state: {
eventHooks: [
{
id: 'hook-1',
name: 'Test Hook',
eventType: 'feature:started',
enabled: true,
actions: [],
},
],
ntfyEndpoints: [
{
id: 'endpoint-1',
name: 'My Endpoint',
serverUrl: 'https://ntfy.sh',
topic: 'test-topic',
authType: 'none',
enabled: true,
},
],
},
}),
};
const result = await settingsService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(true);
const settings = await settingsService.getGlobalSettings();
expect(settings.eventHooks?.length).toBe(1);
expect(settings.ntfyEndpoints?.length).toBe(1);
expect((settings.eventHooks as any)?.[0]?.id).toBe('hook-1');
expect((settings.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
});
it('should handle direct localStorage values', async () => {
const localStorageData = {
'automaker:lastProjectDir': '/path/to/project',

View File

@@ -207,12 +207,21 @@ Let me begin by...
describe('detectTaskCompleteMarker', () => {
it('should detect task complete marker and return task ID', () => {
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001')).toBe('T001');
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T042')).toBe('T042');
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001')).toEqual({
id: 'T001',
summary: undefined,
});
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T042')).toEqual({
id: 'T042',
summary: undefined,
});
});
it('should handle marker with summary', () => {
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001: User model created')).toBe('T001');
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001: User model created')).toEqual({
id: 'T001',
summary: 'User model created',
});
});
it('should return null when no marker present', () => {
@@ -229,7 +238,28 @@ Done with the implementation:
Moving on to...
`;
expect(detectTaskCompleteMarker(accumulated)).toBe('T003');
expect(detectTaskCompleteMarker(accumulated)).toEqual({
id: 'T003',
summary: 'Database setup complete',
});
});
it('should find marker in the middle of a stream with trailing text', () => {
const streamText =
'The implementation is complete! [TASK_COMPLETE] T001: Added user model and tests. Now let me check the next task...';
expect(detectTaskCompleteMarker(streamText)).toEqual({
id: 'T001',
summary: 'Added user model and tests. Now let me check the next task...',
});
});
it('should find marker in the middle of a stream with multiple tasks and return the FIRST match', () => {
const streamText =
'[TASK_COMPLETE] T001: Task one done. Continuing... [TASK_COMPLETE] T002: Task two done. Moving on...';
expect(detectTaskCompleteMarker(streamText)).toEqual({
id: 'T001',
summary: 'Task one done. Continuing...',
});
});
it('should not confuse with TASK_START marker', () => {
@@ -240,6 +270,44 @@ Moving on to...
expect(detectTaskCompleteMarker('[TASK_COMPLETE] TASK1')).toBeNull();
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T1')).toBeNull();
});
it('should allow brackets in summary text', () => {
// Regression test: summaries containing array[index] syntax should not be truncated
expect(
detectTaskCompleteMarker('[TASK_COMPLETE] T001: Supports array[index] access syntax')
).toEqual({
id: 'T001',
summary: 'Supports array[index] access syntax',
});
});
it('should handle summary with multiple brackets', () => {
expect(
detectTaskCompleteMarker('[TASK_COMPLETE] T042: Fixed bug in data[0].items[key] mapping')
).toEqual({
id: 'T042',
summary: 'Fixed bug in data[0].items[key] mapping',
});
});
it('should stop at newline in summary', () => {
const result = detectTaskCompleteMarker(
'[TASK_COMPLETE] T001: First line\nSecond line without marker'
);
expect(result).toEqual({
id: 'T001',
summary: 'First line',
});
});
it('should stop at next TASK_START marker', () => {
expect(
detectTaskCompleteMarker('[TASK_COMPLETE] T001: Summary text[TASK_START] T002')
).toEqual({
id: 'T001',
summary: 'Summary text',
});
});
});
describe('detectPhaseCompleteMarker', () => {
@@ -505,6 +573,55 @@ Implementation details.
`;
expect(extractSummary(text)).toBe('Summary content here.');
});
it('should include ### subsections within the summary (not cut off at ### Root Cause)', () => {
const text = `
## Summary
Overview of changes.
### Root Cause
The bug was caused by X.
### Fix Applied
Changed Y to Z.
## Other Section
More content.
`;
const result = extractSummary(text);
expect(result).not.toBeNull();
expect(result).toContain('Overview of changes.');
expect(result).toContain('### Root Cause');
expect(result).toContain('The bug was caused by X.');
expect(result).toContain('### Fix Applied');
expect(result).toContain('Changed Y to Z.');
expect(result).not.toContain('## Other Section');
});
it('should include ### subsections and stop at next ## header', () => {
const text = `
## Summary
Brief intro.
### Changes
- File A modified
- File B added
### Notes
Important context.
## Implementation
Details here.
`;
const result = extractSummary(text);
expect(result).not.toBeNull();
expect(result).toContain('Brief intro.');
expect(result).toContain('### Changes');
expect(result).toContain('### Notes');
expect(result).not.toContain('## Implementation');
});
});
describe('**Goal**: section (lite planning mode)', () => {
@@ -624,7 +741,7 @@ Summary section content.
expect(extractSummary('Random text without any summary patterns')).toBeNull();
});
it('should handle multiple paragraph summaries (return first paragraph)', () => {
it('should include all paragraphs in ## Summary section', () => {
const text = `
## Summary
@@ -634,7 +751,89 @@ Second paragraph of summary.
## Other
`;
expect(extractSummary(text)).toBe('First paragraph of summary.');
const result = extractSummary(text);
expect(result).toContain('First paragraph of summary.');
expect(result).toContain('Second paragraph of summary.');
});
});
describe('pipeline accumulated output (multiple <summary> tags)', () => {
it('should return only the LAST summary tag from accumulated pipeline output', () => {
// Documents WHY the UI needs server-side feature.summary:
// When pipeline steps accumulate raw output in agent-output.md, each step
// writes its own <summary> tag. extractSummary takes only the LAST match,
// losing all previous steps' summaries.
const accumulatedOutput = `
## Step 1: Code Review
Some review output...
<summary>
## Code Review Summary
- Found 3 issues
- Suggested 2 improvements
</summary>
---
## Follow-up Session
## Step 2: Testing
Running tests...
<summary>
## Testing Summary
- All 15 tests pass
- Coverage at 92%
</summary>
`;
const result = extractSummary(accumulatedOutput);
// Only the LAST summary tag is returned - the Code Review summary is lost
expect(result).toBe('## Testing Summary\n- All 15 tests pass\n- Coverage at 92%');
expect(result).not.toContain('Code Review');
});
it('should return only the LAST summary from three pipeline steps', () => {
const accumulatedOutput = `
<summary>Step 1: Implementation complete</summary>
---
## Follow-up Session
<summary>Step 2: Code review findings</summary>
---
## Follow-up Session
<summary>Step 3: All tests passing</summary>
`;
const result = extractSummary(accumulatedOutput);
expect(result).toBe('Step 3: All tests passing');
expect(result).not.toContain('Step 1');
expect(result).not.toContain('Step 2');
});
it('should handle accumulated output where only one step has a summary tag', () => {
const accumulatedOutput = `
## Step 1: Implementation
Some raw output without summary tags...
---
## Follow-up Session
## Step 2: Testing
<summary>
## Test Results
- All tests pass
</summary>
`;
const result = extractSummary(accumulatedOutput);
expect(result).toBe('## Test Results\n- All tests pass');
});
});
});

View File

@@ -107,6 +107,25 @@ branch refs/heads/feature-y
expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x'));
});
it('should normalize refs/heads and trim when resolving target branch', async () => {
mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' }));
const result = await resolver.findWorktreeForBranch(
'/Users/dev/project',
' refs/heads/feature-x '
);
expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x'));
});
it('should normalize remote-style target branch names', async () => {
mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' }));
const result = await resolver.findWorktreeForBranch('/Users/dev/project', 'origin/feature-x');
expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x'));
});
it('should return null when branch not found', async () => {
mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' }));

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { isPipelineStatus } from '@automaker/types';
describe('isPipelineStatus', () => {
it('should return true for valid pipeline statuses', () => {
expect(isPipelineStatus('pipeline_step1')).toBe(true);
expect(isPipelineStatus('pipeline_testing')).toBe(true);
expect(isPipelineStatus('pipeline_code_review')).toBe(true);
expect(isPipelineStatus('pipeline_complete')).toBe(true);
});
it('should return true for pipeline_ prefix with any non-empty suffix', () => {
expect(isPipelineStatus('pipeline_')).toBe(false); // Empty suffix is invalid
expect(isPipelineStatus('pipeline_123')).toBe(true);
expect(isPipelineStatus('pipeline_step_abc_123')).toBe(true);
});
it('should return false for non-pipeline statuses', () => {
expect(isPipelineStatus('in_progress')).toBe(false);
expect(isPipelineStatus('backlog')).toBe(false);
expect(isPipelineStatus('ready')).toBe(false);
expect(isPipelineStatus('interrupted')).toBe(false);
expect(isPipelineStatus('waiting_approval')).toBe(false);
expect(isPipelineStatus('verified')).toBe(false);
expect(isPipelineStatus('completed')).toBe(false);
});
it('should return false for null and undefined', () => {
expect(isPipelineStatus(null)).toBe(false);
expect(isPipelineStatus(undefined)).toBe(false);
});
it('should return false for empty string', () => {
expect(isPipelineStatus('')).toBe(false);
});
it('should return false for partial matches', () => {
expect(isPipelineStatus('pipeline')).toBe(false);
expect(isPipelineStatus('pipelin_step1')).toBe(false);
expect(isPipelineStatus('Pipeline_step1')).toBe(false);
expect(isPipelineStatus('PIPELINE_step1')).toBe(false);
});
it('should return false for pipeline prefix embedded in longer string', () => {
expect(isPipelineStatus('not_pipeline_step1')).toBe(false);
expect(isPipelineStatus('my_pipeline_step')).toBe(false);
});
});

View File

@@ -0,0 +1,563 @@
/**
* End-to-end integration tests for agent output summary display flow.
*
* These tests validate the complete flow from:
* 1. Server-side summary accumulation (FeatureStateManager.saveFeatureSummary)
* 2. Event emission with accumulated summary (auto_mode_summary event)
* 3. UI-side summary retrieval (feature.summary via API)
* 4. UI-side summary parsing and display (parsePhaseSummaries, extractSummary)
*
* The tests simulate what happens when:
* - A feature goes through multiple pipeline steps
* - Each step produces a summary
* - The server accumulates all summaries
* - The UI displays the accumulated summary
*/
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import { FeatureStateManager } from '@/services/feature-state-manager.js';
import type { Feature } from '@automaker/types';
import type { EventEmitter } from '@/lib/events.js';
import type { FeatureLoader } from '@/services/feature-loader.js';
import { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform';
import { pipelineService } from '@/services/pipeline-service.js';
// Mock dependencies
vi.mock('@/lib/secure-fs.js', () => ({
readFile: vi.fn(),
readdir: vi.fn(),
}));
vi.mock('@automaker/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@automaker/utils')>();
return {
...actual,
atomicWriteJson: vi.fn(),
readJsonWithRecovery: vi.fn(),
logRecoveryWarning: vi.fn(),
};
});
vi.mock('@automaker/platform', () => ({
getFeatureDir: vi.fn(),
getFeaturesDir: vi.fn(),
}));
vi.mock('@/services/notification-service.js', () => ({
getNotificationService: vi.fn(() => ({
createNotification: vi.fn(),
})),
}));
vi.mock('@/services/pipeline-service.js', () => ({
pipelineService: {
getStepIdFromStatus: vi.fn((status: string) => {
if (status.startsWith('pipeline_')) return status.replace('pipeline_', '');
return null;
}),
getStep: vi.fn(),
},
}));
// ============================================================================
// UI-side parsing functions (mirrored from apps/ui/src/lib/log-parser.ts)
// ============================================================================
function parsePhaseSummaries(summary: string | undefined): Map<string, string> {
const phaseSummaries = new Map<string, string>();
if (!summary || !summary.trim()) return phaseSummaries;
const sections = summary.split(/\n\n---\n\n/);
for (const section of sections) {
const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/);
if (headerMatch) {
const phaseName = headerMatch[1].trim().toLowerCase();
const content = section.substring(headerMatch[0].length).trim();
phaseSummaries.set(phaseName, content);
}
}
return phaseSummaries;
}
function extractSummary(rawOutput: string): string | null {
if (!rawOutput || !rawOutput.trim()) return null;
const regexesToTry: Array<{
regex: RegExp;
processor: (m: RegExpMatchArray) => string;
}> = [
{ regex: /<summary>([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] },
{ regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] },
];
for (const { regex, processor } of regexesToTry) {
const matches = [...rawOutput.matchAll(regex)];
if (matches.length > 0) {
const lastMatch = matches[matches.length - 1];
return processor(lastMatch).trim();
}
}
return null;
}
function isAccumulatedSummary(summary: string | undefined): boolean {
if (!summary || !summary.trim()) return false;
return summary.includes('\n\n---\n\n') && (summary.match(/###\s+.+/g)?.length ?? 0) > 0;
}
/**
* Returns the first summary candidate that contains non-whitespace content.
* Mirrors getFirstNonEmptySummary from apps/ui/src/lib/summary-selection.ts
*/
function getFirstNonEmptySummary(...candidates: (string | null | undefined)[]): string | null {
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate;
}
}
return null;
}
// ============================================================================
// Unit tests for helper functions
// ============================================================================
describe('getFirstNonEmptySummary', () => {
it('should return the first non-empty string', () => {
expect(getFirstNonEmptySummary(null, undefined, 'first', 'second')).toBe('first');
});
it('should skip null and undefined candidates', () => {
expect(getFirstNonEmptySummary(null, undefined, 'valid')).toBe('valid');
});
it('should skip whitespace-only strings', () => {
expect(getFirstNonEmptySummary(' ', '\n\t', 'actual content')).toBe('actual content');
});
it('should return null when all candidates are empty', () => {
expect(getFirstNonEmptySummary(null, undefined, '', ' ')).toBeNull();
});
it('should return null when no candidates provided', () => {
expect(getFirstNonEmptySummary()).toBeNull();
});
it('should handle empty string as invalid', () => {
expect(getFirstNonEmptySummary('', 'valid')).toBe('valid');
});
it('should prefer first valid candidate', () => {
expect(getFirstNonEmptySummary('first', 'second', 'third')).toBe('first');
});
it('should handle strings with only spaces as invalid', () => {
expect(getFirstNonEmptySummary(' ', ' \n ', 'valid')).toBe('valid');
});
it('should accept strings with content surrounded by whitespace', () => {
expect(getFirstNonEmptySummary(' content with spaces ')).toBe(' content with spaces ');
});
});
describe('Agent Output Summary E2E Flow', () => {
let manager: FeatureStateManager;
let mockEvents: EventEmitter;
const baseFeature: Feature = {
id: 'e2e-feature-1',
name: 'E2E Feature',
title: 'E2E Feature Title',
description: 'A feature going through complete pipeline',
status: 'pipeline_implementation',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
mockEvents = {
emit: vi.fn(),
subscribe: vi.fn(() => vi.fn()),
};
const mockFeatureLoader = {
syncFeatureToAppSpec: vi.fn(),
} as unknown as FeatureLoader;
manager = new FeatureStateManager(mockEvents, mockFeatureLoader);
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
});
describe('complete pipeline flow: server accumulation → UI display', () => {
it('should maintain complete summary across all pipeline steps', async () => {
// ===== STEP 1: Implementation =====
(pipelineService.getStep as Mock).mockResolvedValue({
name: 'Implementation',
id: 'implementation',
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'e2e-feature-1',
'## Changes\n- Created auth module\n- Added user service'
);
const step1Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
const step1Summary = step1Feature.summary;
// Verify server-side accumulation format
expect(step1Summary).toBe(
'### Implementation\n\n## Changes\n- Created auth module\n- Added user service'
);
// Verify UI can parse this summary
const phases1 = parsePhaseSummaries(step1Summary);
expect(phases1.size).toBe(1);
expect(phases1.get('implementation')).toContain('Created auth module');
// ===== STEP 2: Code Review =====
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({
name: 'Code Review',
id: 'code_review',
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_code_review', summary: step1Summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'e2e-feature-1',
'## Review Results\n- Approved with minor suggestions'
);
const step2Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
const step2Summary = step2Feature.summary;
// Verify accumulation now has both steps
expect(step2Summary).toContain('### Implementation');
expect(step2Summary).toContain('Created auth module');
expect(step2Summary).toContain('### Code Review');
expect(step2Summary).toContain('Approved with minor suggestions');
expect(step2Summary).toContain('\n\n---\n\n'); // Separator
// Verify UI can parse accumulated summary
expect(isAccumulatedSummary(step2Summary)).toBe(true);
const phases2 = parsePhaseSummaries(step2Summary);
expect(phases2.size).toBe(2);
expect(phases2.get('implementation')).toContain('Created auth module');
expect(phases2.get('code review')).toContain('Approved with minor suggestions');
// ===== STEP 3: Testing =====
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'testing' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_testing', summary: step2Summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'e2e-feature-1',
'## Test Results\n- 42 tests pass\n- 98% coverage'
);
const finalFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
const finalSummary = finalFeature.summary;
// Verify final accumulation has all three steps
expect(finalSummary).toContain('### Implementation');
expect(finalSummary).toContain('Created auth module');
expect(finalSummary).toContain('### Code Review');
expect(finalSummary).toContain('Approved with minor suggestions');
expect(finalSummary).toContain('### Testing');
expect(finalSummary).toContain('42 tests pass');
// Verify UI-side parsing of complete pipeline
expect(isAccumulatedSummary(finalSummary)).toBe(true);
const finalPhases = parsePhaseSummaries(finalSummary);
expect(finalPhases.size).toBe(3);
// Verify chronological order (implementation before testing)
const summaryLines = finalSummary!.split('\n');
const implIndex = summaryLines.findIndex((l) => l.includes('### Implementation'));
const reviewIndex = summaryLines.findIndex((l) => l.includes('### Code Review'));
const testIndex = summaryLines.findIndex((l) => l.includes('### Testing'));
expect(implIndex).toBeLessThan(reviewIndex);
expect(reviewIndex).toBeLessThan(testIndex);
});
it('should emit events with accumulated summaries for real-time UI updates', async () => {
// Step 1
(pipelineService.getStep as Mock).mockResolvedValue({
name: 'Implementation',
id: 'implementation',
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Step 1 output');
// Verify event emission
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_summary',
featureId: 'e2e-feature-1',
projectPath: '/project',
summary: '### Implementation\n\nStep 1 output',
});
// Step 2
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'testing' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: {
...baseFeature,
status: 'pipeline_testing',
summary: '### Implementation\n\nStep 1 output',
},
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Step 2 output');
// Event should contain FULL accumulated summary
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_summary',
featureId: 'e2e-feature-1',
projectPath: '/project',
summary: '### Implementation\n\nStep 1 output\n\n---\n\n### Testing\n\nStep 2 output',
});
});
});
describe('UI display logic: feature.summary vs extractSummary()', () => {
it('should prefer feature.summary (server-accumulated) over extractSummary() (last only)', () => {
// Simulate what the server has accumulated
const featureSummary = [
'### Implementation',
'',
'## Changes',
'- Created feature',
'',
'---',
'',
'### Testing',
'',
'## Results',
'- All tests pass',
].join('\n');
// Simulate raw agent output (only contains last summary)
const rawOutput = `
Working on tests...
<summary>
## Results
- All tests pass
</summary>
`;
// UI logic: getFirstNonEmptySummary(feature?.summary, extractSummary(output))
const displaySummary = getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput));
// Should use server-accumulated summary
expect(displaySummary).toBe(featureSummary);
expect(displaySummary).toContain('### Implementation');
expect(displaySummary).toContain('### Testing');
// If server summary was missing, only last summary would be shown
const fallbackSummary = extractSummary(rawOutput);
expect(fallbackSummary).not.toContain('Implementation');
expect(fallbackSummary).toContain('All tests pass');
});
it('should handle legacy features without server accumulation', () => {
// Legacy features have no feature.summary
const featureSummary = undefined;
// Raw output contains the summary
const rawOutput = `
<summary>
## Implementation Complete
- Created the feature
- All tests pass
</summary>
`;
// UI logic: getFirstNonEmptySummary(feature?.summary, extractSummary(output))
const displaySummary = getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput));
// Should fall back to client-side extraction
expect(displaySummary).toContain('Implementation Complete');
expect(displaySummary).toContain('All tests pass');
});
});
describe('error recovery and edge cases', () => {
it('should gracefully handle pipeline interruption', async () => {
// Step 1 completes
(pipelineService.getStep as Mock).mockResolvedValue({
name: 'Implementation',
id: 'implementation',
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Implementation done');
const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
// Pipeline gets interrupted (status changes but summary is preserved)
// When user views the feature later, the summary should still be available
expect(step1Summary).toBe('### Implementation\n\nImplementation done');
// UI can still parse the partial pipeline
const phases = parsePhaseSummaries(step1Summary);
expect(phases.size).toBe(1);
expect(phases.get('implementation')).toBe('Implementation done');
});
it('should handle very large accumulated summaries', async () => {
// Generate large content for each step
const generateLargeContent = (stepNum: number) => {
const lines = [`## Step ${stepNum} Changes`];
for (let i = 0; i < 100; i++) {
lines.push(
`- Change ${i}: This is a detailed description of the change made during step ${stepNum}`
);
}
return lines.join('\n');
};
// Simulate 5 pipeline steps with large content
let currentSummary: string | undefined = undefined;
const stepNames = ['Planning', 'Implementation', 'Code Review', 'Testing', 'Refinement'];
for (let i = 0; i < 5; i++) {
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({
name: stepNames[i],
id: stepNames[i].toLowerCase().replace(' ', '_'),
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: {
...baseFeature,
status: `pipeline_${stepNames[i].toLowerCase().replace(' ', '_')}`,
summary: currentSummary,
},
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'e2e-feature-1', generateLargeContent(i + 1));
currentSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
}
// Final summary should be large but still parseable
expect(currentSummary!.length).toBeGreaterThan(5000);
expect(isAccumulatedSummary(currentSummary)).toBe(true);
const phases = parsePhaseSummaries(currentSummary);
expect(phases.size).toBe(5);
// Verify all steps are present
for (const stepName of stepNames) {
expect(phases.has(stepName.toLowerCase())).toBe(true);
}
});
});
describe('query invalidation simulation', () => {
it('should trigger UI refetch on auto_mode_summary event', async () => {
// This test documents the expected behavior:
// When saveFeatureSummary is called, it emits auto_mode_summary event
// The UI's use-query-invalidation.ts invalidates the feature query
// This causes a refetch of the feature, getting the updated summary
(pipelineService.getStep as Mock).mockResolvedValue({
name: 'Implementation',
id: 'implementation',
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Summary content');
// Verify event was emitted (triggers React Query invalidation)
expect(mockEvents.emit).toHaveBeenCalledWith(
'auto-mode:event',
expect.objectContaining({
type: 'auto_mode_summary',
featureId: 'e2e-feature-1',
summary: expect.any(String),
})
);
// The UI would then:
// 1. Receive the event via WebSocket
// 2. Invalidate the feature query
// 3. Refetch the feature (GET /api/features/:id)
// 4. Display the updated feature.summary
});
});
});
/**
* KEY E2E FLOW SUMMARY:
*
* 1. PIPELINE EXECUTION:
* - Feature starts with status='pipeline_implementation'
* - Agent runs and produces summary
* - FeatureStateManager.saveFeatureSummary() accumulates with step header
* - Status advances to 'pipeline_testing'
* - Process repeats for each step
*
* 2. SERVER-SIDE ACCUMULATION:
* - First step: `### Implementation\n\n<content>`
* - Second step: `### Implementation\n\n<content>\n\n---\n\n### Testing\n\n<content>`
* - Pattern continues with each step
*
* 3. EVENT EMISSION:
* - auto_mode_summary event contains FULL accumulated summary
* - UI receives event via WebSocket
* - React Query invalidates feature query
* - Feature is refetched with updated summary
*
* 4. UI DISPLAY:
* - AgentOutputModal uses: getFirstNonEmptySummary(feature?.summary, extractSummary(output))
* - feature.summary is preferred (contains all steps)
* - extractSummary() is fallback (last summary only)
* - parsePhaseSummaries() can split into individual phases for UI
*
* 5. FALLBACK FOR LEGACY:
* - Old features may not have feature.summary
* - UI falls back to extracting from raw output
* - Only last summary is available in this case
*/

View File

@@ -0,0 +1,403 @@
/**
* Unit tests for the agent output summary priority logic.
*
* These tests verify the summary display logic used in AgentOutputModal
* where the UI must choose between server-accumulated summaries and
* client-side extracted summaries.
*
* Priority order (from agent-output-modal.tsx):
* 1. feature.summary (server-accumulated, contains all pipeline steps)
* 2. extractSummary(output) (client-side fallback, last summary only)
*
* This priority is crucial for pipeline features where the server-side
* accumulation provides the complete history of all step summaries.
*/
import { describe, it, expect } from 'vitest';
// Import the actual extractSummary function to ensure test behavior matches production
import { extractSummary } from '../../../../ui/src/lib/log-parser.ts';
import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts';
/**
* Simulates the summary priority logic from AgentOutputModal.
*
* Priority:
* 1. feature?.summary (server-accumulated)
* 2. extractSummary(output) (client-side fallback)
*/
function getDisplaySummary(
featureSummary: string | undefined | null,
rawOutput: string
): string | null {
return getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput));
}
describe('Agent Output Summary Priority Logic', () => {
describe('priority order: feature.summary over extractSummary', () => {
it('should use feature.summary when available (server-accumulated wins)', () => {
const featureSummary = '### Step 1\n\nFirst step\n\n---\n\n### Step 2\n\nSecond step';
const rawOutput = `
<summary>
Only the last summary is extracted client-side
</summary>
`;
const result = getDisplaySummary(featureSummary, rawOutput);
// Server-accumulated summary should be used, not client-side extraction
expect(result).toBe(featureSummary);
expect(result).toContain('### Step 1');
expect(result).toContain('### Step 2');
expect(result).not.toContain('Only the last summary');
});
it('should use client-side extractSummary when feature.summary is undefined', () => {
const rawOutput = `
<summary>
This is the only summary
</summary>
`;
const result = getDisplaySummary(undefined, rawOutput);
expect(result).toBe('This is the only summary');
});
it('should use client-side extractSummary when feature.summary is null', () => {
const rawOutput = `
<summary>
Client-side extracted summary
</summary>
`;
const result = getDisplaySummary(null, rawOutput);
expect(result).toBe('Client-side extracted summary');
});
it('should use client-side extractSummary when feature.summary is empty string', () => {
const rawOutput = `
<summary>
Fallback content
</summary>
`;
const result = getDisplaySummary('', rawOutput);
// Empty string is falsy, so fallback is used
expect(result).toBe('Fallback content');
});
it('should use client-side extractSummary when feature.summary is whitespace only', () => {
const rawOutput = `
<summary>
Fallback for whitespace summary
</summary>
`;
const result = getDisplaySummary(' \n ', rawOutput);
expect(result).toBe('Fallback for whitespace summary');
});
it('should preserve original server summary formatting when non-empty after trim', () => {
const featureSummary = '\n### Implementation\n\n- Added API route\n';
const result = getDisplaySummary(featureSummary, '');
expect(result).toBe(featureSummary);
expect(result).toContain('### Implementation');
});
});
describe('pipeline step accumulation scenarios', () => {
it('should display all pipeline steps when using server-accumulated summary', () => {
// This simulates a feature that went through 3 pipeline steps
const featureSummary = [
'### Implementation',
'',
'## Changes',
'- Created new module',
'- Added tests',
'',
'---',
'',
'### Code Review',
'',
'## Review Results',
'- Approved with minor suggestions',
'',
'---',
'',
'### Testing',
'',
'## Test Results',
'- All 42 tests pass',
'- Coverage: 98%',
].join('\n');
const rawOutput = `
<summary>
Only testing step visible in raw output
</summary>
`;
const result = getDisplaySummary(featureSummary, rawOutput);
// All pipeline steps should be visible
expect(result).toContain('### Implementation');
expect(result).toContain('### Code Review');
expect(result).toContain('### Testing');
expect(result).toContain('All 42 tests pass');
});
it('should display only last summary when server-side accumulation not available', () => {
// When feature.summary is not available, only the last summary is shown
const rawOutput = `
<summary>
Step 1: Implementation complete
</summary>
---
<summary>
Step 2: Code review complete
</summary>
---
<summary>
Step 3: Testing complete
</summary>
`;
const result = getDisplaySummary(undefined, rawOutput);
// Only the LAST summary should be shown (client-side fallback behavior)
expect(result).toBe('Step 3: Testing complete');
expect(result).not.toContain('Step 1');
expect(result).not.toContain('Step 2');
});
it('should handle single-step pipeline (no accumulation needed)', () => {
const featureSummary = '### Implementation\n\nCreated the feature';
const rawOutput = '';
const result = getDisplaySummary(featureSummary, rawOutput);
expect(result).toBe(featureSummary);
expect(result).not.toContain('---'); // No separator for single step
});
});
describe('edge cases', () => {
it('should return null when both feature.summary and extractSummary are unavailable', () => {
const rawOutput = 'No summary tags here, just regular output.';
const result = getDisplaySummary(undefined, rawOutput);
expect(result).toBeNull();
});
it('should return null when rawOutput is empty and no feature summary', () => {
const result = getDisplaySummary(undefined, '');
expect(result).toBeNull();
});
it('should return null when rawOutput is whitespace only', () => {
const result = getDisplaySummary(undefined, ' \n\n ');
expect(result).toBeNull();
});
it('should use client-side fallback when feature.summary is empty string (falsy)', () => {
// Empty string is falsy in JavaScript, so fallback is correctly used.
// This is the expected behavior - an empty summary has no value to display.
const rawOutput = `
<summary>
Fallback content when server summary is empty
</summary>
`;
// Empty string is falsy, so fallback is used
const result = getDisplaySummary('', rawOutput);
expect(result).toBe('Fallback content when server summary is empty');
});
it('should behave identically when feature is null vs feature.summary is undefined', () => {
// This test verifies that the behavior is consistent whether:
// - The feature object itself is null/undefined
// - The feature object exists but summary property is undefined
const rawOutput = `
<summary>
Client-side extracted summary
</summary>
`;
// Both scenarios should use client-side fallback
const resultWithUndefined = getDisplaySummary(undefined, rawOutput);
const resultWithNull = getDisplaySummary(null, rawOutput);
expect(resultWithUndefined).toBe('Client-side extracted summary');
expect(resultWithNull).toBe('Client-side extracted summary');
expect(resultWithUndefined).toBe(resultWithNull);
});
});
describe('markdown content preservation', () => {
it('should preserve markdown formatting in server-accumulated summary', () => {
const featureSummary = `### Code Review
## Changes Made
- Fixed **critical bug** in \`parser.ts\`
- Added \`validateInput()\` function
\`\`\`typescript
const x = 1;
\`\`\`
| Test | Result |
|------|--------|
| Unit | Pass |`;
const result = getDisplaySummary(featureSummary, '');
expect(result).toContain('**critical bug**');
expect(result).toContain('`parser.ts`');
expect(result).toContain('```typescript');
expect(result).toContain('| Test | Result |');
});
it('should preserve unicode in server-accumulated summary', () => {
const featureSummary = '### Testing\n\n✅ 42 passed\n❌ 0 failed\n🎉 100% coverage';
const result = getDisplaySummary(featureSummary, '');
expect(result).toContain('✅');
expect(result).toContain('❌');
expect(result).toContain('🎉');
});
});
describe('real-world scenarios', () => {
it('should handle typical pipeline feature with server accumulation', () => {
// Simulates a real pipeline feature that went through Implementation → Testing
const featureSummary = `### Implementation
## Changes Made
- Created UserProfile component
- Added authentication middleware
- Updated API endpoints
---
### Testing
## Test Results
- Unit tests: 15 passed
- Integration tests: 8 passed
- E2E tests: 3 passed`;
const rawOutput = `
Working on the feature...
<summary>
## Test Results
- Unit tests: 15 passed
- Integration tests: 8 passed
- E2E tests: 3 passed
</summary>
`;
const result = getDisplaySummary(featureSummary, rawOutput);
// Both steps should be visible
expect(result).toContain('### Implementation');
expect(result).toContain('### Testing');
expect(result).toContain('UserProfile component');
expect(result).toContain('15 passed');
});
it('should handle non-pipeline feature (single summary)', () => {
// Non-pipeline features have a single summary, no accumulation
const featureSummary = '## Implementation Complete\n- Created the feature\n- All tests pass';
const rawOutput = '';
const result = getDisplaySummary(featureSummary, rawOutput);
expect(result).toBe(featureSummary);
expect(result).not.toContain('###'); // No step headers for non-pipeline
});
it('should handle legacy feature without server summary (fallback)', () => {
// Legacy features may not have feature.summary set
const rawOutput = `
<summary>
Legacy implementation from before server-side accumulation
</summary>
`;
const result = getDisplaySummary(undefined, rawOutput);
expect(result).toBe('Legacy implementation from before server-side accumulation');
});
});
describe('view mode determination logic', () => {
/**
* Simulates the effectiveViewMode logic from agent-output-modal.tsx line 86
* Default to 'summary' if summary is available, otherwise 'parsed'
*/
function getEffectiveViewMode(
viewMode: string | null,
summary: string | null
): 'summary' | 'parsed' {
return (viewMode ?? (summary ? 'summary' : 'parsed')) as 'summary' | 'parsed';
}
it('should default to summary view when server summary is available', () => {
const summary = '### Implementation\n\nContent';
const result = getEffectiveViewMode(null, summary);
expect(result).toBe('summary');
});
it('should default to summary view when client-side extraction succeeds', () => {
const summary = 'Extracted from raw output';
const result = getEffectiveViewMode(null, summary);
expect(result).toBe('summary');
});
it('should default to parsed view when no summary is available', () => {
const result = getEffectiveViewMode(null, null);
expect(result).toBe('parsed');
});
it('should respect explicit view mode selection over default', () => {
const summary = 'Summary is available';
expect(getEffectiveViewMode('raw', summary)).toBe('raw');
expect(getEffectiveViewMode('parsed', summary)).toBe('parsed');
expect(getEffectiveViewMode('changes', summary)).toBe('changes');
});
});
});
/**
* KEY ARCHITECTURE INSIGHT:
*
* The priority order (feature.summary > extractSummary(output)) is essential for
* pipeline features because:
*
* 1. Server-side accumulation (FeatureStateManager.saveFeatureSummary) collects
* ALL step summaries with headers and separators in chronological order.
*
* 2. Client-side extractSummary() only returns the LAST summary tag from raw output,
* losing all previous step summaries.
*
* 3. The UI must prefer feature.summary to display the complete history of all
* pipeline steps to the user.
*
* For non-pipeline features (single execution), both sources contain the same
* summary, so the priority doesn't matter. But for pipeline features, using the
* wrong source would result in incomplete information display.
*/

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import {
parseAllPhaseSummaries,
parsePhaseSummaries,
extractPhaseSummary,
extractImplementationSummary,
isAccumulatedSummary,
} from '../../../../ui/src/lib/log-parser.ts';
describe('log-parser mixed summary format compatibility', () => {
const mixedSummary = [
'Implemented core auth flow and API wiring.',
'',
'---',
'',
'### Code Review',
'',
'Addressed lint warnings and improved error handling.',
'',
'---',
'',
'### Testing',
'',
'All tests passing.',
].join('\n');
it('treats leading headerless section as Implementation phase', () => {
const phases = parsePhaseSummaries(mixedSummary);
expect(phases.get('implementation')).toBe('Implemented core auth flow and API wiring.');
expect(phases.get('code review')).toBe('Addressed lint warnings and improved error handling.');
expect(phases.get('testing')).toBe('All tests passing.');
});
it('returns implementation summary from mixed format', () => {
expect(extractImplementationSummary(mixedSummary)).toBe(
'Implemented core auth flow and API wiring.'
);
});
it('includes Implementation as the first parsed phase entry', () => {
const entries = parseAllPhaseSummaries(mixedSummary);
expect(entries[0]).toMatchObject({
phaseName: 'Implementation',
content: 'Implemented core auth flow and API wiring.',
});
expect(entries.map((entry) => entry.phaseName)).toEqual([
'Implementation',
'Code Review',
'Testing',
]);
});
it('extracts specific phase summaries from mixed format', () => {
expect(extractPhaseSummary(mixedSummary, 'Implementation')).toBe(
'Implemented core auth flow and API wiring.'
);
expect(extractPhaseSummary(mixedSummary, 'Code Review')).toBe(
'Addressed lint warnings and improved error handling.'
);
expect(extractPhaseSummary(mixedSummary, 'Testing')).toBe('All tests passing.');
});
it('treats mixed format as accumulated summary', () => {
expect(isAccumulatedSummary(mixedSummary)).toBe(true);
});
});

View File

@@ -0,0 +1,973 @@
/**
* Unit tests for log-parser phase summary parsing functions.
*
* These functions are used to parse accumulated summaries that contain multiple
* pipeline step summaries separated by `---` and identified by `### StepName` headers.
*
* Functions tested:
* - parsePhaseSummaries: Parses the entire accumulated summary into a Map
* - extractPhaseSummary: Extracts a specific phase's content
* - extractImplementationSummary: Extracts implementation phase content (convenience)
* - isAccumulatedSummary: Checks if a summary is in accumulated format
*/
import { describe, it, expect } from 'vitest';
// Mirror the functions from apps/ui/src/lib/log-parser.ts
// (We can't import directly because it's a UI file)
/**
* Parses an accumulated summary string into individual phase summaries.
*/
function parsePhaseSummaries(summary: string | undefined): Map<string, string> {
const phaseSummaries = new Map<string, string>();
if (!summary || !summary.trim()) {
return phaseSummaries;
}
// Split by the horizontal rule separator
const sections = summary.split(/\n\n---\n\n/);
for (const section of sections) {
// Match the phase header pattern: ### Phase Name
const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/);
if (headerMatch) {
const phaseName = headerMatch[1].trim().toLowerCase();
// Extract content after the header (skip the header line and leading newlines)
const content = section.substring(headerMatch[0].length).trim();
phaseSummaries.set(phaseName, content);
}
}
return phaseSummaries;
}
/**
* Extracts a specific phase summary from an accumulated summary string.
*/
function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null {
const phaseSummaries = parsePhaseSummaries(summary);
const normalizedPhaseName = phaseName.toLowerCase();
return phaseSummaries.get(normalizedPhaseName) || null;
}
/**
* Extracts the implementation phase summary from an accumulated summary string.
*/
function extractImplementationSummary(summary: string | undefined): string | null {
if (!summary || !summary.trim()) {
return null;
}
const phaseSummaries = parsePhaseSummaries(summary);
// Try exact match first
const implementationContent = phaseSummaries.get('implementation');
if (implementationContent) {
return implementationContent;
}
// Fallback: find any phase containing "implement"
for (const [phaseName, content] of phaseSummaries) {
if (phaseName.includes('implement')) {
return content;
}
}
// If no phase summaries found, the summary might not be in accumulated format
// (legacy or non-pipeline feature). In this case, return the whole summary
// if it looks like a single summary (no phase headers).
if (!summary.includes('### ') && !summary.includes('\n---\n')) {
return summary;
}
return null;
}
/**
* Checks if a summary string is in the accumulated multi-phase format.
*/
function isAccumulatedSummary(summary: string | undefined): boolean {
if (!summary || !summary.trim()) {
return false;
}
// Check for the presence of phase headers with separator
const hasMultiplePhases =
summary.includes('\n\n---\n\n') && summary.match(/###\s+.+/g)?.length > 0;
return hasMultiplePhases;
}
/**
* Represents a single phase entry in an accumulated summary.
*/
interface PhaseSummaryEntry {
/** The phase name (e.g., "Implementation", "Testing", "Code Review") */
phaseName: string;
/** The content of this phase's summary */
content: string;
/** The original header line (e.g., "### Implementation") */
header: string;
}
/** Default phase name used for non-accumulated summaries */
const DEFAULT_PHASE_NAME = 'Summary';
/**
* Parses an accumulated summary into individual phase entries.
* Returns phases in the order they appear in the summary.
*/
function parseAllPhaseSummaries(summary: string | undefined): PhaseSummaryEntry[] {
const entries: PhaseSummaryEntry[] = [];
if (!summary || !summary.trim()) {
return entries;
}
// Check if this is an accumulated summary (has phase headers)
if (!summary.includes('### ')) {
// Not an accumulated summary - return as single entry with generic name
return [
{ phaseName: DEFAULT_PHASE_NAME, content: summary, header: `### ${DEFAULT_PHASE_NAME}` },
];
}
// Split by the horizontal rule separator
const sections = summary.split(/\n\n---\n\n/);
for (const section of sections) {
// Match the phase header pattern: ### Phase Name
const headerMatch = section.match(/^(###\s+)(.+?)(?:\n|$)/);
if (headerMatch) {
const header = headerMatch[0].trim();
const phaseName = headerMatch[2].trim();
// Extract content after the header (skip the header line and leading newlines)
const content = section.substring(headerMatch[0].length).trim();
entries.push({ phaseName, content, header });
}
}
return entries;
}
describe('parsePhaseSummaries', () => {
describe('basic parsing', () => {
it('should parse single phase summary', () => {
const summary = `### Implementation
## Changes Made
- Created new module
- Added unit tests`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(1);
expect(result.get('implementation')).toBe(
'## Changes Made\n- Created new module\n- Added unit tests'
);
});
it('should parse multiple phase summaries', () => {
const summary = `### Implementation
## Changes Made
- Created new module
---
### Testing
## Test Results
- All tests pass`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(2);
expect(result.get('implementation')).toBe('## Changes Made\n- Created new module');
expect(result.get('testing')).toBe('## Test Results\n- All tests pass');
});
it('should handle three or more phases', () => {
const summary = `### Planning
Plan created
---
### Implementation
Code written
---
### Testing
Tests pass
---
### Refinement
Code polished`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(4);
expect(result.get('planning')).toBe('Plan created');
expect(result.get('implementation')).toBe('Code written');
expect(result.get('testing')).toBe('Tests pass');
expect(result.get('refinement')).toBe('Code polished');
});
});
describe('edge cases', () => {
it('should return empty map for undefined summary', () => {
const result = parsePhaseSummaries(undefined);
expect(result.size).toBe(0);
});
it('should return empty map for null summary', () => {
const result = parsePhaseSummaries(null as unknown as string);
expect(result.size).toBe(0);
});
it('should return empty map for empty string', () => {
const result = parsePhaseSummaries('');
expect(result.size).toBe(0);
});
it('should return empty map for whitespace-only string', () => {
const result = parsePhaseSummaries(' \n\n ');
expect(result.size).toBe(0);
});
it('should handle summary without phase headers', () => {
const summary = 'Just some regular content without headers';
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(0);
});
it('should handle section without header after separator', () => {
const summary = `### Implementation
Content here
---
This section has no header`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(1);
expect(result.get('implementation')).toBe('Content here');
});
});
describe('phase name normalization', () => {
it('should normalize phase names to lowercase', () => {
const summary = `### IMPLEMENTATION
Content`;
const result = parsePhaseSummaries(summary);
expect(result.has('implementation')).toBe(true);
expect(result.has('IMPLEMENTATION')).toBe(false);
});
it('should handle mixed case phase names', () => {
const summary = `### Code Review
Content`;
const result = parsePhaseSummaries(summary);
expect(result.has('code review')).toBe(true);
});
it('should preserve spaces in multi-word phase names', () => {
const summary = `### Code Review
Content`;
const result = parsePhaseSummaries(summary);
expect(result.get('code review')).toBe('Content');
});
});
describe('content preservation', () => {
it('should preserve markdown formatting in content', () => {
const summary = `### Implementation
## Heading
- **Bold text**
- \`code\`
\`\`\`typescript
const x = 1;
\`\`\``;
const result = parsePhaseSummaries(summary);
const content = result.get('implementation');
expect(content).toContain('**Bold text**');
expect(content).toContain('`code`');
expect(content).toContain('```typescript');
});
it('should preserve unicode in content', () => {
const summary = `### Testing
Results: ✅ 42 passed, ❌ 0 failed`;
const result = parsePhaseSummaries(summary);
expect(result.get('testing')).toContain('✅');
expect(result.get('testing')).toContain('❌');
});
it('should preserve tables in content', () => {
const summary = `### Testing
| Test | Result |
|------|--------|
| Unit | Pass |`;
const result = parsePhaseSummaries(summary);
expect(result.get('testing')).toContain('| Test | Result |');
});
it('should handle empty phase content', () => {
const summary = `### Implementation
---
### Testing
Content`;
const result = parsePhaseSummaries(summary);
expect(result.get('implementation')).toBe('');
expect(result.get('testing')).toBe('Content');
});
});
});
describe('extractPhaseSummary', () => {
describe('extraction by phase name', () => {
it('should extract specified phase content', () => {
const summary = `### Implementation
Implementation content
---
### Testing
Testing content`;
expect(extractPhaseSummary(summary, 'Implementation')).toBe('Implementation content');
expect(extractPhaseSummary(summary, 'Testing')).toBe('Testing content');
});
it('should be case-insensitive for phase name', () => {
const summary = `### Implementation
Content`;
expect(extractPhaseSummary(summary, 'implementation')).toBe('Content');
expect(extractPhaseSummary(summary, 'IMPLEMENTATION')).toBe('Content');
expect(extractPhaseSummary(summary, 'ImPlEmEnTaTiOn')).toBe('Content');
});
it('should return null for non-existent phase', () => {
const summary = `### Implementation
Content`;
expect(extractPhaseSummary(summary, 'NonExistent')).toBeNull();
});
});
describe('edge cases', () => {
it('should return null for undefined summary', () => {
expect(extractPhaseSummary(undefined, 'Implementation')).toBeNull();
});
it('should return null for empty summary', () => {
expect(extractPhaseSummary('', 'Implementation')).toBeNull();
});
it('should handle whitespace in phase name', () => {
const summary = `### Code Review
Content`;
expect(extractPhaseSummary(summary, 'Code Review')).toBe('Content');
expect(extractPhaseSummary(summary, 'code review')).toBe('Content');
});
});
});
describe('extractImplementationSummary', () => {
describe('exact match', () => {
it('should extract implementation phase by exact name', () => {
const summary = `### Implementation
## Changes Made
- Created feature
- Added tests
---
### Testing
Tests pass`;
const result = extractImplementationSummary(summary);
expect(result).toBe('## Changes Made\n- Created feature\n- Added tests');
});
it('should be case-insensitive', () => {
const summary = `### IMPLEMENTATION
Content`;
expect(extractImplementationSummary(summary)).toBe('Content');
});
});
describe('partial match fallback', () => {
it('should find phase containing "implement"', () => {
const summary = `### Feature Implementation
Content here`;
const result = extractImplementationSummary(summary);
expect(result).toBe('Content here');
});
it('should find phase containing "implementation"', () => {
const summary = `### Implementation Phase
Content here`;
const result = extractImplementationSummary(summary);
expect(result).toBe('Content here');
});
});
describe('legacy/non-accumulated summary handling', () => {
it('should return full summary if no phase headers present', () => {
const summary = `## Changes Made
- Created feature
- Added tests`;
const result = extractImplementationSummary(summary);
expect(result).toBe(summary);
});
it('should return null if summary has phase headers but no implementation', () => {
const summary = `### Testing
Tests pass
---
### Review
Review complete`;
const result = extractImplementationSummary(summary);
expect(result).toBeNull();
});
it('should not return full summary if it contains phase headers', () => {
const summary = `### Testing
Tests pass`;
const result = extractImplementationSummary(summary);
expect(result).toBeNull();
});
});
describe('edge cases', () => {
it('should return null for undefined summary', () => {
expect(extractImplementationSummary(undefined)).toBeNull();
});
it('should return null for empty string', () => {
expect(extractImplementationSummary('')).toBeNull();
});
it('should return null for whitespace-only string', () => {
expect(extractImplementationSummary(' \n\n ')).toBeNull();
});
});
});
describe('isAccumulatedSummary', () => {
describe('accumulated format detection', () => {
it('should return true for accumulated summary with separator and headers', () => {
const summary = `### Implementation
Content
---
### Testing
Content`;
expect(isAccumulatedSummary(summary)).toBe(true);
});
it('should return true for accumulated summary with multiple phases', () => {
const summary = `### Phase 1
Content 1
---
### Phase 2
Content 2
---
### Phase 3
Content 3`;
expect(isAccumulatedSummary(summary)).toBe(true);
});
it('should return true for accumulated summary with just one phase and separator', () => {
// Even a single phase with a separator suggests it's in accumulated format
const summary = `### Implementation
Content
---
### Testing
More content`;
expect(isAccumulatedSummary(summary)).toBe(true);
});
});
describe('non-accumulated format detection', () => {
it('should return false for summary without separator', () => {
const summary = `### Implementation
Just content`;
expect(isAccumulatedSummary(summary)).toBe(false);
});
it('should return false for summary with separator but no headers', () => {
const summary = `Content
---
More content`;
expect(isAccumulatedSummary(summary)).toBe(false);
});
it('should return false for simple text summary', () => {
const summary = 'Just a simple summary without any special formatting';
expect(isAccumulatedSummary(summary)).toBe(false);
});
it('should return false for markdown summary without phase headers', () => {
const summary = `## Changes Made
- Created feature
- Added tests`;
expect(isAccumulatedSummary(summary)).toBe(false);
});
});
describe('edge cases', () => {
it('should return false for undefined summary', () => {
expect(isAccumulatedSummary(undefined)).toBe(false);
});
it('should return false for null summary', () => {
expect(isAccumulatedSummary(null as unknown as string)).toBe(false);
});
it('should return false for empty string', () => {
expect(isAccumulatedSummary('')).toBe(false);
});
it('should return false for whitespace-only string', () => {
expect(isAccumulatedSummary(' \n\n ')).toBe(false);
});
});
});
describe('Integration: Full parsing workflow', () => {
it('should correctly parse typical server-accumulated pipeline summary', () => {
// This simulates what FeatureStateManager.saveFeatureSummary() produces
const summary = [
'### Implementation',
'',
'## Changes',
'- Added auth module',
'- Created user service',
'',
'---',
'',
'### Code Review',
'',
'## Review Results',
'- Style issues fixed',
'- Added error handling',
'',
'---',
'',
'### Testing',
'',
'## Test Results',
'- 42 tests pass',
'- 98% coverage',
].join('\n');
// Verify isAccumulatedSummary
expect(isAccumulatedSummary(summary)).toBe(true);
// Verify parsePhaseSummaries
const phases = parsePhaseSummaries(summary);
expect(phases.size).toBe(3);
expect(phases.get('implementation')).toContain('Added auth module');
expect(phases.get('code review')).toContain('Style issues fixed');
expect(phases.get('testing')).toContain('42 tests pass');
// Verify extractPhaseSummary
expect(extractPhaseSummary(summary, 'Implementation')).toContain('Added auth module');
expect(extractPhaseSummary(summary, 'Code Review')).toContain('Style issues fixed');
expect(extractPhaseSummary(summary, 'Testing')).toContain('42 tests pass');
// Verify extractImplementationSummary
expect(extractImplementationSummary(summary)).toContain('Added auth module');
});
it('should handle legacy non-pipeline summary correctly', () => {
// Legacy features have simple summaries without accumulation
const summary = `## Implementation Complete
- Created the feature
- All tests pass`;
// Should NOT be detected as accumulated
expect(isAccumulatedSummary(summary)).toBe(false);
// parsePhaseSummaries should return empty
const phases = parsePhaseSummaries(summary);
expect(phases.size).toBe(0);
// extractPhaseSummary should return null
expect(extractPhaseSummary(summary, 'Implementation')).toBeNull();
// extractImplementationSummary should return the full summary (legacy handling)
expect(extractImplementationSummary(summary)).toBe(summary);
});
it('should handle single-step pipeline summary', () => {
// A single pipeline step still gets the header but no separator
const summary = `### Implementation
## Changes
- Created the feature`;
// Should NOT be detected as accumulated (no separator)
expect(isAccumulatedSummary(summary)).toBe(false);
// parsePhaseSummaries should still extract the single phase
const phases = parsePhaseSummaries(summary);
expect(phases.size).toBe(1);
expect(phases.get('implementation')).toContain('Created the feature');
});
});
/**
* KEY ARCHITECTURE NOTES:
*
* 1. The accumulated summary format uses:
* - `### PhaseName` for step headers
* - `\n\n---\n\n` as separator between steps
*
* 2. Phase names are normalized to lowercase in the Map for case-insensitive lookup.
*
* 3. Legacy summaries (non-pipeline features) don't have phase headers and should
* be returned as-is by extractImplementationSummary.
*
* 4. isAccumulatedSummary() checks for BOTH separator AND phase headers to be
* confident that the summary is in the accumulated format.
*
* 5. The server-side FeatureStateManager.saveFeatureSummary() is responsible for
* creating summaries in this accumulated format.
*/
describe('parseAllPhaseSummaries', () => {
describe('basic parsing', () => {
it('should parse single phase summary into array with one entry', () => {
const summary = `### Implementation
## Changes Made
- Created new module
- Added unit tests`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(1);
expect(result[0].phaseName).toBe('Implementation');
expect(result[0].content).toBe('## Changes Made\n- Created new module\n- Added unit tests');
expect(result[0].header).toBe('### Implementation');
});
it('should parse multiple phase summaries in order', () => {
const summary = `### Implementation
## Changes Made
- Created new module
---
### Testing
## Test Results
- All tests pass`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(2);
// Verify order is preserved
expect(result[0].phaseName).toBe('Implementation');
expect(result[0].content).toBe('## Changes Made\n- Created new module');
expect(result[1].phaseName).toBe('Testing');
expect(result[1].content).toBe('## Test Results\n- All tests pass');
});
it('should parse three or more phases in correct order', () => {
const summary = `### Planning
Plan created
---
### Implementation
Code written
---
### Testing
Tests pass
---
### Refinement
Code polished`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(4);
expect(result[0].phaseName).toBe('Planning');
expect(result[1].phaseName).toBe('Implementation');
expect(result[2].phaseName).toBe('Testing');
expect(result[3].phaseName).toBe('Refinement');
});
});
describe('non-accumulated summary handling', () => {
it('should return single entry for summary without phase headers', () => {
const summary = `## Changes Made
- Created feature
- Added tests`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(1);
expect(result[0].phaseName).toBe('Summary');
expect(result[0].content).toBe(summary);
});
it('should return single entry for simple text summary', () => {
const summary = 'Just a simple summary without any special formatting';
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(1);
expect(result[0].phaseName).toBe('Summary');
expect(result[0].content).toBe(summary);
});
});
describe('edge cases', () => {
it('should return empty array for undefined summary', () => {
const result = parseAllPhaseSummaries(undefined);
expect(result.length).toBe(0);
});
it('should return empty array for empty string', () => {
const result = parseAllPhaseSummaries('');
expect(result.length).toBe(0);
});
it('should return empty array for whitespace-only string', () => {
const result = parseAllPhaseSummaries(' \n\n ');
expect(result.length).toBe(0);
});
it('should handle section without header after separator', () => {
const summary = `### Implementation
Content here
---
This section has no header`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(1);
expect(result[0].phaseName).toBe('Implementation');
});
});
describe('content preservation', () => {
it('should preserve markdown formatting in content', () => {
const summary = `### Implementation
## Heading
- **Bold text**
- \`code\`
\`\`\`typescript
const x = 1;
\`\`\``;
const result = parseAllPhaseSummaries(summary);
const content = result[0].content;
expect(content).toContain('**Bold text**');
expect(content).toContain('`code`');
expect(content).toContain('```typescript');
});
it('should preserve unicode in content', () => {
const summary = `### Testing
Results: ✅ 42 passed, ❌ 0 failed`;
const result = parseAllPhaseSummaries(summary);
expect(result[0].content).toContain('✅');
expect(result[0].content).toContain('❌');
});
it('should preserve tables in content', () => {
const summary = `### Testing
| Test | Result |
|------|--------|
| Unit | Pass |`;
const result = parseAllPhaseSummaries(summary);
expect(result[0].content).toContain('| Test | Result |');
});
it('should handle empty phase content', () => {
const summary = `### Implementation
---
### Testing
Content`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(2);
expect(result[0].content).toBe('');
expect(result[1].content).toBe('Content');
});
});
describe('header preservation', () => {
it('should preserve original header text', () => {
const summary = `### Code Review
Content`;
const result = parseAllPhaseSummaries(summary);
expect(result[0].header).toBe('### Code Review');
});
it('should preserve phase name with original casing', () => {
const summary = `### CODE REVIEW
Content`;
const result = parseAllPhaseSummaries(summary);
expect(result[0].phaseName).toBe('CODE REVIEW');
});
});
describe('chronological order preservation', () => {
it('should maintain order: Alpha before Beta before Gamma', () => {
const summary = `### Alpha
First
---
### Beta
Second
---
### Gamma
Third`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(3);
const names = result.map((e) => e.phaseName);
expect(names).toEqual(['Alpha', 'Beta', 'Gamma']);
});
it('should preserve typical pipeline order', () => {
const summary = [
'### Implementation',
'',
'## Changes',
'- Added auth module',
'',
'---',
'',
'### Code Review',
'',
'## Review Results',
'- Style issues fixed',
'',
'---',
'',
'### Testing',
'',
'## Test Results',
'- 42 tests pass',
].join('\n');
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(3);
expect(result[0].phaseName).toBe('Implementation');
expect(result[1].phaseName).toBe('Code Review');
expect(result[2].phaseName).toBe('Testing');
});
});
});

View File

@@ -0,0 +1,453 @@
/**
* Unit tests for the UI's log-parser extractSummary() function.
*
* These tests document the behavior of extractSummary() which is used as a
* CLIENT-SIDE FALLBACK when feature.summary (server-accumulated) is not available.
*
* IMPORTANT: extractSummary() returns only the LAST <summary> tag from raw output.
* For pipeline features with multiple steps, the server-side FeatureStateManager
* accumulates all step summaries into feature.summary, which the UI prefers.
*
* The tests below verify that extractSummary() correctly:
* - Returns the LAST summary when multiple exist (mimicking pipeline accumulation)
* - Handles various summary formats (<summary> tags, markdown headers)
* - Returns null when no summary is found
* - Handles edge cases like empty input and malformed tags
*/
import { describe, it, expect } from 'vitest';
// Recreate the extractSummary logic from apps/ui/src/lib/log-parser.ts
// We can't import directly because it's a UI file, so we mirror the logic here
/**
* Cleans up fragmented streaming text by removing spurious newlines
*/
function cleanFragmentedText(content: string): string {
let cleaned = content.replace(/([a-zA-Z])\n+([a-zA-Z])/g, '$1$2');
cleaned = cleaned.replace(/<([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '<$1$2>');
cleaned = cleaned.replace(/<\/([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '</$1$2>');
return cleaned;
}
/**
* Extracts summary content from raw log output
* Returns the LAST summary text if found, or null if no summary exists
*/
function extractSummary(rawOutput: string): string | null {
if (!rawOutput || !rawOutput.trim()) {
return null;
}
const cleanedOutput = cleanFragmentedText(rawOutput);
const regexesToTry: Array<{
regex: RegExp;
processor: (m: RegExpMatchArray) => string;
}> = [
{ regex: /<summary>([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] },
{ regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] },
{
regex: /^##\s+(Feature|Changes|Implementation)[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm,
processor: (m) => `## ${m[1]}\n${m[2]}`,
},
{
regex: /(^|\n)(All tasks completed[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g,
processor: (m) => m[2],
},
{
regex:
/(^|\n)((I've|I have) (successfully |now )?(completed|finished|implemented)[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g,
processor: (m) => m[2],
},
];
for (const { regex, processor } of regexesToTry) {
const matches = [...cleanedOutput.matchAll(regex)];
if (matches.length > 0) {
const lastMatch = matches[matches.length - 1];
return cleanFragmentedText(processor(lastMatch)).trim();
}
}
return null;
}
describe('log-parser extractSummary (UI fallback)', () => {
describe('basic summary extraction', () => {
it('should extract summary from <summary> tags', () => {
const output = `
Some agent output...
<summary>
## Changes Made
- Fixed the bug in parser.ts
- Added error handling
</summary>
More output...
`;
const result = extractSummary(output);
expect(result).toBe('## Changes Made\n- Fixed the bug in parser.ts\n- Added error handling');
});
it('should prefer <summary> tags over markdown headers', () => {
const output = `
## Summary
Markdown summary here.
<summary>
XML summary here.
</summary>
`;
const result = extractSummary(output);
expect(result).toBe('XML summary here.');
});
});
describe('multiple summaries (pipeline accumulation scenario)', () => {
it('should return ONLY the LAST summary tag when multiple exist', () => {
// This is the key behavior for pipeline features:
// extractSummary returns only the LAST, which is why server-side
// accumulation is needed for multi-step pipelines
const output = `
## Step 1: Code Review
<summary>
- Found 3 issues
- Approved with changes
</summary>
---
## Step 2: Testing
<summary>
- All tests pass
- Coverage 95%
</summary>
`;
const result = extractSummary(output);
expect(result).toBe('- All tests pass\n- Coverage 95%');
expect(result).not.toContain('Code Review');
expect(result).not.toContain('Found 3 issues');
});
it('should return ONLY the LAST summary from three pipeline steps', () => {
const output = `
<summary>Step 1 complete</summary>
---
<summary>Step 2 complete</summary>
---
<summary>Step 3 complete - all done!</summary>
`;
const result = extractSummary(output);
expect(result).toBe('Step 3 complete - all done!');
expect(result).not.toContain('Step 1');
expect(result).not.toContain('Step 2');
});
it('should handle mixed summary formats across pipeline steps', () => {
const output = `
## Step 1
<summary>
Implementation done
</summary>
---
## Step 2
## Summary
Review complete
---
## Step 3
<summary>
All tests passing
</summary>
`;
const result = extractSummary(output);
// The <summary> tag format takes priority, and returns the LAST match
expect(result).toBe('All tests passing');
});
});
describe('priority order of summary patterns', () => {
it('should try patterns in priority order: <summary> first, then markdown headers', () => {
// When both <summary> tags and markdown headers exist,
// <summary> tags should take priority
const output = `
## Summary
This markdown summary should be ignored.
<summary>
This XML summary should be used.
</summary>
`;
const result = extractSummary(output);
expect(result).toBe('This XML summary should be used.');
expect(result).not.toContain('ignored');
});
it('should fall back to Feature/Changes/Implementation headers when no <summary> tag', () => {
// Note: The regex for these headers requires content before the header
// (^ at start or preceded by newline). Adding some content before.
const output = `
Agent output here...
## Feature
New authentication system with OAuth support.
## Next
`;
const result = extractSummary(output);
// Should find the Feature header and include it in result
// Note: Due to regex behavior, it captures content until next ##
expect(result).toContain('## Feature');
});
it('should fall back to completion phrases when no structured summary found', () => {
const output = `
Working on the feature...
Making progress...
All tasks completed successfully. The feature is ready.
🔧 Tool: Bash
`;
const result = extractSummary(output);
expect(result).toContain('All tasks completed');
});
});
describe('edge cases', () => {
it('should return null for empty string', () => {
expect(extractSummary('')).toBeNull();
});
it('should return null for whitespace-only string', () => {
expect(extractSummary(' \n\n ')).toBeNull();
});
it('should return null when no summary pattern found', () => {
expect(extractSummary('Random agent output without any summary patterns')).toBeNull();
});
it('should handle malformed <summary> tags gracefully', () => {
const output = `
<summary>
This summary is never closed...
`;
// Without closing tag, the regex won't match
expect(extractSummary(output)).toBeNull();
});
it('should handle empty <summary> tags', () => {
const output = `
<summary></summary>
`;
const result = extractSummary(output);
expect(result).toBe(''); // Empty string is valid
});
it('should handle <summary> tags with only whitespace', () => {
const output = `
<summary>
</summary>
`;
const result = extractSummary(output);
expect(result).toBe(''); // Trimmed to empty string
});
it('should handle summary with markdown code blocks', () => {
const output = `
<summary>
## Changes
\`\`\`typescript
const x = 1;
\`\`\`
Done!
</summary>
`;
const result = extractSummary(output);
expect(result).toContain('```typescript');
expect(result).toContain('const x = 1;');
});
it('should handle summary with special characters', () => {
const output = `
<summary>
Fixed bug in parser.ts: "quotes" and 'apostrophes'
Special chars: <>&$@#%^*
</summary>
`;
const result = extractSummary(output);
expect(result).toContain('"quotes"');
expect(result).toContain('<>&$@#%^*');
});
});
describe('fragmented streaming text handling', () => {
it('should handle fragmented <summary> tags from streaming', () => {
// Sometimes streaming providers split text like "<sum\n\nmary>"
const output = `
<sum
mary>
Fixed the issue
</sum
mary>
`;
const result = extractSummary(output);
// The cleanFragmentedText function should normalize this
expect(result).toBe('Fixed the issue');
});
it('should handle fragmented text within summary content', () => {
const output = `
<summary>
Fixed the bug in par
ser.ts
</summary>
`;
const result = extractSummary(output);
// cleanFragmentedText should join "par\n\nser" into "parser"
expect(result).toBe('Fixed the bug in parser.ts');
});
});
describe('completion phrase detection', () => {
it('should extract "All tasks completed" summaries', () => {
const output = `
Some output...
All tasks completed successfully. The feature is ready for review.
🔧 Tool: Bash
`;
const result = extractSummary(output);
expect(result).toContain('All tasks completed');
});
it("should extract I've completed summaries", () => {
const output = `
Working on feature...
I've successfully implemented the feature with all requirements met.
🔧 Tool: Read
`;
const result = extractSummary(output);
expect(result).toContain("I've successfully implemented");
});
it('should extract "I have finished" summaries', () => {
const output = `
Implementation phase...
I have finished the implementation.
📋 Planning
`;
const result = extractSummary(output);
expect(result).toContain('I have finished');
});
});
describe('real-world pipeline scenarios', () => {
it('should handle typical multi-step pipeline output (returns last only)', () => {
// This test documents WHY server-side accumulation is essential:
// extractSummary only returns the last step's summary
const output = `
📋 Planning Mode: Full
🔧 Tool: Read
Input: {"file_path": "src/parser.ts"}
<summary>
## Code Review
- Analyzed parser.ts
- Found potential improvements
</summary>
---
## Follow-up Session
🔧 Tool: Edit
Input: {"file_path": "src/parser.ts"}
<summary>
## Implementation
- Applied suggested improvements
- Updated tests
</summary>
---
## Follow-up Session
🔧 Tool: Bash
Input: {"command": "npm test"}
<summary>
## Testing
- All 42 tests pass
- No regressions detected
</summary>
`;
const result = extractSummary(output);
// Only the LAST summary is returned
expect(result).toBe('## Testing\n- All 42 tests pass\n- No regressions detected');
// Earlier summaries are lost
expect(result).not.toContain('Code Review');
expect(result).not.toContain('Implementation');
});
it('should handle single-step non-pipeline output', () => {
// For non-pipeline features, extractSummary works correctly
const output = `
Working on feature...
<summary>
## Implementation Complete
- Created new component
- Added unit tests
- Updated documentation
</summary>
`;
const result = extractSummary(output);
expect(result).toContain('Implementation Complete');
expect(result).toContain('Created new component');
});
});
});
/**
* These tests verify the UI fallback behavior for summary extraction.
*
* KEY INSIGHT: The extractSummary() function returns only the LAST summary,
* which is why the server-side FeatureStateManager.saveFeatureSummary() method
* accumulates all step summaries into feature.summary.
*
* The UI's AgentOutputModal component uses this priority:
* 1. feature.summary (server-accumulated, contains all steps)
* 2. extractSummary(output) (client-side fallback, last summary only)
*
* For pipeline features, this ensures all step summaries are displayed.
*/

View File

@@ -0,0 +1,533 @@
/**
* Unit tests for the UI's log-parser phase summary parsing functions.
*
* These tests verify the behavior of:
* - parsePhaseSummaries(): Parses accumulated summary into individual phases
* - extractPhaseSummary(): Extracts a specific phase's summary
* - extractImplementationSummary(): Extracts only the implementation phase
* - isAccumulatedSummary(): Checks if summary is in accumulated format
*
* The accumulated summary format uses markdown headers with `###` for phase names
* and `---` as separators between phases.
*
* TODO: These test helper functions are mirrored from apps/ui/src/lib/log-parser.ts
* because server-side tests cannot import from the UI module. If the production
* implementation changes, these tests may pass while production fails.
* Consider adding an integration test that validates the actual UI parsing behavior.
*/
import { describe, it, expect } from 'vitest';
// ============================================================================
// MIRRORED FUNCTIONS from apps/ui/src/lib/log-parser.ts
// ============================================================================
// NOTE: These functions are mirrored from the UI implementation because
// server-side tests cannot import from apps/ui/. Keep these in sync with the
// production implementation. The UI implementation includes additional
// handling for getPhaseSections/leadingImplementationSection for backward
// compatibility with mixed formats.
/**
* Parses an accumulated summary string into individual phase summaries.
*/
function parsePhaseSummaries(summary: string | undefined): Map<string, string> {
const phaseSummaries = new Map<string, string>();
if (!summary || !summary.trim()) {
return phaseSummaries;
}
// Split by the horizontal rule separator
const sections = summary.split(/\n\n---\n\n/);
for (const section of sections) {
// Match the phase header pattern: ### Phase Name
const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/);
if (headerMatch) {
const phaseName = headerMatch[1].trim().toLowerCase();
// Extract content after the header (skip the header line and leading newlines)
const content = section.substring(headerMatch[0].length).trim();
phaseSummaries.set(phaseName, content);
}
}
return phaseSummaries;
}
/**
* Extracts a specific phase summary from an accumulated summary string.
*/
function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null {
const phaseSummaries = parsePhaseSummaries(summary);
const normalizedPhaseName = phaseName.toLowerCase();
return phaseSummaries.get(normalizedPhaseName) || null;
}
/**
* Gets the implementation phase summary from an accumulated summary string.
*/
function extractImplementationSummary(summary: string | undefined): string | null {
if (!summary || !summary.trim()) {
return null;
}
const phaseSummaries = parsePhaseSummaries(summary);
// Try exact match first
const implementationContent = phaseSummaries.get('implementation');
if (implementationContent) {
return implementationContent;
}
// Fallback: find any phase containing "implement"
for (const [phaseName, content] of phaseSummaries) {
if (phaseName.includes('implement')) {
return content;
}
}
// If no phase summaries found, the summary might not be in accumulated format
// (legacy or non-pipeline feature). In this case, return the whole summary
// if it looks like a single summary (no phase headers).
if (!summary.includes('### ') && !summary.includes('\n---\n')) {
return summary;
}
return null;
}
/**
* Checks if a summary string is in the accumulated multi-phase format.
*/
function isAccumulatedSummary(summary: string | undefined): boolean {
if (!summary || !summary.trim()) {
return false;
}
// Check for the presence of phase headers with separator
const hasMultiplePhases =
summary.includes('\n\n---\n\n') && summary.match(/###\s+.+/g)?.length > 0;
return hasMultiplePhases;
}
describe('phase summary parser', () => {
describe('parsePhaseSummaries', () => {
it('should parse single phase summary', () => {
const summary = `### Implementation
Created auth module with login functionality.`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(1);
expect(result.get('implementation')).toBe('Created auth module with login functionality.');
});
it('should parse multiple phase summaries', () => {
const summary = `### Implementation
Created auth module.
---
### Testing
All tests pass.
---
### Code Review
Approved with minor suggestions.`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(3);
expect(result.get('implementation')).toBe('Created auth module.');
expect(result.get('testing')).toBe('All tests pass.');
expect(result.get('code review')).toBe('Approved with minor suggestions.');
});
it('should handle empty input', () => {
expect(parsePhaseSummaries('').size).toBe(0);
expect(parsePhaseSummaries(undefined).size).toBe(0);
expect(parsePhaseSummaries(' \n\n ').size).toBe(0);
});
it('should handle phase names with spaces', () => {
const summary = `### Code Review
Review findings here.`;
const result = parsePhaseSummaries(summary);
expect(result.get('code review')).toBe('Review findings here.');
});
it('should normalize phase names to lowercase', () => {
const summary = `### IMPLEMENTATION
Content here.`;
const result = parsePhaseSummaries(summary);
expect(result.get('implementation')).toBe('Content here.');
expect(result.get('IMPLEMENTATION')).toBeUndefined();
});
it('should handle content with markdown', () => {
const summary = `### Implementation
## Changes Made
- Fixed bug in parser.ts
- Added error handling
\`\`\`typescript
const x = 1;
\`\`\``;
const result = parsePhaseSummaries(summary);
expect(result.get('implementation')).toContain('## Changes Made');
expect(result.get('implementation')).toContain('```typescript');
});
it('should return empty map for non-accumulated format', () => {
// Legacy format without phase headers
const summary = `## Summary
This is a simple summary without phase headers.`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(0);
});
});
describe('extractPhaseSummary', () => {
it('should extract specific phase by name (case-insensitive)', () => {
const summary = `### Implementation
Implementation content.
---
### Testing
Testing content.`;
expect(extractPhaseSummary(summary, 'implementation')).toBe('Implementation content.');
expect(extractPhaseSummary(summary, 'IMPLEMENTATION')).toBe('Implementation content.');
expect(extractPhaseSummary(summary, 'Implementation')).toBe('Implementation content.');
expect(extractPhaseSummary(summary, 'testing')).toBe('Testing content.');
});
it('should return null for non-existent phase', () => {
const summary = `### Implementation
Content here.`;
expect(extractPhaseSummary(summary, 'code review')).toBeNull();
});
it('should return null for empty input', () => {
expect(extractPhaseSummary('', 'implementation')).toBeNull();
expect(extractPhaseSummary(undefined, 'implementation')).toBeNull();
});
});
describe('extractImplementationSummary', () => {
it('should extract implementation phase from accumulated summary', () => {
const summary = `### Implementation
Created auth module.
---
### Testing
All tests pass.
---
### Code Review
Approved.`;
const result = extractImplementationSummary(summary);
expect(result).toBe('Created auth module.');
expect(result).not.toContain('Testing');
expect(result).not.toContain('Code Review');
});
it('should return implementation phase even when not first', () => {
const summary = `### Planning
Plan created.
---
### Implementation
Implemented the feature.
---
### Review
Reviewed.`;
const result = extractImplementationSummary(summary);
expect(result).toBe('Implemented the feature.');
});
it('should handle phase with "implementation" in name', () => {
const summary = `### Feature Implementation
Built the feature.`;
const result = extractImplementationSummary(summary);
expect(result).toBe('Built the feature.');
});
it('should return full summary for non-accumulated format (legacy)', () => {
// Non-pipeline features store summary without phase headers
const summary = `## Changes
- Fixed bug
- Added tests`;
const result = extractImplementationSummary(summary);
expect(result).toBe(summary);
});
it('should return null for empty input', () => {
expect(extractImplementationSummary('')).toBeNull();
expect(extractImplementationSummary(undefined)).toBeNull();
expect(extractImplementationSummary(' \n\n ')).toBeNull();
});
it('should return null when no implementation phase in accumulated summary', () => {
const summary = `### Testing
Tests written.
---
### Code Review
Approved.`;
const result = extractImplementationSummary(summary);
expect(result).toBeNull();
});
});
describe('isAccumulatedSummary', () => {
it('should return true for accumulated multi-phase summary', () => {
const summary = `### Implementation
Content.
---
### Testing
Content.`;
expect(isAccumulatedSummary(summary)).toBe(true);
});
it('should return false for single phase summary (no separator)', () => {
const summary = `### Implementation
Content.`;
expect(isAccumulatedSummary(summary)).toBe(false);
});
it('should return false for legacy non-accumulated format', () => {
const summary = `## Summary
This is a simple summary.`;
expect(isAccumulatedSummary(summary)).toBe(false);
});
it('should return false for empty input', () => {
expect(isAccumulatedSummary('')).toBe(false);
expect(isAccumulatedSummary(undefined)).toBe(false);
expect(isAccumulatedSummary(' \n\n ')).toBe(false);
});
it('should return true even for two phases', () => {
const summary = `### Implementation
Content A.
---
### Code Review
Content B.`;
expect(isAccumulatedSummary(summary)).toBe(true);
});
});
describe('acceptance criteria scenarios', () => {
it('AC1: Implementation summary preserved when Testing completes', () => {
// Given a task card completes the Implementation phase,
// when the Testing phase subsequently completes,
// then the Implementation phase summary must remain stored independently
const summary = `### Implementation
- Created auth module
- Added user service
---
### Testing
- 42 tests pass
- 98% coverage`;
const impl = extractImplementationSummary(summary);
const testing = extractPhaseSummary(summary, 'testing');
expect(impl).toBe('- Created auth module\n- Added user service');
expect(testing).toBe('- 42 tests pass\n- 98% coverage');
expect(impl).not.toContain('Testing');
expect(testing).not.toContain('auth module');
});
it('AC4: Implementation Summary tab shows only implementation phase', () => {
// Given a task card has completed the Implementation phase
// (regardless of how many subsequent phases have run),
// when the user opens the "Implementation Summary" tab,
// then it must display only the summary produced by the Implementation phase
const summary = `### Implementation
Implementation phase output here.
---
### Testing
Testing phase output here.
---
### Code Review
Code review output here.`;
const impl = extractImplementationSummary(summary);
expect(impl).toBe('Implementation phase output here.');
expect(impl).not.toContain('Testing');
expect(impl).not.toContain('Code Review');
});
it('AC5: Empty state when implementation not started', () => {
// Given a task card has not yet started the Implementation phase
const summary = `### Planning
Planning phase complete.`;
const impl = extractImplementationSummary(summary);
// Should return null (UI shows "No implementation summary available")
expect(impl).toBeNull();
});
it('AC6: Single phase summary displayed correctly', () => {
// Given a task card where Implementation was the only completed phase
const summary = `### Implementation
Only implementation was done.`;
const impl = extractImplementationSummary(summary);
expect(impl).toBe('Only implementation was done.');
});
it('AC9: Mid-progress shows only completed phases', () => {
// Given a task card is mid-progress
// (e.g., Implementation and Testing complete, Code Review pending)
const summary = `### Implementation
Implementation done.
---
### Testing
Testing done.`;
const phases = parsePhaseSummaries(summary);
expect(phases.size).toBe(2);
expect(phases.has('implementation')).toBe(true);
expect(phases.has('testing')).toBe(true);
expect(phases.has('code review')).toBe(false);
});
it('AC10: All phases in chronological order', () => {
// Given all phases of a task card are complete
const summary = `### Implementation
First phase content.
---
### Testing
Second phase content.
---
### Code Review
Third phase content.`;
// ParsePhaseSummaries should preserve order
const phases = parsePhaseSummaries(summary);
const phaseNames = [...phases.keys()];
expect(phaseNames).toEqual(['implementation', 'testing', 'code review']);
});
it('AC17: Retried phase shows only latest', () => {
// Given a phase was retried, when viewing the Summary tab,
// only one entry for the retried phase must appear (the latest retry's summary)
//
// Note: The server-side FeatureStateManager overwrites the phase summary
// when the same phase runs again, so we only have one entry per phase name.
// This test verifies that the parser correctly handles this.
const summary = `### Implementation
First attempt content.
---
### Testing
First test run.
---
### Implementation
Retry content - fixed issues.
---
### Testing
Retry - all tests now pass.`;
const phases = parsePhaseSummaries(summary);
// The parser will have both entries, but Map keeps last value for same key
expect(phases.get('implementation')).toBe('Retry content - fixed issues.');
expect(phases.get('testing')).toBe('Retry - all tests now pass.');
});
});
});

View File

@@ -0,0 +1,238 @@
/**
* Unit tests for the summary auto-scroll detection logic.
*
* These tests verify the behavior of the scroll detection function used in
* AgentOutputModal to determine if auto-scroll should be enabled.
*
* The logic mirrors the handleSummaryScroll function in:
* apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx
*
* Auto-scroll behavior:
* - When user is at or near the bottom (< 50px from bottom), auto-scroll is enabled
* - When user scrolls up to view older content, auto-scroll is disabled
* - Scrolling back to bottom re-enables auto-scroll
*/
import { describe, it, expect } from 'vitest';
/**
* Determines if the scroll position is at the bottom of the container.
* This is the core logic from handleSummaryScroll in AgentOutputModal.
*
* @param scrollTop - Current scroll position from top
* @param scrollHeight - Total scrollable height
* @param clientHeight - Visible height of the container
* @param threshold - Distance from bottom to consider "at bottom" (default: 50px)
* @returns true if at bottom, false otherwise
*/
function isScrollAtBottom(
scrollTop: number,
scrollHeight: number,
clientHeight: number,
threshold = 50
): boolean {
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
return distanceFromBottom < threshold;
}
describe('Summary Auto-Scroll Detection Logic', () => {
describe('basic scroll position detection', () => {
it('should return true when scrolled to exact bottom', () => {
// Container: 500px tall, content: 1000px tall
// ScrollTop: 500 (scrolled to bottom)
const result = isScrollAtBottom(500, 1000, 500);
expect(result).toBe(true);
});
it('should return true when near bottom (within threshold)', () => {
// 49px from bottom - within 50px threshold
const result = isScrollAtBottom(451, 1000, 500);
expect(result).toBe(true);
});
it('should return true when exactly at threshold boundary (49px)', () => {
// 49px from bottom
const result = isScrollAtBottom(451, 1000, 500);
expect(result).toBe(true);
});
it('should return false when just outside threshold (51px)', () => {
// 51px from bottom - outside 50px threshold
const result = isScrollAtBottom(449, 1000, 500);
expect(result).toBe(false);
});
it('should return false when scrolled to top', () => {
const result = isScrollAtBottom(0, 1000, 500);
expect(result).toBe(false);
});
it('should return false when scrolled to middle', () => {
const result = isScrollAtBottom(250, 1000, 500);
expect(result).toBe(false);
});
});
describe('edge cases with small content', () => {
it('should return true when content fits in viewport (no scroll needed)', () => {
// Content is smaller than container - no scrolling possible
const result = isScrollAtBottom(0, 300, 500);
expect(result).toBe(true);
});
it('should return true when content exactly fits viewport', () => {
const result = isScrollAtBottom(0, 500, 500);
expect(result).toBe(true);
});
it('should return true when content slightly exceeds viewport (within threshold)', () => {
// Content: 540px, Viewport: 500px, can scroll 40px
// At scroll 0, we're 40px from bottom - within threshold
const result = isScrollAtBottom(0, 540, 500);
expect(result).toBe(true);
});
});
describe('large content scenarios', () => {
it('should correctly detect bottom in very long content', () => {
// Simulate accumulated summary from many pipeline steps
// Content: 10000px, Viewport: 500px
const result = isScrollAtBottom(9500, 10000, 500);
expect(result).toBe(true);
});
it('should correctly detect non-bottom in very long content', () => {
// User scrolled up to read earlier summaries
const result = isScrollAtBottom(5000, 10000, 500);
expect(result).toBe(false);
});
it('should detect when user scrolls up from bottom', () => {
// Started at bottom (scroll: 9500), then scrolled up 100px
const result = isScrollAtBottom(9400, 10000, 500);
expect(result).toBe(false);
});
});
describe('custom threshold values', () => {
it('should work with larger threshold (100px)', () => {
// 75px from bottom - within 100px threshold
const result = isScrollAtBottom(425, 1000, 500, 100);
expect(result).toBe(true);
});
it('should work with smaller threshold (10px)', () => {
// 15px from bottom - outside 10px threshold
const result = isScrollAtBottom(485, 1000, 500, 10);
expect(result).toBe(false);
});
it('should work with zero threshold (exact match only)', () => {
// At exact bottom - distanceFromBottom = 0, which is NOT < 0 with strict comparison
// This is an edge case: the implementation uses < not <=
const result = isScrollAtBottom(500, 1000, 500, 0);
expect(result).toBe(false); // 0 < 0 is false
// 1px from bottom - also fails
const result2 = isScrollAtBottom(499, 1000, 500, 0);
expect(result2).toBe(false);
// For exact match with 0 threshold, we need negative distanceFromBottom
// which happens when scrollTop > scrollHeight - clientHeight (overscroll)
const result3 = isScrollAtBottom(501, 1000, 500, 0);
expect(result3).toBe(true); // -1 < 0 is true
});
});
describe('pipeline summary scrolling scenarios', () => {
it('should enable auto-scroll when new content arrives while at bottom', () => {
// User is at bottom viewing step 2 summary
// Step 3 summary is added, increasing scrollHeight from 1000 to 1500
// ScrollTop stays at 950 (was at bottom), but now user needs to scroll
// Before new content: isScrollAtBottom(950, 1000, 500) = true
// After new content: auto-scroll should kick in to scroll to new bottom
// Simulating the auto-scroll effect setting scrollTop to new bottom
const newScrollTop = 1500 - 500; // scrollHeight - clientHeight
const result = isScrollAtBottom(newScrollTop, 1500, 500);
expect(result).toBe(true);
});
it('should not auto-scroll when user is reading earlier summaries', () => {
// User scrolled up to read step 1 summary while step 3 is added
// scrollHeight increases, but scrollTop stays same
// User is now further from bottom
// User was at scroll position 200 (reading early content)
// New content increases scrollHeight from 1000 to 1500
// Distance from bottom goes from 300 to 800
const result = isScrollAtBottom(200, 1500, 500);
expect(result).toBe(false);
});
it('should re-enable auto-scroll when user scrolls back to bottom', () => {
// User was reading step 1 (scrollTop: 200)
// User scrolls back to bottom to see latest content
const result = isScrollAtBottom(1450, 1500, 500);
expect(result).toBe(true);
});
});
describe('decimal scroll values', () => {
it('should handle fractional scroll positions', () => {
// Browsers can report fractional scroll values
const result = isScrollAtBottom(499.5, 1000, 500);
expect(result).toBe(true);
});
it('should handle fractional scroll heights', () => {
const result = isScrollAtBottom(450.7, 1000.3, 500);
expect(result).toBe(true);
});
});
describe('negative and invalid inputs', () => {
it('should handle negative scrollTop (bounce scroll)', () => {
// iOS can report negative scrollTop during bounce
const result = isScrollAtBottom(-10, 1000, 500);
expect(result).toBe(false);
});
it('should handle zero scrollHeight', () => {
// Empty content
const result = isScrollAtBottom(0, 0, 500);
expect(result).toBe(true);
});
it('should handle zero clientHeight', () => {
// Hidden container - distanceFromBottom = 1000 - 0 - 0 = 1000
// This is not < threshold, so returns false
// This edge case represents a broken/invisible container
const result = isScrollAtBottom(0, 1000, 0);
expect(result).toBe(false);
});
});
describe('real-world accumulated summary dimensions', () => {
it('should handle typical 3-step pipeline summary dimensions', () => {
// Approximate: 3 steps x ~800px each = ~2400px
// Viewport: 400px (modal height)
const result = isScrollAtBottom(2000, 2400, 400);
expect(result).toBe(true);
});
it('should handle large 10-step pipeline summary dimensions', () => {
// Approximate: 10 steps x ~800px each = ~8000px
// Viewport: 400px
const result = isScrollAtBottom(7600, 8000, 400);
expect(result).toBe(true);
});
it('should detect scroll to top of large summary', () => {
// User at top of 10-step summary
const result = isScrollAtBottom(0, 8000, 400);
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,128 @@
/**
* Unit tests for summary normalization between UI components and parser functions.
*
* These tests verify that:
* - getFirstNonEmptySummary returns string | null
* - parseAllPhaseSummaries and isAccumulatedSummary expect string | undefined
* - The normalization (summary ?? undefined) correctly converts null to undefined
*
* This ensures the UI components properly bridge the type gap between:
* - getFirstNonEmptySummary (returns string | null)
* - parseAllPhaseSummaries (expects string | undefined)
* - isAccumulatedSummary (expects string | undefined)
*/
import { describe, it, expect } from 'vitest';
import { parseAllPhaseSummaries, isAccumulatedSummary } from '../../../../ui/src/lib/log-parser.ts';
import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts';
describe('Summary Normalization', () => {
describe('getFirstNonEmptySummary', () => {
it('should return the first non-empty string', () => {
const result = getFirstNonEmptySummary(null, undefined, 'valid summary', 'another');
expect(result).toBe('valid summary');
});
it('should return null when all candidates are empty', () => {
const result = getFirstNonEmptySummary(null, undefined, '', ' ');
expect(result).toBeNull();
});
it('should return null when no candidates provided', () => {
const result = getFirstNonEmptySummary();
expect(result).toBeNull();
});
it('should return null for all null/undefined candidates', () => {
const result = getFirstNonEmptySummary(null, undefined, null);
expect(result).toBeNull();
});
it('should preserve original string formatting (not trim)', () => {
const result = getFirstNonEmptySummary(' summary with spaces ');
expect(result).toBe(' summary with spaces ');
});
});
describe('parseAllPhaseSummaries with normalized input', () => {
it('should handle null converted to undefined via ?? operator', () => {
const summary = getFirstNonEmptySummary(null, undefined);
// This is the normalization: summary ?? undefined
const normalizedSummary = summary ?? undefined;
// TypeScript should accept this without error
const result = parseAllPhaseSummaries(normalizedSummary);
expect(result).toEqual([]);
});
it('should parse accumulated summary when non-null is normalized', () => {
const rawSummary =
'### Implementation\n\nDid some work\n\n---\n\n### Testing\n\nAll tests pass';
const summary = getFirstNonEmptySummary(null, rawSummary);
const normalizedSummary = summary ?? undefined;
const result = parseAllPhaseSummaries(normalizedSummary);
expect(result).toHaveLength(2);
expect(result[0].phaseName).toBe('Implementation');
expect(result[1].phaseName).toBe('Testing');
});
});
describe('isAccumulatedSummary with normalized input', () => {
it('should return false for null converted to undefined', () => {
const summary = getFirstNonEmptySummary(null, undefined);
const normalizedSummary = summary ?? undefined;
const result = isAccumulatedSummary(normalizedSummary);
expect(result).toBe(false);
});
it('should return true for valid accumulated summary after normalization', () => {
const rawSummary =
'### Implementation\n\nDid some work\n\n---\n\n### Testing\n\nAll tests pass';
const summary = getFirstNonEmptySummary(rawSummary);
const normalizedSummary = summary ?? undefined;
const result = isAccumulatedSummary(normalizedSummary);
expect(result).toBe(true);
});
it('should return false for single-phase summary after normalization', () => {
const rawSummary = '### Implementation\n\nDid some work';
const summary = getFirstNonEmptySummary(rawSummary);
const normalizedSummary = summary ?? undefined;
const result = isAccumulatedSummary(normalizedSummary);
expect(result).toBe(false);
});
});
describe('Type safety verification', () => {
it('should demonstrate that null must be normalized to undefined', () => {
// This test documents the type mismatch that requires normalization
const summary: string | null = getFirstNonEmptySummary(null);
const normalizedSummary: string | undefined = summary ?? undefined;
// parseAllPhaseSummaries expects string | undefined, not string | null
// The normalization converts null -> undefined, which is compatible
const result = parseAllPhaseSummaries(normalizedSummary);
expect(result).toEqual([]);
});
it('should work with the actual usage pattern from components', () => {
// Simulates the actual pattern used in summary-dialog.tsx and agent-output-modal.tsx
const featureSummary: string | null | undefined = null;
const extractedSummary: string | null | undefined = undefined;
const rawSummary = getFirstNonEmptySummary(featureSummary, extractedSummary);
const normalizedSummary = rawSummary ?? undefined;
// Both parser functions should work with the normalized value
const phases = parseAllPhaseSummaries(normalizedSummary);
const hasMultiple = isAccumulatedSummary(normalizedSummary);
expect(phases).toEqual([]);
expect(hasMultiple).toBe(false);
});
});
});

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest';
import { parseAllPhaseSummaries, isAccumulatedSummary } from '../../../../ui/src/lib/log-parser.ts';
import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts';
/**
* Mirrors summary source priority in agent-info-panel.tsx:
* freshFeature.summary > feature.summary > summaryProp > agentInfo.summary
*/
function getCardEffectiveSummary(params: {
freshFeatureSummary?: string | null;
featureSummary?: string | null;
summaryProp?: string | null;
agentInfoSummary?: string | null;
}): string | undefined | null {
return getFirstNonEmptySummary(
params.freshFeatureSummary,
params.featureSummary,
params.summaryProp,
params.agentInfoSummary
);
}
/**
* Mirrors SummaryDialog raw summary selection in summary-dialog.tsx:
* summaryProp > feature.summary > agentInfo.summary
*/
function getDialogRawSummary(params: {
summaryProp?: string | null;
featureSummary?: string | null;
agentInfoSummary?: string | null;
}): string | undefined | null {
return getFirstNonEmptySummary(
params.summaryProp,
params.featureSummary,
params.agentInfoSummary
);
}
describe('Summary Source Flow Integration', () => {
it('uses fresh per-feature summary in card and preserves it through summary dialog', () => {
const staleListSummary = '## Old summary from stale list cache';
const freshAccumulatedSummary = `### Implementation
Implemented auth + profile flow.
---
### Testing
- Unit tests: 18 passed
- Integration tests: 6 passed`;
const parsedAgentInfoSummary = 'Fallback summary from parsed agent output';
const cardEffectiveSummary = getCardEffectiveSummary({
freshFeatureSummary: freshAccumulatedSummary,
featureSummary: staleListSummary,
summaryProp: undefined,
agentInfoSummary: parsedAgentInfoSummary,
});
expect(cardEffectiveSummary).toBe(freshAccumulatedSummary);
const dialogRawSummary = getDialogRawSummary({
summaryProp: cardEffectiveSummary,
featureSummary: staleListSummary,
agentInfoSummary: parsedAgentInfoSummary,
});
expect(dialogRawSummary).toBe(freshAccumulatedSummary);
expect(isAccumulatedSummary(dialogRawSummary ?? undefined)).toBe(true);
const phases = parseAllPhaseSummaries(dialogRawSummary ?? undefined);
expect(phases).toHaveLength(2);
expect(phases[0]?.phaseName).toBe('Implementation');
expect(phases[1]?.phaseName).toBe('Testing');
});
it('falls back in order when fresher sources are absent', () => {
const cardEffectiveSummary = getCardEffectiveSummary({
freshFeatureSummary: undefined,
featureSummary: '',
summaryProp: undefined,
agentInfoSummary: 'Agent parsed fallback',
});
expect(cardEffectiveSummary).toBe('Agent parsed fallback');
const dialogRawSummary = getDialogRawSummary({
summaryProp: undefined,
featureSummary: undefined,
agentInfoSummary: cardEffectiveSummary,
});
expect(dialogRawSummary).toBe('Agent parsed fallback');
expect(isAccumulatedSummary(dialogRawSummary ?? undefined)).toBe(false);
});
it('treats whitespace-only summaries as empty during fallback selection', () => {
const cardEffectiveSummary = getCardEffectiveSummary({
freshFeatureSummary: ' \n',
featureSummary: '\t',
summaryProp: ' ',
agentInfoSummary: 'Agent parsed fallback',
});
expect(cardEffectiveSummary).toBe('Agent parsed fallback');
});
});

1
apps/ui/.gitignore vendored
View File

@@ -38,6 +38,7 @@ yarn-error.log*
/playwright-report/
/blob-report/
/playwright/.cache/
/tests/.auth/
# Electron
/release/

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/ui",
"version": "0.15.0",
"version": "1.0.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": {
@@ -9,6 +9,7 @@
},
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"desktopName": "automaker.desktop",
"private": true,
"engines": {
"node": ">=22.0.0 <23.0.0"
@@ -144,6 +145,9 @@
"@playwright/test": "1.57.0",
"@tailwindcss/vite": "4.1.18",
"@tanstack/router-plugin": "1.141.7",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/dagre": "0.7.53",
"@types/node": "22.19.3",
"@types/react": "19.2.7",
@@ -156,6 +160,7 @@
"electron-builder": "26.0.12",
"eslint": "9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"jsdom": "^28.1.0",
"tailwindcss": "4.1.18",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
@@ -202,6 +207,10 @@
"filter": [
"**/*"
]
},
{
"from": "public/logo_larger.png",
"to": "logo_larger.png"
}
],
"mac": {
@@ -261,7 +270,12 @@
"maintainer": "webdevcody@gmail.com",
"executableName": "automaker",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"synopsis": "AI-powered autonomous development studio"
"synopsis": "AI-powered autonomous development studio",
"desktop": {
"entry": {
"Icon": "/opt/Automaker/resources/logo_larger.png"
}
}
},
"rpm": {
"depends": [
@@ -275,7 +289,8 @@
"libuuid"
],
"compression": "xz",
"vendor": "AutoMaker Team"
"vendor": "AutoMaker Team",
"afterInstall": "scripts/rpm-after-install.sh"
},
"nsis": {
"oneClick": false,

View File

@@ -1,28 +1,60 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
const port = process.env.TEST_PORT || 3107;
// PATH that includes common git locations so the E2E server can run git (worktree list, etc.)
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const extraPath =
process.platform === 'win32'
? [
process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`,
process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}\\Git\\cmd`,
].filter(Boolean)
: [
'/opt/homebrew/bin',
'/usr/local/bin',
'/usr/bin',
'/home/linuxbrew/.linuxbrew/bin',
process.env.HOME && `${process.env.HOME}/.local/bin`,
].filter(Boolean);
const e2eServerPath = [process.env.PATH, ...extraPath].filter(Boolean).join(pathSeparator);
const serverPort = process.env.TEST_SERVER_PORT || 3108;
// When true, no webServer is started; you must run UI (port 3107) and server (3108) yourself.
const reuseServer = process.env.TEST_REUSE_SERVER === 'true';
const useExternalBackend = !!process.env.VITE_SERVER_URL;
// Only skip backend startup when explicitly requested for E2E runs.
// VITE_SERVER_URL may be set in user shells for local dev and should not affect tests.
const useExternalBackend = process.env.TEST_USE_EXTERNAL_BACKEND === 'true';
// Always use mock agent for tests (disables rate limiting, uses mock Claude responses)
const mockAgent = true;
// Auth state file written by global setup, reused by all tests to skip per-test login
const AUTH_STATE_PATH = path.join(__dirname, 'tests/.auth/storage-state.json');
export default defineConfig({
testDir: './tests',
// Keep Playwright scoped to E2E specs so Vitest unit files are not executed here.
testMatch: '**/*.spec.ts',
testIgnore: ['**/unit/**'],
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1, // Run sequentially to avoid auth conflicts with shared server
reporter: 'html',
retries: process.env.CI ? 2 : 0,
// Use multiple workers for parallelism. CI gets 2 workers (constrained resources),
// local runs use 8 workers for faster test execution.
workers: process.env.CI ? 2 : 8,
reporter: process.env.CI ? 'github' : 'html',
timeout: 30000,
use: {
baseURL: `http://localhost:${port}`,
baseURL: `http://127.0.0.1:${port}`,
trace: 'on-failure',
screenshot: 'only-on-failure',
serviceWorkers: 'block',
// Reuse auth state from global setup - avoids per-test login overhead
storageState: AUTH_STATE_PATH,
},
// Global setup - authenticate before each test
// Global setup - authenticate once and save state for all workers
globalSetup: require.resolve('./tests/global-setup.ts'),
globalTeardown: require.resolve('./tests/global-teardown.ts'),
projects: [
{
name: 'chromium',
@@ -40,13 +72,15 @@ export default defineConfig({
: [
{
command: `cd ../server && npm run dev:test`,
url: `http://localhost:${serverPort}/api/health`,
url: `http://127.0.0.1:${serverPort}/api/health`,
// Don't reuse existing server to ensure we use the test API key
reuseExistingServer: false,
timeout: 60000,
env: {
...process.env,
PORT: String(serverPort),
// Ensure server can find git in CI/minimal env (worktree list, etc.)
PATH: e2eServerPath,
// Enable mock agent in CI to avoid real API calls
AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false',
// Set a test API key for web mode authentication
@@ -59,13 +93,17 @@ export default defineConfig({
ALLOWED_ROOT_DIRECTORY: '',
// Simulate containerized environment to skip sandbox confirmation dialogs
IS_CONTAINERIZED: 'true',
// Increase Node.js memory limit to prevent OOM during tests
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--max-old-space-size=4096']
.filter(Boolean)
.join(' '),
},
},
]),
// Frontend Vite dev server
{
command: `npm run dev`,
url: `http://localhost:${port}`,
url: `http://127.0.0.1:${port}`,
reuseExistingServer: false,
timeout: 120000,
env: {
@@ -77,6 +115,11 @@ export default defineConfig({
VITE_SKIP_SETUP: 'true',
// Always skip electron plugin during tests - prevents duplicate server spawning
VITE_SKIP_ELECTRON: 'true',
// Clear VITE_SERVER_URL to force the frontend to use the Vite proxy (/api)
// instead of calling the backend directly. Direct calls bypass the proxy and
// cause cookie domain mismatches (cookies are bound to 127.0.0.1 but
// VITE_SERVER_URL typically uses localhost).
VITE_SERVER_URL: '',
},
},
],

View File

@@ -10,7 +10,9 @@ const execAsync = promisify(exec);
const SERVER_PORT = process.env.TEST_SERVER_PORT || 3108;
const UI_PORT = process.env.TEST_PORT || 3107;
const USE_EXTERNAL_SERVER = !!process.env.VITE_SERVER_URL;
// Match Playwright config semantics: only explicit opt-in should skip backend startup/cleanup.
// VITE_SERVER_URL may exist in local shells and should not implicitly affect test behavior.
const USE_EXTERNAL_SERVER = process.env.TEST_USE_EXTERNAL_BACKEND === 'true';
console.log(`[KillTestServers] SERVER_PORT ${SERVER_PORT}`);
console.log(`[KillTestServers] UI_PORT ${UI_PORT}`);
async function killProcessOnPort(port) {

View File

@@ -0,0 +1,14 @@
#!/bin/bash
# Set the setuid bit on chrome-sandbox so Electron's sandbox works on systems
# where unprivileged user namespaces are restricted (e.g. hardened kernels).
# On Fedora/RHEL with standard kernel settings this is a safe no-op.
chmod 4755 /opt/Automaker/chrome-sandbox 2>/dev/null || true
# Refresh the GTK icon cache so GNOME/KDE picks up the newly installed icon
# immediately without requiring a logout. The -f flag forces a rebuild even
# if the cache is up-to-date; -t suppresses the mtime check warning.
gtk-update-icon-cache -f -t /usr/share/icons/hicolor 2>/dev/null || true
# Rebuild the desktop entry database so the app appears in the app launcher
# straight after install.
update-desktop-database /usr/share/applications 2>/dev/null || true

View File

@@ -18,6 +18,8 @@ const __dirname = path.dirname(__filename);
const WORKSPACE_ROOT = path.resolve(__dirname, '../../..');
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, 'test/fixtures/projectA');
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, '.automaker/app_spec.txt');
const CONTEXT_DIR = path.join(FIXTURE_PATH, '.automaker/context');
const CONTEXT_METADATA_PATH = path.join(CONTEXT_DIR, 'context-metadata.json');
const SERVER_SETTINGS_PATH = path.join(WORKSPACE_ROOT, 'apps/server/data/settings.json');
// Create a shared test workspace directory that will be used as default for project creation
const TEST_WORKSPACE_DIR = path.join(os.tmpdir(), 'automaker-e2e-workspace');
@@ -145,6 +147,14 @@ function setupFixtures() {
fs.writeFileSync(SPEC_FILE_PATH, SPEC_CONTENT);
console.log(`Created fixture file: ${SPEC_FILE_PATH}`);
// Create .automaker/context and context-metadata.json (expected by context view / FS read)
if (!fs.existsSync(CONTEXT_DIR)) {
fs.mkdirSync(CONTEXT_DIR, { recursive: true });
console.log(`Created directory: ${CONTEXT_DIR}`);
}
fs.writeFileSync(CONTEXT_METADATA_PATH, JSON.stringify({ files: {} }, null, 2));
console.log(`Created fixture file: ${CONTEXT_METADATA_PATH}`);
// Reset server settings.json to a clean state for E2E tests
const settingsDir = path.dirname(SERVER_SETTINGS_PATH);
if (!fs.existsSync(settingsDir)) {

View File

@@ -312,7 +312,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md overflow-y-auto">
<SheetHeader className="px-6 pt-6">
<SheetHeader
className="px-6"
style={{
paddingTop: 'max(1.5rem, calc(env(safe-area-inset-top, 0px) + 1rem))',
}}
>
<SheetTitle className="flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-brand-500" />
Board Background Settings

View File

@@ -177,7 +177,7 @@ export function FileBrowserDialog({
onSelect(currentPath);
onOpenChange(false);
}
}, [currentPath, onSelect, onOpenChange]);
}, [currentPath, onSelect, onOpenChange, addRecentFolder]);
// Handle Command/Ctrl+Enter keyboard shortcut to select current folder
useEffect(() => {

View File

@@ -37,7 +37,7 @@ import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { Markdown } from '@/components/ui/markdown';
import { cn, modelSupportsThinking, generateUUID } from '@/lib/utils';
import { cn, generateUUID, normalizeModelEntry } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { useGitHubPRReviewComments } from '@/hooks/queries';
import { useCreateFeature, useResolveReviewThread } from '@/hooks/mutations';
@@ -45,7 +45,7 @@ import { toast } from 'sonner';
import type { PRReviewComment } from '@/lib/electron';
import type { Feature } from '@/store/app-store';
import type { PhaseModelEntry } from '@automaker/types';
import { supportsReasoningEffort, normalizeThinkingLevelForModel } from '@automaker/types';
import { normalizeThinkingLevelForModel } from '@automaker/types';
import { resolveModelString } from '@automaker/model-resolver';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults';
@@ -62,6 +62,8 @@ export interface PRCommentResolutionPRInfo {
title: string;
/** The branch name (headRefName) associated with this PR, used to assign features to the correct worktree */
headRefName?: string;
/** The URL of the PR, used to set prUrl on created features */
url?: string;
}
interface PRCommentResolutionDialogProps {
@@ -730,14 +732,9 @@ export function PRCommentResolutionDialog({
const selectedComments = comments.filter((c) => selectedIds.has(c.id));
// Resolve model settings from the current model entry
const selectedModel = resolveModelString(modelEntry.model);
const normalizedThinking = modelSupportsThinking(selectedModel)
? modelEntry.thinkingLevel || 'none'
: 'none';
const normalizedReasoning = supportsReasoningEffort(selectedModel)
? modelEntry.reasoningEffort || 'none'
: 'none';
// Resolve and normalize model settings
const normalizedEntry = normalizeModelEntry(modelEntry);
const selectedModel = resolveModelString(normalizedEntry.model);
setIsCreating(true);
setCreationErrors([]);
@@ -753,8 +750,13 @@ export function PRCommentResolutionDialog({
steps: [],
status: 'backlog',
model: selectedModel,
thinkingLevel: normalizedThinking,
reasoningEffort: normalizedReasoning,
thinkingLevel: normalizedEntry.thinkingLevel,
reasoningEffort: normalizedEntry.reasoningEffort,
providerId: normalizedEntry.providerId,
planningMode: 'skip',
requirePlanApproval: false,
dependencies: [],
...(pr.url ? { prUrl: pr.url } : {}),
// Associate feature with the PR's branch so it appears on the correct worktree
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
};
@@ -779,8 +781,13 @@ export function PRCommentResolutionDialog({
steps: [],
status: 'backlog',
model: selectedModel,
thinkingLevel: normalizedThinking,
reasoningEffort: normalizedReasoning,
thinkingLevel: normalizedEntry.thinkingLevel,
reasoningEffort: normalizedEntry.reasoningEffort,
providerId: normalizedEntry.providerId,
planningMode: 'skip',
requirePlanApproval: false,
dependencies: [],
...(pr.url ? { prUrl: pr.url } : {}),
// Associate feature with the PR's branch so it appears on the correct worktree
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
};

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