diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a989a68806..c70e4ea41f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,6 @@ jobs: node-version: '22' cache: 'npm' - run: npm ci - - run: npm run build:prompts - run: npx prettier --check . - run: npx eslint src/ - run: npm run lint # TypeScript type checking @@ -30,5 +29,4 @@ jobs: node-version: '22' cache: 'npm' - run: npm ci - - run: npm run build:prompts - run: npm run test diff --git a/.gitignore b/.gitignore index 8c136325be..4a15c6e0bb 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,6 @@ node_modules/ # Build outputs dist/ release/ -src/generated/ *.log tmp/ scratch/ diff --git a/BUILDING_WINDOWS.md b/BUILDING_WINDOWS.md index 04cff04478..84a3a07f5b 100644 --- a/BUILDING_WINDOWS.md +++ b/BUILDING_WINDOWS.md @@ -62,7 +62,7 @@ If you encounter issues with the `dev:win` script or prefer to run the steps man 3. **Start the Electron main process:** Open a **new** PowerShell terminal and run the following command: ```powershell - npm run build:prompts; npx tsc -p tsconfig.main.json; $env:NODE_ENV='development'; npx electron . + npx tsc -p tsconfig.main.json; $env:NODE_ENV='development'; npx electron . ``` This will launch the application in development mode with hot-reloading for the renderer. diff --git a/CLAUDE.md b/CLAUDE.md index 12eb0d3b48..ab862e083c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,46 +167,47 @@ src/ ## Key Files for Common Tasks -| Task | Primary Files | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Add IPC handler | `src/main/index.ts`, `src/main/preload.ts` | -| Add UI component | `src/renderer/components/` | -| Add web/mobile component | `src/web/components/`, `src/web/mobile/` | -| Add keyboard shortcut | `src/renderer/constants/shortcuts.ts`, `App.tsx` | -| Add theme | `src/renderer/constants/themes.ts` | -| Add modal | Component + `src/renderer/constants/modalPriorities.ts` | -| Add tab overlay menu | See Tab Hover Overlay Menu pattern in [[CLAUDE-PATTERNS.md]] | -| Add setting | `src/renderer/hooks/useSettings.ts`, `src/main/index.ts` | -| Add template variable | `src/shared/templateVariables.ts`, `src/renderer/utils/templateVariables.ts` | -| Modify system prompts | `src/prompts/*.md` (wizard, Auto Run, etc.) | -| Add Spec-Kit command | `src/prompts/speckit/`, `src/main/speckit-manager.ts` | -| Add OpenSpec command | `src/prompts/openspec/`, `src/main/openspec-manager.ts` | -| Add CLI command | `src/cli/commands/`, `src/cli/index.ts` | -| Add new agent | `src/shared/agentIds.ts`, `src/main/agents/definitions.ts`, `src/main/agents/capabilities.ts`, `src/shared/agentMetadata.ts` — see [AGENT_SUPPORT.md](AGENT_SUPPORT.md) | -| Add agent output parser | `src/main/parsers/`, `src/main/parsers/index.ts` | -| Add agent session storage | `src/main/storage/` (extend `BaseSessionStorage`), `src/main/storage/index.ts` | -| Add agent error patterns | `src/main/parsers/error-patterns.ts` | -| Add agent context window | `src/shared/agentConstants.ts` (`DEFAULT_CONTEXT_WINDOWS`, `FALLBACK_CONTEXT_WINDOW`) | -| Add playbook feature | `src/cli/services/playbooks.ts` | -| Add marketplace playbook | `src/main/ipc/handlers/marketplace.ts` (import from GitHub) | -| Playbook import/export | `src/main/ipc/handlers/playbooks.ts` (ZIP handling with assets) | -| Modify wizard flow | `src/renderer/components/Wizard/` (see [[CLAUDE-WIZARD.md]]) | -| Add tour step | `src/renderer/components/Wizard/tour/tourSteps.ts` | -| Modify file linking | `src/renderer/utils/remarkFileLinks.ts` (remark plugin for `[[wiki]]` and path links) | -| Add documentation page | `docs/*.md`, `docs/docs.json` (navigation) | -| Add documentation screenshot | `docs/screenshots/` (PNG, kebab-case naming) | -| MCP server integration | See [MCP Server docs](https://docs.runmaestro.ai/mcp-server) | -| Add stats/analytics feature | `src/main/stats-db.ts`, `src/main/ipc/handlers/stats.ts` | -| Add Usage Dashboard chart | `src/renderer/components/UsageDashboard/` | -| Add Document Graph feature | `src/renderer/components/DocumentGraph/`, `src/main/ipc/handlers/documentGraph.ts` | -| Add colorblind palette | `src/renderer/constants/colorblindPalettes.ts` | -| Add performance metrics | `src/shared/performance-metrics.ts` | -| Add power management | `src/main/power-manager.ts`, `src/main/ipc/handlers/system.ts` | -| Spawn agent with SSH support | `src/main/utils/ssh-spawn-wrapper.ts` (required for SSH remote execution) | -| Modify file preview tabs | `TabBar.tsx`, `FilePreview.tsx`, `MainPanel.tsx` (see ARCHITECTURE.md → File Preview Tab System) | -| Add Director's Notes feature | `src/renderer/components/DirectorNotes/`, `src/main/ipc/handlers/director-notes.ts` | -| Add Encore Feature | `src/renderer/types/index.ts` (flag), `useSettings.ts` (state), `SettingsModal.tsx` (toggle UI), gate in `App.tsx` + keyboard handler | -| Modify history components | `src/renderer/components/History/` | +| Task | Primary Files | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Add IPC handler | `src/main/index.ts`, `src/main/preload.ts` | +| Add UI component | `src/renderer/components/` | +| Add web/mobile component | `src/web/components/`, `src/web/mobile/` | +| Add keyboard shortcut | `src/renderer/constants/shortcuts.ts`, `App.tsx` | +| Add theme | `src/renderer/constants/themes.ts` | +| Add modal | Component + `src/renderer/constants/modalPriorities.ts` | +| Add tab overlay menu | See Tab Hover Overlay Menu pattern in [[CLAUDE-PATTERNS.md]] | +| Add setting | `src/renderer/hooks/useSettings.ts`, `src/main/index.ts` | +| Add template variable | `src/shared/templateVariables.ts`, `src/renderer/utils/templateVariables.ts` | +| Modify system prompts | `src/prompts/*.md` (wizard, Auto Run, etc.) | +| Add Spec-Kit command | `src/prompts/speckit/`, `src/main/speckit-manager.ts` | +| Add OpenSpec command | `src/prompts/openspec/`, `src/main/openspec-manager.ts` | +| Add CLI command | `src/cli/commands/`, `src/cli/index.ts` | +| Configure agent | `src/main/agent-detector.ts`, `src/main/agent-capabilities.ts` | +| Add agent output parser | `src/main/parsers/`, `src/main/parsers/index.ts` | +| Add agent session storage | `src/main/storage/`, `src/main/agent-session-storage.ts` | +| Add agent error patterns | `src/main/parsers/error-patterns.ts` | +| Add playbook feature | `src/cli/services/playbooks.ts` | +| Add marketplace playbook | `src/main/ipc/handlers/marketplace.ts` (import from GitHub) | +| Playbook import/export | `src/main/ipc/handlers/playbooks.ts` (ZIP handling with assets) | +| Modify wizard flow | `src/renderer/components/Wizard/` (see [[CLAUDE-WIZARD.md]]) | +| Add tour step | `src/renderer/components/Wizard/tour/tourSteps.ts` | +| Modify file linking | `src/renderer/utils/remarkFileLinks.ts` (remark plugin for `[[wiki]]` and path links) | +| Add documentation page | `docs/*.md`, `docs/docs.json` (navigation) | +| Add documentation screenshot | `docs/screenshots/` (PNG, kebab-case naming) | +| MCP server integration | See [MCP Server docs](https://docs.runmaestro.ai/mcp-server) | +| Add stats/analytics feature | `src/main/stats-db.ts`, `src/main/ipc/handlers/stats.ts` | +| Add Usage Dashboard chart | `src/renderer/components/UsageDashboard/` | +| Add Document Graph feature | `src/renderer/components/DocumentGraph/`, `src/main/ipc/handlers/documentGraph.ts` | +| Add colorblind palette | `src/renderer/constants/colorblindPalettes.ts` | +| Add performance metrics | `src/shared/performance-metrics.ts` | +| Add power management | `src/main/power-manager.ts`, `src/main/ipc/handlers/system.ts` | +| Spawn agent with SSH support | `src/main/utils/ssh-spawn-wrapper.ts` (required for SSH remote execution) | +| Modify file preview tabs | `TabBar.tsx`, `FilePreview.tsx`, `MainPanel.tsx` (see ARCHITECTURE.md → File Preview Tab System) | +| Add Director's Notes feature | `src/renderer/components/DirectorNotes/`, `src/main/ipc/handlers/director-notes.ts` | +| Add Encore Feature | `src/renderer/types/index.ts` (flag), `useSettings.ts` (state), `SettingsModal.tsx` (toggle UI), gate in `App.tsx` + keyboard handler | +| Modify history components | `src/renderer/components/History/` | +| Customize prompts | Use "Maestro Prompts" tab in Right Bar, or edit `userData/core-prompts-customizations.json` | +| Add new prompt | `src/prompts/*.md`, `src/main/prompt-manager.ts` (add to CORE_PROMPTS array) | --- @@ -352,3 +353,27 @@ Maestro provides a hosted MCP (Model Context Protocol) server for AI application ``` See [MCP Server documentation](https://docs.runmaestro.ai/mcp-server) for full details. + +--- + +## Prompt Customization + +Core system prompts can be customized via the **Maestro Prompts** tab in the Right Bar. + +### Using the UI +1. Open the Right Bar +2. Select the "Maestro Prompts" tab +3. Browse prompts by category (Wizard, Auto Run, Group Chat, etc.) +4. Edit prompt content in the editor +5. Click "Save" to apply changes (takes effect immediately) + +### How It Works +- **Bundled prompts**: Ship with the app in `Resources/prompts/core/` +- **User customizations**: Stored in `userData/core-prompts-customizations.json` +- **Load priority**: User customization wins if present, else bundled default +- **Immediate effect**: Changes are applied to the in-memory cache immediately (no restart required) + +### Resetting Prompts +Click "Reset to Default" to restore the bundled version (takes effect immediately). + +**Note:** Unlike SpecKit/OpenSpec, core prompts do not have an "Update from GitHub" button since they ship with each release. diff --git a/docs/docs.json b/docs/docs.json index e786b6ea5f..7ef24e43aa 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -58,7 +58,8 @@ "group-chat", "remote-control", "ssh-remote-execution", - "configuration" + "configuration", + "maestro-prompts" ] }, { diff --git a/docs/maestro-prompts.md b/docs/maestro-prompts.md new file mode 100644 index 0000000000..0a4400d1b7 --- /dev/null +++ b/docs/maestro-prompts.md @@ -0,0 +1,51 @@ +--- +title: Maestro Prompts +description: Browse, edit, and customize the core system prompts that power Maestro's AI features. +icon: file-pen +--- + +The **Maestro Prompts** tab in the Right Bar lets you browse and customize the core system prompts that drive Maestro's AI-powered features — the Onboarding Wizard, Auto Run, Group Chat, and more. + +## Opening Maestro Prompts + +**Keyboard shortcut:** + +- macOS: `Cmd+Shift+2` +- Windows/Linux: `Ctrl+Shift+2` + +**From the Right Bar:** + +- Open the Right Bar and select the "Maestro Prompts" tab + +## Browsing Prompts + +Prompts are organized by category: + +- **Wizard** — Onboarding and setup prompts +- **Auto Run** — Prompts used during Auto Run sessions +- **Group Chat** — Multi-agent coordination prompts +- **Other** — Additional system prompts + +Select a prompt from the list to view its content in the editor panel. + +## Editing Prompts + +1. Select a prompt from the list +2. Modify the content in the editor +3. Click **Save** to apply your changes + +Changes take effect immediately — no restart required. Your edits are stored separately from the bundled defaults, so app updates won't overwrite your customizations. + +## Resetting to Default + +Click **Reset to Default** to restore any prompt to its original bundled version. The reset takes effect immediately. + +## How It Works + +- **Bundled prompts** ship with each release in the app's bundled resources prompts directory (example: `Resources/prompts/core/`; exact resource location can vary by platform) +- **User customizations** are stored in `userData/core-prompts-customizations.json` +- When loading a prompt, Maestro checks for a user customization first; if none exists, it falls back to the bundled default + + +Unlike SpecKit and OpenSpec commands, core prompts do not have an "Update from GitHub" button — they ship with each Maestro release. + diff --git a/package-lock.json b/package-lock.json index 7482623e10..19ea4736dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.15.0", + "version": "0.15.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.15.0", + "version": "0.15.2", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { diff --git a/package.json b/package.json index 73cbab52aa..c418224a1e 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,12 @@ "dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"", "dev:prod-data": "USE_PROD_DATA=1 concurrently \"npm run dev:main:prod-data\" \"npm run dev:renderer\"", "dev:demo": "MAESTRO_DEMO_DIR=/tmp/maestro-demo npm run dev", - "dev:main": "npm run build:prompts && tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development electron .", - "dev:main:prod-data": "npm run build:prompts && tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development USE_PROD_DATA=1 electron .", + "dev:main": "tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development electron .", + "dev:main:prod-data": "tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development USE_PROD_DATA=1 electron .", "dev:renderer": "vite", "dev:web": "vite --config vite.config.web.mts", "dev:win": "powershell -NoProfile -ExecutionPolicy Bypass -File ./scripts/start-dev.ps1", - "build": "npm run build:prompts && npm run build:main && npm run build:preload && npm run build:renderer && npm run build:web && npm run build:cli", - "build:prompts": "node scripts/generate-prompts.mjs", + "build": "npm run build:main && npm run build:preload && npm run build:renderer && npm run build:web && npm run build:cli", "build:main": "tsc -p tsconfig.main.json", "build:preload": "node scripts/build-preload.mjs", "build:cli": "node scripts/build-cli.mjs", @@ -116,6 +115,11 @@ { "from": "src/prompts/openspec", "to": "prompts/openspec" + }, + { + "from": "src/prompts", + "to": "prompts/core", + "filter": ["*.md"] } ] }, @@ -148,6 +152,11 @@ { "from": "src/prompts/openspec", "to": "prompts/openspec" + }, + { + "from": "src/prompts", + "to": "prompts/core", + "filter": ["*.md"] } ] }, @@ -172,6 +181,11 @@ { "from": "src/prompts/openspec", "to": "prompts/openspec" + }, + { + "from": "src/prompts", + "to": "prompts/core", + "filter": ["*.md"] } ] }, diff --git a/scripts/generate-prompts.mjs b/scripts/generate-prompts.mjs deleted file mode 100644 index eb6f6e310a..0000000000 --- a/scripts/generate-prompts.mjs +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env node -/** - * Build script to generate TypeScript from prompt markdown files. - * - * Reads all .md files from src/prompts/ and generates src/generated/prompts.ts - * with the content as exported string constants. - * - * This allows prompts to be: - * - Edited as readable markdown files - * - Imported as regular TypeScript constants (no runtime file I/O) - * - Used consistently in both renderer and main process - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootDir = path.resolve(__dirname, '..'); -const promptsDir = path.join(rootDir, 'src/prompts'); -const outputDir = path.join(rootDir, 'src/generated'); -const outputFile = path.join(outputDir, 'prompts.ts'); - -/** - * Convert a filename like "wizard-system.md" to a camelCase variable name - * like "wizardSystemPrompt" - */ -function filenameToVarName(filename) { - const base = filename.replace(/\.md$/, ''); - const parts = base.split('-'); - const camelCase = parts - .map((part, i) => (i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))) - .join(''); - // Only add "Prompt" suffix if the name doesn't already end with it - if (camelCase.toLowerCase().endsWith('prompt')) { - return camelCase; - } - return camelCase + 'Prompt'; -} - -/** - * Escape backticks and ${} in template literal content - */ -function escapeTemplateString(content) { - return content.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${'); -} - -async function generate() { - console.log('Generating prompts from markdown files...'); - - // Ensure output directory exists - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - // Find all .md files in prompts directory (not in subdirectories) - const files = fs.readdirSync(promptsDir).filter((f) => f.endsWith('.md')); - - if (files.length === 0) { - console.error('No .md files found in', promptsDir); - process.exit(1); - } - - // Build the output - const exports = []; - const lines = [ - '/**', - ' * AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY', - ' *', - ' * This file is generated by scripts/generate-prompts.mjs', - ' * Edit the source .md files in src/prompts/ instead.', - ' *', - ` * Generated: ${new Date().toISOString()}`, - ' */', - '', - ]; - - for (const file of files.sort()) { - const filePath = path.join(promptsDir, file); - const content = fs.readFileSync(filePath, 'utf8'); - const varName = filenameToVarName(file); - - lines.push(`export const ${varName} = \`${escapeTemplateString(content)}\`;`); - lines.push(''); - exports.push(varName); - } - - // Write the file - fs.writeFileSync(outputFile, lines.join('\n')); - - console.log(`✓ Generated ${outputFile}`); - console.log(` ${exports.length} prompts: ${exports.join(', ')}`); -} - -generate().catch((error) => { - console.error('Generation failed:', error); - process.exit(1); -}); diff --git a/scripts/start-dev.ps1 b/scripts/start-dev.ps1 index 6e0792ecad..feed3848be 100644 --- a/scripts/start-dev.ps1 +++ b/scripts/start-dev.ps1 @@ -15,7 +15,7 @@ Start-Process powershell -ArgumentList '-NoExit', '-Command', $cmdRenderer Write-Host "Waiting for renderer dev server to start..." -ForegroundColor Yellow Start-Sleep -Seconds 5 -$cmdBuild = "Set-Location -LiteralPath '$repoRootEscaped'; npm run build:prompts; npx tsc -p tsconfig.main.json; npm run build:preload; `$env:NODE_ENV='development'; npx electron ." +$cmdBuild = "Set-Location -LiteralPath '$repoRootEscaped'; npx tsc -p tsconfig.main.json; npm run build:preload; `$env:NODE_ENV='development'; npx electron ." Start-Process powershell -ArgumentList '-NoExit', '-Command', $cmdBuild Write-Host "Launched renderer and main developer windows." -ForegroundColor Green diff --git a/src/__tests__/main/group-chat/group-chat-moderator.test.ts b/src/__tests__/main/group-chat/group-chat-moderator.test.ts index 88a4a36cd2..7adcec7b22 100644 --- a/src/__tests__/main/group-chat/group-chat-moderator.test.ts +++ b/src/__tests__/main/group-chat/group-chat-moderator.test.ts @@ -42,6 +42,17 @@ vi.mock('electron-store', () => { }; }); +// Mock the prompt-manager +vi.mock('../../../main/prompt-manager', () => ({ + getPrompt: vi.fn((id: string) => { + if (id === 'group-chat-moderator-system') return 'Coordinate the group chat between @agents. Review responses and decide next steps.'; + if (id === 'group-chat-moderator-synthesis') return 'Synthesize the agents responses into a coherent answer.'; + if (id === 'group-chat-participant') return 'You are {{PARTICIPANT_NAME}} in {{GROUP_CHAT_NAME}}. Log path: {{LOG_PATH}}'; + if (id === 'group-chat-participant-request') return '{{PARTICIPANT_NAME}} in {{GROUP_CHAT_NAME}}: {{MESSAGE}}'; + throw new Error(`Unexpected prompt ID in test: ${id}`); + }), +})); + import { spawnModerator, sendToModerator, diff --git a/src/__tests__/main/ipc/handlers/director-notes.test.ts b/src/__tests__/main/ipc/handlers/director-notes.test.ts index db5301e960..d376dd860c 100644 --- a/src/__tests__/main/ipc/handlers/director-notes.test.ts +++ b/src/__tests__/main/ipc/handlers/director-notes.test.ts @@ -52,9 +52,12 @@ vi.mock('../../../../main/utils/context-groomer', () => ({ groomContext: vi.fn(), })); -// Mock the prompts module -vi.mock('../../../../../prompts', () => ({ - directorNotesPrompt: 'Mock director notes prompt', +// Mock the prompt-manager +vi.mock('../../../../main/prompt-manager', () => ({ + getPrompt: vi.fn((id: string) => { + if (id === 'director-notes') return 'Mock director notes prompt'; + throw new Error(`Unexpected prompt id: ${id}`); + }), })); describe('director-notes IPC handlers', () => { diff --git a/src/__tests__/main/ipc/handlers/prompts.test.ts b/src/__tests__/main/ipc/handlers/prompts.test.ts new file mode 100644 index 0000000000..aa984ac65a --- /dev/null +++ b/src/__tests__/main/ipc/handlers/prompts.test.ts @@ -0,0 +1,215 @@ +// ABOUTME: Tests for the core prompts IPC handlers. +// ABOUTME: Verifies get, getAll, getAllIds, save, and reset handler registration and behavior. + +/** + * Tests for the Core Prompts IPC handlers + * + * These tests verify the IPC handlers for managing core system prompts: + * - Getting a single prompt by ID + * - Getting all prompts with metadata + * - Getting all prompt IDs + * - Saving user customizations + * - Resetting to bundled defaults + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ipcMain } from 'electron'; +import { registerPromptsHandlers } from '../../../../main/ipc/handlers/prompts'; +import * as promptManager from '../../../../main/prompt-manager'; + +// Mock electron's ipcMain +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn(), + }, +})); + +// Mock the prompt-manager module +vi.mock('../../../../main/prompt-manager', () => ({ + getPrompt: vi.fn(), + getAllPrompts: vi.fn(), + getAllPromptIds: vi.fn(), + savePrompt: vi.fn(), + resetPrompt: vi.fn(), + arePromptsInitialized: vi.fn(), +})); + +// Mock the logger +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +describe('prompts IPC handlers', () => { + let handlers: Map; + + beforeEach(() => { + vi.clearAllMocks(); + + // Capture all registered handlers + handlers = new Map(); + vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { + handlers.set(channel, handler); + }); + + // Register handlers + registerPromptsHandlers(); + }); + + afterEach(() => { + handlers.clear(); + }); + + describe('registration', () => { + it('should register all prompts handlers', () => { + const expectedChannels = [ + 'prompts:get', + 'prompts:getAll', + 'prompts:getAllIds', + 'prompts:save', + 'prompts:reset', + ]; + + for (const channel of expectedChannels) { + expect(handlers.has(channel)).toBe(true); + } + }); + }); + + describe('prompts:get', () => { + it('should return prompt content when initialized', async () => { + vi.mocked(promptManager.arePromptsInitialized).mockReturnValue(true); + vi.mocked(promptManager.getPrompt).mockReturnValue('prompt content'); + + const handler = handlers.get('prompts:get')!; + const result = await handler({}, 'wizard-system'); + + expect(result).toEqual({ success: true, content: 'prompt content' }); + expect(promptManager.getPrompt).toHaveBeenCalledWith('wizard-system'); + }); + + it('should return error when not initialized', async () => { + vi.mocked(promptManager.arePromptsInitialized).mockReturnValue(false); + + const handler = handlers.get('prompts:get')!; + const result = await handler({}, 'wizard-system'); + + expect(result).toEqual({ success: false, error: 'Prompts not yet initialized' }); + }); + + it('should return error on exception', async () => { + vi.mocked(promptManager.arePromptsInitialized).mockReturnValue(true); + vi.mocked(promptManager.getPrompt).mockImplementation(() => { + throw new Error('Unknown prompt ID: invalid'); + }); + + const handler = handlers.get('prompts:get')!; + const result = await handler({}, 'invalid'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Unknown prompt ID'); + }); + }); + + describe('prompts:getAll', () => { + it('should return all prompts when initialized', async () => { + const mockPrompts = [ + { + id: 'wizard-system', + filename: 'wizard-system.md', + description: 'Main wizard system prompt', + category: 'wizard', + content: 'wizard content', + isModified: false, + }, + ]; + vi.mocked(promptManager.arePromptsInitialized).mockReturnValue(true); + vi.mocked(promptManager.getAllPrompts).mockReturnValue(mockPrompts); + + const handler = handlers.get('prompts:getAll')!; + const result = await handler({}); + + expect(result).toEqual({ success: true, prompts: mockPrompts }); + }); + + it('should return error when not initialized', async () => { + vi.mocked(promptManager.arePromptsInitialized).mockReturnValue(false); + + const handler = handlers.get('prompts:getAll')!; + const result = await handler({}); + + expect(result).toEqual({ success: false, error: 'Prompts not yet initialized' }); + }); + }); + + describe('prompts:getAllIds', () => { + it('should return all prompt IDs when initialized', async () => { + const mockIds = ['wizard-system', 'autorun-default']; + vi.mocked(promptManager.arePromptsInitialized).mockReturnValue(true); + vi.mocked(promptManager.getAllPromptIds).mockReturnValue(mockIds); + + const handler = handlers.get('prompts:getAllIds')!; + const result = await handler({}); + + expect(result).toEqual({ success: true, ids: mockIds }); + }); + + it('should return error when not initialized', async () => { + vi.mocked(promptManager.arePromptsInitialized).mockReturnValue(false); + + const handler = handlers.get('prompts:getAllIds')!; + const result = await handler({}); + + expect(result).toEqual({ success: false, error: 'Prompts not yet initialized' }); + }); + }); + + describe('prompts:save', () => { + it('should save prompt successfully', async () => { + vi.mocked(promptManager.savePrompt).mockResolvedValue(undefined); + + const handler = handlers.get('prompts:save')!; + const result = await handler({}, 'wizard-system', 'new content'); + + expect(result).toEqual({ success: true }); + expect(promptManager.savePrompt).toHaveBeenCalledWith('wizard-system', 'new content'); + }); + + it('should return error on save failure', async () => { + vi.mocked(promptManager.savePrompt).mockRejectedValue(new Error('Write failed')); + + const handler = handlers.get('prompts:save')!; + const result = await handler({}, 'wizard-system', 'new content'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Write failed'); + }); + }); + + describe('prompts:reset', () => { + it('should reset prompt and return bundled content', async () => { + vi.mocked(promptManager.resetPrompt).mockResolvedValue('bundled default'); + + const handler = handlers.get('prompts:reset')!; + const result = await handler({}, 'wizard-system'); + + expect(result).toEqual({ success: true, content: 'bundled default' }); + expect(promptManager.resetPrompt).toHaveBeenCalledWith('wizard-system'); + }); + + it('should return error on reset failure', async () => { + vi.mocked(promptManager.resetPrompt).mockRejectedValue(new Error('Reset failed')); + + const handler = handlers.get('prompts:reset')!; + const result = await handler({}, 'wizard-system'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Reset failed'); + }); + }); +}); diff --git a/src/__tests__/main/ipc/handlers/tabNaming.test.ts b/src/__tests__/main/ipc/handlers/tabNaming.test.ts index b93836feef..74e8802dc7 100644 --- a/src/__tests__/main/ipc/handlers/tabNaming.test.ts +++ b/src/__tests__/main/ipc/handlers/tabNaming.test.ts @@ -33,9 +33,12 @@ vi.mock('uuid', () => ({ v4: vi.fn(() => 'mock-uuid-1234'), })); -// Mock the prompts -vi.mock('../../../../prompts', () => ({ - tabNamingPrompt: 'You are a tab naming assistant. Generate a concise tab name.', +// Mock the prompt-manager +vi.mock('../../../../main/prompt-manager', () => ({ + getPrompt: vi.fn((id: string) => { + if (id === 'tab-naming') return 'You are a tab naming assistant. Generate a concise tab name.'; + throw new Error(`Unexpected prompt id: ${id}`); + }), })); // Mock the agent args utilities diff --git a/src/__tests__/main/preload/prompts.test.ts b/src/__tests__/main/preload/prompts.test.ts new file mode 100644 index 0000000000..fb45387c55 --- /dev/null +++ b/src/__tests__/main/preload/prompts.test.ts @@ -0,0 +1,117 @@ +// ABOUTME: Tests for the core prompts preload API. +// ABOUTME: Verifies that createPromptsApi correctly invokes IPC channels for get, getAll, getAllIds, save, and reset. + +/** + * Tests for prompts preload API + * + * Coverage: + * - createPromptsApi: get, getAll, getAllIds, save, reset + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + }, +})); + +import { createPromptsApi } from '../../../main/preload/prompts'; + +describe('Prompts Preload API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createPromptsApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createPromptsApi(); + }); + + describe('get', () => { + it('should invoke prompts:get with id', async () => { + const response = { success: true, content: 'prompt content' }; + mockInvoke.mockResolvedValue(response); + + const result = await api.get('wizard-system'); + + expect(mockInvoke).toHaveBeenCalledWith('prompts:get', 'wizard-system'); + expect(result).toEqual(response); + }); + + it('should handle error response', async () => { + const response = { success: false, error: 'Prompts not yet initialized' }; + mockInvoke.mockResolvedValue(response); + + const result = await api.get('invalid'); + + expect(result).toEqual(response); + }); + }); + + describe('getAll', () => { + it('should invoke prompts:getAll', async () => { + const response = { + success: true, + prompts: [ + { + id: 'wizard-system', + filename: 'wizard-system.md', + description: 'Main wizard system prompt', + category: 'wizard', + content: 'wizard content', + isModified: false, + }, + ], + }; + mockInvoke.mockResolvedValue(response); + + const result = await api.getAll(); + + expect(mockInvoke).toHaveBeenCalledWith('prompts:getAll'); + expect(result).toEqual(response); + }); + }); + + describe('getAllIds', () => { + it('should invoke prompts:getAllIds', async () => { + const response = { success: true, ids: ['wizard-system', 'autorun-default'] }; + mockInvoke.mockResolvedValue(response); + + const result = await api.getAllIds(); + + expect(mockInvoke).toHaveBeenCalledWith('prompts:getAllIds'); + expect(result).toEqual(response); + }); + }); + + describe('save', () => { + it('should invoke prompts:save with id and content', async () => { + const response = { success: true }; + mockInvoke.mockResolvedValue(response); + + const result = await api.save('wizard-system', 'new content'); + + expect(mockInvoke).toHaveBeenCalledWith('prompts:save', 'wizard-system', 'new content'); + expect(result).toEqual(response); + }); + }); + + describe('reset', () => { + it('should invoke prompts:reset with id', async () => { + const response = { success: true, content: 'bundled default' }; + mockInvoke.mockResolvedValue(response); + + const result = await api.reset('wizard-system'); + + expect(mockInvoke).toHaveBeenCalledWith('prompts:reset', 'wizard-system'); + expect(result).toEqual(response); + }); + }); + }); +}); diff --git a/src/__tests__/main/prompt-manager.test.ts b/src/__tests__/main/prompt-manager.test.ts new file mode 100644 index 0000000000..0dc225998a --- /dev/null +++ b/src/__tests__/main/prompt-manager.test.ts @@ -0,0 +1,342 @@ +// ABOUTME: Tests for the prompt-manager module that loads core system prompts from disk. +// ABOUTME: Verifies initialization, caching, user customization overlay, save, and reset flows. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs/promises'; + +// Mock electron app module +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: vi.fn().mockReturnValue('/tmp/test-user-data'), + }, +})); + +// Mock fs/promises +vi.mock('fs/promises', () => ({ + default: { + readFile: vi.fn(), + writeFile: vi.fn(), + }, +})); + +// Mock the logger +vi.mock('../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +describe('prompt-manager', () => { + const mockReadFile = vi.mocked(fs.readFile); + const mockWriteFile = vi.mocked(fs.writeFile); + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should load all prompts from disk', async () => { + // Return ENOENT for customizations (no user customizations) + // Return mock content for all bundled prompt files + mockReadFile.mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes('core-prompts-customizations.json')) { + return Promise.reject(new Error('ENOENT')); + } + return Promise.resolve('mock prompt content'); + }); + + const { initializePrompts, getPrompt } = await import('../../main/prompt-manager'); + + await initializePrompts(); + + expect(getPrompt('wizard-system')).toBe('mock prompt content'); + expect(getPrompt('autorun-default')).toBe('mock prompt content'); + }); + + it('should prefer user customizations over bundled', async () => { + mockReadFile.mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes('core-prompts-customizations.json')) { + return Promise.resolve(JSON.stringify({ + prompts: { + 'wizard-system': { content: 'customized content', isModified: true }, + }, + })); + } + return Promise.resolve('bundled content'); + }); + + const { initializePrompts, getPrompt } = await import('../../main/prompt-manager'); + + await initializePrompts(); + + expect(getPrompt('wizard-system')).toBe('customized content'); + // Non-customized prompts should use bundled content + expect(getPrompt('autorun-default')).toBe('bundled content'); + }); + + it('should throw if getPrompt called before init', async () => { + const { getPrompt } = await import('../../main/prompt-manager'); + + expect(() => getPrompt('wizard-system')).toThrow('Prompts not initialized'); + }); + + it('should throw for unknown prompt ID', async () => { + mockReadFile.mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes('core-prompts-customizations.json')) { + return Promise.reject(new Error('ENOENT')); + } + return Promise.resolve('mock content'); + }); + + const { initializePrompts, getPrompt } = await import('../../main/prompt-manager'); + + await initializePrompts(); + + expect(() => getPrompt('unknown-prompt')).toThrow('Unknown prompt ID'); + }); + + it('should save user customization', async () => { + mockReadFile.mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes('core-prompts-customizations.json')) { + return Promise.reject(new Error('ENOENT')); + } + return Promise.resolve('bundled content'); + }); + mockWriteFile.mockResolvedValue(undefined); + + const { initializePrompts, savePrompt, getPrompt } = await import('../../main/prompt-manager'); + + await initializePrompts(); + await savePrompt('wizard-system', 'new content'); + + // Verify file was written with correct structure + expect(mockWriteFile).toHaveBeenCalled(); + const writtenContent = JSON.parse(mockWriteFile.mock.calls[0][1] as string); + expect(writtenContent.prompts['wizard-system'].content).toBe('new content'); + expect(writtenContent.prompts['wizard-system'].isModified).toBe(true); + + // Verify in-memory cache was updated immediately + expect(getPrompt('wizard-system')).toBe('new content'); + }); + + it('should reject blank prompt customization content', async () => { + mockReadFile.mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes('core-prompts-customizations.json')) { + return Promise.reject(new Error('ENOENT')); + } + return Promise.resolve('bundled content'); + }); + mockWriteFile.mockResolvedValue(undefined); + + const { initializePrompts, savePrompt, getPrompt } = await import('../../main/prompt-manager'); + + await initializePrompts(); + await expect(savePrompt('wizard-system', ' \n\t')).rejects.toThrow( + 'Prompt content cannot be empty or whitespace' + ); + + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(getPrompt('wizard-system')).toBe('bundled content'); + }); + + it('should reset prompt to bundled default', async () => { + // First init with a customization + mockReadFile.mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes('core-prompts-customizations.json')) { + return Promise.resolve(JSON.stringify({ + prompts: { + 'wizard-system': { content: 'custom', isModified: true }, + }, + })); + } + return Promise.resolve('bundled default'); + }); + mockWriteFile.mockResolvedValue(undefined); + + const { initializePrompts, resetPrompt, getPrompt } = await import('../../main/prompt-manager'); + + await initializePrompts(); + expect(getPrompt('wizard-system')).toBe('custom'); + + const bundledContent = await resetPrompt('wizard-system'); + + expect(bundledContent).toBe('bundled default'); + // Verify in-memory cache was updated + expect(getPrompt('wizard-system')).toBe('bundled default'); + }); + + it('should not delete customization when bundled reset read fails', async () => { + let failBundledReadOnReset = false; + + mockReadFile.mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes('core-prompts-customizations.json')) { + return Promise.resolve( + JSON.stringify({ + prompts: { + 'wizard-system': { content: 'custom', isModified: true }, + }, + }) + ); + } + if (failBundledReadOnReset && p.endsWith('wizard-system.md')) { + return Promise.reject(new Error('EACCES: permission denied')); + } + return Promise.resolve('bundled default'); + }); + mockWriteFile.mockResolvedValue(undefined); + + const { initializePrompts, resetPrompt, getPrompt } = await import('../../main/prompt-manager'); + + await initializePrompts(); + expect(getPrompt('wizard-system')).toBe('custom'); + + failBundledReadOnReset = true; + await expect(resetPrompt('wizard-system')).rejects.toThrow('EACCES'); + + // Customization file should not be rewritten if bundled read fails. + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(getPrompt('wizard-system')).toBe('custom'); + }); + + it('should return all prompts with metadata via getAllPrompts', async () => { + mockReadFile.mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes('core-prompts-customizations.json')) { + return Promise.reject(new Error('ENOENT')); + } + return Promise.resolve('content'); + }); + + const { initializePrompts, getAllPrompts } = await import('../../main/prompt-manager'); + + await initializePrompts(); + + const prompts = getAllPrompts(); + expect(prompts.length).toBeGreaterThan(0); + + const wizardPrompt = prompts.find((p) => p.id === 'wizard-system'); + expect(wizardPrompt).toBeDefined(); + expect(wizardPrompt!.filename).toBe('wizard-system.md'); + expect(wizardPrompt!.category).toBe('wizard'); + expect(wizardPrompt!.content).toBe('content'); + expect(wizardPrompt!.isModified).toBe(false); + }); + + it('should return all prompt IDs via getAllPromptIds', async () => { + const { getAllPromptIds } = await import('../../main/prompt-manager'); + + const ids = getAllPromptIds(); + expect(ids).toContain('wizard-system'); + expect(ids).toContain('autorun-default'); + expect(ids.length).toBeGreaterThan(0); + }); + + it('should report initialization state via arePromptsInitialized', async () => { + mockReadFile.mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes('core-prompts-customizations.json')) { + return Promise.reject(new Error('ENOENT')); + } + return Promise.resolve('content'); + }); + + const { arePromptsInitialized, initializePrompts } = await import('../../main/prompt-manager'); + + expect(arePromptsInitialized()).toBe(false); + + await initializePrompts(); + + expect(arePromptsInitialized()).toBe(true); + }); + + it('should skip re-initialization if already initialized', async () => { + mockReadFile.mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes('core-prompts-customizations.json')) { + return Promise.reject(new Error('ENOENT')); + } + return Promise.resolve('content'); + }); + + const { initializePrompts, arePromptsInitialized } = await import('../../main/prompt-manager'); + + await initializePrompts(); + expect(arePromptsInitialized()).toBe(true); + + // Clear mock call count, then init again + mockReadFile.mockClear(); + await initializePrompts(); + + // Should not have read any files the second time + expect(mockReadFile).not.toHaveBeenCalled(); + }); + + it('should throw if bundled prompt file is missing', async () => { + mockReadFile.mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes('core-prompts-customizations.json')) { + return Promise.reject(new Error('ENOENT')); + } + // Fail for the first prompt file + return Promise.reject(new Error('ENOENT: no such file')); + }); + + const { initializePrompts } = await import('../../main/prompt-manager'); + + await expect(initializePrompts()).rejects.toThrow('Failed to load required prompt'); + }); + + it('should throw when saving unknown prompt ID', async () => { + mockReadFile.mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes('core-prompts-customizations.json')) { + return Promise.reject(new Error('ENOENT')); + } + return Promise.resolve('content'); + }); + + const { initializePrompts, savePrompt } = await import('../../main/prompt-manager'); + + await initializePrompts(); + + await expect(savePrompt('nonexistent-id', 'content')).rejects.toThrow('Unknown prompt ID'); + }); + + it('should throw when resetting unknown prompt ID', async () => { + mockReadFile.mockImplementation((filePath: any) => { + const p = String(filePath); + if (p.includes('core-prompts-customizations.json')) { + return Promise.reject(new Error('ENOENT')); + } + return Promise.resolve('content'); + }); + + const { initializePrompts, resetPrompt } = await import('../../main/prompt-manager'); + + await initializePrompts(); + + await expect(resetPrompt('nonexistent-id')).rejects.toThrow('Unknown prompt ID'); + }); + + it('should keep prompt IDs in sync with src/prompts constants', async () => { + const { getAllPromptIds } = await import('../../main/prompt-manager'); + const { PROMPT_IDS } = await import('../../prompts'); + + expect(new Set(getAllPromptIds())).toEqual(new Set(Object.values(PROMPT_IDS))); + }); +}); diff --git a/src/__tests__/renderer/components/BatchRunnerModal.test.tsx b/src/__tests__/renderer/components/BatchRunnerModal.test.tsx index 52ebf2a962..5e0959e22e 100644 --- a/src/__tests__/renderer/components/BatchRunnerModal.test.tsx +++ b/src/__tests__/renderer/components/BatchRunnerModal.test.tsx @@ -1,13 +1,34 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, within, act } from '@testing-library/react'; import React from 'react'; +import { loadBatchPrompts } from '../../../renderer/hooks/batch/batchUtils'; import { BatchRunnerModal, - DEFAULT_BATCH_PROMPT, + getDefaultBatchPrompt, validateAgentPromptHasTaskReference, } from '../../../renderer/components/BatchRunnerModal'; import type { Theme, Playbook } from '../../../renderer/types'; +// Realistic mock prompt with markdown task references that validateAgentPromptHasTaskReference expects +const MOCK_AUTORUN_DEFAULT_PROMPT = `Process the markdown task document at {{DOCUMENT_PATH}}. +Agent: {{AGENT_NAME}} at {{AGENT_PATH}}. +Work through each unchecked task - [ ] sequentially and check off task when completed.`; + +// Set up window.maestro.prompts and load batch prompts before tests +beforeAll(async () => { + if (!(window as any).maestro) { + (window as any).maestro = {}; + } + (window as any).maestro.prompts = { + get: vi.fn((id: string) => { + if (id === 'autorun-default') return { success: true, content: MOCK_AUTORUN_DEFAULT_PROMPT }; + if (id === 'autorun-synopsis') return { success: true, content: 'Generate a synopsis.' }; + return { success: false, error: `Unknown prompt: ${id}` }; + }), + }; + await loadBatchPrompts(true); +}); + // Mock LayerStackContext const mockRegisterLayer = vi.fn(() => 'layer-123'); const mockUnregisterLayer = vi.fn(); @@ -116,7 +137,7 @@ function createMockPlaybook(overrides: Partial = {}): Playbook { { filename: 'doc2', resetOnCompletion: true }, ], loopEnabled: false, - prompt: DEFAULT_BATCH_PROMPT, + prompt: getDefaultBatchPrompt(), ...overrides, }; } @@ -553,12 +574,12 @@ describe('BatchRunnerModal', () => { }); }); - it('displays default prompt in textarea', async () => { - render(); + it('displays default prompt in textarea', async () => { + render(); - const textarea = screen.getByPlaceholderText('Enter the system prompt for auto-run...'); - expect(textarea).toHaveValue(DEFAULT_BATCH_PROMPT); - }); + const textarea = screen.getByPlaceholderText('Enter the system prompt for auto-run...'); + expect(textarea).toHaveValue(getDefaultBatchPrompt()); + }); it('displays CUSTOMIZED badge when prompt is modified', async () => { render(); @@ -577,11 +598,11 @@ describe('BatchRunnerModal', () => { fireEvent.change(textarea, { target: { value: 'Custom prompt' } }); const resetButton = screen.getByTitle('Reset to default prompt'); - fireEvent.click(resetButton); + fireEvent.click(resetButton); - expect(props.showConfirmation).toHaveBeenCalled(); - expect(textarea).toHaveValue(DEFAULT_BATCH_PROMPT); - }); + expect(props.showConfirmation).toHaveBeenCalled(); + expect(textarea).toHaveValue(getDefaultBatchPrompt()); + }); it('opens prompt composer modal when expand button is clicked', async () => { render(); @@ -906,16 +927,16 @@ describe('BatchRunnerModal', () => { expect(props.onGo).toHaveBeenCalledWith( expect.objectContaining({ - documents: expect.arrayContaining([ - expect.objectContaining({ - filename: 'test-doc', - resetOnCompletion: false, - }), - ]), - prompt: DEFAULT_BATCH_PROMPT, - loopEnabled: false, - }) - ); + documents: expect.arrayContaining([ + expect.objectContaining({ + filename: 'test-doc', + resetOnCompletion: false, + }), + ]), + prompt: getDefaultBatchPrompt(), + loopEnabled: false, + }) + ); expect(props.onClose).toHaveBeenCalled(); }); @@ -962,6 +983,43 @@ describe('BatchRunnerModal', () => { const saveButton = screen.getByRole('button', { name: /Save/ }); expect(saveButton).toBeDisabled(); }); + + it('preserves an explicitly empty saved prompt after default prompt loads', async () => { + const props = createDefaultProps(); + props.initialPrompt = ''; + render(); + + const resetButton = screen.getByTitle('Reset to default prompt'); + await waitFor(() => { + expect(resetButton).toBeEnabled(); + }); + + const textarea = screen.getByPlaceholderText('Enter the system prompt for auto-run...'); + expect(textarea).toHaveValue(''); + + const saveButton = screen.getByRole('button', { name: /Save/ }); + expect(saveButton).toBeDisabled(); + }); + + it('allows saving when resetting a previously customized prompt to default', async () => { + const props = createDefaultProps(); + props.initialPrompt = 'Previously customized prompt'; + render(); + + const saveButton = screen.getByRole('button', { name: /Save/ }); + expect(saveButton).toBeDisabled(); + + fireEvent.click(screen.getByTitle('Reset to default prompt')); + + const textarea = screen.getByPlaceholderText('Enter the system prompt for auto-run...'); + await waitFor(() => { + expect(textarea).toHaveValue(getDefaultBatchPrompt()); + }); + + await waitFor(() => { + expect(saveButton).toBeEnabled(); + }); + }); }); describe('Cancel Functionality', () => { @@ -1141,13 +1199,11 @@ describe('Helper Functions', () => { }); }); -describe('DEFAULT_BATCH_PROMPT export', () => { - it('exports DEFAULT_BATCH_PROMPT constant', () => { - expect(DEFAULT_BATCH_PROMPT).toBeDefined(); - expect(typeof DEFAULT_BATCH_PROMPT).toBe('string'); - expect(DEFAULT_BATCH_PROMPT).toContain('{{DOCUMENT_PATH}}'); - expect(DEFAULT_BATCH_PROMPT).toContain('{{AGENT_NAME}}'); - expect(DEFAULT_BATCH_PROMPT).toContain('{{AGENT_PATH}}'); +describe('getDefaultBatchPrompt export', () => { + it('returns default batch prompt as a string', () => { + const prompt = getDefaultBatchPrompt(); + expect(prompt).toBeDefined(); + expect(typeof prompt).toBe('string'); }); }); @@ -1200,8 +1256,8 @@ describe('validateAgentPromptHasTaskReference', () => { ); }); - it('returns true for the DEFAULT_BATCH_PROMPT', () => { - expect(validateAgentPromptHasTaskReference(DEFAULT_BATCH_PROMPT)).toBe(true); + it('returns true for the default batch prompt', () => { + expect(validateAgentPromptHasTaskReference(getDefaultBatchPrompt())).toBe(true); }); }); diff --git a/src/__tests__/renderer/components/MaestroPromptsTab.test.tsx b/src/__tests__/renderer/components/MaestroPromptsTab.test.tsx new file mode 100644 index 0000000000..9c726ed1f4 --- /dev/null +++ b/src/__tests__/renderer/components/MaestroPromptsTab.test.tsx @@ -0,0 +1,358 @@ +// ABOUTME: Unit tests for the MaestroPromptsTab component. +// ABOUTME: Tests prompt loading, selection, editing, saving, resetting, and category grouping. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import React from 'react'; +import { MaestroPromptsTab } from '../../../renderer/components/MaestroPromptsTab'; +import type { Theme } from '../../../renderer/types'; + +const { mockReloadRendererPrompts } = vi.hoisted(() => ({ + mockReloadRendererPrompts: vi.fn(), +})); + +vi.mock('../../../renderer/services/promptInit', () => ({ + reloadRendererPrompts: mockReloadRendererPrompts, +})); + +const mockTheme: Theme = { + id: 'dark', + name: 'Dark', + mode: 'dark', + colors: { + bgMain: '#1e1e1e', + bgSidebar: '#252526', + bgActivity: '#2d2d30', + textMain: '#cccccc', + textDim: '#808080', + textFaint: '#555555', + accent: '#007acc', + border: '#3c3c3c', + error: '#f44747', + warning: '#cca700', + success: '#4caf50', + info: '#3794ff', + selection: '#264f78', + }, +}; + +const mockPrompts = [ + { + id: 'wizard-system', + filename: 'wizard-system.md', + description: 'Main wizard system prompt', + category: 'wizard', + content: 'Wizard system content', + isModified: false, + }, + { + id: 'wizard-iterate', + filename: 'wizard-iterate.md', + description: 'Wizard iterate prompt', + category: 'wizard', + content: 'Wizard iterate content', + isModified: true, + }, + { + id: 'autorun-default', + filename: 'autorun-default.md', + description: 'Default Auto Run prompt', + category: 'autorun', + content: 'Auto run content', + isModified: false, + }, +]; + +const mockPromptsApi = { + getAll: vi.fn(), + get: vi.fn(), + getAllIds: vi.fn(), + save: vi.fn(), + reset: vi.fn(), +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockReloadRendererPrompts.mockResolvedValue(undefined); + // Add prompts namespace to existing window.maestro mock + (window as any).maestro.prompts = mockPromptsApi; +}); + +afterEach(() => { + cleanup(); +}); + +describe('MaestroPromptsTab', () => { + describe('loading state', () => { + it('should show loading text while prompts are being fetched', () => { + mockPromptsApi.getAll.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText('Loading prompts...')).toBeTruthy(); + }); + }); + + describe('error handling', () => { + it('should display error when getAll fails', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: false, + error: 'Failed to load', + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Failed to load')).toBeTruthy(); + }); + }); + + it('should display error when getAll throws', async () => { + mockPromptsApi.getAll.mockRejectedValue(new Error('Network error')); + + render(); + + await waitFor(() => { + expect(screen.getByText('Error: Network error')).toBeTruthy(); + }); + }); + }); + + describe('prompt list', () => { + it('should render prompts grouped by category', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Wizard')).toBeTruthy(); + expect(screen.getByText('Auto Run')).toBeTruthy(); + }); + }); + + it('should display prompt IDs in the list', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + + render(); + + // Wait for prompts to load by checking for category header first + await waitFor(() => { + expect(screen.getByText('Wizard')).toBeTruthy(); + }); + + // The selected prompt ID appears both in list and editor header, + // so use getAllByText. The non-selected ones appear once. + expect(screen.getAllByText('wizard-system').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('wizard-iterate')).toBeTruthy(); + expect(screen.getByText('autorun-default')).toBeTruthy(); + }); + + it('should select first prompt by default', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Main wizard system prompt')).toBeTruthy(); + }); + }); + }); + + describe('prompt selection', () => { + it('should display selected prompt content in editor', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('autorun-default')).toBeTruthy(); + }); + + fireEvent.click(screen.getByText('autorun-default')); + + expect(screen.getByText('Default Auto Run prompt')).toBeTruthy(); + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement; + expect(textarea.value).toBe('Auto run content'); + }); + }); + + describe('editing', () => { + it('should enable Save button when content changes', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeTruthy(); + }); + + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement; + const saveButton = screen.getByText('Save').closest('button')!; + + expect(saveButton.disabled).toBe(true); + + fireEvent.change(textarea, { target: { value: 'Modified content' } }); + + expect(saveButton.disabled).toBe(false); + }); + }); + + describe('saving', () => { + it('should call prompts.save and show success message', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + mockPromptsApi.save.mockResolvedValue({ success: true }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeTruthy(); + }); + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'Modified content' }, + }); + fireEvent.click(screen.getByText('Save')); + + expect(mockPromptsApi.save).toHaveBeenCalledWith('wizard-system', 'Modified content'); + + await waitFor(() => { + expect(screen.getByText('Changes saved and applied')).toBeTruthy(); + }); + }); + + it('should show error when save fails', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + mockPromptsApi.save.mockResolvedValue({ success: false, error: 'Write failed' }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeTruthy(); + }); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New' } }); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(screen.getByText('Write failed')).toBeTruthy(); + }); + }); + }); + + describe('resetting', () => { + it('should call prompts.reset and update content', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + mockPromptsApi.reset.mockResolvedValue({ + success: true, + content: 'Original wizard iterate content', + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('wizard-iterate')).toBeTruthy(); + }); + + // Select the modified prompt + fireEvent.click(screen.getByText('wizard-iterate')); + + expect(screen.getByText('Modified')).toBeTruthy(); + + const resetButton = screen.getByText('Reset to Default').closest('button')!; + expect(resetButton.disabled).toBe(false); + + fireEvent.click(resetButton); + + expect(mockPromptsApi.reset).toHaveBeenCalledWith('wizard-iterate'); + + await waitFor(() => { + expect(screen.getByText('Prompt reset to default')).toBeTruthy(); + }); + }); + + it('should disable Reset button for unmodified prompts', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeTruthy(); + }); + + const resetButton = screen.getByText('Reset to Default').closest('button')!; + expect(resetButton.disabled).toBe(true); + }); + }); + + describe('modified indicator', () => { + it('should show modified indicator dot for customized prompts', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('wizard-iterate')).toBeTruthy(); + }); + + const modifiedItem = screen.getByText('wizard-iterate').closest('button')!; + expect(modifiedItem.innerHTML).toContain('•'); + }); + }); + + describe('category collapsing', () => { + it('should toggle category visibility when clicking category header', async () => { + mockPromptsApi.getAll.mockResolvedValue({ + success: true, + prompts: mockPrompts, + }); + + render(); + + await waitFor(() => { + // wizard-iterate only shows in the list (not editor header since wizard-system is selected) + expect(screen.getByText('wizard-iterate')).toBeTruthy(); + }); + + // Click on "Wizard" category header to collapse + fireEvent.click(screen.getByText('Wizard')); + + // wizard-iterate should be hidden from the list + expect(screen.queryByText('wizard-iterate')).toBeNull(); + + // Other category still visible + expect(screen.getByText('autorun-default')).toBeTruthy(); + + // Click again to expand + fireEvent.click(screen.getByText('Wizard')); + expect(screen.getByText('wizard-iterate')).toBeTruthy(); + }); + }); +}); diff --git a/src/__tests__/renderer/components/RightPanel.test.tsx b/src/__tests__/renderer/components/RightPanel.test.tsx index 048117af7d..78b039afb6 100644 --- a/src/__tests__/renderer/components/RightPanel.test.tsx +++ b/src/__tests__/renderer/components/RightPanel.test.tsx @@ -1342,7 +1342,7 @@ describe('RightPanel', () => { const props = createDefaultProps(); render(); - expect(screen.getAllByRole('button')).toHaveLength(4); // toggle + 3 tabs + expect(screen.getAllByRole('button')).toHaveLength(5); // toggle + 4 tabs }); }); diff --git a/src/__tests__/renderer/hooks/batch/batchUtils.test.ts b/src/__tests__/renderer/hooks/batch/batchUtils.test.ts new file mode 100644 index 0000000000..c958037aa8 --- /dev/null +++ b/src/__tests__/renderer/hooks/batch/batchUtils.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +describe('batchUtils prompt guards', () => { + let originalPromptsApi: unknown; + + beforeEach(() => { + vi.resetModules(); + originalPromptsApi = (window as any).maestro?.prompts; + (window as any).maestro = { + ...(window as any).maestro, + prompts: { + get: vi.fn((id: string) => { + if (id === 'autorun-default') { + return Promise.resolve({ success: true, content: 'default prompt' }); + } + if (id === 'autorun-synopsis') { + return Promise.resolve({ success: true, content: 'synopsis prompt' }); + } + return Promise.resolve({ success: false, error: `Unknown prompt: ${id}` }); + }), + }, + }; + }); + + afterEach(() => { + (window as any).maestro.prompts = originalPromptsApi; + }); + + it('throws if prompt getters are called before load', async () => { + const batchUtils = await import('../../../../renderer/hooks/batch/batchUtils'); + expect(() => batchUtils.getDefaultBatchPrompt()).toThrow('Default Auto Run prompt not loaded'); + expect(() => batchUtils.getAutorunSynopsisPrompt()).toThrow( + 'Auto Run synopsis prompt not loaded' + ); + }); + + it('returns prompt values after load', async () => { + const batchUtils = await import('../../../../renderer/hooks/batch/batchUtils'); + await batchUtils.loadBatchPrompts(); + + expect(batchUtils.getDefaultBatchPrompt()).toBe('default prompt'); + expect(batchUtils.getAutorunSynopsisPrompt()).toBe('synopsis prompt'); + }); +}); diff --git a/src/__tests__/renderer/hooks/input/useInputProcessing.prompts.test.ts b/src/__tests__/renderer/hooks/input/useInputProcessing.prompts.test.ts new file mode 100644 index 0000000000..2c85010b50 --- /dev/null +++ b/src/__tests__/renderer/hooks/input/useInputProcessing.prompts.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +describe('useInputProcessing prompt guards', () => { + let originalPromptsApi: unknown; + + beforeEach(() => { + vi.resetModules(); + originalPromptsApi = (window as any).maestro?.prompts; + (window as any).maestro = { + ...(window as any).maestro, + prompts: { + get: vi.fn((id: string) => { + if (id === 'image-only-default') { + return Promise.resolve({ success: true, content: 'image prompt' }); + } + if (id === 'maestro-system-prompt') { + return Promise.resolve({ success: true, content: 'system prompt' }); + } + return Promise.resolve({ success: false, error: `Unknown prompt: ${id}` }); + }), + }, + }; + }); + + afterEach(() => { + (window as any).maestro.prompts = originalPromptsApi; + }); + + it('throws if prompt getters are called before load', async () => { + const inputPrompts = await import('../../../../renderer/hooks/input/useInputProcessing'); + + expect(() => inputPrompts.getImageOnlyPrompt()).toThrow('Image-only prompt not loaded'); + expect(() => inputPrompts.getMaestroSystemPrompt()).toThrow( + 'Maestro system prompt not loaded' + ); + }); + + it('returns prompt values after load', async () => { + const inputPrompts = await import('../../../../renderer/hooks/input/useInputProcessing'); + await inputPrompts.loadInputProcessingPrompts(); + + expect(inputPrompts.getImageOnlyPrompt()).toBe('image prompt'); + expect(inputPrompts.getMaestroSystemPrompt()).toBe('system prompt'); + }); +}); diff --git a/src/__tests__/renderer/hooks/useInputProcessing.test.ts b/src/__tests__/renderer/hooks/useInputProcessing.test.ts index 670e96f9d1..7423445ca0 100644 --- a/src/__tests__/renderer/hooks/useInputProcessing.test.ts +++ b/src/__tests__/renderer/hooks/useInputProcessing.test.ts @@ -108,12 +108,12 @@ describe('useInputProcessing', () => { // Store original window.maestro const originalMaestro = { ...window.maestro }; - beforeEach(() => { - vi.clearAllMocks(); - mockGetBatchState.mockReturnValue(defaultBatchState); +beforeEach(async () => { + vi.clearAllMocks(); + mockGetBatchState.mockReturnValue(defaultBatchState); - // Mock window.maestro.process.spawn - window.maestro = { + // Mock window.maestro.process.spawn + window.maestro = { ...window.maestro, process: { ...window.maestro?.process, @@ -121,7 +121,7 @@ describe('useInputProcessing', () => { write: vi.fn().mockResolvedValue(undefined), runCommand: vi.fn().mockResolvedValue(undefined), }, - agents: { + agents: { ...window.maestro?.agents, get: vi.fn().mockResolvedValue({ id: 'claude-code', @@ -129,13 +129,30 @@ describe('useInputProcessing', () => { path: '/usr/local/bin/claude', args: ['--print', '--verbose'], }), - }, - web: { - ...window.maestro?.web, - broadcastUserInput: vi.fn().mockResolvedValue(undefined), - }, - } as typeof window.maestro; - }); + }, + prompts: { + ...window.maestro?.prompts, + get: vi.fn((id: string) => { + if (id === 'image-only-default') { + return Promise.resolve({ success: true, content: 'Describe this image' }); + } + if (id === 'maestro-system-prompt') { + return Promise.resolve({ success: true, content: 'You are Maestro' }); + } + return Promise.resolve({ success: false, error: `Unknown prompt: ${id}` }); + }), + }, + web: { + ...window.maestro?.web, + broadcastUserInput: vi.fn().mockResolvedValue(undefined), + }, + } as typeof window.maestro; + + const { loadInputProcessingPrompts } = await import( + '../../../renderer/hooks/input/useInputProcessing' + ); + await loadInputProcessingPrompts(true); +}); afterEach(() => { Object.assign(window.maestro, originalMaestro); diff --git a/src/__tests__/renderer/hooks/useSymphonyContribution.test.ts b/src/__tests__/renderer/hooks/useSymphonyContribution.test.ts index 5c08e397f0..7026af7b2d 100644 --- a/src/__tests__/renderer/hooks/useSymphonyContribution.test.ts +++ b/src/__tests__/renderer/hooks/useSymphonyContribution.test.ts @@ -58,8 +58,17 @@ vi.mock('../../../renderer/stores/modalStore', async () => { }; }); -vi.mock('../../../renderer/components/BatchRunnerModal', () => ({ - DEFAULT_BATCH_PROMPT: 'mock-default-batch-prompt', +let batchPromptsLoaded = false; +vi.mock('../../../renderer/hooks/batch/batchUtils', () => ({ + getDefaultBatchPrompt: vi.fn(() => { + if (!batchPromptsLoaded) { + throw new Error('batch prompts not loaded'); + } + return 'mock-default-batch-prompt'; + }), + loadBatchPrompts: vi.fn().mockImplementation(async () => { + batchPromptsLoaded = true; + }), })); // ============================================================================ @@ -77,6 +86,7 @@ import { useModalStore, getModalActions } from '../../../renderer/stores/modalSt import { notifyToast } from '../../../renderer/stores/notificationStore'; import { gitService } from '../../../renderer/services/git'; import { validateNewSession } from '../../../renderer/utils/sessionValidation'; +import { loadBatchPrompts } from '../../../renderer/hooks/batch/batchUtils'; import type { SymphonyContributionData } from '../../../renderer/components/SymphonyModal'; import type { RegisteredRepository, SymphonyIssue } from '../../../shared/symphony-types'; @@ -161,7 +171,7 @@ function createDeps( overrides: Partial = {} ): UseSymphonyContributionDeps { return { - startBatchRun: vi.fn(), + startBatchRun: vi.fn().mockResolvedValue(undefined), inputRef: { current: { focus: vi.fn() } } as any, ...overrides, }; @@ -173,6 +183,7 @@ function createDeps( beforeEach(() => { idCounter = 0; + batchPromptsLoaded = false; vi.clearAllMocks(); // Re-establish default mock return values cleared by clearAllMocks @@ -186,6 +197,9 @@ beforeEach(() => { (gitService.getBranches as ReturnType).mockResolvedValue(['main']); (gitService.getTags as ReturnType).mockResolvedValue([]); (validateNewSession as ReturnType).mockReturnValue({ valid: true, error: null }); + (loadBatchPrompts as ReturnType).mockImplementation(async () => { + batchPromptsLoaded = true; + }); (getModalActions as ReturnType).mockReturnValue({ setSymphonyModalOpen: vi.fn(), }); @@ -1002,7 +1016,7 @@ describe('useSymphonyContribution', () => { describe('batch run auto-start', () => { it('calls startBatchRun when autoRunPath and documents are present', async () => { vi.useFakeTimers(); - const startBatchRun = vi.fn(); + const startBatchRun = vi.fn().mockResolvedValue(undefined); const deps = createDeps({ startBatchRun }); const issue = createIssue({ documentPaths: [ @@ -1022,17 +1036,18 @@ describe('useSymphonyContribution', () => { }); // startBatchRun fires after 500ms - act(() => { - vi.advanceTimersByTime(500); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); }); expect(startBatchRun).toHaveBeenCalledTimes(1); + expect(loadBatchPrompts).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); it('calls startBatchRun with the new session ID and autoRunPath', async () => { vi.useFakeTimers(); - const startBatchRun = vi.fn(); + const startBatchRun = vi.fn().mockResolvedValue(undefined); const deps = createDeps({ startBatchRun }); const data = createContributionData({ autoRunPath: '/tmp/repo/docs' }); @@ -1042,8 +1057,8 @@ describe('useSymphonyContribution', () => { await result.current.handleStartContribution(data); }); - act(() => { - vi.advanceTimersByTime(500); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); }); const newSessionId = useSessionStore.getState().sessions[0].id; @@ -1057,7 +1072,7 @@ describe('useSymphonyContribution', () => { it('calls startBatchRun with a BatchRunConfig containing documents from the issue', async () => { vi.useFakeTimers(); - const startBatchRun = vi.fn(); + const startBatchRun = vi.fn().mockResolvedValue(undefined); const deps = createDeps({ startBatchRun }); const issue = createIssue({ documentPaths: [ @@ -1073,8 +1088,8 @@ describe('useSymphonyContribution', () => { await result.current.handleStartContribution(data); }); - act(() => { - vi.advanceTimersByTime(500); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); }); const [, batchConfig] = startBatchRun.mock.calls[0]; @@ -1089,7 +1104,7 @@ describe('useSymphonyContribution', () => { it('sets resetOnCompletion and isDuplicate to false for each document', async () => { vi.useFakeTimers(); - const startBatchRun = vi.fn(); + const startBatchRun = vi.fn().mockResolvedValue(undefined); const deps = createDeps({ startBatchRun }); const issue = createIssue({ documentPaths: [{ name: 'task1.md', path: 'docs/task1.md', isExternal: false }], @@ -1102,8 +1117,8 @@ describe('useSymphonyContribution', () => { await result.current.handleStartContribution(data); }); - act(() => { - vi.advanceTimersByTime(500); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); }); const [, batchConfig] = startBatchRun.mock.calls[0]; @@ -1114,7 +1129,7 @@ describe('useSymphonyContribution', () => { it('does not call startBatchRun when autoRunPath is undefined', async () => { vi.useFakeTimers(); - const startBatchRun = vi.fn(); + const startBatchRun = vi.fn().mockResolvedValue(undefined); const deps = createDeps({ startBatchRun }); const data = createContributionData({ autoRunPath: undefined }); @@ -1124,17 +1139,18 @@ describe('useSymphonyContribution', () => { await result.current.handleStartContribution(data); }); - act(() => { - vi.advanceTimersByTime(1000); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); }); expect(startBatchRun).not.toHaveBeenCalled(); + expect(loadBatchPrompts).not.toHaveBeenCalled(); vi.useRealTimers(); }); it('does not call startBatchRun when documentPaths is empty', async () => { vi.useFakeTimers(); - const startBatchRun = vi.fn(); + const startBatchRun = vi.fn().mockResolvedValue(undefined); const deps = createDeps({ startBatchRun }); const issue = createIssue({ documentPaths: [] }); const data = createContributionData({ autoRunPath: '/tmp/repo/docs', issue }); @@ -1145,17 +1161,18 @@ describe('useSymphonyContribution', () => { await result.current.handleStartContribution(data); }); - act(() => { - vi.advanceTimersByTime(1000); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); }); expect(startBatchRun).not.toHaveBeenCalled(); + expect(loadBatchPrompts).not.toHaveBeenCalled(); vi.useRealTimers(); }); it('does not call startBatchRun before 500ms delay', async () => { vi.useFakeTimers(); - const startBatchRun = vi.fn(); + const startBatchRun = vi.fn().mockResolvedValue(undefined); const deps = createDeps({ startBatchRun }); const data = createContributionData({ autoRunPath: '/tmp/repo/docs' }); @@ -1165,13 +1182,74 @@ describe('useSymphonyContribution', () => { await result.current.handleStartContribution(data); }); - act(() => { - vi.advanceTimersByTime(499); + await act(async () => { + await vi.advanceTimersByTimeAsync(499); }); expect(startBatchRun).not.toHaveBeenCalled(); vi.useRealTimers(); }); + + it('handles auto-start batch run rejection without unhandled promise rejection', async () => { + vi.useFakeTimers(); + const startBatchRun = vi.fn().mockRejectedValue(new Error('batch failed')); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const deps = createDeps({ startBatchRun }); + const data = createContributionData({ autoRunPath: '/tmp/repo/docs' }); + + const { result } = renderHook(() => useSymphonyContribution(deps)); + + await act(async () => { + await result.current.handleStartContribution(data); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + expect(startBatchRun).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledWith( + '[Symphony] Failed to auto-start batch run:', + expect.any(Error) + ); + expect(notifyToast).toHaveBeenCalledWith({ + type: 'error', + title: 'Symphony Error', + message: 'Failed to start Auto Run.', + }); + + consoleError.mockRestore(); + vi.useRealTimers(); + }); + + it('handles loadBatchPrompts failure by showing auto-run error and skipping batch start', async () => { + const startBatchRun = vi.fn().mockResolvedValue(undefined); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + (loadBatchPrompts as ReturnType).mockRejectedValueOnce( + new Error('prompt load failed') + ); + const deps = createDeps({ startBatchRun }); + const data = createContributionData({ autoRunPath: '/tmp/repo/docs' }); + + const { result } = renderHook(() => useSymphonyContribution(deps)); + + await act(async () => { + await result.current.handleStartContribution(data); + }); + + expect(startBatchRun).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + '[Symphony] Failed to auto-start batch run:', + expect.any(Error) + ); + expect(notifyToast).toHaveBeenCalledWith({ + type: 'error', + title: 'Symphony Error', + message: 'Failed to start Auto Run.', + }); + + consoleError.mockRestore(); + }); }); // ======================================================================== diff --git a/src/__tests__/renderer/hooks/useWizardHandlers.test.ts b/src/__tests__/renderer/hooks/useWizardHandlers.test.ts index 2c2d8efad3..aaccb6af7f 100644 --- a/src/__tests__/renderer/hooks/useWizardHandlers.test.ts +++ b/src/__tests__/renderer/hooks/useWizardHandlers.test.ts @@ -49,10 +49,11 @@ vi.mock('../../../renderer/constants/app', () => ({ getSlashCommandDescription: vi.fn((cmd: string) => `Description for ${cmd}`), })); -vi.mock('../../../prompts', async () => { - const actual = await vi.importActual('../../../prompts'); - return { ...actual, autorunSynopsisPrompt: 'Generate a synopsis of all work done.' }; -}); +vi.mock('../../../renderer/hooks/batch/batchUtils', () => ({ + getAutorunSynopsisPrompt: vi.fn(() => 'Generate a synopsis of all work done.'), + getDefaultBatchPrompt: vi.fn(() => 'Run each task sequentially.'), + loadBatchPrompts: vi.fn().mockResolvedValue(undefined), +})); vi.mock('../../../shared/synopsis', () => ({ parseSynopsis: vi.fn((response: string) => ({ @@ -70,10 +71,6 @@ vi.mock('../../../renderer/components/Wizard', () => ({ AUTO_RUN_FOLDER_NAME: 'Auto Run Docs', })); -vi.mock('../../../renderer/components/BatchRunnerModal', () => ({ - DEFAULT_BATCH_PROMPT: 'Run each task sequentially.', -})); - import { useWizardHandlers } from '../../../renderer/hooks/wizard/useWizardHandlers'; import type { UseWizardHandlersDeps } from '../../../renderer/hooks/wizard/useWizardHandlers'; import { useSessionStore } from '../../../renderer/stores/sessionStore'; @@ -83,6 +80,7 @@ import { useModalStore, getModalActions } from '../../../renderer/stores/modalSt import { notifyToast } from '../../../renderer/stores/notificationStore'; import { gitService } from '../../../renderer/services/git'; import { validateNewSession } from '../../../renderer/utils/sessionValidation'; +import { loadBatchPrompts } from '../../../renderer/hooks/batch/batchUtils'; import { parseSynopsis } from '../../../shared/synopsis'; import type { Session, AITab } from '../../../renderer/types'; @@ -290,6 +288,7 @@ describe('useWizardHandlers', () => { beforeEach(() => { vi.clearAllMocks(); idCounter = 0; + (loadBatchPrompts as ReturnType).mockResolvedValue(undefined); useSessionStore.setState({ sessions: [], activeSessionId: null, diff --git a/src/__tests__/renderer/services/inlineWizardDocumentGeneration.test.ts b/src/__tests__/renderer/services/inlineWizardDocumentGeneration.test.ts index e3f26202ce..458fca2562 100644 --- a/src/__tests__/renderer/services/inlineWizardDocumentGeneration.test.ts +++ b/src/__tests__/renderer/services/inlineWizardDocumentGeneration.test.ts @@ -4,7 +4,7 @@ * These tests verify the document parsing and iterate mode functionality. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeAll } from 'vitest'; import { parseGeneratedDocuments, splitIntoPhases, @@ -12,10 +12,93 @@ import { generateWizardFolderBaseName, countTasks, generateDocumentPrompt, + loadInlineWizardDocGenPrompts, type DocumentGenerationConfig, } from '../../../renderer/services/inlineWizardDocumentGeneration'; +// Mock prompt content with the template variables used by generateDocumentPrompt +const MOCK_DOC_GEN_PROMPT = `You are creating documents for "{{PROJECT_NAME}}". + +**WRITE ACCESS:** +Write to: {{DIRECTORY_PATH}}/{{AUTO_RUN_FOLDER_NAME}}/ + +**READ ACCESS:** +Read any file in: \`{{DIRECTORY_PATH}}\` + +## Conversation Summary +{{CONVERSATION_SUMMARY}}`; + +const MOCK_ITERATE_GEN_PROMPT = `You are updating documents for "{{PROJECT_NAME}}". + +**WRITE ACCESS:** +Write to: {{DIRECTORY_PATH}}/{{AUTO_RUN_FOLDER_NAME}}/ + +**READ ACCESS:** +Read any file in: \`{{DIRECTORY_PATH}}\` + +## Existing Documents +{{EXISTING_DOCS}} + +## Goal +{{ITERATE_GOAL}} + +## Conversation Summary +{{CONVERSATION_SUMMARY}}`; + +const getPromptsMock = () => window.maestro.prompts.get as ReturnType; +const setDefaultPromptMockResponses = () => { + getPromptsMock().mockImplementation((id: string) => { + if (id === 'wizard-document-generation') return { success: true, content: MOCK_DOC_GEN_PROMPT }; + if (id === 'wizard-inline-iterate-generation') { + return { success: true, content: MOCK_ITERATE_GEN_PROMPT }; + } + return { success: false, error: `Unknown prompt: ${id}` }; + }); +}; + +// Set up window.maestro.prompts mock before import resolves +beforeAll(async () => { + if (!(window as any).maestro) { + (window as any).maestro = {}; + } + (window as any).maestro.prompts = { + get: vi.fn(), + }; + setDefaultPromptMockResponses(); + await loadInlineWizardDocGenPrompts(); +}); + describe('inlineWizardDocumentGeneration', () => { + describe('loadInlineWizardDocGenPrompts', () => { + it('rejects blank wizard-document-generation prompt content', async () => { + getPromptsMock().mockImplementation((id: string) => { + if (id === 'wizard-document-generation') return { success: true, content: ' ' }; + if (id === 'wizard-inline-iterate-generation') { + return { success: true, content: MOCK_ITERATE_GEN_PROMPT }; + } + return { success: false, error: `Unknown prompt: ${id}` }; + }); + + await expect(loadInlineWizardDocGenPrompts(true)).rejects.toThrow( + 'Failed to load prompt: wizard-document-generation' + ); + setDefaultPromptMockResponses(); + }); + + it('rejects blank wizard-inline-iterate-generation prompt content', async () => { + getPromptsMock().mockImplementation((id: string) => { + if (id === 'wizard-document-generation') return { success: true, content: MOCK_DOC_GEN_PROMPT }; + if (id === 'wizard-inline-iterate-generation') return { success: true, content: '\n\t' }; + return { success: false, error: `Unknown prompt: ${id}` }; + }); + + await expect(loadInlineWizardDocGenPrompts(true)).rejects.toThrow( + 'Failed to load prompt: wizard-inline-iterate-generation' + ); + setDefaultPromptMockResponses(); + }); + }); + describe('parseGeneratedDocuments', () => { it('should parse documents with standard markers', () => { const output = ` diff --git a/src/__tests__/renderer/stores/agentStore.test.ts b/src/__tests__/renderer/stores/agentStore.test.ts index 4f7030eee3..8d63f05e7c 100644 --- a/src/__tests__/renderer/stores/agentStore.test.ts +++ b/src/__tests__/renderer/stores/agentStore.test.ts @@ -113,12 +113,23 @@ vi.mock('../../../renderer/services/git', () => ({ }, })); -// Mock prompts -vi.mock('../../../prompts', () => ({ - maestroSystemPrompt: 'Mock system prompt for {{CWD}}', - autorunSynopsisPrompt: '', - imageOnlyDefaultPrompt: 'Describe this image', -})); +// Mock prompts module (now provides only PROMPT_IDS constants) +vi.mock('../../../prompts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + }; +}); + +// Mock the input processing prompt getters (loaded from disk via IPC at runtime) +vi.mock('../../../renderer/hooks/input/useInputProcessing', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getMaestroSystemPrompt: vi.fn(() => 'Mock system prompt for {{CWD}}'), + getImageOnlyPrompt: vi.fn(() => 'Describe this image'), + }; +}); // Mock substituteTemplateVariables — pass through the template as-is for simplicity vi.mock('../../../renderer/utils/templateVariables', () => ({ diff --git a/src/__tests__/renderer/stores/settingsStore.test.ts b/src/__tests__/renderer/stores/settingsStore.test.ts index 5f3424ea3c..a0700fc498 100644 --- a/src/__tests__/renderer/stores/settingsStore.test.ts +++ b/src/__tests__/renderer/stores/settingsStore.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { useSettingsStore, + loadSettingsStorePrompts, loadAllSettings, getBadgeLevelForTime, selectIsLeaderboardRegistered, @@ -117,8 +118,8 @@ describe('settingsStore', () => { // 1. Initial State // ======================================================================== - describe('initial state', () => { - it('has correct default values for all 65 fields', () => { + describe('initial state', () => { + it('has correct default values for all 65 fields', () => { const state = useSettingsStore.getState(); expect(state.settingsLoaded).toBe(false); @@ -221,6 +222,30 @@ describe('settingsStore', () => { }); }); + describe('prompt loading', () => { + it('throws when commit command prompt is blank and does not overwrite state', async () => { + const initialCommitPrompt = useSettingsStore + .getState() + .customAICommands.find((command) => command.id === 'commit')?.prompt; + + (window.maestro.prompts as any) = { + get: vi.fn().mockResolvedValue({ + success: true, + content: ' ', + }), + }; + + await expect(loadSettingsStorePrompts(true)).rejects.toThrow( + 'Failed to load prompt: commit-command' + ); + + const commitCommand = useSettingsStore + .getState() + .customAICommands.find((command) => command.id === 'commit'); + expect(commitCommand?.prompt).toBe(initialCommitPrompt); + }); + }); + describe('Shell', () => { it('setDefaultShell updates state and persists', () => { useSettingsStore.getState().setDefaultShell('bash'); diff --git a/src/cli/index.ts b/src/cli/index.ts index 95c99bdce5..636d103537 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -77,7 +77,7 @@ show .option('--json', 'Output as JSON (for scripting)') .action(showPlaybook); -// Playbook command (lazy-loaded to avoid eager resolution of generated/prompts) +// Playbook command (lazy-loaded to avoid eager import of run-playbook dependencies) program .command('playbook ') .description('Run a playbook') diff --git a/src/cli/services/batch-processor.ts b/src/cli/services/batch-processor.ts index 3127cac6a3..c32ac022ed 100644 --- a/src/cli/services/batch-processor.ts +++ b/src/cli/services/batch-processor.ts @@ -15,13 +15,45 @@ import { addHistoryEntry, readGroups } from './storage'; import { substituteTemplateVariables, TemplateContext } from '../../shared/templateVariables'; import { registerCliActivity, unregisterCliActivity } from '../../shared/cli-activity'; import { logger } from '../../main/utils/logger'; -import { autorunSynopsisPrompt, autorunDefaultPrompt } from '../../prompts'; +import fs from 'fs/promises'; +import path from 'path'; import { parseSynopsis } from '../../shared/synopsis'; import { generateUUID } from '../../shared/uuid'; import { formatElapsedTime } from '../../shared/formatters'; -// Synopsis prompt for batch tasks -const BATCH_SYNOPSIS_PROMPT = autorunSynopsisPrompt; +// CLI prompt cache (loaded once on first use) +const cliPromptCache = new Map(); + +async function getCliPrompt(id: string): Promise { + if (cliPromptCache.has(id)) { + return cliPromptCache.get(id)!; + } + + // Map ID to filename + const filenameMap: Record = { + 'autorun-synopsis': 'autorun-synopsis.md', + 'autorun-default': 'autorun-default.md', + }; + + const filename = filenameMap[id]; + if (!filename) { + throw new Error(`Unknown prompt ID: ${id}`); + } + + // Try development path first, then bundled + const devPath = path.join(__dirname, '..', '..', 'prompts', filename); + try { + const content = await fs.readFile(devPath, 'utf-8'); + cliPromptCache.set(id, content); + return content; + } catch { + // Try bundled path (when running from packaged app) + const bundledPath = path.join(process.resourcesPath || '', 'prompts', 'core', filename); + const content = await fs.readFile(bundledPath, 'utf-8'); + cliPromptCache.set(id, content); + return content; + } +} /** * Get the current git branch for a directory @@ -88,6 +120,7 @@ export async function* runPlaybook( pid: process.pid, }); + try { // Emit start event yield { type: 'start', @@ -150,7 +183,6 @@ export async function* runPlaybook( } if (initialTotalTasks === 0) { - unregisterCliActivity(session.id); yield { type: 'error', timestamp: Date.now(), @@ -201,7 +233,6 @@ export async function* runPlaybook( }; } - unregisterCliActivity(session.id); yield { type: 'complete', timestamp: Date.now(), @@ -407,7 +438,7 @@ export async function* runPlaybook( // Use default Auto Run prompt if playbook.prompt is empty/null // Marketplace playbooks with prompt: null will use the default const basePrompt = substituteTemplateVariables( - playbook.prompt || autorunDefaultPrompt, + playbook.prompt || await getCliPrompt('autorun-default'), templateContext ); @@ -475,7 +506,7 @@ export async function* runPlaybook( const synopsisResult = await spawnAgent( session.toolType, session.cwd, - BATCH_SYNOPSIS_PROMPT, + await getCliPrompt('autorun-synopsis'), result.agentSessionId ); @@ -737,9 +768,6 @@ export async function* runPlaybook( loopIteration++; } - // Unregister CLI activity - session is no longer busy - unregisterCliActivity(session.id); - // Add total Auto Run summary (only if looping was used) createAutoRunSummary(); @@ -752,4 +780,8 @@ export async function* runPlaybook( totalElapsedMs: Date.now() - batchStartTime, totalCost, }; + } finally { + // Ensure CLI busy state is always cleared (including early returns and throw paths) + unregisterCliActivity(session.id); + } } diff --git a/src/main/group-chat/group-chat-agent.ts b/src/main/group-chat/group-chat-agent.ts index 8e42179a49..9e6a6a526d 100644 --- a/src/main/group-chat/group-chat-agent.ts +++ b/src/main/group-chat/group-chat-agent.ts @@ -25,7 +25,7 @@ import { applyAgentConfigOverrides, getContextWindowValue, } from '../utils/agent-args'; -import { groupChatParticipantPrompt } from '../../prompts'; +import { getPrompt } from '../prompt-manager'; import { wrapSpawnWithSsh } from '../utils/ssh-spawn-wrapper'; import type { SshRemoteSettingsStore } from '../utils/ssh-remote-resolver'; import { getWindowsSpawnConfig } from './group-chat-config'; @@ -52,7 +52,7 @@ export function getParticipantSystemPrompt( groupChatName: string, logPath: string ): string { - return groupChatParticipantPrompt + return getPrompt('group-chat-participant') .replace(/\{\{GROUP_CHAT_NAME\}\}/g, groupChatName) .replace(/\{\{PARTICIPANT_NAME\}\}/g, participantName) .replace(/\{\{LOG_PATH\}\}/g, logPath); diff --git a/src/main/group-chat/group-chat-moderator.ts b/src/main/group-chat/group-chat-moderator.ts index 732e86a9d9..2be3802d04 100644 --- a/src/main/group-chat/group-chat-moderator.ts +++ b/src/main/group-chat/group-chat-moderator.ts @@ -11,7 +11,7 @@ import * as os from 'os'; import { GroupChat, loadGroupChat, updateGroupChat } from './group-chat-storage'; import { appendToLog, readLog } from './group-chat-log'; -import { groupChatModeratorSystemPrompt, groupChatModeratorSynthesisPrompt } from '../../prompts'; +import { getPrompt } from '../prompt-manager'; import { powerManager } from '../power-manager'; /** @@ -126,7 +126,7 @@ function touchSession(groupChatId: string): void { * Loaded from src/prompts/group-chat-moderator-system.md */ export function getModeratorSystemPrompt(): string { - return groupChatModeratorSystemPrompt; + return getPrompt('group-chat-moderator-system'); } /** @@ -135,7 +135,7 @@ export function getModeratorSystemPrompt(): string { * Loaded from src/prompts/group-chat-moderator-synthesis.md */ export function getModeratorSynthesisPrompt(): string { - return groupChatModeratorSynthesisPrompt; + return getPrompt('group-chat-moderator-synthesis'); } /** diff --git a/src/main/group-chat/group-chat-router.ts b/src/main/group-chat/group-chat-router.ts index 1ac924f6ee..a9cbe97fe1 100644 --- a/src/main/group-chat/group-chat-router.ts +++ b/src/main/group-chat/group-chat-router.ts @@ -40,7 +40,8 @@ import { applyAgentConfigOverrides, getContextWindowValue, } from '../utils/agent-args'; -import { groupChatParticipantRequestPrompt } from '../../prompts'; +import { getPrompt } from '../prompt-manager'; +import { PROMPT_IDS } from '../../prompts'; import { wrapSpawnWithSsh } from '../utils/ssh-spawn-wrapper'; import type { SshRemoteSettingsStore } from '../utils/ssh-remote-resolver'; import { setGetCustomShellPathCallback, getWindowsSpawnConfig } from './group-chat-config'; @@ -109,6 +110,12 @@ const pendingParticipantResponses = new Map>(); */ const groupChatReadOnlyState = new Map(); +const PROMPT_TOKEN_PATTERN = /\{\{([A-Z_]+)\}\}/g; + +function applyPromptTemplate(prompt: string, replacements: Record): string { + return prompt.replace(PROMPT_TOKEN_PATTERN, (match, token: string) => replacements[token] ?? match); +} + /** * Gets the current read-only state for a group chat. */ @@ -835,15 +842,19 @@ export async function routeModeratorResponse( // Get the group chat folder path for file access permissions const groupChatFolder = getGroupChatDir(groupChatId); - const participantPrompt = groupChatParticipantRequestPrompt - .replace(/\{\{PARTICIPANT_NAME\}\}/g, participantName) - .replace(/\{\{GROUP_CHAT_NAME\}\}/g, updatedChat.name) - .replace(/\{\{READ_ONLY_NOTE\}\}/g, readOnlyNote) - .replace(/\{\{GROUP_CHAT_FOLDER\}\}/g, groupChatFolder) - .replace(/\{\{HISTORY_CONTEXT\}\}/g, historyContext) - .replace(/\{\{READ_ONLY_LABEL\}\}/g, readOnlyLabel) - .replace(/\{\{MESSAGE\}\}/g, message) - .replace(/\{\{READ_ONLY_INSTRUCTION\}\}/g, readOnlyInstruction); + const participantPrompt = applyPromptTemplate( + getPrompt(PROMPT_IDS.GROUP_CHAT_PARTICIPANT_REQUEST), + { + PARTICIPANT_NAME: participantName, + GROUP_CHAT_NAME: updatedChat.name, + READ_ONLY_NOTE: readOnlyNote, + GROUP_CHAT_FOLDER: groupChatFolder, + HISTORY_CONTEXT: historyContext, + READ_ONLY_LABEL: readOnlyLabel, + MESSAGE: message, + READ_ONLY_INSTRUCTION: readOnlyInstruction, + } + ); // Create a unique session ID for this batch process const sessionId = `group-chat-${groupChatId}-participant-${participantName}-${Date.now()}`; @@ -1385,18 +1396,16 @@ export async function respawnParticipantWithRecovery( const groupChatFolder = getGroupChatDir(groupChatId); // Build the recovery prompt - includes standard prompt plus recovery context - const basePrompt = groupChatParticipantRequestPrompt - .replace(/\{\{PARTICIPANT_NAME\}\}/g, participantName) - .replace(/\{\{GROUP_CHAT_NAME\}\}/g, chat.name) - .replace(/\{\{READ_ONLY_NOTE\}\}/g, readOnlyNote) - .replace(/\{\{GROUP_CHAT_FOLDER\}\}/g, groupChatFolder) - .replace(/\{\{HISTORY_CONTEXT\}\}/g, historyContext) - .replace(/\{\{READ_ONLY_LABEL\}\}/g, readOnlyLabel) - .replace( - /\{\{MESSAGE\}\}/g, - 'Please continue from where you left off based on the recovery context below.' - ) - .replace(/\{\{READ_ONLY_INSTRUCTION\}\}/g, readOnlyInstruction); + const basePrompt = applyPromptTemplate(getPrompt(PROMPT_IDS.GROUP_CHAT_PARTICIPANT_REQUEST), { + PARTICIPANT_NAME: participantName, + GROUP_CHAT_NAME: chat.name, + READ_ONLY_NOTE: readOnlyNote, + GROUP_CHAT_FOLDER: groupChatFolder, + HISTORY_CONTEXT: historyContext, + READ_ONLY_LABEL: readOnlyLabel, + MESSAGE: 'Please continue from where you left off based on the recovery context below.', + READ_ONLY_INSTRUCTION: readOnlyInstruction, + }); // Prepend recovery context const fullPrompt = `${recoveryContext}\n\n${basePrompt}`; diff --git a/src/main/index.ts b/src/main/index.ts index 577f81530f..edb4140f03 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, Menu, powerMonitor } from 'electron'; +import { app, BrowserWindow, dialog, Menu, powerMonitor } from 'electron'; import { isMacOS } from '../shared/platformDetection'; import path from 'path'; import os from 'os'; @@ -49,6 +49,7 @@ import { registerWebHandlers, registerLeaderboardHandlers, registerNotificationsHandlers, + registerPromptsHandlers, registerSymphonyHandlers, registerTabNamingHandlers, registerAgentErrorHandlers, @@ -77,6 +78,7 @@ import { createSshRemoteStoreAdapter } from './utils/ssh-remote-resolver'; import { updateParticipant, loadGroupChat, updateGroupChat } from './group-chat/group-chat-storage'; import { needsSessionRecovery, initiateSessionRecovery } from './group-chat/session-recovery'; import { initializeSessionStorages } from './storage'; +import { initializePrompts } from './prompt-manager'; import { initializeOutputParsers } from './parsers'; import { calculateContextTokens } from './parsers/usage-aggregator'; import { @@ -364,6 +366,24 @@ app.whenReady().then(async () => { logger.warn('Continuing without stats - usage tracking will be unavailable', 'Startup'); } + // Initialize prompts from disk (must happen before features that use them) + logger.info('Initializing core prompts', 'Startup'); + try { + await initializePrompts(); + logger.info('Core prompts initialized', 'Startup'); + } catch (error) { + logger.error(`Critical: Failed to initialize prompts: ${error}`, 'Startup'); + dialog.showErrorBox( + 'Startup Error', + 'Failed to load system prompts. Please reinstall the application.' + ); + app.quit(); + return; + } + + // Register core prompts IPC handlers (no dependencies, prompts already initialized above) + registerPromptsHandlers(); + // Set up IPC handlers logger.debug('Setting up IPC handlers', 'Startup'); setupIpcHandlers(); diff --git a/src/main/ipc/handlers/director-notes.ts b/src/main/ipc/handlers/director-notes.ts index 8622d3d80b..830f3c3d4e 100644 --- a/src/main/ipc/handlers/director-notes.ts +++ b/src/main/ipc/handlers/director-notes.ts @@ -23,7 +23,7 @@ import { CreateHandlerOptions, } from '../../utils/ipcHandler'; import { groomContext } from '../../utils/context-groomer'; -import { directorNotesPrompt } from '../../../prompts'; +import { getPrompt } from '../../prompt-manager'; import type { ProcessManager } from '../../process-manager'; import type { AgentDetector } from '../../agents'; import type Store from 'electron-store'; @@ -233,7 +233,10 @@ export function registerDirectorNotesHandlers(deps: DirectorNotesHandlerDependen } // Build file-path manifest so the agent reads history files directly - const cutoffTime = Date.now() - options.lookbackDays * 24 * 60 * 60 * 1000; + const hasLookbackWindow = options.lookbackDays > 0; + const cutoffTime = hasLookbackWindow + ? Date.now() - options.lookbackDays * 24 * 60 * 60 * 1000 + : 0; const sessionIds = historyManager.listSessionsWithHistory(); const sessionNameMap = buildSessionNameMap(); @@ -257,7 +260,7 @@ export function registerDirectorNotesHandlers(deps: DirectorNotesHandlerDependen const entries = historyManager.getEntries(sessionId); let agentHasEntries = false; for (const entry of entries) { - if (entry.timestamp >= cutoffTime) { + if (!hasLookbackWindow || entry.timestamp >= cutoffTime) { entryCount++; agentHasEntries = true; } @@ -268,7 +271,9 @@ export function registerDirectorNotesHandlers(deps: DirectorNotesHandlerDependen if (sessionManifest.length === 0) { return { success: true, - synopsis: `# Director's Notes\n\n*Generated for the past ${options.lookbackDays} days*\n\nNo history files found.`, + synopsis: hasLookbackWindow + ? `# Director's Notes\n\n*Generated for the past ${options.lookbackDays} days*\n\nNo history files found.` + : "# Director's Notes\n\n*Generated for all available history*\n\nNo history files found.", generatedAt: Date.now(), stats: { agentCount: 0, entryCount: 0, durationMs: 0 }, }; @@ -282,11 +287,13 @@ export function registerDirectorNotesHandlers(deps: DirectorNotesHandlerDependen ) .join('\n'); - const cutoffDate = new Date(cutoffTime).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); + const cutoffDate = hasLookbackWindow + ? new Date(cutoffTime).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + : 'N/A'; const nowDate = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', @@ -294,14 +301,18 @@ export function registerDirectorNotesHandlers(deps: DirectorNotesHandlerDependen }); const prompt = [ - directorNotesPrompt, + getPrompt('director-notes'), '', '---', '', '## Session History Files', '', - `Lookback period: ${options.lookbackDays} days (${cutoffDate} – ${nowDate})`, - `Timestamp cutoff: ${cutoffTime} (only consider entries with timestamp >= this value)`, + hasLookbackWindow + ? `Lookback period: ${options.lookbackDays} days (${cutoffDate} – ${nowDate})` + : 'Lookback period: all time (no cutoff)', + hasLookbackWindow + ? `Timestamp cutoff: ${cutoffTime} (only consider entries with timestamp >= this value)` + : 'Timestamp cutoff: none (include all history entries)', `${agentCount} agents had ${entryCount} qualifying entries.`, '', manifestLines, diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index 1dfd2e8053..df633b6270 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -53,6 +53,7 @@ import { registerAgentErrorHandlers } from './agent-error'; import { registerTabNamingHandlers, TabNamingHandlerDependencies } from './tabNaming'; import { registerDirectorNotesHandlers, DirectorNotesHandlerDependencies } from './director-notes'; import { registerWakatimeHandlers } from './wakatime'; +import { registerPromptsHandlers } from './prompts'; import { AgentDetector } from '../../agents'; import { ProcessManager } from '../../process-manager'; import { WebServer } from '../../web-server'; @@ -97,6 +98,7 @@ export type { TabNamingHandlerDependencies }; export { registerDirectorNotesHandlers }; export type { DirectorNotesHandlerDependencies }; export { registerWakatimeHandlers }; +export { registerPromptsHandlers }; export type { AgentsHandlerDependencies }; export type { ProcessHandlerDependencies }; export type { PersistenceHandlerDependencies }; @@ -282,6 +284,8 @@ export function registerAllHandlers(deps: HandlerDependencies): void { getAgentDetector: deps.getAgentDetector, agentConfigsStore: deps.agentConfigsStore, }); + // Register core prompts handlers (no dependencies needed) + registerPromptsHandlers(); // Setup logger event forwarding to renderer setupLoggerEventForwarding(deps.getMainWindow); } diff --git a/src/main/ipc/handlers/prompts.ts b/src/main/ipc/handlers/prompts.ts new file mode 100644 index 0000000000..c6a0396445 --- /dev/null +++ b/src/main/ipc/handlers/prompts.ts @@ -0,0 +1,90 @@ +// ABOUTME: IPC handlers for core system prompts. +// ABOUTME: Provides full CRUD for the Maestro Prompts UI tab with immediate in-memory updates. + +/** + * IPC handlers for core prompts + * + * Provides full CRUD for the Maestro Prompts UI tab. + * Changes are saved to customizations file AND applied immediately in memory. + */ + +import { ipcMain } from 'electron'; +import { + getPrompt, + getAllPrompts, + getAllPromptIds, + savePrompt, + resetPrompt, + arePromptsInitialized, +} from '../../prompt-manager'; +import { logger } from '../../utils/logger'; + +const LOG_CONTEXT = '[IPC:Prompts]'; + +export function registerPromptsHandlers(): void { + // Get a single prompt by ID + ipcMain.handle('prompts:get', async (_, id: string) => { + try { + if (!arePromptsInitialized()) { + return { success: false, error: 'Prompts not yet initialized' }; + } + const content = getPrompt(id); + return { success: true, content }; + } catch (error) { + logger.error(`Failed to get prompt ${id}: ${error}`, LOG_CONTEXT); + return { success: false, error: String(error) }; + } + }); + + // Get all prompts with metadata (for UI) + ipcMain.handle('prompts:getAll', async () => { + try { + if (!arePromptsInitialized()) { + return { success: false, error: 'Prompts not yet initialized' }; + } + const prompts = getAllPrompts(); + return { success: true, prompts }; + } catch (error) { + logger.error(`Failed to get all prompts: ${error}`, LOG_CONTEXT); + return { success: false, error: String(error) }; + } + }); + + // Get all prompt IDs (for reference) + ipcMain.handle('prompts:getAllIds', async () => { + try { + if (!arePromptsInitialized()) { + return { success: false, error: 'Prompts not yet initialized' }; + } + const ids = getAllPromptIds(); + return { success: true, ids }; + } catch (error) { + logger.error(`Failed to get prompt IDs: ${error}`, LOG_CONTEXT); + return { success: false, error: String(error) }; + } + }); + + // Save user's edit to a prompt (immediate effect) + ipcMain.handle('prompts:save', async (_, id: string, content: string) => { + try { + await savePrompt(id, content); + return { success: true }; + } catch (error) { + logger.error(`Failed to save prompt ${id}: ${error}`, LOG_CONTEXT); + return { success: false, error: String(error) }; + } + }); + + // Reset a prompt to bundled default (immediate effect) + ipcMain.handle('prompts:reset', async (_, id: string) => { + try { + const content = await resetPrompt(id); + return { success: true, content }; + } catch (error) { + logger.error(`Failed to reset prompt ${id}: ${error}`, LOG_CONTEXT); + return { success: false, error: String(error) }; + } + }); + + logger.info('Prompts IPC handlers registered', LOG_CONTEXT); +} diff --git a/src/main/ipc/handlers/tabNaming.ts b/src/main/ipc/handlers/tabNaming.ts index 6eeb2b2488..f58c216fb3 100644 --- a/src/main/ipc/handlers/tabNaming.ts +++ b/src/main/ipc/handlers/tabNaming.ts @@ -21,7 +21,7 @@ import { import { buildAgentArgs, applyAgentConfigOverrides } from '../../utils/agent-args'; import { getSshRemoteConfig, createSshRemoteStoreAdapter } from '../../utils/ssh-remote-resolver'; import { buildSshCommand } from '../../utils/ssh-command-builder'; -import { tabNamingPrompt } from '../../../prompts'; +import { getPrompt } from '../../prompt-manager'; import type { ProcessManager } from '../../process-manager'; import type { AgentDetector } from '../../agents'; import type { MaestroSettings } from './persistence'; @@ -113,7 +113,7 @@ export function registerTabNamingHandlers(deps: TabNamingHandlerDependencies): v } // Build the prompt: combine the tab naming prompt with the user's message - const fullPrompt = `${tabNamingPrompt}\n\n---\n\nUser's message:\n\n${config.userMessage}`; + const fullPrompt = `${getPrompt('tab-naming')}\n\n---\n\nUser's message:\n\n${config.userMessage}`; // Build agent arguments - read-only mode, runs in parallel // Filter out --dangerously-skip-permissions from base args since tab naming diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts index e91a1f1f81..0123c4984f 100644 --- a/src/main/preload/index.ts +++ b/src/main/preload/index.ts @@ -35,6 +35,7 @@ import { createLoggerApi } from './logger'; import { createClaudeApi, createAgentSessionsApi } from './sessions'; import { createTempfileApi, createHistoryApi, createCliApi } from './files'; import { createSpeckitApi, createOpenspecApi } from './commands'; +import { createPromptsApi } from './prompts'; import { createAutorunApi, createPlaybooksApi, createMarketplaceApi } from './autorun'; import { createDebugApi, createDocumentGraphApi } from './debug'; import { createGroupChatApi } from './groupChat'; @@ -144,6 +145,9 @@ contextBridge.exposeInMainWorld('maestro', { // OpenSpec API openspec: createOpenspecApi(), + // Core Prompts API (Maestro system prompts) + prompts: createPromptsApi(), + // Notification API notification: createNotificationApi(), @@ -231,6 +235,8 @@ export { // Commands createSpeckitApi, createOpenspecApi, + // Prompts + createPromptsApi, // Auto Run createAutorunApi, createPlaybooksApi, @@ -335,6 +341,11 @@ export type { CommandMetadata, CommandDefinition, } from './commands'; +export type { + // From prompts + PromptsApi, + CorePromptEntry, +} from './prompts'; export type { // From autorun AutorunApi, diff --git a/src/main/preload/prompts.ts b/src/main/preload/prompts.ts new file mode 100644 index 0000000000..0d369f01a3 --- /dev/null +++ b/src/main/preload/prompts.ts @@ -0,0 +1,69 @@ +// ABOUTME: Preload API for core system prompts. +// ABOUTME: Provides window.maestro.prompts namespace for get, getAll, save, and reset operations. + +/** + * Preload API for core system prompts + * + * Provides the window.maestro.prompts namespace for: + * - Getting individual prompts by ID + * - Getting all prompts with metadata (for the Prompts UI tab) + * - Saving user edits (immediate in-memory + disk) + * - Resetting prompts to bundled defaults + */ + +import { ipcRenderer } from 'electron'; + +/** + * Core prompt definition returned by the prompts API + */ +export interface CorePromptEntry { + id: string; + filename: string; + description: string; + category: string; + content: string; + isModified: boolean; +} + +/** + * Creates the Prompts API object for preload exposure + */ +export function createPromptsApi() { + return { + // Get a single prompt by ID + get: (id: string): Promise<{ + success: boolean; + content?: string; + error?: string; + }> => ipcRenderer.invoke('prompts:get', id), + + // Get all prompts with metadata (for UI) + getAll: (): Promise<{ + success: boolean; + prompts?: CorePromptEntry[]; + error?: string; + }> => ipcRenderer.invoke('prompts:getAll'), + + // Get all prompt IDs + getAllIds: (): Promise<{ + success: boolean; + ids?: string[]; + error?: string; + }> => ipcRenderer.invoke('prompts:getAllIds'), + + // Save user's edit (immediate effect) + save: (id: string, content: string): Promise<{ + success: boolean; + error?: string; + }> => ipcRenderer.invoke('prompts:save', id, content), + + // Reset to bundled default (immediate effect) + reset: (id: string): Promise<{ + success: boolean; + content?: string; + error?: string; + }> => ipcRenderer.invoke('prompts:reset', id), + }; +} + +export type PromptsApi = ReturnType; diff --git a/src/main/prompt-manager.ts b/src/main/prompt-manager.ts new file mode 100644 index 0000000000..08e580f273 --- /dev/null +++ b/src/main/prompt-manager.ts @@ -0,0 +1,296 @@ +// ABOUTME: Prompt Manager - Loads core system prompts from disk at startup. +// ABOUTME: Supports user customizations stored separately, with immediate in-memory updates on save/reset. + +/** + * Prompt Manager - Core System Prompts + * + * Loads all core prompts from disk exactly once at application startup. + * User customizations are stored separately and take precedence over bundled defaults. + * + * Architecture (same as SpecKit/OpenSpec): + * - Bundled prompts: Resources/prompts/core/*.md (read-only) + * - User customizations: userData/core-prompts-customizations.json + * - On load: User customization wins if isModified=true, else bundled + * - On save: Writes to customizations JSON AND updates in-memory cache immediately + * - On reset: Removes from customizations JSON AND updates in-memory cache immediately + */ + +import { app } from 'electron'; +import fs from 'fs/promises'; +import path from 'path'; +import { logger } from './utils/logger'; +import { CORE_PROMPT_DEFINITIONS, type PromptDefinition } from '../prompts/catalog'; + +const LOG_CONTEXT = '[PromptManager]'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface CorePrompt { + id: string; + filename: string; + description: string; + category: string; + content: string; + isModified: boolean; +} + +interface StoredPrompt { + content: string; + isModified: boolean; + modifiedAt?: string; +} + +interface StoredData { + prompts: Record; +} + +function normalizeStoredData(value: unknown): StoredData { + const normalized: StoredData = { prompts: {} }; + if (!value || typeof value !== 'object') { + return normalized; + } + + const promptsValue = (value as { prompts?: unknown }).prompts; + if (!promptsValue || typeof promptsValue !== 'object') { + return normalized; + } + + for (const [id, rawPrompt] of Object.entries(promptsValue as Record)) { + if (!rawPrompt || typeof rawPrompt !== 'object') { + continue; + } + + const content = (rawPrompt as { content?: unknown }).content; + if (typeof content !== 'string') { + continue; + } + + const isModified = (rawPrompt as { isModified?: unknown }).isModified; + const modifiedAt = (rawPrompt as { modifiedAt?: unknown }).modifiedAt; + + normalized.prompts[id] = { + content, + isModified: typeof isModified === 'boolean' ? isModified : true, + ...(typeof modifiedAt === 'string' ? { modifiedAt } : {}), + }; + } + + return normalized; +} + +// ============================================================================ +// Prompt Definitions +// ============================================================================ + +const CORE_PROMPTS: PromptDefinition[] = CORE_PROMPT_DEFINITIONS; + +// ============================================================================ +// State +// ============================================================================ + +const promptCache = new Map(); +let initialized = false; +let customizationWriteQueue: Promise = Promise.resolve(); + +async function withSerializedCustomizationMutation(mutation: () => Promise): Promise { + const next = customizationWriteQueue.then(mutation, mutation); + customizationWriteQueue = next.then( + () => undefined, + () => undefined + ); + return next; +} + +// ============================================================================ +// Path Helpers +// ============================================================================ + +function getBundledPromptsPath(): string { + if (app.isPackaged) { + return path.join(process.resourcesPath, 'prompts', 'core'); + } + return path.join(__dirname, '..', '..', 'src', 'prompts'); +} + +function getCustomizationsPath(): string { + return path.join(app.getPath('userData'), 'core-prompts-customizations.json'); +} + +// ============================================================================ +// Customizations Storage +// ============================================================================ + +async function loadUserCustomizations(): Promise { + try { + const content = await fs.readFile(getCustomizationsPath(), 'utf-8'); + const parsed = JSON.parse(content) as unknown; + return normalizeStoredData(parsed); + } catch (error) { + const fsError = error as NodeJS.ErrnoException; + if (fsError?.code === 'ENOENT' || fsError?.message?.includes('ENOENT')) { + return null; + } + throw error; + } +} + +async function saveUserCustomizations(data: StoredData): Promise { + await fs.writeFile(getCustomizationsPath(), JSON.stringify(data, null, 2), 'utf-8'); +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Initialize all prompts from disk. Called once at app startup. + * Loads bundled prompts, then overlays user customizations. + */ +export async function initializePrompts(): Promise { + if (initialized) { + logger.warn('Prompts already initialized, skipping', LOG_CONTEXT); + return; + } + + const promptsPath = getBundledPromptsPath(); + const customizations = await loadUserCustomizations(); + + logger.info(`Loading ${CORE_PROMPTS.length} prompts from: ${promptsPath}`, LOG_CONTEXT); + + let customizedCount = 0; + for (const prompt of CORE_PROMPTS) { + const filePath = path.join(promptsPath, prompt.filename); + + // Load bundled content + let bundledContent: string; + try { + bundledContent = await fs.readFile(filePath, 'utf-8'); + } catch (error) { + logger.error(`Failed to load prompt ${prompt.id} from ${filePath}: ${error}`, LOG_CONTEXT); + throw new Error(`Failed to load required prompt: ${prompt.id}`); + } + + // Check for user customization + const customPrompt = customizations?.prompts?.[prompt.id]; + const isModified = customPrompt?.isModified ?? false; + const content = isModified && customPrompt ? customPrompt.content : bundledContent; + + if (isModified) customizedCount++; + promptCache.set(prompt.id, { content, isModified }); + } + + initialized = true; + logger.info(`Successfully loaded ${promptCache.size} prompts (${customizedCount} customized)`, LOG_CONTEXT); +} + +/** + * Get a prompt by ID. Returns cached value (prompts are loaded once at startup). + */ +export function getPrompt(id: string): string { + if (!initialized) { + throw new Error('Prompts not initialized. Call initializePrompts() first.'); + } + + const cached = promptCache.get(id); + if (!cached) { + throw new Error(`Unknown prompt ID: ${id}`); + } + + return cached.content; +} + +/** + * Get all prompts with metadata (for UI display). + */ +export function getAllPrompts(): CorePrompt[] { + if (!initialized) { + throw new Error('Prompts not initialized. Call initializePrompts() first.'); + } + + return CORE_PROMPTS.map((def) => { + const cached = promptCache.get(def.id)!; + return { + id: def.id, + filename: def.filename, + description: def.description, + category: def.category, + content: cached.content, + isModified: cached.isModified, + }; + }); +} + +/** + * Save user's edit to a prompt. Updates both disk and in-memory cache immediately. + */ +export async function savePrompt(id: string, content: string): Promise { + const def = CORE_PROMPTS.find((p) => p.id === id); + if (!def) { + throw new Error(`Unknown prompt ID: ${id}`); + } + if (!content.trim()) { + throw new Error('Prompt content cannot be empty or whitespace'); + } + + await withSerializedCustomizationMutation(async () => { + const customizations = (await loadUserCustomizations()) || { prompts: {} }; + customizations.prompts[id] = { + content, + isModified: true, + modifiedAt: new Date().toISOString(), + }; + await saveUserCustomizations(customizations); + }); + + // Update in-memory cache immediately + promptCache.set(id, { content, isModified: true }); + + logger.info(`Saved and applied customization for ${id}`, LOG_CONTEXT); +} + +/** + * Reset a prompt to bundled default. Updates both disk and in-memory cache immediately. + * Returns the bundled content for UI confirmation. + */ +export async function resetPrompt(id: string): Promise { + const def = CORE_PROMPTS.find((p) => p.id === id); + if (!def) { + throw new Error(`Unknown prompt ID: ${id}`); + } + + return withSerializedCustomizationMutation(async () => { + // Read bundled content first so reset does not delete customization if disk read fails. + const promptsPath = getBundledPromptsPath(); + const filePath = path.join(promptsPath, def.filename); + const bundledContent = await fs.readFile(filePath, 'utf-8'); + + // Remove from customizations on disk + const customizations = await loadUserCustomizations(); + if (customizations?.prompts?.[id]) { + delete customizations.prompts[id]; + await saveUserCustomizations(customizations); + } + + // Update in-memory cache immediately + promptCache.set(id, { content: bundledContent, isModified: false }); + + logger.info(`Reset and applied bundled default for ${id}`, LOG_CONTEXT); + return bundledContent; + }); +} + +/** + * Check if prompts have been initialized. + */ +export function arePromptsInitialized(): boolean { + return initialized; +} + +/** + * Get all prompt IDs. + */ +export function getAllPromptIds(): string[] { + return CORE_PROMPTS.map((p) => p.id); +} diff --git a/src/prompts/catalog.ts b/src/prompts/catalog.ts new file mode 100644 index 0000000000..d5d4c087ee --- /dev/null +++ b/src/prompts/catalog.ts @@ -0,0 +1,50 @@ +// ABOUTME: Shared core prompt catalog used by both prompt-manager and PROMPT_IDS exports. +// ABOUTME: Prevents prompt ID drift by defining prompt metadata in a single source. + +export interface PromptDefinition { + id: string; + filename: string; + description: string; + category: string; +} + +const CORE_PROMPT_ENTRIES = [ + ['WIZARD_SYSTEM', { id: 'wizard-system', filename: 'wizard-system.md', description: 'Main wizard conversation system prompt', category: 'wizard' }], + ['WIZARD_SYSTEM_CONTINUATION', { id: 'wizard-system-continuation', filename: 'wizard-system-continuation.md', description: 'Wizard continuation prompt', category: 'wizard' }], + ['WIZARD_DOCUMENT_GENERATION', { id: 'wizard-document-generation', filename: 'wizard-document-generation.md', description: 'Wizard document generation prompt', category: 'wizard' }], + ['WIZARD_INLINE_SYSTEM', { id: 'wizard-inline-system', filename: 'wizard-inline-system.md', description: 'Inline wizard system prompt', category: 'inline-wizard' }], + ['WIZARD_INLINE_ITERATE', { id: 'wizard-inline-iterate', filename: 'wizard-inline-iterate.md', description: 'Inline wizard iteration prompt', category: 'inline-wizard' }], + ['WIZARD_INLINE_NEW', { id: 'wizard-inline-new', filename: 'wizard-inline-new.md', description: 'Inline wizard new session prompt', category: 'inline-wizard' }], + ['WIZARD_INLINE_ITERATE_GENERATION', { id: 'wizard-inline-iterate-generation', filename: 'wizard-inline-iterate-generation.md', description: 'Inline wizard iteration generation', category: 'inline-wizard' }], + ['AUTORUN_DEFAULT', { id: 'autorun-default', filename: 'autorun-default.md', description: 'Default Auto Run behavior prompt', category: 'autorun' }], + ['AUTORUN_SYNOPSIS', { id: 'autorun-synopsis', filename: 'autorun-synopsis.md', description: 'Auto Run synopsis generation prompt', category: 'autorun' }], + ['IMAGE_ONLY_DEFAULT', { id: 'image-only-default', filename: 'image-only-default.md', description: 'Default prompt for image-only messages', category: 'commands' }], + ['COMMIT_COMMAND', { id: 'commit-command', filename: 'commit-command.md', description: 'Git commit command prompt', category: 'commands' }], + ['MAESTRO_SYSTEM_PROMPT', { id: 'maestro-system-prompt', filename: 'maestro-system-prompt.md', description: 'Maestro system context prompt', category: 'system' }], + ['GROUP_CHAT_MODERATOR_SYSTEM', { id: 'group-chat-moderator-system', filename: 'group-chat-moderator-system.md', description: 'Group chat moderator system prompt', category: 'group-chat' }], + ['GROUP_CHAT_MODERATOR_SYNTHESIS', { id: 'group-chat-moderator-synthesis', filename: 'group-chat-moderator-synthesis.md', description: 'Group chat synthesis prompt', category: 'group-chat' }], + ['GROUP_CHAT_PARTICIPANT', { id: 'group-chat-participant', filename: 'group-chat-participant.md', description: 'Group chat participant prompt', category: 'group-chat' }], + ['GROUP_CHAT_PARTICIPANT_REQUEST', { id: 'group-chat-participant-request', filename: 'group-chat-participant-request.md', description: 'Group chat participant request prompt', category: 'group-chat' }], + ['CONTEXT_GROOMING', { id: 'context-grooming', filename: 'context-grooming.md', description: 'Context grooming prompt', category: 'context' }], + ['CONTEXT_TRANSFER', { id: 'context-transfer', filename: 'context-transfer.md', description: 'Context transfer prompt', category: 'context' }], + ['CONTEXT_SUMMARIZE', { id: 'context-summarize', filename: 'context-summarize.md', description: 'Context summarization prompt', category: 'context' }], + ['TAB_NAMING', { id: 'tab-naming', filename: 'tab-naming.md', description: 'Tab naming prompt', category: 'commands' }], + ['DIRECTOR_NOTES', { id: 'director-notes', filename: 'director-notes.md', description: 'Director notes synopsis prompt', category: 'system' }], +] as const satisfies ReadonlyArray; + +type PromptEntry = (typeof CORE_PROMPT_ENTRIES)[number]; +type PromptKey = PromptEntry[0]; + +export const CORE_PROMPT_DEFINITIONS: PromptDefinition[] = CORE_PROMPT_ENTRIES.map( + ([, definition]) => ({ ...definition }) +); + +const promptIdEntries = CORE_PROMPT_ENTRIES.map(([key, definition]) => [key, definition.id] as const); + +export const PROMPT_IDS = Object.freeze( + Object.fromEntries(promptIdEntries) +) as { + readonly [K in PromptKey]: Extract<(typeof promptIdEntries)[number], readonly [K, string]>[1]; +}; + +export type PromptId = (typeof PROMPT_IDS)[keyof typeof PROMPT_IDS]; diff --git a/src/prompts/index.ts b/src/prompts/index.ts index cacd282e97..03a005a3cf 100644 --- a/src/prompts/index.ts +++ b/src/prompts/index.ts @@ -1,51 +1,5 @@ -/** - * Centralized prompts module - * - * All prompts are stored as .md files in this directory and compiled - * to TypeScript at build time by scripts/generate-prompts.mjs. - * - * The generated file is at src/generated/prompts.ts - */ +// ABOUTME: Public prompt exports for type-safe prompt IDs. +// ABOUTME: Backed by shared catalog definitions to avoid runtime drift. -export { - // Wizard - wizardSystemPrompt, - wizardSystemContinuationPrompt, - wizardDocumentGenerationPrompt, - - // Inline Wizard - wizardInlineSystemPrompt, - wizardInlineIteratePrompt, - wizardInlineNewPrompt, - wizardInlineIterateGenerationPrompt, - - // AutoRun - autorunDefaultPrompt, - autorunSynopsisPrompt, - - // Input processing - imageOnlyDefaultPrompt, - - // Commands - commitCommandPrompt, - - // Maestro system prompt - maestroSystemPrompt, - - // Group chat prompts - groupChatModeratorSystemPrompt, - groupChatModeratorSynthesisPrompt, - groupChatParticipantPrompt, - groupChatParticipantRequestPrompt, - - // Context management - contextGroomingPrompt, - contextTransferPrompt, - contextSummarizePrompt, - - // Tab naming - tabNamingPrompt, - - // Director's Notes - directorNotesPrompt, -} from '../generated/prompts'; +export { PROMPT_IDS } from './catalog'; +export type { PromptId } from './catalog'; diff --git a/src/renderer/components/BatchRunnerModal.tsx b/src/renderer/components/BatchRunnerModal.tsx index e1ff1bef96..da4ecbec65 100644 --- a/src/renderer/components/BatchRunnerModal.tsx +++ b/src/renderer/components/BatchRunnerModal.tsx @@ -28,14 +28,17 @@ import { useSessionStore } from '../stores/sessionStore'; import { getModalActions } from '../stores/modalStore'; import { usePlaybookManagement, - DEFAULT_BATCH_PROMPT, - validateAgentPromptHasTaskReference, } from '../hooks'; +import { + getDefaultBatchPrompt, + loadBatchPrompts, + validateAgentPromptHasTaskReference, +} from '../hooks/batch/batchUtils'; import { generateId } from '../utils/ids'; import { formatMetaKey } from '../utils/shortcutFormatter'; // Re-export for external consumers -export { DEFAULT_BATCH_PROMPT, validateAgentPromptHasTaskReference } from '../hooks'; +export { getDefaultBatchPrompt, validateAgentPromptHasTaskReference } from '../hooks/batch/batchUtils'; interface BatchRunnerModalProps { theme: Theme; @@ -100,6 +103,14 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { onOpenMarketplace, } = props; + const initialDefaultBatchPrompt = (() => { + try { + return getDefaultBatchPrompt(); + } catch { + return ''; + } + })(); + // Worktree run target state const [worktreeTarget, setWorktreeTarget] = useState(null); const [isPreparingWorktree, setIsPreparingWorktree] = useState(false); @@ -148,14 +159,17 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { const initialMaxLoopsRef = useRef(null); // Prompt state - const [prompt, setPrompt] = useState(initialPrompt || DEFAULT_BATCH_PROMPT); + const hasInitialPromptRef = useRef(initialPrompt !== undefined); + const resolvedInitialPrompt = initialPrompt ?? initialDefaultBatchPrompt; + const [defaultBatchPrompt, setDefaultBatchPrompt] = useState(initialDefaultBatchPrompt); + const [prompt, setPrompt] = useState(resolvedInitialPrompt); const [variablesExpanded, setVariablesExpanded] = useState(false); - const [savedPrompt, setSavedPrompt] = useState(initialPrompt || ''); + const [savedPrompt, setSavedPrompt] = useState(resolvedInitialPrompt); const [promptComposerOpen, setPromptComposerOpen] = useState(false); const textareaRef = useRef(null); // Track initial prompt for dirty checking - const initialPromptRef = useRef(initialPrompt || DEFAULT_BATCH_PROMPT); + const initialPromptRef = useRef(resolvedInitialPrompt); // Compute if there are unsaved configuration changes // This checks if documents, loop settings, or prompt have changed from initial values @@ -346,9 +360,45 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { setTimeout(() => textareaRef.current?.focus(), 100); }, []); + useEffect(() => { + let cancelled = false; + + const loadDefaultPrompt = async () => { + try { + await loadBatchPrompts(); + if (cancelled) return; + + const loadedDefaultPrompt = getDefaultBatchPrompt(); + setDefaultBatchPrompt(loadedDefaultPrompt); + setPrompt((existingPrompt) => + !hasInitialPromptRef.current && existingPrompt === '' + ? loadedDefaultPrompt + : existingPrompt + ); + setSavedPrompt((existingPrompt) => + !hasInitialPromptRef.current && existingPrompt === '' + ? loadedDefaultPrompt + : existingPrompt + ); + if (!hasInitialPromptRef.current && initialPromptRef.current === '') { + initialPromptRef.current = loadedDefaultPrompt; + } + } catch (error) { + console.error('[BatchRunnerModal] Failed to load default prompt:', error); + } + }; + + loadDefaultPrompt(); + + return () => { + cancelled = true; + }; + }, []); + const handleReset = () => { + if (!defaultBatchPrompt) return; showConfirmation('Reset the prompt to the default? Your customizations will be lost.', () => { - setPrompt(DEFAULT_BATCH_PROMPT); + setPrompt(defaultBatchPrompt); }); }; @@ -400,8 +450,9 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { } }; - const isModified = prompt !== DEFAULT_BATCH_PROMPT; - const hasUnsavedChanges = prompt !== savedPrompt && prompt !== DEFAULT_BATCH_PROMPT; + const isModified = !!defaultBatchPrompt && prompt !== defaultBatchPrompt; + const persistedPrompt = savedPrompt; + const hasUnsavedChanges = prompt !== persistedPrompt; return (
)}
- + {expandedCategories.has(category) && + categoryPrompts.map((prompt) => ( + + ))} + + ))} + + + {/* Editor Panel - Right side */} +
+ {selectedPrompt ? ( + <> + {/* Header */} +
+

+ {selectedPrompt.id} +

+

+ {selectedPrompt.description} +

+ {selectedPrompt.isModified && ( + + Modified + + )} +
+ + {/* Success / Error messages */} + {successMessage && ( +
+ {successMessage} +
+ )} + {error && ( +
+ {error} +
+ )} + + {/* Textarea editor */} +