diff --git a/.gitignore b/.gitignore index 8c136325be..24eca450f8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ Work\ Trees/ community-data/ .mcp.json specs/ +.maestro/ +maestro-cue.yaml # Tests coverage/ diff --git a/.prettierignore b/.prettierignore index adadb71119..64727c86cb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,4 @@ node_modules/ coverage/ *.min.js .gitignore +.prettierignore diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7650f41dd2..29110bc8b7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -18,6 +18,7 @@ Deep technical documentation for Maestro's architecture and design patterns. For - [Achievement System](#achievement-system) - [AI Tab System](#ai-tab-system) - [File Preview Tab System](#file-preview-tab-system) +- [Terminal Tab System](#terminal-tab-system) - [Execution Queue](#execution-queue) - [Navigation History](#navigation-history) - [Group Chat System](#group-chat-system) @@ -1116,6 +1117,54 @@ File tabs display a colored badge based on file extension. Colors are theme-awar --- +## Terminal Tab System + +Persistent PTY-backed terminal tabs that integrate into the unified tab bar alongside AI and file tabs. Built on xterm.js for full terminal emulation with ANSI support. + +### Features + +- **Persistent PTY**: Each tab spawns a dedicated PTY via `process:spawnTerminalTab` IPC — the shell stays alive between tab switches +- **xterm.js rendering**: Full terminal emulation via `XTerminal.tsx` (wraps `@xterm/xterm`); raw PTY data passes through unchanged +- **Multi-tab**: Multiple independent shells per agent; tabs are closable and renameable +- **State persistence**: `terminalTabs` array saved with the session; PTYs are re-spawned on restore +- **Spawn failure UX**: `state === 'exited' && pid === 0` shows an error overlay with a Retry button +- **Exit message**: PTY exit writes a yellow ANSI banner and new-terminal hint to the xterm buffer + +### Terminal Tab Interface + +```typescript +interface TerminalTab { + id: string; // Unique tab ID (UUID) + name: string; // Display name (custom or auto "Terminal N") + shellType: string; // Shell binary (e.g., "zsh", "bash") + cwd: string; // Working directory + pid: number; // PTY process ID (0 = not yet spawned) + state: 'idle' | 'running' | 'exited'; + exitCode: number | null; + createdAt: number; +} +``` + +### Session Fields + +```typescript +// In Session interface +terminalTabs: TerminalTab[]; // Array of terminal tabs +activeTerminalTabId: string | null; // Active terminal tab (null if not in terminal mode) +``` + +### Key Files + +| File | Purpose | +| --------------------------- | -------------------------------------------------------------------- | +| `XTerminal.tsx` | xterm.js wrapper; handles PTY data I/O and terminal lifecycle | +| `TerminalView.tsx` | Layout container; manages tab selection and spawn/exit state | +| `terminalTabHelpers.ts` | CRUD helpers (`createTerminalTab`, `addTerminalTab`, `closeTerminalTab`, etc.) | +| `tabStore.ts` | Zustand selectors for terminal tab state | +| `src/main/ipc/handlers/process.ts` | `process:spawnTerminalTab` IPC handler with SSH support | + +--- + ## Execution Queue Sequential message processing system that prevents race conditions when multiple operations target the same agent. diff --git a/CLAUDE-IPC.md b/CLAUDE-IPC.md index 1e9c9334b6..aa12a746e3 100644 --- a/CLAUDE-IPC.md +++ b/CLAUDE-IPC.md @@ -43,6 +43,7 @@ The `window.maestro` API exposes the following namespaces: - `history` - Per-agent execution history (see History API below) - `cli` - CLI activity detection for playbook runs - `tempfile` - Temporary file management for batch processing +- `cue` - Maestro Cue event-driven automation (see Cue API below) ## Analytics & Visualization @@ -74,6 +75,40 @@ window.maestro.history = { **AI Context Integration**: Use `getFilePath(sessionId)` to get the path to an agent's history file. This file can be passed directly to AI agents as context, giving them visibility into past completed tasks, decisions, and work patterns. +## Cue API + +Maestro Cue event-driven automation engine. Gated behind the `maestroCue` Encore Feature flag. + +```typescript +window.maestro.cue = { + // Query engine state + getStatus: () => Promise, + getActiveRuns: () => Promise, + getActivityLog: (limit?) => Promise, + + // Engine controls + enable: () => Promise, + disable: () => Promise, + + // Run management + stopRun: (runId) => Promise, + stopAll: () => Promise, + + // Session config management + refreshSession: (sessionId, projectRoot) => Promise, + + // YAML config file operations + readYaml: (projectRoot) => Promise, + writeYaml: (projectRoot, content) => Promise, + validateYaml: (content) => Promise<{ valid: boolean; errors: string[] }>, + + // Real-time updates + onActivityUpdate: (callback) => () => void, // Returns unsubscribe function +}; +``` + +**Events:** `cue:activityUpdate` is pushed from main process on subscription triggers, run completions, config reloads, and config removals. + ## Power Management - `power` - Sleep prevention: setEnabled, isEnabled, getStatus, addReason, removeReason diff --git a/CLAUDE-PATTERNS.md b/CLAUDE-PATTERNS.md index 2b4d39977e..ec3e2494c8 100644 --- a/CLAUDE-PATTERNS.md +++ b/CLAUDE-PATTERNS.md @@ -348,9 +348,9 @@ When adding a new Encore Feature, gate **all** access points: 6. **Hamburger menu** — Make the setter optional, conditionally render the menu item in `SessionList.tsx` 7. **Command palette** — Pass `undefined` for the handler in `QuickActionsModal.tsx` (already conditionally renders based on handler existence) -### Reference Implementation: Director's Notes +### Reference Implementations -Director's Notes is the first Encore Feature and serves as the canonical example: +**Director's Notes** — First Encore Feature, canonical example: - **Flag:** `encoreFeatures.directorNotes` in `EncoreFeatureFlags` - **App.tsx gating:** Modal render wrapped in `{encoreFeatures.directorNotes && directorNotesOpen && (…)}`, callback passed as `encoreFeatures.directorNotes ? () => setDirectorNotesOpen(true) : undefined` @@ -358,6 +358,15 @@ Director's Notes is the first Encore Feature and serves as the canonical example - **Hamburger menu:** `setDirectorNotesOpen` made optional in `SessionList.tsx`, button conditionally rendered with `{setDirectorNotesOpen && (…)}` - **Command palette:** `onOpenDirectorNotes` already conditionally renders in `QuickActionsModal.tsx` — passing `undefined` from App.tsx is sufficient +**Maestro Cue** — Event-driven automation, second Encore Feature: + +- **Flag:** `encoreFeatures.maestroCue` in `EncoreFeatureFlags` +- **App.tsx gating:** Cue modal, hooks (`useCue`, `useCueAutoDiscovery`), and engine lifecycle gated on `encoreFeatures.maestroCue` +- **Keyboard shortcut:** `ctx.encoreFeatures?.maestroCue` guard in `useMainKeyboardHandler.ts` +- **Hamburger menu:** `setMaestroCueOpen` made optional in `SessionList.tsx` +- **Command palette:** `onOpenMaestroCue` conditionally renders in `QuickActionsModal.tsx` +- **Session list:** Cue status indicator (Zap icon) gated on `maestroCueEnabled` + When adding a new Encore Feature, mirror this pattern across all access points. See [CONTRIBUTING.md → Encore Features](CONTRIBUTING.md#encore-features-feature-gating) for the full contributor guide. diff --git a/CLAUDE-WIZARD.md b/CLAUDE-WIZARD.md index ec1b1c9c2c..aedb5b44b3 100644 --- a/CLAUDE-WIZARD.md +++ b/CLAUDE-WIZARD.md @@ -38,7 +38,7 @@ src/renderer/components/Wizard/ 3. **Conversation** → AI asks clarifying questions, builds confidence score (0-100) 4. **Phase Review** → View/edit generated Phase 1 document, choose to start tour -When confidence reaches 80+ and agent signals "ready", user proceeds to Phase Review where Auto Run documents are generated and saved to `Auto Run Docs/Initiation/`. The `Initiation/` subfolder keeps wizard-generated documents separate from user-created playbooks. +When confidence reaches 80+ and agent signals "ready", user proceeds to Phase Review where Auto Run documents are generated and saved to `.maestro/playbooks/initiation/`. The `initiation/` subfolder keeps wizard-generated documents separate from user-created playbooks. ### Triggering the Wizard @@ -179,7 +179,7 @@ The Inline Wizard creates Auto Run Playbook documents from within an existing ag - Multiple wizards can run in different tabs simultaneously - Wizard state is **per-tab** (`AITab.wizardState`), not per-agent -- Documents written to unique subfolder under Auto Run folder (e.g., `Auto Run Docs/Project-Name/`) +- Documents written to unique subfolder under playbooks folder (e.g., `.maestro/playbooks/project-name/`) - On completion, tab renamed to "Project: {SubfolderName}" - Final AI message summarizes generated docs and next steps - Same `agentSessionId` preserved for context continuity diff --git a/CLAUDE.md b/CLAUDE.md index 0c9c611271..94a6e1220f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,11 @@ Use "agent" in user-facing language. Reserve "session" for provider-level conver - **Command Terminal** - Main window in terminal/shell mode - **System Log Viewer** - Special view for system logs (`LogViewer.tsx`) +### Automation + +- **Cue** — Event-driven automation system (Maestro Cue), gated as an Encore Feature. Watches for file changes, time intervals, agent completions, GitHub PRs/issues, and pending markdown tasks to trigger automated prompts. Configured via `.maestro/cue.yaml` per project. +- **Cue Modal** — Dashboard for managing Cue subscriptions and viewing activity (`CueModal.tsx`) + ### Agent States (color-coded) - **Green** - Ready/idle @@ -131,9 +136,10 @@ src/ │ ├── preload.ts # Secure IPC bridge │ ├── process-manager.ts # Process spawning (PTY + child_process) │ ├── agent-*.ts # Agent detection, capabilities, session storage +│ ├── cue/ # Maestro Cue event-driven automation engine │ ├── parsers/ # Per-agent output parsers + error patterns │ ├── storage/ # Per-agent session storage implementations -│ ├── ipc/handlers/ # IPC handler modules (stats, git, playbooks, etc.) +│ ├── ipc/handlers/ # IPC handler modules (stats, git, playbooks, cue, etc.) │ └── utils/ # Utilities (execFile, ssh-spawn-wrapper, etc.) │ ├── renderer/ # React frontend (desktop) @@ -202,6 +208,10 @@ src/ | 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/` | +| Add Cue event type | `src/main/cue/cue-types.ts`, `src/main/cue/cue-engine.ts` | +| Add Cue template variable | `src/shared/templateVariables.ts`, `src/main/cue/cue-executor.ts` | +| Modify Cue modal | `src/renderer/components/CueModal.tsx` | +| Configure Cue engine | `src/main/cue/cue-engine.ts`, `src/main/ipc/handlers/cue.ts` | --- diff --git a/docs/assets/theme-hint.js b/docs/assets/theme-hint.js new file mode 100644 index 0000000000..c2bbff3c4e --- /dev/null +++ b/docs/assets/theme-hint.js @@ -0,0 +1,31 @@ +/* global window, document, localStorage, URLSearchParams */ +/** + * Theme Hint Script for Maestro Docs + * + * When the Maestro app opens a docs URL with a ?theme= query parameter, + * this script sets the Mintlify theme to match. + * + * Supported values: ?theme=dark | ?theme=light + * + * Mintlify stores the user's theme preference in localStorage under the + * key "mintlify-color-scheme". Setting this key and dispatching a storage + * event causes Mintlify to switch themes without a page reload. + */ +(function () { + var params = new URLSearchParams(window.location.search); + var theme = params.get('theme'); + + if (theme === 'dark' || theme === 'light') { + // Mintlify reads this localStorage key for theme preference + try { + localStorage.setItem('mintlify-color-scheme', theme); + } catch { + // localStorage unavailable — ignore + } + + // Apply the class immediately to prevent flash of wrong theme + document.documentElement.classList.remove('light', 'dark'); + document.documentElement.classList.add(theme); + document.documentElement.style.colorScheme = theme; + } +})(); diff --git a/docs/autorun-playbooks.md b/docs/autorun-playbooks.md index 287b5939f6..623fe7a6de 100644 --- a/docs/autorun-playbooks.md +++ b/docs/autorun-playbooks.md @@ -42,7 +42,7 @@ Auto Run supports running multiple documents in sequence: 2. Click **+ Add Docs** to add more documents to the queue 3. Drag to reorder documents as needed 4. Configure options per document: - - **Reset on Completion** - Creates a working copy in `Runs/` subfolder instead of modifying the original. The original document is never touched, and working copies (e.g., `TASK-1735192800000-loop-1.md`) serve as audit logs. + - **Reset on Completion** - Creates a working copy in `runs/` subfolder instead of modifying the original. The original document is never touched, and working copies (e.g., `TASK-1735192800000-loop-1.md`) serve as audit logs. - **Duplicate** - Add the same document multiple times 5. Enable **Loop Mode** to cycle back to the first document after completing the last 6. Click **Go** to start running documents diff --git a/docs/deep-links.md b/docs/deep-links.md new file mode 100644 index 0000000000..a0e618dd71 --- /dev/null +++ b/docs/deep-links.md @@ -0,0 +1,96 @@ +--- +title: Deep Links +description: Navigate to specific agents, tabs, and groups using maestro:// URLs from external apps, scripts, and OS notifications. +icon: link +--- + +# Deep Links + +Maestro registers the `maestro://` URL protocol, enabling navigation to specific agents, tabs, and groups from external tools, scripts, shell commands, and OS notification clicks. + +## URL Format + +``` +maestro://[action]/[parameters] +``` + +### Available Actions + +| URL | Action | +| ------------------------------------------- | ------------------------------------------ | +| `maestro://focus` | Bring Maestro window to foreground | +| `maestro://session/{sessionId}` | Navigate to an agent | +| `maestro://session/{sessionId}/tab/{tabId}` | Navigate to a specific tab within an agent | +| `maestro://group/{groupId}` | Expand a group and focus its first agent | + +IDs containing special characters (`/`, `?`, `#`, `%`, etc.) are automatically URI-encoded and decoded. + +## Usage + +### From Terminal + +```bash +# macOS +open "maestro://session/abc123" +open "maestro://session/abc123/tab/def456" +open "maestro://group/my-group-id" +open "maestro://focus" + +# Linux +xdg-open "maestro://session/abc123" + +# Windows +start maestro://session/abc123 +``` + +### OS Notification Clicks + +When Maestro is running in the background and an agent completes a task, the OS notification is automatically linked to the originating agent and tab. Clicking the notification brings Maestro to the foreground and navigates directly to that agent's tab. + +This works out of the box — no configuration needed. Ensure **OS Notifications** are enabled in Settings. + +### Template Variables + +Deep link URLs are available as template variables in system prompts, custom AI commands, and Auto Run documents: + +| Variable | Description | Example Value | +| --------------------- | ---------------------------------------------- | ------------------------------------- | +| `{{AGENT_DEEP_LINK}}` | Link to the current agent | `maestro://session/abc123` | +| `{{TAB_DEEP_LINK}}` | Link to the current agent + active tab | `maestro://session/abc123/tab/def456` | +| `{{GROUP_DEEP_LINK}}` | Link to the agent's group (empty if ungrouped) | `maestro://group/grp789` | + +These variables can be used in: + +- **System prompts** — give AI agents awareness of their own deep link for cross-referencing +- **Custom AI commands** — include deep links in generated output +- **Auto Run documents** — reference agents in batch automation workflows +- **Custom notification commands** — include deep links in TTS or logging scripts + +### From Scripts and External Tools + +Any application can launch Maestro deep links by opening the URL. This enables integrations like: + +- CI/CD pipelines that open a specific agent after deployment +- Shell scripts that navigate to a group after batch operations +- Alfred/Raycast workflows for quick agent access +- Bookmarks for frequently-used agents + +## Platform Behavior + +| Platform | Mechanism | +| ----------------- | ----------------------------------------------------------------------------- | +| **macOS** | `app.on('open-url')` delivers the URL to the running instance | +| **Windows/Linux** | `app.on('second-instance')` delivers the URL via argv to the primary instance | +| **Cold start** | URL is buffered and processed after the window is ready | + +Maestro uses a single-instance lock — opening a deep link when Maestro is already running delivers the URL to the existing instance rather than launching a new one. + + +In development mode, protocol registration is skipped by default to avoid overriding the production app's handler. Set `REGISTER_DEEP_LINKS_IN_DEV=1` to enable it during development. + + +## Related + +- [Configuration](./configuration) — OS notification settings +- [General Usage](./general-usage) — Core UI and workflow patterns +- [MCP Server](./mcp-server) — Connect AI applications to Maestro diff --git a/docs/docs.json b/docs/docs.json index 069f0c48b7..4222d881a2 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -9,6 +9,7 @@ "href": "https://runmaestro.ai" }, "favicon": "/assets/icon.ico", + "js": "/assets/theme-hint.js", "colors": { "primary": "#BD93F9", "light": "#F8F8F2", @@ -52,11 +53,9 @@ "history", "context-management", "document-graph", - "usage-dashboard", - "symphony", "git-worktrees", "group-chat", - "remote-access", + "remote-control", "ssh-remote-execution", "configuration" ] @@ -74,7 +73,17 @@ { "group": "Encore Features", "icon": "flask", - "pages": ["encore-features", "director-notes"] + "pages": [ + "encore-features", + "director-notes", + "usage-dashboard", + "symphony", + "maestro-cue", + "maestro-cue-configuration", + "maestro-cue-events", + "maestro-cue-advanced", + "maestro-cue-examples" + ] }, { "group": "Providers & CLI", @@ -82,7 +91,7 @@ }, { "group": "Integrations", - "pages": ["mcp-server"], + "pages": ["mcp-server", "deep-links"], "icon": "plug" }, { diff --git a/docs/encore-features.md b/docs/encore-features.md index 9b4928de7f..e18fc0eecc 100644 --- a/docs/encore-features.md +++ b/docs/encore-features.md @@ -16,11 +16,12 @@ Open **Settings** (`Cmd+,` / `Ctrl+,`) and navigate to the **Encore Features** t ## Available Features -| Feature | Shortcut | Description | -| ------------------------------------ | ------------------------------ | --------------------------------------------------------------- | -| [Director's Notes](./director-notes) | `Cmd+Shift+O` / `Ctrl+Shift+O` | Unified timeline of all agent activity with AI-powered synopses | - -More features will be added here as they ship. +| Feature | Shortcut | Description | +| ------------------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------ | +| [Director's Notes](./director-notes) | `Cmd+Shift+O` / `Ctrl+Shift+O` | Unified timeline of all agent activity with AI-powered synopses | +| [Usage Dashboard](./usage-dashboard) | `Opt+Cmd+U` / `Alt+Ctrl+U` | Comprehensive analytics for tracking AI usage patterns | +| [Maestro Symphony](./symphony) | `Cmd+Shift+Y` / `Ctrl+Shift+Y` | Contribute to open source by donating AI tokens | +| [Maestro Cue](./maestro-cue) | `Cmd+Shift+Q` / `Ctrl+Shift+Q` | Event-driven automation: file changes, timers, agent chaining, GitHub polling, and task tracking | ## For Developers diff --git a/docs/features.md b/docs/features.md index 8c556e17f9..c668b598c0 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ --- title: Features -description: Explore Maestro's power features including Git Worktrees, Auto Run, Group Chat, and Remote Access. +description: Explore Maestro's power features including Git Worktrees, Auto Run, Group Chat, and Remote Control. icon: sparkles --- @@ -9,9 +9,9 @@ icon: sparkles - 🌳 **[Git Worktrees](./git-worktrees)** - Run AI agents in parallel on isolated branches. Create worktree sub-agents from the git branch menu, each operating in their own directory. Work interactively in the main repo while sub-agents process tasks independently — then create PRs with one click. True parallel development without conflicts. - 🤖 **[Auto Run & Playbooks](./autorun-playbooks)** - File-system-based task runner that processes markdown checklists through AI agents. Create Playbooks (collections of Auto Run documents) for repeatable workflows, run in loops, and track progress with full history. Each task gets its own AI session for clean conversation context. - 🏪 **[Playbook Exchange](./playbook-exchange)** - Browse and import community-contributed playbooks directly into your Auto Run folder. Categories, search, and one-click import get you started with proven workflows for security audits, code reviews, documentation, and more. -- 🎵 **[Maestro Symphony](./symphony)** - Contribute to open source by donating AI tokens. Browse registered projects, select GitHub issues, and let Maestro clone, process Auto Run docs, and create PRs automatically. Distributed computing for AI-assisted development. +- 🎵 **[Maestro Symphony](./symphony)** - Contribute to open source by donating AI tokens. Browse registered projects, select GitHub issues, and let Maestro clone, process Auto Run docs, and create PRs automatically. Distributed computing for AI-assisted development. _(Encore Feature — enable in Settings > Encore Features)_ - 💬 **[Group Chat](./group-chat)** - Coordinate multiple AI agents in a single conversation. A moderator AI orchestrates discussions, routing questions to the right agents and synthesizing their responses for cross-project questions and architecture discussions. -- 🌐 **[Remote Access](./remote-access)** - Built-in web server with QR code access. Monitor and control all your agents from your phone. Supports local network access and remote tunneling via Cloudflare for access from anywhere. +- 🌐 **[Remote Control](./remote-control)** - Built-in web server with QR code access. Monitor and control all your agents from your phone. Supports local network access and remote tunneling via Cloudflare for access from anywhere. - 🔗 **[SSH Remote Execution](./ssh-remote-execution)** - Run AI agents on remote hosts via SSH. Leverage powerful cloud VMs, access tools not installed locally, or work with projects requiring specific environments — all while controlling everything from your local Maestro instance. - 💻 **[Command Line Interface](./cli)** - Full CLI (`maestro-cli`) for headless operation. List agents/groups, run playbooks from cron jobs or CI/CD pipelines, with human-readable or JSONL output for scripting. - 🚀 **Multi-Agent Management** - Run unlimited agents in parallel. Each agent has its own workspace, conversation history, and isolated context. @@ -34,7 +34,7 @@ icon: sparkles - 🎨 **[Beautiful Themes](https://github.com/RunMaestro/Maestro/blob/main/THEMES.md)** - 17 built-in themes across dark (Dracula, Monokai, Nord, Tokyo Night, Catppuccin Mocha, Gruvbox Dark), light (GitHub, Solarized, One Light, Gruvbox Light, Catppuccin Latte, Ayu Light), and vibe (Pedurple, Maestro's Choice, Dre Synth, InQuest) categories, plus a fully customizable theme builder. - ⏱️ **[WakaTime Integration](./configuration#wakatime-integration)** - Automatic time tracking via WakaTime with optional per-file write activity tracking across all supported agents. - 💰 **Cost Tracking** - Real-time token usage and cost tracking per session and globally. -- 📊 **[Usage Dashboard](./usage-dashboard)** - Comprehensive analytics for tracking AI usage patterns. View aggregated statistics, compare agent performance, analyze activity heatmaps, and export data to CSV. Access via `Opt+Cmd+U` / `Alt+Ctrl+U`. +- 📊 **[Usage Dashboard](./usage-dashboard)** - Comprehensive analytics for tracking AI usage patterns. View aggregated statistics, compare agent performance, analyze activity heatmaps, and export data to CSV. Access via `Opt+Cmd+U` / `Alt+Ctrl+U`. _(Encore Feature — enable in Settings > Encore Features)_ - 🎬 **[Director's Notes](./director-notes)** - Bird's-eye view of all agent activity in a unified timeline. Aggregate history from every agent, search and filter entries, and generate AI-powered synopses of recent work. Access via `Cmd+Shift+O` / `Ctrl+Shift+O`. _(Encore Feature — enable in Settings > Encore Features)_ - 🏆 **[Achievements](./achievements)** - Level up from Apprentice to Titan of the Baton based on cumulative Auto Run time. 11 conductor-themed ranks to unlock. diff --git a/docs/getting-started.md b/docs/getting-started.md index 9e4ac14a01..cb3f4f5c0f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -31,7 +31,7 @@ Press `Cmd+Shift+N` / `Ctrl+Shift+N` to launch the **Onboarding Wizard**, which ![Wizard Document Generation](./screenshots/wizard-doc-generation.png) -The Wizard creates a fully configured agent with an Auto Run document folder ready to go. Generated documents are saved to an `Initiation/` subfolder within `Auto Run Docs/` to keep them organized separately from documents you create later. +The Wizard creates a fully configured agent with an Auto Run document folder ready to go. Generated documents are saved to an `Initiation/` subfolder within `.maestro/playbooks/` to keep them organized separately from documents you create later. ### Introductory Tour diff --git a/docs/maestro-cue-advanced.md b/docs/maestro-cue-advanced.md new file mode 100644 index 0000000000..5be71bff1d --- /dev/null +++ b/docs/maestro-cue-advanced.md @@ -0,0 +1,372 @@ +--- +title: Cue Advanced Patterns +description: Fan-in/fan-out, payload filtering, agent chaining, template variables, and concurrency control. +icon: diagram-project +--- + +Cue supports sophisticated automation patterns beyond simple trigger-prompt pairings. This guide covers the advanced features that enable complex multi-agent workflows. + +## Fan-Out + +Fan-out sends a single trigger's prompt to multiple target agents simultaneously. Use this when one event should kick off parallel work across several agents. + +**How it works:** Add a `fan_out` field with a list of agent names. When the trigger fires, Cue spawns a run against each target agent. + +```yaml +subscriptions: + - name: parallel-deploy + event: agent.completed + source_session: 'build-agent' + fan_out: + - 'deploy-staging' + - 'deploy-production' + - 'deploy-docs' + prompt: | + Build completed. Deploy the latest artifacts. + Source output: {{CUE_SOURCE_OUTPUT}} +``` + +In this example, when `build-agent` finishes, Cue sends the same prompt to three different agents in parallel. + +**Notes:** + +- Each fan-out target runs independently — failures in one don't affect others +- All targets receive the same prompt with the same template variable values +- Fan-out targets must be agent names visible in the Left Bar +- Fan-out respects `max_concurrent` — if slots are full, excess runs are queued + +## Fan-In + +Fan-in waits for **multiple** source agents to complete before firing a single trigger. Use this to coordinate work that depends on several agents finishing first. + +**How it works:** Set `source_session` to a list of agent names. Cue waits for all of them to complete before firing the subscription. + +```yaml +subscriptions: + - name: integration-tests + event: agent.completed + source_session: + - 'backend-build' + - 'frontend-build' + - 'api-tests' + prompt: | + All prerequisite agents have completed. + Run the full integration test suite with `npm run test:integration`. + +settings: + timeout_minutes: 60 # Wait up to 60 minutes for all sources + timeout_on_fail: continue # Fire anyway if timeout is reached +``` + +**Behavior:** + +- Cue tracks completions from each source agent independently +- The subscription fires only when **all** listed sources have completed +- If `timeout_on_fail` is `'continue'`, the subscription fires with partial data after the timeout +- If `timeout_on_fail` is `'break'` (default), the subscription is marked as timed out and does not fire +- Completion tracking resets after the subscription fires + +## Filtering + +Filters let you conditionally trigger subscriptions based on event payload data. All filter conditions are AND'd — every condition must pass for the subscription to fire. + +### Filter Syntax + +Filters are key-value pairs where the key is a payload field name and the value is an expression: + +```yaml +filter: + field_name: expression +``` + +### Expression Types + +| Expression | Meaning | Example | +| -------------- | --------------------- | ---------------------- | +| `"value"` | Exact string match | `extension: ".ts"` | +| `123` | Exact numeric match | `exitCode: 0` | +| `true`/`false` | Exact boolean match | `draft: false` | +| `"!value"` | Negation (not equal) | `status: "!failed"` | +| `">=N"` | Greater than or equal | `taskCount: ">=3"` | +| `">N"` | Greater than | `durationMs: ">60000"` | +| `"<=N"` | Less than or equal | `exitCode: "<=1"` | +| `"=3' + prompt: | + {{CUE_TASK_COUNT}} tasks are pending. Work through them in priority order. +``` + +**Skip files in test directories:** + +```yaml +- name: lint-src-only + event: file.changed + watch: '**/*.ts' + filter: + path: '!**/test/**' + prompt: Lint {{CUE_FILE_PATH}}. +``` + +## Agent Chaining + +Agent chaining connects multiple agents in a pipeline where each agent's completion triggers the next. This is built on `agent.completed` events with optional filtering. + +### Simple Chain + +```yaml +subscriptions: + # Step 1: Lint + - name: lint + event: file.changed + watch: 'src/**/*.ts' + prompt: Run the linter on {{CUE_FILE_PATH}}. + + # Step 2: Test (after lint passes) + - name: test-after-lint + event: agent.completed + source_session: 'lint-agent' + filter: + exitCode: 0 + prompt: Lint passed. Run the related test suite. + + # Step 3: Build (after tests pass) + - name: build-after-test + event: agent.completed + source_session: 'test-agent' + filter: + exitCode: 0 + prompt: Tests passed. Build the project with `npm run build`. +``` + +### Diamond Pattern + +Combine fan-out and fan-in for complex workflows: + +``` + ┌─── backend-build ───┐ +trigger ──┤ ├── integration-tests + └─── frontend-build ──┘ +``` + +```yaml +subscriptions: + # Fan-out: trigger both builds + - name: parallel-builds + event: file.changed + watch: 'src/**/*' + fan_out: + - 'backend-agent' + - 'frontend-agent' + prompt: Source changed. Rebuild your component. + + # Fan-in: wait for both, then test + - name: integration-tests + event: agent.completed + source_session: + - 'backend-agent' + - 'frontend-agent' + prompt: Both builds complete. Run integration tests. +``` + +## Template Variables + +All prompts support `{{VARIABLE}}` syntax. Variables are replaced with event payload data before the prompt is sent to the agent. + +### Common Variables (All Events) + +| Variable | Description | +| ------------------------- | ------------------------------ | +| `{{CUE_EVENT_TYPE}}` | Event type that triggered this | +| `{{CUE_EVENT_TIMESTAMP}}` | ISO 8601 timestamp | +| `{{CUE_TRIGGER_NAME}}` | Subscription name | +| `{{CUE_RUN_ID}}` | Unique run UUID | + +### File Variables (`file.changed`, `task.pending`) + +| Variable | Description | +| -------------------------- | -------------------------------------- | +| `{{CUE_FILE_PATH}}` | Absolute file path | +| `{{CUE_FILE_NAME}}` | Filename only | +| `{{CUE_FILE_DIR}}` | Directory path | +| `{{CUE_FILE_EXT}}` | Extension (with dot) | +| `{{CUE_FILE_CHANGE_TYPE}}` | Change type: `add`, `change`, `unlink` | + +### Task Variables (`task.pending`) + +| Variable | Description | +| ------------------------ | --------------------------------------- | +| `{{CUE_TASK_FILE}}` | File path with pending tasks | +| `{{CUE_TASK_FILE_NAME}}` | Filename only | +| `{{CUE_TASK_FILE_DIR}}` | Directory path | +| `{{CUE_TASK_COUNT}}` | Number of pending tasks | +| `{{CUE_TASK_LIST}}` | Formatted list (line number: task text) | +| `{{CUE_TASK_CONTENT}}` | Full file content (truncated to 10K) | + +### Agent Variables (`agent.completed`) + +| Variable | Description | +| ----------------------------- | --------------------------------------------- | +| `{{CUE_SOURCE_SESSION}}` | Source agent name(s) | +| `{{CUE_SOURCE_OUTPUT}}` | Source agent output (truncated to 5K) | +| `{{CUE_SOURCE_STATUS}}` | Run status (`completed`, `failed`, `timeout`) | +| `{{CUE_SOURCE_EXIT_CODE}}` | Process exit code | +| `{{CUE_SOURCE_DURATION}}` | Run duration in milliseconds | +| `{{CUE_SOURCE_TRIGGERED_BY}}` | Subscription that triggered the source run | + +### GitHub Variables (`github.pull_request`, `github.issue`) + +| Variable | Description | PR | Issue | +| ------------------------ | --------------------------- | --- | ----- | +| `{{CUE_GH_TYPE}}` | `pull_request` or `issue` | Y | Y | +| `{{CUE_GH_NUMBER}}` | PR/issue number | Y | Y | +| `{{CUE_GH_TITLE}}` | Title | Y | Y | +| `{{CUE_GH_AUTHOR}}` | Author login | Y | Y | +| `{{CUE_GH_URL}}` | HTML URL | Y | Y | +| `{{CUE_GH_BODY}}` | Body text (truncated) | Y | Y | +| `{{CUE_GH_LABELS}}` | Labels (comma-separated) | Y | Y | +| `{{CUE_GH_STATE}}` | State (`open` / `closed`) | Y | Y | +| `{{CUE_GH_REPO}}` | Repository (`owner/repo`) | Y | Y | +| `{{CUE_GH_BRANCH}}` | Head branch | Y | | +| `{{CUE_GH_BASE_BRANCH}}` | Base branch | Y | | +| `{{CUE_GH_ASSIGNEES}}` | Assignees (comma-separated) | | Y | + +### Standard Variables + +Cue prompts also have access to all standard Maestro template variables (like `{{PROJECT_ROOT}}`, `{{TIMESTAMP}}`, etc.) — the same variables available in Auto Run playbooks and system prompts. + +## Concurrency Control + +Control how many Cue-triggered runs can execute simultaneously and how overflow events are handled. + +### max_concurrent + +Limits parallel runs per agent. When all slots are occupied, new events are queued. + +```yaml +settings: + max_concurrent: 3 # Up to 3 runs at once +``` + +**Range:** 1–10. **Default:** 1 (serial execution). + +With `max_concurrent: 1` (default), events are processed one at a time in order. This is the safest setting — it prevents agents from receiving overlapping prompts. + +Increase `max_concurrent` when your subscriptions are independent and don't conflict with each other (e.g., reviewing different PRs, scanning different files). + +### queue_size + +Controls how many events can wait when all concurrent slots are full. + +```yaml +settings: + queue_size: 20 # Buffer up to 20 events +``` + +**Range:** 0–50. **Default:** 10. + +- Events beyond the queue limit are **dropped** (silently discarded) +- Set to `0` to disable queuing — events that can't run immediately are discarded +- The current queue depth is visible in the Cue Modal's sessions table + +### Timeout + +Prevents runaway agents from blocking the pipeline. + +```yaml +settings: + timeout_minutes: 45 # Kill runs after 45 minutes + timeout_on_fail: continue # Let downstream subscriptions proceed anyway +``` + +**`timeout_on_fail` options:** + +- `break` (default) — Timed-out runs are marked as failed. Downstream `agent.completed` subscriptions see the failure. +- `continue` — Timed-out runs are stopped, but downstream subscriptions still fire with whatever data is available. Useful for fan-in patterns where you'd rather proceed with partial results than block the entire pipeline. + +## Sleep/Wake Reconciliation + +Cue handles system sleep gracefully: + +- **`time.interval`** subscriptions reconcile missed intervals on wake. If your machine sleeps through three intervals, Cue fires one catch-up event (not three). +- **File watchers** (`file.changed`, `task.pending`) resume monitoring on wake. Changes that occurred during sleep may trigger events depending on the OS file system notification behavior. +- **GitHub pollers** resume polling on wake. Any PRs/issues created during sleep are detected on the next poll. + +The engine uses a heartbeat mechanism to detect sleep periods. This is transparent — no configuration needed. + +## Persistence + +Cue persists its state in a local SQLite database: + +- **Event journal** — Records all events (completed, failed, timed out) for the Activity Log +- **GitHub seen tracking** — Remembers which PRs/issues have already triggered events (30-day retention) +- **Heartbeat** — Tracks engine uptime for sleep/wake detection + +Events older than 7 days are automatically pruned to keep the database lean. diff --git a/docs/maestro-cue-configuration.md b/docs/maestro-cue-configuration.md new file mode 100644 index 0000000000..808c9877f6 --- /dev/null +++ b/docs/maestro-cue-configuration.md @@ -0,0 +1,244 @@ +--- +title: Cue Configuration Reference +description: Complete YAML schema reference for .maestro/cue.yaml configuration files. +icon: file-code +--- + +Cue is configured via a `.maestro/cue.yaml` file placed inside the `.maestro/` directory at your project root. The engine watches this file for changes and hot-reloads automatically. + +## File Location + +``` +your-project/ +├── .maestro/ +│ └── cue.yaml # Cue configuration +├── src/ +├── package.json +└── ... +``` + +Maestro discovers this file automatically when the Cue Encore Feature is enabled. Each agent that has a `.maestro/cue.yaml` in its project root gets its own independent Cue engine instance. + +## Full Schema + +```yaml +# Subscriptions define trigger-prompt pairings +subscriptions: + - name: string # Required. Unique identifier for this subscription + event: string # Required. Event type (see Event Types) + enabled: boolean # Optional. Default: true + prompt: string # Required. Prompt text or path to a .md file + + # Event-specific fields + interval_minutes: number # Required for time.interval + watch: string # Required for file.changed, task.pending (glob pattern) + source_session: string | list # Required for agent.completed + fan_out: list # Optional. Target session names for fan-out + filter: object # Optional. Payload field conditions + repo: string # Optional for github.* (auto-detected if omitted) + poll_minutes: number # Optional for github.*, task.pending + +# Global settings (all optional — sensible defaults applied) +settings: + timeout_minutes: number # Default: 30. Max run duration before timeout + timeout_on_fail: string # Default: 'break'. What to do on timeout: 'break' or 'continue' + max_concurrent: number # Default: 1. Simultaneous runs (1-10) + queue_size: number # Default: 10. Max queued events (0-50) +``` + +## Subscriptions + +Each subscription is a trigger-prompt pairing. When the trigger fires, Cue sends the prompt to the agent. + +### Required Fields + +| Field | Type | Description | +| -------- | ------ | ---------------------------------------------------------------------- | +| `name` | string | Unique identifier. Used in logs, history, and as a reference in chains | +| `event` | string | One of the six [event types](./maestro-cue-events) | +| `prompt` | string | The prompt to send, either inline text or a path to a `.md` file | + +### Optional Fields + +| Field | Type | Default | Description | +| ------------------ | --------------- | ------- | ----------------------------------------------------------------------- | +| `enabled` | boolean | `true` | Set to `false` to pause a subscription without removing it | +| `interval_minutes` | number | — | Timer interval. Required for `time.interval` | +| `watch` | string (glob) | — | File glob pattern. Required for `file.changed`, `task.pending` | +| `source_session` | string or list | — | Source agent name(s). Required for `agent.completed` | +| `fan_out` | list of strings | — | Target agent names to fan out to | +| `filter` | object | — | Payload conditions (see [Filtering](./maestro-cue-advanced#filtering)) | +| `repo` | string | — | GitHub repo (`owner/repo`). Auto-detected from git remote | +| `poll_minutes` | number | varies | Poll interval for `github.*` (default 5) and `task.pending` (default 1) | + +### Prompt Field + +The `prompt` field accepts either inline text or a file path: + +**Inline prompt:** + +```yaml +prompt: | + Please lint the file {{CUE_FILE_PATH}} and fix any errors. +``` + +**File reference:** + +```yaml +prompt: prompts/lint-check.md +``` + +File paths are resolved relative to the project root. Prompt files support the same `{{VARIABLE}}` template syntax as inline prompts. + +### Disabling Subscriptions + +Set `enabled: false` to pause a subscription without deleting it: + +```yaml +subscriptions: + - name: nightly-report + event: time.interval + interval_minutes: 1440 + enabled: false # Paused — won't fire until re-enabled + prompt: Generate a daily summary report. +``` + +## Settings + +The optional `settings` block configures global engine behavior. All fields have sensible defaults — you only need to include settings you want to override. + +### timeout_minutes + +**Default:** `30` | **Type:** positive number + +Maximum duration (in minutes) for a single Cue-triggered run. If an agent takes longer than this, the run is terminated. + +```yaml +settings: + timeout_minutes: 60 # Allow up to 1 hour per run +``` + +### timeout_on_fail + +**Default:** `'break'` | **Type:** `'break'` or `'continue'` + +What happens when a run times out: + +- **`break`** — Stop the run and mark it as failed. No further processing for this event. +- **`continue`** — Stop the run but allow downstream subscriptions (in fan-in chains) to proceed with partial data. + +```yaml +settings: + timeout_on_fail: continue # Don't block the pipeline on slow agents +``` + +### max_concurrent + +**Default:** `1` | **Type:** integer, 1–10 + +Maximum number of Cue-triggered runs that can execute simultaneously for this agent. Additional events are queued. + +```yaml +settings: + max_concurrent: 3 # Allow up to 3 parallel runs +``` + +### queue_size + +**Default:** `10` | **Type:** integer, 0–50 + +Maximum number of events that can be queued when all concurrent slots are occupied. Events beyond this limit are dropped. + +Set to `0` to disable queueing — events that can't run immediately are discarded. + +```yaml +settings: + queue_size: 20 # Buffer up to 20 events +``` + +## Validation + +The engine validates your YAML on every load. Common validation errors: + +| Error | Fix | +| --------------------------------------- | ------------------------------------------------------------ | +| `"name" is required` | Every subscription needs a unique `name` field | +| `"event" is required` | Specify one of the six event types | +| `"prompt" is required` | Provide inline text or a file path | +| `"interval_minutes" is required` | `time.interval` events must specify a positive interval | +| `"watch" is required` | `file.changed` and `task.pending` events need a glob pattern | +| `"source_session" is required` | `agent.completed` events need the name of the source agent | +| `"max_concurrent" must be between 1-10` | Keep concurrent runs within the allowed range | +| `"queue_size" must be between 0-50` | Keep queue size within the allowed range | +| `filter key must be string/number/bool` | Filter values only accept primitive types | + +The inline YAML editor in the Cue Modal shows validation errors in real-time as you type. + +## Complete Example + +A realistic configuration demonstrating multiple event types working together: + +```yaml +subscriptions: + # Lint TypeScript files on save + - name: lint-on-save + event: file.changed + watch: 'src/**/*.ts' + filter: + extension: '.ts' + prompt: | + The file {{CUE_FILE_PATH}} was modified. + Run `npx eslint {{CUE_FILE_PATH}} --fix` and report any remaining issues. + + # Run tests every 30 minutes + - name: periodic-tests + event: time.interval + interval_minutes: 30 + prompt: | + Run the test suite with `npm test`. + If any tests fail, investigate and fix them. + + # Review new PRs automatically + - name: pr-review + event: github.pull_request + poll_minutes: 3 + filter: + draft: false + prompt: | + A new PR needs review: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Branch: {{CUE_GH_BRANCH}} -> {{CUE_GH_BASE_BRANCH}} + URL: {{CUE_GH_URL}} + + {{CUE_GH_BODY}} + + Please review this PR for code quality, potential bugs, and style issues. + + # Work on pending tasks from TODO.md + - name: task-worker + event: task.pending + watch: 'TODO.md' + poll_minutes: 5 + prompt: | + There are {{CUE_TASK_COUNT}} pending tasks in {{CUE_TASK_FILE}}: + + {{CUE_TASK_LIST}} + + Pick the highest priority task and complete it. + When done, check off the task in the file. + + # Chain: deploy after tests pass + - name: deploy-after-tests + event: agent.completed + source_session: 'test-runner' + filter: + status: completed + exitCode: 0 + prompt: | + Tests passed successfully. Deploy to staging with `npm run deploy:staging`. + +settings: + timeout_minutes: 45 + max_concurrent: 2 + queue_size: 15 +``` diff --git a/docs/maestro-cue-events.md b/docs/maestro-cue-events.md new file mode 100644 index 0000000000..fe0df4a155 --- /dev/null +++ b/docs/maestro-cue-events.md @@ -0,0 +1,311 @@ +--- +title: Cue Event Types +description: Detailed reference for all six Maestro Cue event types with configuration, payloads, and examples. +icon: calendar-check +--- + +Cue supports six event types. Each type watches for a different kind of activity and produces a payload that can be injected into prompts via [template variables](./maestro-cue-advanced#template-variables). + +## time.interval + +Fires on a periodic timer. The subscription triggers immediately when the engine starts, then repeats at the configured interval. + +**Required fields:** + +| Field | Type | Description | +| ------------------ | ------ | -------------------------------------- | +| `interval_minutes` | number | Minutes between triggers (must be > 0) | + +**Behavior:** + +- Fires immediately on engine start (or when the subscription is first loaded) +- Reconciles missed intervals after system sleep — if your machine sleeps through one or more intervals, Cue fires a catch-up event on wake +- The interval resets after each trigger, not after each run completes + +**Example:** + +```yaml +subscriptions: + - name: hourly-summary + event: time.interval + interval_minutes: 60 + prompt: | + Generate a summary of git activity in the last hour. + Run `git log --oneline --since="1 hour ago"` and organize by author. +``` + +**Payload fields:** None specific to this event type. Use common variables like `{{CUE_TRIGGER_NAME}}` and `{{CUE_EVENT_TIMESTAMP}}`. + +--- + +## file.changed + +Fires when files matching a glob pattern are created, modified, or deleted. + +**Required fields:** + +| Field | Type | Description | +| ------- | ------------- | --------------------------------- | +| `watch` | string (glob) | Glob pattern for files to monitor | + +**Behavior:** + +- Monitors for `add`, `change`, and `unlink` (delete) events +- Debounces by 5 seconds per file — rapid saves to the same file produce a single event +- The glob is evaluated relative to the project root +- Standard glob syntax: `*` matches within a directory, `**` matches across directories + +**Example:** + +```yaml +subscriptions: + - name: test-on-change + event: file.changed + watch: 'src/**/*.{ts,tsx}' + filter: + changeType: '!unlink' # Don't trigger on file deletions + prompt: | + The file {{CUE_FILE_PATH}} was {{CUE_EVENT_TYPE}}. + Run the tests related to this file and report results. +``` + +**Payload fields:** + +| Variable | Description | Example | +| -------------------------- | --------------------------------- | ------------------------- | +| `{{CUE_FILE_PATH}}` | Absolute path to the changed file | `/project/src/app.ts` | +| `{{CUE_FILE_NAME}}` | Filename only | `app.ts` | +| `{{CUE_FILE_DIR}}` | Directory containing the file | `/project/src` | +| `{{CUE_FILE_EXT}}` | File extension (with dot) | `.ts` | +| `{{CUE_FILE_CHANGE_TYPE}}` | Change type | `add`, `change`, `unlink` | + +The `changeType` field is also available in [filters](./maestro-cue-advanced#filtering). + +--- + +## agent.completed + +Fires when another Maestro agent finishes a task. This is the foundation for agent chaining — building multi-step pipelines where one agent's completion triggers the next. + +**Required fields:** + +| Field | Type | Description | +| ---------------- | -------------- | ----------------------------------------------- | +| `source_session` | string or list | Name(s) of the agent(s) to watch for completion | + +**Behavior:** + +- **Single source** (string): Fires immediately when the named agent completes +- **Multiple sources** (list): Waits for **all** named agents to complete before firing (fan-in). See [Fan-In](./maestro-cue-advanced#fan-in) +- The source agent's output is captured and available via `{{CUE_SOURCE_OUTPUT}}` (truncated to 5,000 characters) +- Matches agent names as shown in the Left Bar + +**Example — single source:** + +```yaml +subscriptions: + - name: deploy-after-build + event: agent.completed + source_session: 'builder' + filter: + exitCode: 0 # Only deploy if build succeeded + prompt: | + The build agent completed successfully. + Output: {{CUE_SOURCE_OUTPUT}} + + Deploy to staging with `npm run deploy:staging`. +``` + +**Example — fan-in (multiple sources):** + +```yaml +subscriptions: + - name: integration-tests + event: agent.completed + source_session: + - 'backend-build' + - 'frontend-build' + prompt: | + Both builds completed. Run the full integration test suite. +``` + +**Payload fields:** + +| Variable | Description | Example | +| ----------------------------- | ------------------------------------------------------ | ----------------- | +| `{{CUE_SOURCE_SESSION}}` | Name of the completing agent(s) | `builder` | +| `{{CUE_SOURCE_OUTPUT}}` | Truncated stdout from the source (max 5K chars) | `Build succeeded` | +| `{{CUE_SOURCE_STATUS}}` | Run status (`completed`, `failed`, `timeout`) | `completed` | +| `{{CUE_SOURCE_EXIT_CODE}}` | Process exit code | `0` | +| `{{CUE_SOURCE_DURATION}}` | Run duration in milliseconds | `15000` | +| `{{CUE_SOURCE_TRIGGERED_BY}}` | Name of the subscription that triggered the source run | `lint-on-save` | + +These fields are also available in [filters](./maestro-cue-advanced#filtering). + +The `triggeredBy` field is particularly useful when a source agent has multiple Cue subscriptions but you only want to chain from a specific one. See [Selective Chaining](./maestro-cue-examples#selective-chaining-with-triggeredby) for a complete example. + +--- + +## task.pending + +Watches markdown files for unchecked task items (`- [ ]`) and fires when pending tasks are found. + +**Required fields:** + +| Field | Type | Description | +| ------- | ------------- | --------------------------------------- | +| `watch` | string (glob) | Glob pattern for markdown files to scan | + +**Optional fields:** + +| Field | Type | Default | Description | +| -------------- | ------ | ------- | --------------------------------- | +| `poll_minutes` | number | 1 | Minutes between scans (minimum 1) | + +**Behavior:** + +- Scans files matching the glob pattern at the configured interval +- Fires when unchecked tasks (`- [ ]`) are found +- Only fires when the task list changes (new tasks appear or existing ones are modified) +- The full task list is formatted and available via `{{CUE_TASK_LIST}}` +- File content (truncated to 10K characters) is available via `{{CUE_TASK_CONTENT}}` + +**Example:** + +```yaml +subscriptions: + - name: todo-worker + event: task.pending + watch: '**/*.md' + poll_minutes: 5 + prompt: | + Found {{CUE_TASK_COUNT}} pending tasks in {{CUE_TASK_FILE}}: + + {{CUE_TASK_LIST}} + + Pick the most important task and complete it. + When finished, mark it as done by changing `- [ ]` to `- [x]`. +``` + +**Payload fields:** + +| Variable | Description | Example | +| ------------------------ | ------------------------------------------ | ---------------------- | +| `{{CUE_TASK_FILE}}` | Path to the file containing tasks | `/project/TODO.md` | +| `{{CUE_TASK_FILE_NAME}}` | Filename only | `TODO.md` | +| `{{CUE_TASK_FILE_DIR}}` | Directory containing the file | `/project` | +| `{{CUE_TASK_COUNT}}` | Number of pending tasks found | `3` | +| `{{CUE_TASK_LIST}}` | Formatted list with line numbers | `L5: Write unit tests` | +| `{{CUE_TASK_CONTENT}}` | Full file content (truncated to 10K chars) | _(file contents)_ | + +--- + +## github.pull_request + +Polls GitHub for new pull requests using the GitHub CLI (`gh`). + +**Optional fields:** + +| Field | Type | Default | Description | +| -------------- | ------ | ------- | ---------------------------------------------------------------------------- | +| `repo` | string | auto | GitHub repo in `owner/repo` format. Auto-detected from git remote if omitted | +| `poll_minutes` | number | 5 | Minutes between polls (minimum 1) | + +**Behavior:** + +- Requires the [GitHub CLI](https://cli.github.com/) (`gh`) to be installed and authenticated +- On first run, seeds the "seen" list with existing PRs — only **new** PRs trigger events +- Tracks seen PRs in a local database with 30-day retention +- Auto-detects the repository from the git remote if `repo` is not specified + +**Example:** + +```yaml +subscriptions: + - name: pr-reviewer + event: github.pull_request + poll_minutes: 3 + filter: + draft: false # Skip draft PRs + base_branch: main # Only PRs targeting main + prompt: | + New PR: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Branch: {{CUE_GH_BRANCH}} -> {{CUE_GH_BASE_BRANCH}} + Labels: {{CUE_GH_LABELS}} + URL: {{CUE_GH_URL}} + + {{CUE_GH_BODY}} + + Review this PR for: + 1. Code quality and style consistency + 2. Potential bugs or edge cases + 3. Test coverage +``` + +**Payload fields:** + +| Variable | Description | Example | +| ------------------------ | --------------------------------- | ------------------------------------- | +| `{{CUE_GH_TYPE}}` | Always `pull_request` | `pull_request` | +| `{{CUE_GH_NUMBER}}` | PR number | `42` | +| `{{CUE_GH_TITLE}}` | PR title | `Add user authentication` | +| `{{CUE_GH_AUTHOR}}` | Author's GitHub login | `octocat` | +| `{{CUE_GH_URL}}` | HTML URL to the PR | `https://github.com/org/repo/pull/42` | +| `{{CUE_GH_BODY}}` | PR description (truncated) | _(PR body text)_ | +| `{{CUE_GH_LABELS}}` | Comma-separated label names | `bug, priority-high` | +| `{{CUE_GH_STATE}}` | PR state | `open` | +| `{{CUE_GH_BRANCH}}` | Head (source) branch | `feature/auth` | +| `{{CUE_GH_BASE_BRANCH}}` | Base (target) branch | `main` | +| `{{CUE_GH_REPO}}` | Repository in `owner/repo` format | `RunMaestro/Maestro` | + +--- + +## github.issue + +Polls GitHub for new issues using the GitHub CLI (`gh`). Behaves identically to `github.pull_request` but for issues. + +**Optional fields:** + +| Field | Type | Default | Description | +| -------------- | ------ | ------- | ---------------------------------- | +| `repo` | string | auto | GitHub repo in `owner/repo` format | +| `poll_minutes` | number | 5 | Minutes between polls (minimum 1) | + +**Behavior:** + +Same as `github.pull_request` — requires GitHub CLI, seeds on first run, tracks seen issues. + +**Example:** + +```yaml +subscriptions: + - name: issue-triage + event: github.issue + poll_minutes: 5 + filter: + labels: '!wontfix' # Skip issues labeled wontfix + prompt: | + New issue: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Assignees: {{CUE_GH_ASSIGNEES}} + Labels: {{CUE_GH_LABELS}} + + {{CUE_GH_BODY}} + + Triage this issue: + 1. Identify the area of the codebase affected + 2. Estimate complexity (small/medium/large) + 3. Suggest which team member should handle it +``` + +**Payload fields:** + +Same as `github.pull_request`, except: + +| Variable | Description | Example | +| ---------------------- | ------------------------------- | ------------ | +| `{{CUE_GH_TYPE}}` | Always `issue` | `issue` | +| `{{CUE_GH_ASSIGNEES}}` | Comma-separated assignee logins | `alice, bob` | + +The branch-specific variables (`{{CUE_GH_BRANCH}}`, `{{CUE_GH_BASE_BRANCH}}`) are not available for issues. diff --git a/docs/maestro-cue-examples.md b/docs/maestro-cue-examples.md new file mode 100644 index 0000000000..c593317723 --- /dev/null +++ b/docs/maestro-cue-examples.md @@ -0,0 +1,407 @@ +--- +title: Cue Examples +description: Real-world Maestro Cue configurations for common automation workflows. +icon: lightbulb +--- + +Complete, copy-paste-ready `.maestro/cue.yaml` configurations for common workflows. Each example is self-contained — drop it into your project's `.maestro/` directory and adjust agent names to match your Left Bar. + +## CI-Style Pipeline + +Lint, test, and deploy in sequence. Each step only runs if the previous one succeeded. + +**Agents needed:** `linter`, `tester`, `deployer` + +The `linter` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: lint-on-save + event: file.changed + watch: 'src/**/*.{ts,tsx}' + prompt: | + Run `npx eslint {{CUE_FILE_PATH}} --fix`. + Report any errors that couldn't be auto-fixed. +``` + +The `tester` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: test-after-lint + event: agent.completed + source_session: 'linter' + filter: + status: completed + exitCode: 0 + prompt: | + Lint passed. Run `npm test` and report results. +``` + +The `deployer` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: deploy-after-tests + event: agent.completed + source_session: 'tester' + filter: + status: completed + exitCode: 0 + prompt: | + Tests passed. Deploy to staging with `npm run deploy:staging`. +``` + +--- + +## Selective Chaining with triggeredBy + +When an agent has multiple subscriptions but only one should chain to another agent, use the `triggeredBy` filter. This field contains the subscription name that triggered the completing run. + +**Agents needed:** `worker` (has multiple cue subscriptions), `reviewer` + +The `worker` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + # This one should NOT trigger the reviewer + - name: routine-cleanup + event: time.interval + interval_minutes: 60 + prompt: Run `npm run clean` and remove stale build artifacts. + + # This one should NOT trigger the reviewer either + - name: lint-check + event: file.changed + watch: 'src/**/*.ts' + prompt: Lint {{CUE_FILE_PATH}}. + + # Only THIS one should trigger the reviewer + - name: implement-feature + event: github.issue + filter: + labels: 'enhancement' + prompt: | + New feature request: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + {{CUE_GH_BODY}} + + Implement this feature following existing patterns. +``` + +The `reviewer` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: review-new-feature + event: agent.completed + source_session: 'worker' + filter: + triggeredBy: 'implement-feature' # Only chains from this specific subscription + status: completed + prompt: | + The worker just implemented a feature. Review the changes: + + {{CUE_SOURCE_OUTPUT}} + + Check for: + 1. Code quality and consistency + 2. Missing test coverage + 3. Documentation gaps +``` + +The `triggeredBy` filter also supports glob patterns: `triggeredBy: "implement-*"` matches any subscription name starting with `implement-`. + +--- + +## Research Swarm + +Fan out a question to multiple agents, then fan in to synthesize results. + +**Agents needed:** `coordinator`, `researcher-a`, `researcher-b`, `researcher-c` + +The `coordinator` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + # Fan-out: send the research question to all researchers + - name: dispatch-research + event: file.changed + watch: 'research-question.md' + fan_out: + - 'researcher-a' + - 'researcher-b' + - 'researcher-c' + prompt: | + Research the following question from different angles. + File: {{CUE_FILE_PATH}} + + Read the file and provide a thorough analysis. + + # Fan-in: synthesize when all researchers finish + - name: synthesize-results + event: agent.completed + source_session: + - 'researcher-a' + - 'researcher-b' + - 'researcher-c' + prompt: | + All researchers have completed their analysis. + + Combined outputs: + {{CUE_SOURCE_OUTPUT}} + + Synthesize these perspectives into a single coherent report. + Highlight agreements, contradictions, and key insights. + +settings: + timeout_minutes: 60 + timeout_on_fail: continue # Synthesize with partial results if someone times out +``` + +--- + +## PR Review with Targeted Follow-Up + +Auto-review new PRs, then selectively notify a security reviewer only for PRs that touch auth code. + +**Agents needed:** `pr-reviewer`, `security-reviewer` + +The `pr-reviewer` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: review-all-prs + event: github.pull_request + poll_minutes: 3 + filter: + draft: false + base_branch: main + prompt: | + New PR: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Branch: {{CUE_GH_BRANCH}} -> {{CUE_GH_BASE_BRANCH}} + URL: {{CUE_GH_URL}} + + {{CUE_GH_BODY}} + + Review for code quality, bugs, and style. + In your output, list all files changed. +``` + +The `security-reviewer` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: security-review + event: agent.completed + source_session: 'pr-reviewer' + filter: + triggeredBy: 'review-all-prs' + status: completed + prompt: | + A PR was just reviewed. Check if any auth/security-sensitive files were changed: + + {{CUE_SOURCE_OUTPUT}} + + If auth, session, or permission-related code was modified: + 1. Audit the changes for security vulnerabilities + 2. Check for injection, XSS, or auth bypass risks + 3. Verify proper input validation + + If no security-sensitive files were changed, respond with "No security review needed." +``` + +--- + +## TODO Task Queue + +Watch a markdown file for unchecked tasks and work through them sequentially. + +**Agents needed:** `task-worker` + +```yaml +subscriptions: + - name: work-todos + event: task.pending + watch: 'TODO.md' + poll_minutes: 2 + filter: + taskCount: '>=1' + prompt: | + There are {{CUE_TASK_COUNT}} pending tasks in {{CUE_TASK_FILE}}: + + {{CUE_TASK_LIST}} + + Pick the FIRST unchecked task and complete it. + When done, change `- [ ]` to `- [x]` in the file. + Do NOT work on more than one task at a time. + +settings: + max_concurrent: 1 # Serial execution — one task at a time +``` + +--- + +## Multi-Environment Deploy + +Fan out deployments to staging, production, and docs after a build passes. + +**Agents needed:** `builder`, `deploy-staging`, `deploy-prod`, `deploy-docs` + +The `builder` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: build-on-push + event: file.changed + watch: 'src/**/*' + prompt: | + Source files changed. Run a full build with `npm run build`. + Report success or failure. +``` + +Any agent with visibility to `builder` (e.g., `deploy-staging`): + +```yaml +subscriptions: + - name: fan-out-deploy + event: agent.completed + source_session: 'builder' + filter: + triggeredBy: 'build-on-push' + exitCode: 0 + fan_out: + - 'deploy-staging' + - 'deploy-prod' + - 'deploy-docs' + prompt: | + Build succeeded. Deploy your target environment. + Build output: {{CUE_SOURCE_OUTPUT}} +``` + +--- + +## Issue Triage Bot + +Auto-triage new GitHub issues by labeling and assigning them. + +**Agents needed:** `triage-bot` + +```yaml +subscriptions: + - name: triage-issues + event: github.issue + poll_minutes: 5 + filter: + state: open + labels: '!triaged' # Skip already-triaged issues + prompt: | + New issue needs triage: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Labels: {{CUE_GH_LABELS}} + + {{CUE_GH_BODY}} + + Triage this issue: + 1. Identify the component/area affected + 2. Estimate complexity (small / medium / large) + 3. Suggest priority (P0-P3) + 4. Recommend an assignee based on the area + 5. Run `gh issue edit {{CUE_GH_NUMBER}} --add-label "triaged"` to mark as triaged +``` + +--- + +## Debate Pattern + +Two agents analyze a problem independently, then a third synthesizes their perspectives. + +**Agents needed:** `advocate`, `critic`, `judge` + +The config that triggers the debate (on any agent with visibility): + +```yaml +subscriptions: + - name: start-debate + event: file.changed + watch: 'debate-topic.md' + fan_out: + - 'advocate' + - 'critic' + prompt: | + Read {{CUE_FILE_PATH}} and analyze the proposal. + + You are assigned a role — argue from that perspective: + - advocate: argue IN FAVOR, highlight benefits and opportunities + - critic: argue AGAINST, highlight risks and weaknesses + + Be thorough and specific. +``` + +The `judge` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: synthesize-debate + event: agent.completed + source_session: + - 'advocate' + - 'critic' + prompt: | + Both sides of the debate have been presented. + + Arguments: + {{CUE_SOURCE_OUTPUT}} + + As the judge: + 1. Summarize each side's strongest points + 2. Identify where they agree and disagree + 3. Render a verdict with your reasoning + 4. Propose a path forward that addresses both perspectives + +settings: + timeout_minutes: 45 + timeout_on_fail: continue +``` + +--- + +## Scheduled Report with Conditional Chain + +Generate an hourly report, but only notify a summary agent when there's meaningful activity. + +**Agents needed:** `reporter`, `summarizer` + +The `reporter` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: hourly-git-report + event: time.interval + interval_minutes: 60 + prompt: | + Generate a report of git activity in the last hour. + Run `git log --oneline --since="1 hour ago"`. + + If there are commits, format them as a structured report. + If there are no commits, respond with exactly: "NO_ACTIVITY" +``` + +The `summarizer` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: summarize-activity + event: agent.completed + source_session: 'reporter' + filter: + triggeredBy: 'hourly-git-report' + status: completed + prompt: | + The hourly reporter just finished. Here's its output: + + {{CUE_SOURCE_OUTPUT}} + + If the output says "NO_ACTIVITY", respond with "Nothing to summarize." + Otherwise, create a concise executive summary of the development activity. +``` diff --git a/docs/maestro-cue.md b/docs/maestro-cue.md new file mode 100644 index 0000000000..d6d7b1557c --- /dev/null +++ b/docs/maestro-cue.md @@ -0,0 +1,176 @@ +--- +title: Maestro Cue +description: Event-driven automation that triggers agent prompts in response to file changes, timers, agent completions, GitHub activity, and pending tasks. +icon: bolt +--- + +Maestro Cue is an event-driven automation engine that watches for things happening in your projects and automatically sends prompts to your agents in response. Instead of manually kicking off tasks, you define **subscriptions** — trigger-prompt pairings — in a YAML file, and Cue handles the rest. + + +Maestro Cue is an **Encore Feature** — it's disabled by default. Enable it in **Settings > Encore Features** to access the shortcut, modal, and automation engine. + + +![Encore Features settings panel](./screenshots/encore-features.png) + +## What Can Cue Do? + +A few examples of what you can automate with Cue: + +- **Run linting whenever TypeScript files change** — watch `src/**/*.ts` and prompt an agent to lint on every save +- **Generate a daily standup summary** — fire every 60 minutes to scan recent git activity and draft a report +- **Chain agents together** — when your build agent finishes, automatically trigger a test agent, then a deploy agent +- **Triage new GitHub PRs** — poll for new pull requests and prompt an agent to review the diff +- **Track TODO progress** — scan markdown files for unchecked tasks and prompt an agent to work on the next one +- **Fan out deployments** — when a build completes, trigger multiple deploy agents simultaneously + +## Enabling Cue + +1. Open **Settings** (`Cmd+,` / `Ctrl+,`) +2. Navigate to the **Encore Features** tab +3. Toggle **Maestro Cue** on + +Once enabled, Maestro automatically scans all your active agents for `.maestro/cue.yaml` files in their project roots. The Cue engine starts immediately — no restart required. + +## Quick Start + +Create a file called `.maestro/cue.yaml` in your project (inside the `.maestro/` directory at the project root): + +```yaml +subscriptions: + - name: lint-on-save + event: file.changed + watch: 'src/**/*.ts' + prompt: | + The file {{CUE_FILE_PATH}} was just modified. + Please run the linter and fix any issues. +``` + +That's it. Whenever a `.ts` file in `src/` changes, Cue sends that prompt to the agent with the file path filled in automatically. + +## The Cue Modal + +Open the Cue dashboard to monitor and manage all automation activity. + +**Keyboard shortcut:** + +- macOS: `Cmd+Shift+Q` +- Windows/Linux: `Ctrl+Shift+Q` + +**From Quick Actions:** + +- Press `Cmd+K` / `Ctrl+K` and search for "Maestro Cue" + +### Sessions Table + +The primary view shows all agents that have a `.maestro/cue.yaml` file: + + + +| Column | Description | +| ------------------ | ------------------------------------------------ | +| **Session** | Agent name | +| **Agent** | Provider type (Claude Code, Codex, etc.) | +| **Status** | Green dot = active, yellow = paused, gray = none | +| **Last Triggered** | How long ago the most recent event fired | +| **Subs** | Number of subscriptions in the YAML | +| **Queue** | Events waiting to be processed | +| **Edit** | Opens the inline YAML editor for that agent | + +### Active Runs + +Shows currently executing Cue-triggered prompts with elapsed time and which subscription triggered them. + +### Activity Log + +A chronological record of completed and failed runs. Each entry shows: + +- Subscription name and event type +- Status (completed, failed, timeout, stopped) +- Duration +- Timestamp + +### YAML Editor + +Click the edit button on any session row to open the inline YAML editor. Changes are validated in real-time — errors appear immediately so you can fix them before saving. The engine hot-reloads your config automatically when the file changes. + +### Help + +Built-in reference guide accessible from the modal header. Covers configuration syntax, event types, and template variables. + +## Configuration File + +Cue is configured via a `.maestro/cue.yaml` file placed inside the `.maestro/` directory at your project root. See the [Configuration Reference](./maestro-cue-configuration) for the complete YAML schema. + +## Event Types + +Cue supports six event types that trigger subscriptions: + +| Event Type | Trigger | Key Fields | +| --------------------- | ---------------------------------- | ---------------------- | +| `time.interval` | Periodic timer | `interval_minutes` | +| `file.changed` | File created, modified, or deleted | `watch` (glob pattern) | +| `agent.completed` | Another agent finishes a task | `source_session` | +| `task.pending` | Unchecked markdown tasks found | `watch` (glob pattern) | +| `github.pull_request` | New PR opened on GitHub | `repo` (optional) | +| `github.issue` | New issue opened on GitHub | `repo` (optional) | + +See [Event Types](./maestro-cue-events) for detailed documentation and examples for each type. + +## Template Variables + +Prompts support `{{VARIABLE}}` syntax for injecting event data. When Cue fires a subscription, it replaces template variables with the actual event payload before sending the prompt to the agent. + +```yaml +prompt: | + A new PR was opened: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Branch: {{CUE_GH_BRANCH}} -> {{CUE_GH_BASE_BRANCH}} + URL: {{CUE_GH_URL}} + + Please review this PR and provide feedback. +``` + +See [Advanced Patterns](./maestro-cue-advanced) for the complete template variable reference. + +## Advanced Features + +Cue supports sophisticated automation patterns beyond simple trigger-prompt pairings: + +- **[Fan-out](./maestro-cue-advanced#fan-out)** — One trigger fires against multiple target agents simultaneously +- **[Fan-in](./maestro-cue-advanced#fan-in)** — Wait for multiple agents to complete before triggering +- **[Payload filtering](./maestro-cue-advanced#filtering)** — Conditionally trigger based on event data (glob matching, comparisons, negation) +- **[Agent chaining](./maestro-cue-advanced#agent-chaining)** — Build multi-step pipelines where each agent's output feeds the next +- **[Concurrency control](./maestro-cue-advanced#concurrency-control)** — Limit simultaneous runs and queue overflow events + +See [Advanced Patterns](./maestro-cue-advanced) for full documentation. + +## Keyboard Shortcuts + +| Shortcut | Action | +| ------------------------------ | -------------- | +| `Cmd+Shift+Q` / `Ctrl+Shift+Q` | Open Cue Modal | +| `Esc` | Close modal | + +## History Integration + +Cue-triggered runs appear in the History panel with a teal **CUE** badge. Each entry records: + +- The subscription name that triggered it +- The event type +- The source session (for agent completion chains) + +Filter by CUE entries in the History panel or in Director's Notes (when both Encore Features are enabled) to isolate automated activity from manual work. + +## Requirements + +- **GitHub CLI (`gh`)** — Required only for `github.pull_request` and `github.issue` events. Must be installed and authenticated (`gh auth login`). +- **File watching** — `file.changed` and `task.pending` events use filesystem watchers. No additional dependencies required. + +## Tips + +- **Start simple** — Begin with a single `file.changed` or `time.interval` subscription before building complex chains +- **Use the YAML editor** — The inline editor validates your config in real-time, catching errors before they reach the engine +- **Check the Activity Log** — If a subscription isn't firing, the activity log shows failures with error details +- **Prompt files vs inline** — For complex prompts, point the `prompt` field at a `.md` file instead of inlining YAML +- **Hot reload** — The engine watches `.maestro/cue.yaml` for changes and reloads automatically — no need to restart Maestro +- **Template variables** — Use `{{CUE_TRIGGER_NAME}}` in prompts so the agent knows which automation triggered it diff --git a/docs/openspec-commands.md b/docs/openspec-commands.md index c9d0ab57bb..34b1a217b4 100644 --- a/docs/openspec-commands.md +++ b/docs/openspec-commands.md @@ -83,7 +83,7 @@ Bridges OpenSpec with Maestro's Auto Run: 1. Reads the proposal and tasks from a change 2. Converts tasks into Auto Run document format with phases -3. Saves to `Auto Run Docs/` with task checkboxes (filename: `OpenSpec--Phase-XX-[Description].md`) +3. Saves to `.maestro/playbooks/` with task checkboxes (filename: `OpenSpec--Phase-XX-[Description].md`) 4. Preserves task IDs (T001, T002, etc.) for traceability 5. Groups related tasks into logical phases (5–15 tasks each) diff --git a/docs/remote-access.md b/docs/remote-control.md similarity index 97% rename from docs/remote-access.md rename to docs/remote-control.md index 645461a644..706c509e20 100644 --- a/docs/remote-access.md +++ b/docs/remote-control.md @@ -1,5 +1,5 @@ --- -title: Remote Access +title: Remote Control description: Control Maestro from your phone via the built-in web server and Cloudflare tunnels. icon: wifi --- @@ -45,13 +45,13 @@ The mobile web interface provides a comprehensive remote control experience: The web interface uses your local IP address (e.g., `192.168.x.x`) for LAN accessibility. Both devices must be on the same network. -## Remote Access (Outside Your Network) +## Remote Control (Outside Your Network) To access Maestro from outside your local network (e.g., on mobile data or from another location): 1. Install cloudflared: `brew install cloudflared` (macOS) or [download for other platforms](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) 2. Enable the web interface (**OFFLINE** → **LIVE**) -3. Toggle **Remote Access** in the Live overlay panel +3. Toggle **Remote Control** in the Live overlay panel 4. A secure Cloudflare tunnel URL (e.g., `https://abc123.trycloudflare.com`) will be generated within ~30 seconds 5. Use the **Local/Remote** pill selector to switch between QR codes 6. The tunnel stays active as long as Maestro is running — no time limits, no Cloudflare account required diff --git a/docs/speckit-commands.md b/docs/speckit-commands.md index 707bbd9f33..7ecc9917b8 100644 --- a/docs/speckit-commands.md +++ b/docs/speckit-commands.md @@ -12,14 +12,14 @@ Spec-Kit is a structured specification workflow from [GitHub's spec-kit project] Maestro offers two paths to structured development: -| Feature | Spec-Kit | Onboarding Wizard | -| -------------------- | ------------------------------------------ | --------------------------- | -| **Approach** | Manual, command-driven workflow | Guided, conversational flow | -| **Best For** | Experienced users, complex projects | New users, quick setup | -| **Output** | Constitution, specs, tasks → Auto Run docs | Phase 1 Auto Run document | -| **Control** | Full control at each step | Streamlined, opinionated | -| **Learning Curve** | Moderate | Low | -| **Storage Location** | `.specify/` directory in project root | `Auto Run Docs/Initiation/` | +| Feature | Spec-Kit | Onboarding Wizard | +| -------------------- | ------------------------------------------ | -------------------------------- | +| **Approach** | Manual, command-driven workflow | Guided, conversational flow | +| **Best For** | Experienced users, complex projects | New users, quick setup | +| **Output** | Constitution, specs, tasks → Auto Run docs | Phase 1 Auto Run document | +| **Control** | Full control at each step | Streamlined, opinionated | +| **Learning Curve** | Moderate | Low | +| **Storage Location** | `.specify/` directory in project root | `.maestro/playbooks/Initiation/` | **Use Spec-Kit when:** @@ -98,11 +98,11 @@ Each task has an ID (T001, T002...), optional `[P]` marker for parallelizable ta **Maestro-specific command.** Converts your tasks into Auto Run documents that Maestro can execute autonomously. This bridges spec-kit's structured approach with Maestro's multi-agent capabilities. -**Creates:** Markdown documents in `Auto Run Docs/` with naming pattern: +**Creates:** Markdown documents in `.maestro/playbooks/` with naming pattern: ``` -Auto Run Docs/SpecKit--Phase-01-[Description].md -Auto Run Docs/SpecKit--Phase-02-[Description].md +.maestro/playbooks/SpecKit--Phase-01-[Description].md +.maestro/playbooks/SpecKit--Phase-02-[Description].md ``` Each phase document is self-contained, includes Spec Kit context references, preserves task IDs (T001, T002...) and user story markers ([US1], [US2]) for traceability. diff --git a/e2e/autorun-batch.spec.ts b/e2e/autorun-batch.spec.ts index 334f38efd9..0c32cd1c82 100644 --- a/e2e/autorun-batch.spec.ts +++ b/e2e/autorun-batch.spec.ts @@ -33,7 +33,7 @@ test.describe('Auto Run Batch Processing', () => { test.beforeEach(async () => { // Create a temporary project directory testProjectDir = path.join(os.tmpdir(), `maestro-batch-test-${Date.now()}`); - testAutoRunFolder = path.join(testProjectDir, 'Auto Run Docs'); + testAutoRunFolder = path.join(testProjectDir, '.maestro/playbooks'); fs.mkdirSync(testAutoRunFolder, { recursive: true }); // Create test markdown files with tasks diff --git a/e2e/autorun-editing.spec.ts b/e2e/autorun-editing.spec.ts index 92d73149d9..ba9ba908a3 100644 --- a/e2e/autorun-editing.spec.ts +++ b/e2e/autorun-editing.spec.ts @@ -33,7 +33,7 @@ test.describe('Auto Run Editing', () => { test.beforeEach(async () => { // Create a temporary project directory testProjectDir = path.join(os.tmpdir(), `maestro-test-project-${Date.now()}`); - testAutoRunFolder = path.join(testProjectDir, 'Auto Run Docs'); + testAutoRunFolder = path.join(testProjectDir, '.maestro/playbooks'); fs.mkdirSync(testAutoRunFolder, { recursive: true }); // Create test markdown files diff --git a/e2e/autorun-sessions.spec.ts b/e2e/autorun-sessions.spec.ts index 7183842bcd..15feb6fe6f 100644 --- a/e2e/autorun-sessions.spec.ts +++ b/e2e/autorun-sessions.spec.ts @@ -37,8 +37,8 @@ test.describe('Auto Run Session Switching', () => { const timestamp = Date.now(); testProjectDir1 = path.join(os.tmpdir(), `maestro-session-test-1-${timestamp}`); testProjectDir2 = path.join(os.tmpdir(), `maestro-session-test-2-${timestamp}`); - testAutoRunFolder1 = path.join(testProjectDir1, 'Auto Run Docs'); - testAutoRunFolder2 = path.join(testProjectDir2, 'Auto Run Docs'); + testAutoRunFolder1 = path.join(testProjectDir1, '.maestro/playbooks'); + testAutoRunFolder2 = path.join(testProjectDir2, '.maestro/playbooks'); fs.mkdirSync(testAutoRunFolder1, { recursive: true }); fs.mkdirSync(testAutoRunFolder2, { recursive: true }); diff --git a/e2e/autorun-setup.spec.ts b/e2e/autorun-setup.spec.ts index 92c219f517..233abd8a08 100644 --- a/e2e/autorun-setup.spec.ts +++ b/e2e/autorun-setup.spec.ts @@ -190,11 +190,11 @@ test.describe('Auto Run Setup Wizard', () => { }); test.describe('Document Creation Flow', () => { - test.skip('should create Auto Run Docs folder in project', async ({ window }) => { + test.skip('should create .maestro/playbooks folder in project', async ({ window }) => { // This test requires completing the wizard flow // Would verify: // 1. Complete all wizard steps - // 2. 'Auto Run Docs' folder is created in project + // 2. '.maestro/playbooks' folder is created in project // 3. Initial documents are created }); diff --git a/e2e/fixtures/electron-app.ts b/e2e/fixtures/electron-app.ts index 3aa153ebe7..feb07f77bc 100644 --- a/e2e/fixtures/electron-app.ts +++ b/e2e/fixtures/electron-app.ts @@ -360,7 +360,7 @@ export const helpers = { * Create an Auto Run test folder with sample documents */ createAutoRunTestFolder(basePath: string): string { - const autoRunFolder = path.join(basePath, 'Auto Run Docs'); + const autoRunFolder = path.join(basePath, '.maestro/playbooks'); fs.mkdirSync(autoRunFolder, { recursive: true }); // Create sample documents @@ -496,7 +496,7 @@ More content for the second phase. * Create an Auto Run test folder with batch processing test documents */ createBatchTestFolder(basePath: string): string { - const autoRunFolder = path.join(basePath, 'Auto Run Docs'); + const autoRunFolder = path.join(basePath, '.maestro/playbooks'); fs.mkdirSync(autoRunFolder, { recursive: true }); // Create documents with varying task counts @@ -647,8 +647,8 @@ All tasks complete in this document. * Create test folders for multiple sessions with unique content */ createMultiSessionTestFolders(basePath: string): { session1: string; session2: string } { - const session1Path = path.join(basePath, 'session1', 'Auto Run Docs'); - const session2Path = path.join(basePath, 'session2', 'Auto Run Docs'); + const session1Path = path.join(basePath, 'session1', '.maestro/playbooks'); + const session2Path = path.join(basePath, 'session2', '.maestro/playbooks'); fs.mkdirSync(session1Path, { recursive: true }); fs.mkdirSync(session2Path, { recursive: true }); diff --git a/package-lock.json b/package-lock.json index 7482623e10..3fb2232798 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.15.0", + "version": "0.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.15.0", + "version": "0.16.0", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { @@ -21,6 +21,12 @@ "@tanstack/react-virtual": "^3.13.13", "@types/d3-force": "^3.0.10", "@types/dompurify": "^3.0.5", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "adm-zip": "^0.5.16", "ansi-to-html": "^0.7.2", "archiver": "^7.0.1", @@ -36,9 +42,11 @@ "electron-updater": "^6.6.2", "fastify": "^4.25.2", "js-tiktoken": "^1.0.21", + "js-yaml": "^4.1.1", "marked": "^17.0.1", "mermaid": "^11.12.1", "node-pty": "^1.1.0", + "picomatch": "^4.0.3", "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react-diff-view": "^3.3.2", @@ -67,7 +75,9 @@ "@types/better-sqlite3": "^7.6.13", "@types/canvas-confetti": "^1.9.0", "@types/electron-devtools-installer": "^2.2.5", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.10.6", + "@types/picomatch": "^4.0.2", "@types/qrcode": "^1.5.6", "@types/react": "^18.2.47", "@types/react-dom": "^18.2.18", @@ -264,6 +274,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -667,6 +678,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -710,6 +722,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2283,6 +2296,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2304,6 +2318,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz", "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2316,6 +2331,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2331,6 +2347,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", @@ -2718,6 +2735,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2734,6 +2752,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -2751,6 +2770,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -3809,8 +3829,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4239,6 +4258,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4309,6 +4335,13 @@ "@types/pg": "*" } }, + "node_modules/@types/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/plist": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", @@ -4348,6 +4381,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4359,6 +4393,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4484,6 +4519,7 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -4877,6 +4913,45 @@ "node": ">=10.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" + }, + "node_modules/@xterm/addon-unicode11": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz", + "integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/7zip-bin": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", @@ -4914,6 +4989,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4995,6 +5071,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5130,6 +5207,18 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/app-builder-bin": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz", @@ -5998,6 +6087,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6480,6 +6570,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -7205,6 +7296,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -7614,6 +7706,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -8111,6 +8204,7 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -8206,8 +8300,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dompurify": { "version": "3.3.0", @@ -8351,7 +8444,6 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -8365,7 +8457,6 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -8385,7 +8476,6 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -8408,7 +8498,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8425,7 +8514,6 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -8442,7 +8530,6 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -8457,7 +8544,6 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -8473,7 +8559,6 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -8486,8 +8571,7 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-builder-squirrel-windows/node_modules/string_decoder": { "version": "1.1.1", @@ -8495,7 +8579,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -8506,7 +8589,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -8517,7 +8599,6 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -8533,7 +8614,6 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -9215,6 +9295,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11134,6 +11215,7 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -11954,6 +12036,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12423,16 +12506,14 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", @@ -12445,8 +12526,7 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -12460,8 +12540,7 @@ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -12475,8 +12554,7 @@ "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", @@ -12567,7 +12645,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -13728,6 +13805,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -14841,12 +14931,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -15065,6 +15156,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15305,7 +15397,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -15321,7 +15412,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -15666,6 +15756,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -15695,6 +15786,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -15742,6 +15834,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -15871,6 +15964,18 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -15928,7 +16033,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -17679,19 +17785,6 @@ } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinyrainbow": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", @@ -17995,6 +18088,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18368,6 +18462,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -18873,6 +18968,7 @@ "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", @@ -19457,25 +19553,13 @@ } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest/node_modules/vite": { "version": "7.2.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -20073,6 +20157,7 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index cdc26a0b7c..b7b07b66c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maestro", - "version": "0.15.1", + "version": "0.16.0", "description": "Maestro hones fractured attention into focused intent.", "main": "dist/main/index.js", "author": { @@ -61,6 +61,14 @@ "npmRebuild": false, "appId": "com.maestro.app", "productName": "Maestro", + "protocols": [ + { + "name": "Maestro", + "schemes": [ + "maestro" + ] + } + ], "publish": { "provider": "github", "owner": "RunMaestro", @@ -225,6 +233,12 @@ "@tanstack/react-virtual": "^3.13.13", "@types/d3-force": "^3.0.10", "@types/dompurify": "^3.0.5", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "adm-zip": "^0.5.16", "ansi-to-html": "^0.7.2", "archiver": "^7.0.1", @@ -240,9 +254,11 @@ "electron-updater": "^6.6.2", "fastify": "^4.25.2", "js-tiktoken": "^1.0.21", + "js-yaml": "^4.1.1", "marked": "^17.0.1", "mermaid": "^11.12.1", "node-pty": "^1.1.0", + "picomatch": "^4.0.3", "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react-diff-view": "^3.3.2", @@ -268,7 +284,9 @@ "@types/better-sqlite3": "^7.6.13", "@types/canvas-confetti": "^1.9.0", "@types/electron-devtools-installer": "^2.2.5", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.10.6", + "@types/picomatch": "^4.0.2", "@types/qrcode": "^1.5.6", "@types/react": "^18.2.47", "@types/react-dom": "^18.2.18", diff --git a/src/__tests__/cli/commands/list-sessions.test.ts b/src/__tests__/cli/commands/list-sessions.test.ts index 9bec0d4f40..e68e24c10c 100644 --- a/src/__tests__/cli/commands/list-sessions.test.ts +++ b/src/__tests__/cli/commands/list-sessions.test.ts @@ -22,11 +22,12 @@ vi.mock('../../../cli/services/storage', () => ({ // Mock agent-sessions vi.mock('../../../cli/services/agent-sessions', () => ({ listClaudeSessions: vi.fn(), + listGeminiSessions: vi.fn(), })); import { listSessions } from '../../../cli/commands/list-sessions'; import { resolveAgentId, getSessionById } from '../../../cli/services/storage'; -import { listClaudeSessions } from '../../../cli/services/agent-sessions'; +import { listClaudeSessions, listGeminiSessions } from '../../../cli/services/agent-sessions'; describe('list sessions command', () => { let consoleSpy: MockInstance; @@ -255,4 +256,81 @@ describe('list sessions command', () => { expect(consoleSpy).toHaveBeenCalledTimes(1); expect(processExitSpy).not.toHaveBeenCalled(); }); + + it('should route gemini-cli agents to listGeminiSessions', () => { + vi.mocked(resolveAgentId).mockReturnValue('agent-gemini-1'); + vi.mocked(getSessionById).mockReturnValue( + mockAgent({ id: 'agent-gemini-1', name: 'Gemini Agent', toolType: 'gemini-cli' }) + ); + vi.mocked(listGeminiSessions).mockReturnValue({ + sessions: [ + { + sessionId: 'gemini-session-1', + sessionName: 'Gemini Session', + projectPath: '/path/to/project', + timestamp: '2026-02-08T10:00:00.000Z', + modifiedAt: '2026-02-08T10:05:00.000Z', + firstMessage: 'Help with Gemini', + messageCount: 4, + sizeBytes: 3000, + costUsd: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + durationSeconds: 300, + }, + ], + totalCount: 1, + filteredCount: 1, + }); + + listSessions('agent-gemini', {}); + + expect(listGeminiSessions).toHaveBeenCalledWith('/path/to/project', { + limit: 25, + skip: 0, + search: undefined, + }); + expect(listClaudeSessions).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(consoleSpy.mock.calls[0][0]).toContain('Gemini Agent'); + }); + + it('should route gemini-cli agents to listGeminiSessions in JSON mode', () => { + vi.mocked(resolveAgentId).mockReturnValue('agent-gemini-1'); + vi.mocked(getSessionById).mockReturnValue( + mockAgent({ id: 'agent-gemini-1', name: 'Gemini Agent', toolType: 'gemini-cli' }) + ); + vi.mocked(listGeminiSessions).mockReturnValue({ + sessions: [], + totalCount: 0, + filteredCount: 0, + }); + + listSessions('agent-gemini', { json: true }); + + expect(listGeminiSessions).toHaveBeenCalled(); + expect(listClaudeSessions).not.toHaveBeenCalled(); + const output = JSON.parse(consoleSpy.mock.calls[0][0]); + expect(output.success).toBe(true); + expect(output.agentName).toBe('Gemini Agent'); + }); + + it('should route claude-code agents to listClaudeSessions (not listGeminiSessions)', () => { + vi.mocked(resolveAgentId).mockReturnValue('agent-claude-1'); + vi.mocked(getSessionById).mockReturnValue( + mockAgent({ id: 'agent-claude-1', name: 'Claude Agent', toolType: 'claude-code' }) + ); + vi.mocked(listClaudeSessions).mockReturnValue({ + sessions: [], + totalCount: 0, + filteredCount: 0, + }); + + listSessions('agent-claude', {}); + + expect(listClaudeSessions).toHaveBeenCalled(); + expect(listGeminiSessions).not.toHaveBeenCalled(); + }); }); diff --git a/src/__tests__/cli/commands/run-playbook.test.ts b/src/__tests__/cli/commands/run-playbook.test.ts index b85aaf4e0a..85c1f236cf 100644 --- a/src/__tests__/cli/commands/run-playbook.test.ts +++ b/src/__tests__/cli/commands/run-playbook.test.ts @@ -45,6 +45,8 @@ vi.mock('../../../cli/services/batch-processor', () => ({ // Mock the agent-spawner service vi.mock('../../../cli/services/agent-spawner', () => ({ detectClaude: vi.fn(), + detectCodex: vi.fn(), + detectGemini: vi.fn(), })); // Mock the jsonl output @@ -74,7 +76,7 @@ import { runPlaybook } from '../../../cli/commands/run-playbook'; import { getSessionById } from '../../../cli/services/storage'; import { findPlaybookById } from '../../../cli/services/playbooks'; import { runPlaybook as executePlaybook } from '../../../cli/services/batch-processor'; -import { detectClaude } from '../../../cli/services/agent-spawner'; +import { detectClaude, detectCodex, detectGemini } from '../../../cli/services/agent-spawner'; import { emitError } from '../../../cli/output/jsonl'; import { formatRunEvent, @@ -124,12 +126,14 @@ describe('run-playbook command', () => { throw new Error(`process.exit(${code})`); }); - // Default: Claude is available + // Default: CLI agents are available vi.mocked(detectClaude).mockResolvedValue({ available: true, version: '1.0.0', path: '/usr/local/bin/claude', }); + vi.mocked(detectCodex).mockResolvedValue({ available: true, path: '/usr/local/bin/codex' }); + vi.mocked(detectGemini).mockResolvedValue({ available: true, path: '/usr/local/bin/gemini' }); // Default: agent is not busy vi.mocked(isSessionBusyWithCli).mockReturnValue(false); @@ -341,6 +345,39 @@ describe('run-playbook command', () => { }); }); + describe('Gemini CLI not found', () => { + beforeEach(() => { + vi.mocked(findPlaybookById).mockReturnValue({ + playbook: mockPlaybook(), + agentId: 'agent-gem', + }); + vi.mocked(getSessionById).mockReturnValue( + mockSession({ id: 'agent-gem', toolType: 'gemini-cli' }) + ); + }); + + it('should error when Gemini CLI is not available (human-readable)', async () => { + vi.mocked(detectGemini).mockResolvedValue({ available: false }); + + await expect(runPlaybook('pb-123', {})).rejects.toThrow('process.exit(1)'); + + expect(formatError).toHaveBeenCalledWith( + 'Gemini CLI not found. Please install @google/gemini-cli.' + ); + }); + + it('should error when Gemini CLI is not available (JSON)', async () => { + vi.mocked(detectGemini).mockResolvedValue({ available: false }); + + await expect(runPlaybook('pb-123', { json: true })).rejects.toThrow('process.exit(1)'); + + expect(emitError).toHaveBeenCalledWith( + 'Gemini CLI not found. Please install @google/gemini-cli.', + 'GEMINI_NOT_FOUND' + ); + }); + }); + describe('playbook not found', () => { it('should error when playbook is not found (human-readable)', async () => { vi.mocked(findPlaybookById).mockImplementation(() => { diff --git a/src/__tests__/cli/commands/send.test.ts b/src/__tests__/cli/commands/send.test.ts index 85241621e0..3c554e6c33 100644 --- a/src/__tests__/cli/commands/send.test.ts +++ b/src/__tests__/cli/commands/send.test.ts @@ -18,6 +18,7 @@ vi.mock('../../../cli/services/agent-spawner', () => ({ spawnAgent: vi.fn(), detectClaude: vi.fn(), detectCodex: vi.fn(), + detectGemini: vi.fn(), })); // Mock storage @@ -32,7 +33,12 @@ vi.mock('../../../main/parsers/usage-aggregator', () => ({ })); import { send } from '../../../cli/commands/send'; -import { spawnAgent, detectClaude, detectCodex } from '../../../cli/services/agent-spawner'; +import { + spawnAgent, + detectClaude, + detectCodex, + detectGemini, +} from '../../../cli/services/agent-spawner'; import { resolveAgentId, getSessionById } from '../../../cli/services/storage'; import { estimateContextUsage } from '../../../main/parsers/usage-aggregator'; @@ -81,7 +87,8 @@ describe('send command', () => { 'claude-code', '/path/to/project', 'Hello world', - undefined + undefined, + { readOnlyMode: undefined } ); expect(consoleSpy).toHaveBeenCalledTimes(1); @@ -128,7 +135,8 @@ describe('send command', () => { 'claude-code', '/path/to/project', 'Continue from before', - 'session-xyz-789' + 'session-xyz-789', + { readOnlyMode: undefined } ); const output = JSON.parse(consoleSpy.mock.calls[0][0]); @@ -153,7 +161,8 @@ describe('send command', () => { 'claude-code', '/custom/project/path', 'Do something', - undefined + undefined, + { readOnlyMode: undefined } ); }); @@ -173,7 +182,51 @@ describe('send command', () => { expect(detectCodex).toHaveBeenCalled(); expect(detectClaude).not.toHaveBeenCalled(); - expect(spawnAgent).toHaveBeenCalledWith('codex', expect.any(String), 'Use codex', undefined); + expect(spawnAgent).toHaveBeenCalledWith('codex', expect.any(String), 'Use codex', undefined, { + readOnlyMode: undefined, + }); + }); + + it('should pass readOnlyMode when --read-only flag is set', async () => { + vi.mocked(resolveAgentId).mockReturnValue('agent-abc-123'); + vi.mocked(getSessionById).mockReturnValue(mockAgent()); + vi.mocked(detectClaude).mockResolvedValue({ available: true, path: '/usr/bin/claude' }); + vi.mocked(spawnAgent).mockResolvedValue({ + success: true, + response: 'Read-only response', + agentSessionId: 'session-ro', + }); + + await send('agent-abc', 'Analyze this code', { readOnly: true }); + + expect(spawnAgent).toHaveBeenCalledWith( + 'claude-code', + '/path/to/project', + 'Analyze this code', + undefined, + { readOnlyMode: true } + ); + }); + + it('should work with gemini-cli agent type', async () => { + vi.mocked(resolveAgentId).mockReturnValue('agent-gem-1'); + vi.mocked(getSessionById).mockReturnValue( + mockAgent({ id: 'agent-gem-1', toolType: 'gemini-cli' }) + ); + vi.mocked(detectGemini).mockResolvedValue({ + available: true, + path: '/usr/local/bin/gemini', + }); + vi.mocked(spawnAgent).mockResolvedValue({ + success: true, + response: 'Gemini response', + agentSessionId: 'gem-session', + }); + + await send('agent-gem', 'Use gemini', {}); + + expect(detectGemini).toHaveBeenCalled(); + expect(spawnAgent).toHaveBeenCalledWith('gemini-cli', expect.any(String), 'Use gemini', undefined); }); it('should exit with error when agent ID is not found', async () => { @@ -216,6 +269,21 @@ describe('send command', () => { expect(processExitSpy).toHaveBeenCalledWith(1); }); + it('should exit with error when Gemini CLI is not found', async () => { + vi.mocked(resolveAgentId).mockReturnValue('agent-gem-1'); + vi.mocked(getSessionById).mockReturnValue( + mockAgent({ id: 'agent-gem-1', toolType: 'gemini-cli' }) + ); + vi.mocked(detectGemini).mockResolvedValue({ available: false }); + + await send('agent-gem', 'Hello', {}); + + const output = JSON.parse(consoleSpy.mock.calls[0][0]); + expect(output.success).toBe(false); + expect(output.code).toBe('GEMINI_NOT_FOUND'); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + it('should handle agent failure with error in response', async () => { vi.mocked(resolveAgentId).mockReturnValue('agent-abc-123'); vi.mocked(getSessionById).mockReturnValue(mockAgent()); diff --git a/src/__tests__/cli/services/agent-sessions.test.ts b/src/__tests__/cli/services/agent-sessions.test.ts index f454fea463..51b872cf2c 100644 --- a/src/__tests__/cli/services/agent-sessions.test.ts +++ b/src/__tests__/cli/services/agent-sessions.test.ts @@ -2,13 +2,14 @@ * @file agent-sessions.test.ts * @description Tests for the CLI agent-sessions service * - * Tests session listing from Claude Code's .jsonl files on disk. + * Tests session listing from Claude Code's .jsonl files and Gemini CLI's JSON files on disk. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as fs from 'fs'; import * as os from 'os'; -import { listClaudeSessions } from '../../../cli/services/agent-sessions'; +import * as path from 'path'; +import { listClaudeSessions, listGeminiSessions } from '../../../cli/services/agent-sessions'; // Mock fs vi.mock('fs', () => ({ @@ -476,3 +477,604 @@ describe('listClaudeSessions', () => { expect(session.durationSeconds).toBe(5); }); }); + +describe('listGeminiSessions', () => { + const projectPath = '/path/to/project'; + const geminiHistoryDir = '/home/testuser/.gemini/history/project'; + + const makeGeminiSession = ( + opts: { + sessionId?: string; + messages?: Array<{ type: string; content: string }>; + startTime?: string; + lastUpdated?: string; + summary?: string; + } = {} + ) => { + return JSON.stringify({ + sessionId: opts.sessionId || 'test-session-id', + messages: opts.messages || [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi there' }, + ], + startTime: opts.startTime || '2026-02-08T10:00:00.000Z', + lastUpdated: opts.lastUpdated || '2026-02-08T10:05:00.000Z', + summary: opts.summary, + }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return empty result when history directory does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.readdirSync).mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const result = listGeminiSessions(projectPath); + + expect(result.sessions).toEqual([]); + expect(result.totalCount).toBe(0); + }); + + it('should fallback to scan when .project_root is missing on basename match', () => { + // Regression: basename-matched dir without .project_root should NOT be + // returned blindly — it could belong to a different project with the same + // folder name. Instead, fall through to the full scan. + const altProjectPath = '/other/parent/project'; // same basename "project" + const altHistoryDir = '/home/testuser/.gemini/history/renamed-project'; + + vi.mocked(fs.existsSync).mockImplementation((p) => { + // The basename "project" dir exists at the default location + if (p === geminiHistoryDir) return true; + return false; + }); + + vi.mocked(fs.readdirSync).mockImplementation((p) => { + const pStr = p.toString(); + // Fallback scan returns both dirs + if (pStr === '/home/testuser/.gemini/history') { + return ['project' as unknown as fs.Dirent, 'renamed-project' as unknown as fs.Dirent]; + } + // Session files in the correct dir + if (pStr === altHistoryDir) { + return ['session-1000-found.json' as unknown as fs.Dirent]; + } + return []; + }); + + vi.mocked(fs.statSync).mockImplementation((p) => { + return { + size: 500, + mtimeMs: Date.now(), + isDirectory: () => true, + } as unknown as fs.Stats; + }); + + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pStr = p.toString(); + // The basename-matched dir's .project_root doesn't exist + if (pStr === path.join(geminiHistoryDir, '.project_root')) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + } + // The renamed dir's .project_root points to our alt project + if (pStr === path.join(altHistoryDir, '.project_root')) { + return altProjectPath; + } + // project dir's .project_root (during scan) — doesn't exist + if (pStr.includes('project') && pStr.endsWith('.project_root')) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + } + if (pStr.includes('found')) { + return makeGeminiSession({ + sessionId: 'found-id', + messages: [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi' }, + ], + }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const result = listGeminiSessions(altProjectPath); + + expect(result.totalCount).toBe(1); + expect(result.sessions[0].sessionId).toBe('found-id'); + }); + + it('should return empty when basename match has no .project_root and no scan match', () => { + // When basename dir exists but has no .project_root, and no other dir + // matches via scan, should return empty — not the wrong project's dir. + vi.mocked(fs.existsSync).mockImplementation((p) => { + if (p === geminiHistoryDir) return true; + return false; + }); + + vi.mocked(fs.readdirSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr === '/home/testuser/.gemini/history') { + return ['project' as unknown as fs.Dirent]; + } + return []; + }); + + vi.mocked(fs.statSync).mockImplementation(() => { + return { + size: 500, + mtimeMs: Date.now(), + isDirectory: () => true, + } as unknown as fs.Stats; + }); + + vi.mocked(fs.readFileSync).mockImplementation((p) => { + // .project_root doesn't exist for any dir + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + // Different project with same basename + const result = listGeminiSessions('/different/parent/project'); + + expect(result.totalCount).toBe(0); + expect(result.sessions).toEqual([]); + }); + + it('should parse Gemini session JSON files and return sorted results', () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + if (p === geminiHistoryDir) return true; + return false; + }); + + vi.mocked(fs.readdirSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr === geminiHistoryDir) { + return [ + 'session-1000-old-id.json' as unknown as fs.Dirent, + 'session-2000-new-id.json' as unknown as fs.Dirent, + ]; + } + // Base history dir scan fallback + return []; + }); + + vi.mocked(fs.statSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr.includes('old-id')) { + return { + size: 500, + mtimeMs: new Date('2026-02-01T00:00:00Z').getTime(), + isDirectory: () => false, + } as unknown as fs.Stats; + } + return { + size: 800, + mtimeMs: new Date('2026-02-08T00:00:00Z').getTime(), + isDirectory: () => false, + } as unknown as fs.Stats; + }); + + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr.includes('.project_root')) { + return projectPath; + } + if (pStr.includes('old-id')) { + return makeGeminiSession({ + sessionId: 'old-id', + startTime: '2026-02-01T00:00:00.000Z', + lastUpdated: '2026-02-01T00:05:00.000Z', + messages: [ + { type: 'user', content: 'Old task' }, + { type: 'gemini', content: 'Old response' }, + ], + }); + } + if (pStr.includes('new-id')) { + return makeGeminiSession({ + sessionId: 'new-id', + startTime: '2026-02-08T00:00:00.000Z', + lastUpdated: '2026-02-08T00:10:00.000Z', + messages: [ + { type: 'user', content: 'New task' }, + { type: 'gemini', content: 'New response' }, + ], + }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const result = listGeminiSessions(projectPath); + + expect(result.totalCount).toBe(2); + expect(result.sessions).toHaveLength(2); + // Newest first (by lastUpdated) + expect(result.sessions[0].sessionId).toBe('new-id'); + expect(result.sessions[1].sessionId).toBe('old-id'); + }); + + it('should skip empty session files', () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + if (p === geminiHistoryDir) return true; + return false; + }); + + vi.mocked(fs.readdirSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr === geminiHistoryDir) { + return [ + 'session-1000-empty.json' as unknown as fs.Dirent, + 'session-2000-valid.json' as unknown as fs.Dirent, + ]; + } + return []; + }); + + vi.mocked(fs.statSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr.includes('empty')) { + return { size: 0, mtimeMs: Date.now(), isDirectory: () => false } as unknown as fs.Stats; + } + return { size: 500, mtimeMs: Date.now(), isDirectory: () => false } as unknown as fs.Stats; + }); + + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr.includes('.project_root')) return projectPath; + if (pStr.includes('valid')) { + return makeGeminiSession({ sessionId: 'valid-id' }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const result = listGeminiSessions(projectPath); + + expect(result.totalCount).toBe(1); + expect(result.sessions[0].sessionId).toBe('valid-id'); + }); + + it('should count only conversation messages (not info/error/warning)', () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + if (p === geminiHistoryDir) return true; + return false; + }); + + vi.mocked(fs.readdirSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr === geminiHistoryDir) { + return ['session-1000-mixed.json' as unknown as fs.Dirent]; + } + return []; + }); + + vi.mocked(fs.statSync).mockReturnValue({ + size: 500, + mtimeMs: Date.now(), + isDirectory: () => false, + } as unknown as fs.Stats); + + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr.includes('.project_root')) return projectPath; + if (pStr.includes('mixed')) { + return JSON.stringify({ + sessionId: 'mixed-id', + messages: [ + { type: 'user', content: 'Hello' }, + { type: 'info', content: 'System info' }, + { type: 'gemini', content: 'Response' }, + { type: 'warning', content: 'Warning msg' }, + { type: 'error', content: 'Error msg' }, + { type: 'user', content: 'Follow up' }, + { type: 'gemini', content: 'Follow up response' }, + ], + startTime: '2026-02-08T10:00:00.000Z', + lastUpdated: '2026-02-08T10:05:00.000Z', + }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const result = listGeminiSessions(projectPath); + + expect(result.sessions).toHaveLength(1); + // Only user + gemini messages count: 2 user + 2 gemini = 4 + expect(result.sessions[0].messageCount).toBe(4); + }); + + it('should apply limit and skip for pagination', () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + if (p === geminiHistoryDir) return true; + return false; + }); + + vi.mocked(fs.readdirSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr === geminiHistoryDir) { + return [ + 'session-1000-a.json' as unknown as fs.Dirent, + 'session-2000-b.json' as unknown as fs.Dirent, + 'session-3000-c.json' as unknown as fs.Dirent, + ]; + } + return []; + }); + + vi.mocked(fs.statSync).mockReturnValue({ + size: 500, + mtimeMs: Date.now(), + isDirectory: () => false, + } as unknown as fs.Stats); + + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr.includes('.project_root')) return projectPath; + if (pStr.includes('-a.json')) { + return makeGeminiSession({ + sessionId: 'a', + lastUpdated: '2026-02-01T00:00:00.000Z', + }); + } + if (pStr.includes('-b.json')) { + return makeGeminiSession({ + sessionId: 'b', + lastUpdated: '2026-02-05T00:00:00.000Z', + }); + } + if (pStr.includes('-c.json')) { + return makeGeminiSession({ + sessionId: 'c', + lastUpdated: '2026-02-08T00:00:00.000Z', + }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + // Skip 1, take 1 + const result = listGeminiSessions(projectPath, { skip: 1, limit: 1 }); + + expect(result.totalCount).toBe(3); + expect(result.sessions).toHaveLength(1); + // Sorted newest first: c, b, a — skip 1 → b + expect(result.sessions[0].sessionId).toBe('b'); + }); + + it('should filter sessions by search keyword', () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + if (p === geminiHistoryDir) return true; + return false; + }); + + vi.mocked(fs.readdirSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr === geminiHistoryDir) { + return [ + 'session-1000-auth.json' as unknown as fs.Dirent, + 'session-2000-tests.json' as unknown as fs.Dirent, + ]; + } + return []; + }); + + vi.mocked(fs.statSync).mockReturnValue({ + size: 500, + mtimeMs: Date.now(), + isDirectory: () => false, + } as unknown as fs.Stats); + + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr.includes('.project_root')) return projectPath; + if (pStr.includes('auth')) { + return makeGeminiSession({ + sessionId: 'auth-session', + messages: [ + { type: 'user', content: 'Fix authentication flow' }, + { type: 'gemini', content: 'I will fix it' }, + ], + }); + } + if (pStr.includes('tests')) { + return makeGeminiSession({ + sessionId: 'tests-session', + messages: [ + { type: 'user', content: 'Write unit tests' }, + { type: 'gemini', content: 'Writing tests' }, + ], + }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const result = listGeminiSessions(projectPath, { search: 'auth' }); + + expect(result.totalCount).toBe(2); + expect(result.filteredCount).toBe(1); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0].sessionId).toBe('auth-session'); + }); + + it('should attach origins metadata when available', () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + + vi.mocked(fs.existsSync).mockImplementation((p) => { + if (p === geminiHistoryDir) return true; + return false; + }); + + vi.mocked(fs.readdirSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr === geminiHistoryDir) { + return ['session-1000-named.json' as unknown as fs.Dirent]; + } + return []; + }); + + vi.mocked(fs.statSync).mockReturnValue({ + size: 500, + mtimeMs: Date.now(), + isDirectory: () => false, + } as unknown as fs.Stats); + + const resolvedProjectPath = path.resolve(projectPath); + + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr.includes('.project_root')) return projectPath; + if (pStr.includes('maestro-agent-session-origins.json')) { + return JSON.stringify({ + origins: { + 'gemini-cli': { + [resolvedProjectPath]: { + 'named-session-id': { + origin: 'user', + sessionName: 'My Gemini Session', + starred: true, + }, + }, + }, + }, + }); + } + if (pStr.includes('named')) { + return makeGeminiSession({ + sessionId: 'named-session-id', + messages: [ + { type: 'user', content: 'Work on auth' }, + { type: 'gemini', content: 'OK' }, + ], + }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const result = listGeminiSessions(projectPath); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0].sessionName).toBe('My Gemini Session'); + expect(result.sessions[0].starred).toBe(true); + expect(result.sessions[0].origin).toBe('user'); + }); + + it('should use summary as display name when available', () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + if (p === geminiHistoryDir) return true; + return false; + }); + + vi.mocked(fs.readdirSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr === geminiHistoryDir) { + return ['session-1000-summary.json' as unknown as fs.Dirent]; + } + return []; + }); + + vi.mocked(fs.statSync).mockReturnValue({ + size: 500, + mtimeMs: Date.now(), + isDirectory: () => false, + } as unknown as fs.Stats); + + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr.includes('.project_root')) return projectPath; + if (pStr.includes('summary')) { + return makeGeminiSession({ + sessionId: 'summary-id', + summary: 'Authentication refactor session', + messages: [ + { type: 'user', content: 'Help me refactor auth' }, + { type: 'gemini', content: 'Sure' }, + ], + }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const result = listGeminiSessions(projectPath); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0].sessionName).toBe('Authentication refactor session'); + }); + + it('should calculate duration from startTime and lastUpdated', () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + if (p === geminiHistoryDir) return true; + return false; + }); + + vi.mocked(fs.readdirSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr === geminiHistoryDir) { + return ['session-1000-timed.json' as unknown as fs.Dirent]; + } + return []; + }); + + vi.mocked(fs.statSync).mockReturnValue({ + size: 500, + mtimeMs: Date.now(), + isDirectory: () => false, + } as unknown as fs.Stats); + + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr.includes('.project_root')) return projectPath; + if (pStr.includes('timed')) { + return makeGeminiSession({ + sessionId: 'timed-id', + startTime: '2026-02-08T10:00:00.000Z', + lastUpdated: '2026-02-08T10:05:00.000Z', + }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const result = listGeminiSessions(projectPath); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0].durationSeconds).toBe(300); // 5 minutes + expect(result.sessions[0].costUsd).toBe(0); // Gemini doesn't expose cost + }); + + it('should return durationSeconds 0 when timestamps are invalid (NaN guard)', () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + if (p === geminiHistoryDir) return true; + return false; + }); + + vi.mocked(fs.readdirSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr === geminiHistoryDir) { + return ['session-1000-badtime.json' as unknown as fs.Dirent]; + } + return []; + }); + + vi.mocked(fs.statSync).mockReturnValue({ + size: 500, + mtimeMs: Date.now(), + isDirectory: () => false, + } as unknown as fs.Stats); + + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pStr = p.toString(); + if (pStr.includes('.project_root')) return projectPath; + if (pStr.includes('badtime')) { + return makeGeminiSession({ + sessionId: 'badtime-id', + startTime: 'not-a-date', + lastUpdated: 'also-not-a-date', + }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const result = listGeminiSessions(projectPath); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0].durationSeconds).toBe(0); + }); +}); diff --git a/src/__tests__/cli/services/agent-spawner-gemini.test.ts b/src/__tests__/cli/services/agent-spawner-gemini.test.ts new file mode 100644 index 0000000000..cf12077e0e --- /dev/null +++ b/src/__tests__/cli/services/agent-spawner-gemini.test.ts @@ -0,0 +1,729 @@ +/** + * @file agent-spawner-gemini.test.ts + * @description Dedicated tests for spawnGeminiCli() and related Gemini CLI helpers. + * + * Covers: + * - Successful spawn with JSON stdout parsing (text events, partial streaming) + * - Spawn with non-zero exit code (Gemini-specific exit codes) + * - Spawn with malformed/empty stdout + * - getGeminiCommand() resolution (custom path, fallback) + * - mergeUsageStats() via multi-event accumulation + * - Timeout behavior + * - Model validation rejection + * - Session ID validation rejection + * + * TASK-T01 (P0 test coverage) + */ + +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import { EventEmitter } from 'events'; + +// ----------------------------------------------------------------------- +// Module-level mock setup +// ----------------------------------------------------------------------- + +const mockSpawn = vi.fn(); + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: (...args: unknown[]) => mockSpawn(...args), + default: { + ...actual, + spawn: (...args: unknown[]) => mockSpawn(...args), + }, + }; +}); + +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + existsSync: vi.fn(() => false), + promises: { + stat: vi.fn(), + access: vi.fn(), + readdir: vi.fn().mockResolvedValue([]), + }, + }; +}); + +const mockGetAgentCustomPath = vi.fn().mockReturnValue(undefined); +vi.mock('../../../cli/services/storage', () => ({ + getAgentCustomPath: (...args: unknown[]) => mockGetAgentCustomPath(...args), +})); + +vi.mock('../../../shared/uuid', () => ({ + generateUUID: () => '00000000-0000-4000-8000-000000000001', +})); + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => '/Users/testuser', + }; +}); + +// ----------------------------------------------------------------------- +// Helpers: create fresh mock child process per test +// ----------------------------------------------------------------------- + +function createMockChild() { + const stdin = { end: vi.fn() }; + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const child = Object.assign(new EventEmitter(), { stdin, stdout, stderr }); + return { child, stdin, stdout, stderr }; +} + +/** Emit NDJSON lines on stdout then close with given exit code */ +function emitAndClose(mock: ReturnType, lines: string[], exitCode = 0) { + for (const line of lines) { + mock.stdout.emit('data', Buffer.from(line + '\n')); + } + mock.child.emit('close', exitCode); +} + +// ----------------------------------------------------------------------- +// Import SUT (after mocks) +// ----------------------------------------------------------------------- + +import { + spawnGeminiCli, + getGeminiCommand, + detectGemini, +} from '../../../cli/services/agent-spawner'; + +// ----------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------- + +describe('spawnGeminiCli', () => { + let mock: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mock = createMockChild(); + mockSpawn.mockReturnValue(mock.child); + // Default: no matching session files + (fs.promises.readdir as Mock).mockResolvedValue([]); + }); + + // ------------------------------------------------------------------- + // 1. Successful spawn with JSON stdout parsing + // ------------------------------------------------------------------- + describe('successful spawn with JSON stdout', () => { + it('parses text events and returns concatenated response', async () => { + const promise = spawnGeminiCli({ prompt: 'hello', cwd: '/project' }); + await tick(); + + emitAndClose(mock, [ + JSON.stringify({ type: 'init', session_id: 'sess-1', model: 'gemini-2.0-flash' }), + JSON.stringify({ type: 'message', role: 'assistant', content: 'Hello ', delta: true }), + JSON.stringify({ type: 'message', role: 'assistant', content: 'world!' }), + JSON.stringify({ + type: 'result', + status: 'success', + stats: { input_tokens: 10, output_tokens: 5 }, + }), + ]); + + const result = await promise; + expect(result.success).toBe(true); + expect(result.response).toBe('Hello world!'); + expect(result.agentSessionId).toBe('sess-1'); + }); + + it('captures session ID from init event only once', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + emitAndClose(mock, [ + JSON.stringify({ type: 'init', session_id: 'first-id', model: 'gemini-2.0-flash' }), + JSON.stringify({ type: 'init', session_id: 'second-id', model: 'gemini-2.0-flash' }), + JSON.stringify({ type: 'result', status: 'success' }), + ]); + + const result = await promise; + expect(result.agentSessionId).toBe('first-id'); + }); + + it('handles partial streaming with delta events', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + emitAndClose(mock, [ + JSON.stringify({ type: 'message', role: 'assistant', content: 'chunk1', delta: true }), + JSON.stringify({ type: 'message', role: 'assistant', content: 'chunk2', delta: true }), + JSON.stringify({ type: 'message', role: 'assistant', content: 'chunk3' }), + JSON.stringify({ type: 'result', status: 'success' }), + ]); + + const result = await promise; + expect(result.success).toBe(true); + expect(result.response).toBe('chunk1chunk2chunk3'); + }); + + it('flushes pending partial text on close', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + // Only partial events, no final non-delta message + emitAndClose(mock, [ + JSON.stringify({ + type: 'message', + role: 'assistant', + content: 'partial only', + delta: true, + }), + JSON.stringify({ type: 'result', status: 'success' }), + ]); + + const result = await promise; + expect(result.success).toBe(true); + expect(result.response).toBe('partial only'); + }); + + it('passes correct args to spawn (base args + prompt)', async () => { + const promise = spawnGeminiCli({ prompt: 'my prompt', cwd: '/project' }); + await tick(); + + expect(mockSpawn).toHaveBeenCalledTimes(1); + const [cmd, args, opts] = mockSpawn.mock.calls[0]; + + expect(cmd).toBe('gemini'); + expect(args).toContain('-y'); + expect(args).toContain('--output-format'); + expect(args).toContain('stream-json'); + expect(args).toContain('-p'); + expect(args).toContain('my prompt'); + expect(opts.cwd).toBe('/project'); + + mock.child.emit('close', 0); + await promise; + }); + + it('includes -m flag when model is provided', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project', model: 'gemini-2.5-pro' }); + await tick(); + + const args = mockSpawn.mock.calls[0][1] as string[]; + expect(args).toContain('-m'); + expect(args).toContain('gemini-2.5-pro'); + + mock.child.emit('close', 0); + await promise; + }); + + it('includes --resume when session file exists', async () => { + (fs.promises.readdir as Mock).mockResolvedValue(['session-1709900000-my-session-id.json']); + + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project', resume: 'my-session-id' }); + await tick(); + + const args = mockSpawn.mock.calls[0][1] as string[]; + expect(args).toContain('--resume'); + expect(args).toContain('my-session-id'); + + mock.child.emit('close', 0); + await promise; + }); + + it('closes stdin immediately', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + expect(mock.stdin.end).toHaveBeenCalled(); + + mock.child.emit('close', 0); + await promise; + }); + }); + + // ------------------------------------------------------------------- + // 2. Spawn with non-zero exit code + // ------------------------------------------------------------------- + describe('non-zero exit code', () => { + it('returns error on non-zero exit with stderr', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + mock.stderr.emit('data', Buffer.from('Something went wrong')); + mock.child.emit('close', 1); + + const result = await promise; + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('returns error for auth failure (exit code 41)', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + mock.child.emit('close', 41); + + const result = await promise; + expect(result.success).toBe(false); + expect(result.error).toContain('authentication'); + }); + + it('returns error for turn limit (exit code 53)', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + mock.child.emit('close', 53); + + const result = await promise; + expect(result.success).toBe(false); + expect(result.error).toContain('turn limit'); + }); + + it('returns error from error event in stdout', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + emitAndClose( + mock, + [JSON.stringify({ type: 'error', severity: 'error', message: 'Rate limit exceeded' })], + 1 + ); + + const result = await promise; + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('returns generic error when exit code is non-zero with no stderr', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + mock.child.emit('close', 99); + + const result = await promise; + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('preserves session ID and usage stats on error', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + emitAndClose( + mock, + [ + JSON.stringify({ type: 'init', session_id: 'err-sess' }), + JSON.stringify({ + type: 'result', + status: 'success', + stats: { input_tokens: 50, output_tokens: 25 }, + }), + ], + 1 + ); + + const result = await promise; + expect(result.success).toBe(false); + expect(result.agentSessionId).toBe('err-sess'); + expect(result.usageStats).toBeDefined(); + }); + }); + + // ------------------------------------------------------------------- + // 3. Malformed / empty stdout + // ------------------------------------------------------------------- + describe('malformed and empty stdout', () => { + it('handles completely empty stdout', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + mock.child.emit('close', 0); + + const result = await promise; + // No error event but also no response text + expect(result.success).toBe(true); + expect(result.response).toBeUndefined(); + }); + + it('ignores non-JSON lines in stdout', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + emitAndClose(mock, [ + 'Some debug text', + 'More noise', + JSON.stringify({ type: 'message', role: 'assistant', content: 'Hello' }), + JSON.stringify({ type: 'result', status: 'success' }), + ]); + + const result = await promise; + expect(result.success).toBe(true); + expect(result.response).toBe('Hello'); + }); + + it('handles malformed JSON lines gracefully', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + emitAndClose(mock, [ + '{"type":"message","role":"assis', // truncated + 'tant","content":"Bad"}', // continuation on next line (won't pair) + JSON.stringify({ type: 'message', role: 'assistant', content: 'Good' }), + JSON.stringify({ type: 'result', status: 'success' }), + ]); + + const result = await promise; + expect(result.success).toBe(true); + expect(result.response).toBe('Good'); + }); + + it('handles partial JSON buffering across data events', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + // Send JSON split across two data events + mock.stdout.emit('data', Buffer.from('{"type":"message","role":"assistant",')); + mock.stdout.emit('data', Buffer.from('"content":"buffered"}\n')); + mock.stdout.emit( + 'data', + Buffer.from(JSON.stringify({ type: 'result', status: 'success' }) + '\n') + ); + mock.child.emit('close', 0); + + const result = await promise; + expect(result.success).toBe(true); + expect(result.response).toBe('buffered'); + }); + + it('ignores user role messages', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + emitAndClose(mock, [ + JSON.stringify({ type: 'message', role: 'user', content: 'Should be skipped' }), + JSON.stringify({ type: 'message', role: 'assistant', content: 'Only this' }), + JSON.stringify({ type: 'result', status: 'success' }), + ]); + + const result = await promise; + expect(result.success).toBe(true); + expect(result.response).toBe('Only this'); + }); + }); + + // ------------------------------------------------------------------- + // 4. getGeminiCommand() resolution + // ------------------------------------------------------------------- + describe('getGeminiCommand resolution', () => { + it('returns default "gemini" when no custom path is set', () => { + expect(getGeminiCommand()).toBe('gemini'); + }); + + it('uses default command in spawn when no detection performed', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + const cmd = mockSpawn.mock.calls[0][0]; + expect(cmd).toBe('gemini'); + + mock.child.emit('close', 0); + await promise; + }); + }); + + // ------------------------------------------------------------------- + // 5. Usage stats accumulation (mergeUsageStats via spawnGeminiCli) + // ------------------------------------------------------------------- + describe('usage stats accumulation', () => { + it('extracts flat usage stats from result event', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + emitAndClose(mock, [ + JSON.stringify({ + type: 'result', + status: 'success', + stats: { input_tokens: 100, output_tokens: 50, cached: 20 }, + }), + ]); + + const result = await promise; + expect(result.success).toBe(true); + expect(result.usageStats).toBeDefined(); + expect(result.usageStats!.inputTokens).toBe(100); + expect(result.usageStats!.outputTokens).toBe(50); + expect(result.usageStats!.cacheReadInputTokens).toBe(20); + }); + + it('extracts nested model-based usage stats', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + emitAndClose(mock, [ + JSON.stringify({ + type: 'result', + status: 'success', + stats: { + models: { + 'gemini-2.0-flash': { + tokens: { input: 200, prompt: 50, candidates: 80, cached: 30, thoughts: 10 }, + }, + }, + }, + }), + ]); + + const result = await promise; + expect(result.success).toBe(true); + expect(result.usageStats).toBeDefined(); + // input = input(200) + prompt(50) = 250 + expect(result.usageStats!.inputTokens).toBe(250); + expect(result.usageStats!.outputTokens).toBe(80); + expect(result.usageStats!.cacheReadInputTokens).toBe(30); + }); + + it('accumulates usage across multiple result events', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + emitAndClose(mock, [ + JSON.stringify({ + type: 'result', + status: 'success', + stats: { input_tokens: 100, output_tokens: 50 }, + }), + JSON.stringify({ + type: 'result', + status: 'success', + stats: { input_tokens: 200, output_tokens: 100 }, + }), + ]); + + const result = await promise; + expect(result.usageStats).toBeDefined(); + // mergeUsageStats adds up across events + expect(result.usageStats!.inputTokens).toBe(300); + expect(result.usageStats!.outputTokens).toBe(150); + }); + + it('returns no usageStats when result has no stats', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + emitAndClose(mock, [JSON.stringify({ type: 'result', status: 'success' })]); + + const result = await promise; + expect(result.success).toBe(true); + expect(result.usageStats).toBeUndefined(); + }); + + it('tracks contextWindow as max across events', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + // Emit two result events with different contextWindow values + // Note: contextWindow comes through mergeUsageStats but Gemini stats + // don't have a direct contextWindow field — test that mergeUsageStats + // takes max + emitAndClose(mock, [ + JSON.stringify({ + type: 'message', + role: 'assistant', + content: 'ok', + }), + JSON.stringify({ type: 'result', status: 'success' }), + ]); + + const result = await promise; + expect(result.success).toBe(true); + }); + }); + + // ------------------------------------------------------------------- + // 6. Timeout behavior + // ------------------------------------------------------------------- + describe('timeout behavior', () => { + it('passes timeout to spawn options when provided', async () => { + const promise = spawnGeminiCli({ + prompt: 'test', + cwd: '/project', + timeout: 30000, + }); + await tick(); + + const spawnOpts = mockSpawn.mock.calls[0][2]; + expect(spawnOpts.timeout).toBe(30000); + + mock.child.emit('close', 0); + await promise; + }); + + it('does not set timeout when not provided', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + const spawnOpts = mockSpawn.mock.calls[0][2]; + expect(spawnOpts.timeout).toBeUndefined(); + + mock.child.emit('close', 0); + await promise; + }); + }); + + // ------------------------------------------------------------------- + // 7. Model validation rejection + // ------------------------------------------------------------------- + describe('model validation', () => { + it('rejects model containing shell metacharacters', async () => { + await expect( + spawnGeminiCli({ prompt: 'test', cwd: '/project', model: 'model; rm -rf /' }) + ).rejects.toThrow('Invalid model identifier'); + }); + + it('rejects model containing backticks', async () => { + await expect( + spawnGeminiCli({ prompt: 'test', cwd: '/project', model: 'model`id`' }) + ).rejects.toThrow('Invalid model identifier'); + }); + + it('rejects model containing spaces', async () => { + await expect( + spawnGeminiCli({ prompt: 'test', cwd: '/project', model: 'model name' }) + ).rejects.toThrow('Invalid model identifier'); + }); + + it('rejects model containing pipes', async () => { + await expect( + spawnGeminiCli({ prompt: 'test', cwd: '/project', model: 'model|cat' }) + ).rejects.toThrow('Invalid model identifier'); + }); + + it('accepts valid model with dots, hyphens, slashes', async () => { + const promise = spawnGeminiCli({ + prompt: 'test', + cwd: '/project', + model: 'gemini-2.5-pro/latest', + }); + await tick(); + + mock.child.emit('close', 0); + const result = await promise; + expect(result).toBeDefined(); + }); + }); + + // ------------------------------------------------------------------- + // 8. Session ID validation rejection + // ------------------------------------------------------------------- + describe('session ID validation', () => { + it('rejects resume ID with $() command substitution', async () => { + await expect( + spawnGeminiCli({ prompt: 'test', cwd: '/project', resume: 'id$(whoami)' }) + ).rejects.toThrow('Invalid session ID for resume'); + }); + + it('rejects resume ID with pipe', async () => { + await expect( + spawnGeminiCli({ prompt: 'test', cwd: '/project', resume: 'id|cat' }) + ).rejects.toThrow('Invalid session ID for resume'); + }); + + it('rejects resume ID with spaces', async () => { + await expect( + spawnGeminiCli({ prompt: 'test', cwd: '/project', resume: 'id with spaces' }) + ).rejects.toThrow('Invalid session ID for resume'); + }); + + it('accepts valid resume ID with dots, colons, hyphens', async () => { + (fs.promises.readdir as Mock).mockResolvedValue([ + 'session-1709900000-session-2025:03.08_test.json', + ]); + + const promise = spawnGeminiCli({ + prompt: 'test', + cwd: '/project', + resume: 'session-2025:03.08_test', + }); + await tick(); + + mock.child.emit('close', 0); + const result = await promise; + expect(result).toBeDefined(); + }); + + it('omits --resume when session file not found and logs warning', async () => { + (fs.promises.readdir as Mock).mockResolvedValue([]); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const promise = spawnGeminiCli({ + prompt: 'test', + cwd: '/project', + resume: 'nonexistent', + }); + await tick(); + + const args = mockSpawn.mock.calls[0][1] as string[]; + expect(args).not.toContain('--resume'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Session file not found')); + + mock.child.emit('close', 0); + await promise; + warnSpy.mockRestore(); + }); + }); + + // ------------------------------------------------------------------- + // 9. Spawn error (ENOENT) + // ------------------------------------------------------------------- + describe('spawn error', () => { + it('returns error when binary is not found (ENOENT)', async () => { + const promise = spawnGeminiCli({ prompt: 'test', cwd: '/project' }); + await tick(); + + mock.child.emit('error', new Error('spawn gemini ENOENT')); + + const result = await promise; + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to spawn Gemini CLI'); + expect(result.error).toContain('ENOENT'); + }); + }); + + // ------------------------------------------------------------------- + // 10. Custom env passthrough + // ------------------------------------------------------------------- + describe('custom environment', () => { + it('merges custom env vars into spawn environment', async () => { + const promise = spawnGeminiCli({ + prompt: 'test', + cwd: '/project', + env: { GEMINI_API_KEY: 'test-key' }, + }); + await tick(); + + const spawnOpts = mockSpawn.mock.calls[0][2]; + expect(spawnOpts.env.GEMINI_API_KEY).toBe('test-key'); + + mock.child.emit('close', 0); + await promise; + }); + }); +}); + +// ----------------------------------------------------------------------- +// getGeminiCommand — default behavior (detectGemini caching is tested +// in agent-spawner.test.ts, which has proper vi.resetModules() isolation) +// ----------------------------------------------------------------------- + +// ----------------------------------------------------------------------- +// Utility +// ----------------------------------------------------------------------- + +function tick() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} diff --git a/src/__tests__/cli/services/agent-spawner.test.ts b/src/__tests__/cli/services/agent-spawner.test.ts index 3e69a0772e..15d0ee66f9 100644 --- a/src/__tests__/cli/services/agent-spawner.test.ts +++ b/src/__tests__/cli/services/agent-spawner.test.ts @@ -52,9 +52,11 @@ vi.mock('fs', async (importOriginal) => { ...actual, readFileSync: vi.fn(), writeFileSync: vi.fn(), + existsSync: vi.fn(() => false), promises: { stat: vi.fn(), access: vi.fn(), + readdir: vi.fn(), }, constants: { X_OK: 1, @@ -81,6 +83,7 @@ import { getClaudeCommand, detectClaude, spawnAgent, + spawnGeminiCli, AgentResult, } from '../../../cli/services/agent-spawner'; @@ -678,6 +681,98 @@ Some text with [x] in it that's not a checkbox }); }); + describe('detectGemini', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('should detect Gemini CLI via custom path', async () => { + mockGetAgentCustomPath.mockImplementation((agentId: string) => { + if (agentId === 'gemini-cli') { + return '/custom/path/to/gemini'; + } + return undefined; + }); + vi.mocked(fs.promises.stat).mockResolvedValue({ + isFile: () => true, + } as fs.Stats); + vi.mocked(fs.promises.access).mockResolvedValue(undefined); + + const { detectGemini } = await import('../../../cli/services/agent-spawner'); + const result = await detectGemini(); + + expect(result.available).toBe(true); + expect(result.path).toBe('/custom/path/to/gemini'); + expect(result.source).toBe('settings'); + }); + + it('should return unavailable when Gemini CLI is not found', async () => { + mockGetAgentCustomPath.mockReturnValue(undefined); + mockSpawn.mockReturnValue(mockChild); + + const { detectGemini } = await import('../../../cli/services/agent-spawner'); + const resultPromise = detectGemini(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + mockChild.emit('close', 1); + + const result = await resultPromise; + expect(result.available).toBe(false); + expect(result.path).toBeUndefined(); + }); + }); + + describe('binary detection timeout', () => { + beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should return undefined when which command hangs past 5s timeout', async () => { + mockGetAgentCustomPath.mockReturnValue(undefined); + // Mock spawn that never emits close (simulates a hung process) + const hangingChild = Object.assign(new EventEmitter(), { + stdin: { end: vi.fn() }, + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); + mockSpawn.mockReturnValue(hangingChild); + + const { detectGemini } = await import('../../../cli/services/agent-spawner'); + const resultPromise = detectGemini(); + + // Advance past the 5s timeout + await vi.advanceTimersByTimeAsync(5100); + + const result = await resultPromise; + expect(result.available).toBe(false); + expect(result.path).toBeUndefined(); + }); + + it('should resolve before timeout when which responds quickly', async () => { + mockGetAgentCustomPath.mockReturnValue(undefined); + mockSpawn.mockReturnValue(mockChild); + + const { detectClaude: freshDetectClaude } = + await import('../../../cli/services/agent-spawner'); + const resultPromise = freshDetectClaude(); + + // Simulate which finding claude immediately + await vi.advanceTimersByTimeAsync(0); + mockStdout.emit('data', Buffer.from('/usr/bin/claude\n')); + await vi.advanceTimersByTimeAsync(0); + mockChild.emit('close', 0); + + const result = await resultPromise; + expect(result.available).toBe(true); + expect(result.path).toBe('/usr/bin/claude'); + }); + }); + describe('spawnAgent', () => { beforeEach(() => { mockSpawn.mockReturnValue(mockChild); @@ -1075,6 +1170,42 @@ Some text with [x] in it that's not a checkbox } }); + it('should include read-only args for Claude when readOnlyMode is true', async () => { + const resultPromise = spawnAgent('claude-code', '/project', 'prompt', undefined, { + readOnlyMode: true, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const [, args] = mockSpawn.mock.calls[0]; + // Should include Claude's read-only args from centralized definitions + expect(args).toContain('--permission-mode'); + expect(args).toContain('plan'); + // Should still have base args + expect(args).toContain('--print'); + expect(args).toContain('--dangerously-skip-permissions'); + + mockStdout.emit('data', Buffer.from('{"type":"result","result":"Done"}\n')); + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should not include read-only args when readOnlyMode is false', async () => { + const resultPromise = spawnAgent('claude-code', '/project', 'prompt', undefined, { + readOnlyMode: false, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const [, args] = mockSpawn.mock.calls[0]; + expect(args).not.toContain('--permission-mode'); + expect(args).not.toContain('plan'); + + mockStdout.emit('data', Buffer.from('{"type":"result","result":"Done"}\n')); + mockChild.emit('close', 0); + await resultPromise; + }); + it('should generate unique session-id for each spawn', async () => { // First spawn const promise1 = spawnAgent('claude-code', '/project', 'prompt1'); @@ -1291,4 +1422,147 @@ Some text with [x] in it that's not a checkbox Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); }); }); + + // ----------------------------------------------------------------------- + // spawnGeminiCli input validation (TASK-S04) + // ----------------------------------------------------------------------- + describe('spawnGeminiCli — input validation', () => { + it('rejects model with shell metacharacters', async () => { + await expect( + spawnGeminiCli({ prompt: 'test', cwd: '/tmp', model: 'model; rm -rf /' }) + ).rejects.toThrow('Invalid model identifier: contains disallowed characters'); + }); + + it('rejects model with backticks', async () => { + await expect( + spawnGeminiCli({ prompt: 'test', cwd: '/tmp', model: 'model`cmd`' }) + ).rejects.toThrow('Invalid model identifier: contains disallowed characters'); + }); + + it('rejects model with spaces', async () => { + await expect( + spawnGeminiCli({ prompt: 'test', cwd: '/tmp', model: 'model name' }) + ).rejects.toThrow('Invalid model identifier: contains disallowed characters'); + }); + + it('accepts valid model identifiers', async () => { + mockSpawn.mockReturnValue(mockChild); + // Should not throw — the promise resolves when process exits + const promise = spawnGeminiCli({ + prompt: 'test', + cwd: '/tmp', + model: 'gemini-2.0-flash/latest', + }); + // Trigger process close to resolve the promise + (mockChild as EventEmitter).emit('close', 0); + const result = await promise; + expect(result).toBeDefined(); + }); + + it('rejects resume ID with shell metacharacters', async () => { + await expect( + spawnGeminiCli({ prompt: 'test', cwd: '/tmp', resume: 'id$(whoami)' }) + ).rejects.toThrow('Invalid session ID for resume: contains disallowed characters'); + }); + + it('rejects resume ID with pipe characters', async () => { + await expect( + spawnGeminiCli({ prompt: 'test', cwd: '/tmp', resume: 'id|cat /etc/passwd' }) + ).rejects.toThrow('Invalid session ID for resume: contains disallowed characters'); + }); + + it('accepts valid resume IDs with dots, colons, and hyphens', async () => { + // Emit close after spawn is called (async readdir resolves first) + mockSpawn.mockImplementation(() => { + process.nextTick(() => (mockChild as EventEmitter).emit('close', 0)); + return mockChild; + }); + (fs.promises.readdir as Mock).mockResolvedValue([ + 'session-1709900000-session-2025:03.08_abc.json', + ]); + const result = await spawnGeminiCli({ + prompt: 'test', + cwd: '/tmp', + resume: 'session-2025:03.08_abc', + }); + expect(result).toBeDefined(); + }); + }); + + // ----------------------------------------------------------------------- + // spawnGeminiCli session resume validation (TASK-M08) + // ----------------------------------------------------------------------- + describe('spawnGeminiCli — session resume validation', () => { + it('omits --resume when session file does not exist', async () => { + // Emit close after spawn is called + mockSpawn.mockImplementation(() => { + process.nextTick(() => (mockChild as EventEmitter).emit('close', 0)); + return mockChild; + }); + // No matching session file in history dir + (fs.promises.readdir as Mock).mockResolvedValue(['session-1709900000-other-session-id.json']); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await spawnGeminiCli({ + prompt: 'test', + cwd: '/tmp/myproject', + resume: 'nonexistent-session-id', + }); + + // --resume should NOT be in the spawn args + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + expect(spawnArgs).not.toContain('--resume'); + expect(spawnArgs).not.toContain('nonexistent-session-id'); + + // Warning should be logged + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Session file not found for resume ID "nonexistent-session-id"') + ); + warnSpy.mockRestore(); + }); + + it('includes --resume when session file exists', async () => { + mockSpawn.mockImplementation(() => { + process.nextTick(() => (mockChild as EventEmitter).emit('close', 0)); + return mockChild; + }); + (fs.promises.readdir as Mock).mockResolvedValue(['session-1709900000-valid-session-id.json']); + + await spawnGeminiCli({ + prompt: 'test', + cwd: '/tmp/myproject', + resume: 'valid-session-id', + }); + + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + expect(spawnArgs).toContain('--resume'); + expect(spawnArgs).toContain('valid-session-id'); + }); + + it('omits --resume when history directory does not exist', async () => { + mockSpawn.mockImplementation(() => { + process.nextTick(() => (mockChild as EventEmitter).emit('close', 0)); + return mockChild; + }); + // readdir throws ENOENT when directory doesn't exist + (fs.promises.readdir as Mock).mockRejectedValue( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + ); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await spawnGeminiCli({ + prompt: 'test', + cwd: '/tmp/myproject', + resume: 'some-session-id', + }); + + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + expect(spawnArgs).not.toContain('--resume'); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Session file not found for resume ID "some-session-id"') + ); + warnSpy.mockRestore(); + }); + }); }); diff --git a/src/__tests__/integration/AutoRunBatchProcessing.test.tsx b/src/__tests__/integration/AutoRunBatchProcessing.test.tsx index 7fcead2134..01a55e6f5b 100644 --- a/src/__tests__/integration/AutoRunBatchProcessing.test.tsx +++ b/src/__tests__/integration/AutoRunBatchProcessing.test.tsx @@ -46,6 +46,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/integration/AutoRunRightPanel.test.tsx b/src/__tests__/integration/AutoRunRightPanel.test.tsx index bd86b4d32c..7166462af0 100644 --- a/src/__tests__/integration/AutoRunRightPanel.test.tsx +++ b/src/__tests__/integration/AutoRunRightPanel.test.tsx @@ -35,6 +35,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/integration/AutoRunSessionList.test.tsx b/src/__tests__/integration/AutoRunSessionList.test.tsx index 420b44820a..291021ba55 100644 --- a/src/__tests__/integration/AutoRunSessionList.test.tsx +++ b/src/__tests__/integration/AutoRunSessionList.test.tsx @@ -52,6 +52,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/integration/symphony.integration.test.ts b/src/__tests__/integration/symphony.integration.test.ts index 0963fd47b7..1ba1564545 100644 --- a/src/__tests__/integration/symphony.integration.test.ts +++ b/src/__tests__/integration/symphony.integration.test.ts @@ -1986,7 +1986,7 @@ error: failed to push some refs to 'https://github.com/owner/protected-repo.git' // Test paths with spaces - common in user-created directories const pathsWithSpaces = [ 'docs/my document.md', - 'Auto Run Docs/task 1.md', + '.maestro/playbooks/task 1.md', 'path with spaces/sub folder/file.md', ' leading-spaces.md', // Leading spaces 'trailing-spaces.md ', // Trailing spaces (may be trimmed) diff --git a/src/__tests__/main/agents/capabilities.test.ts b/src/__tests__/main/agents/capabilities.test.ts index b2b74fb41d..6e5f5df58e 100644 --- a/src/__tests__/main/agents/capabilities.test.ts +++ b/src/__tests__/main/agents/capabilities.test.ts @@ -113,9 +113,27 @@ describe('agent-capabilities', () => { it('should have capabilities for gemini-cli', () => { const capabilities = AGENT_CAPABILITIES['gemini-cli']; expect(capabilities).toBeDefined(); - // Gemini supports multimodal - expect(capabilities.supportsImageInput).toBe(true); - expect(capabilities.supportsStreaming).toBe(true); + // Verified against Gemini CLI v0.29.5 docs + expect(capabilities.supportsResume).toBe(true); // --resume [index|UUID] + expect(capabilities.supportsReadOnlyMode).toBe(true); // --approval-mode plan (experimental) + expect(capabilities.supportsJsonOutput).toBe(true); // --output-format stream-json + expect(capabilities.supportsSessionId).toBe(true); // session_id in init event + expect(capabilities.supportsImageInput).toBe(false); // No --image flag for batch mode + expect(capabilities.supportsImageInputOnResume).toBe(false); + expect(capabilities.supportsSlashCommands).toBe(false); // Not in JSON output + expect(capabilities.supportsSessionStorage).toBe(true); // ~/.gemini/tmp//chats/ + expect(capabilities.supportsCostTracking).toBe(false); // Free tier + expect(capabilities.supportsUsageStats).toBe(true); // Token stats in result event + expect(capabilities.supportsBatchMode).toBe(true); // -p or positional args + expect(capabilities.requiresPromptToStart).toBe(true); + expect(capabilities.supportsStreaming).toBe(true); // NDJSON stream + expect(capabilities.supportsResultMessages).toBe(true); // 'result' event + expect(capabilities.supportsModelSelection).toBe(true); // -m/--model + expect(capabilities.supportsStreamJsonInput).toBe(false); + expect(capabilities.supportsThinkingDisplay).toBe(true); // includeThoughts + expect(capabilities.supportsContextMerge).toBe(true); + expect(capabilities.supportsContextExport).toBe(true); + expect(capabilities.imageResumeMode).toBeUndefined(); }); it('should have capabilities for qwen3-coder', () => { diff --git a/src/__tests__/main/autorun-folder-validation.test.ts b/src/__tests__/main/autorun-folder-validation.test.ts index aefad5018b..ed7b198353 100644 --- a/src/__tests__/main/autorun-folder-validation.test.ts +++ b/src/__tests__/main/autorun-folder-validation.test.ts @@ -256,8 +256,8 @@ describe('Auto Run Folder Validation', () => { }); it('should handle paths with spaces', () => { - const folderPath = '/test/Auto Run Docs'; - const filePath = '/test/Auto Run Docs/My Document.md'; + const folderPath = '/test/.maestro/playbooks'; + const filePath = '/test/.maestro/playbooks/My Document.md'; expect(validatePathWithinFolder(filePath, folderPath)).toBe(true); }); diff --git a/src/__tests__/main/autorun-ipc.test.ts b/src/__tests__/main/autorun-ipc.test.ts index 5a4f0d2069..213da88d15 100644 --- a/src/__tests__/main/autorun-ipc.test.ts +++ b/src/__tests__/main/autorun-ipc.test.ts @@ -8,7 +8,7 @@ * - autorun:listImages - list images for a document * - autorun:saveImage - save image with timestamp naming * - autorun:deleteImage - delete image file - * - autorun:deleteFolder - delete Auto Run Docs folder + * - autorun:deleteFolder - delete .maestro/playbooks folder * - autorun:createBackup - create backup copy of document for reset-on-completion * - autorun:restoreBackup - restore document from backup and delete backup file * - autorun:deleteBackups - delete all backup files in folder recursively @@ -961,12 +961,12 @@ describe('Auto Run IPC Handlers', () => { describe('autorun:deleteFolder', () => { describe('successful operations', () => { - it('should delete Auto Run Docs folder recursively', async () => { + it('should delete .maestro/playbooks folder recursively', async () => { mockStat.mockResolvedValue({ isDirectory: () => true }); mockRm.mockResolvedValue(undefined); const projectPath = '/test/project'; - const autoRunFolder = path.join(projectPath, 'Auto Run Docs'); + const autoRunFolder = path.join(projectPath, '.maestro/playbooks'); await mockStat(autoRunFolder); await mockRm(autoRunFolder, { recursive: true, force: true }); @@ -984,12 +984,13 @@ describe('Auto Run IPC Handlers', () => { }); describe('path validation', () => { - it('should only delete Auto Run Docs folder', () => { + it('should only delete playbooks folder', () => { + const ALLOWED_FOLDER_NAMES = new Set(['playbooks', 'Auto Run Docs']); const validateFolderName = (folderPath: string): boolean => { - return path.basename(folderPath) === 'Auto Run Docs'; + return ALLOWED_FOLDER_NAMES.has(path.basename(folderPath)); }; - expect(validateFolderName('/project/Auto Run Docs')).toBe(true); + expect(validateFolderName('/project/.maestro/playbooks')).toBe(true); expect(validateFolderName('/project/Documents')).toBe(false); expect(validateFolderName('/project/node_modules')).toBe(false); }); @@ -1011,8 +1012,8 @@ describe('Auto Run IPC Handlers', () => { it('should return error for non-directory path', async () => { mockStat.mockResolvedValue({ isDirectory: () => false }); - const result = { success: false, error: 'Auto Run Docs path is not a directory' }; - expect(result.error).toBe('Auto Run Docs path is not a directory'); + const result = { success: false, error: '.maestro/playbooks path is not a directory' }; + expect(result.error).toBe('.maestro/playbooks path is not a directory'); }); it('should return error for rm failure', async () => { @@ -1020,19 +1021,20 @@ describe('Auto Run IPC Handlers', () => { mockRm.mockRejectedValue(new Error('EACCES: permission denied')); await expect( - mockRm('/protected/Auto Run Docs', { recursive: true, force: true }) + mockRm('/protected/.maestro/playbooks', { recursive: true, force: true }) ).rejects.toThrow('EACCES'); }); it('should fail safety check for wrong folder name', () => { + const ALLOWED_FOLDER_NAMES = new Set(['playbooks', 'Auto Run Docs']); const folderName = path.basename('/project/WrongFolder'); - if (folderName !== 'Auto Run Docs') { + if (!ALLOWED_FOLDER_NAMES.has(folderName)) { const result = { success: false, - error: 'Safety check failed: not an Auto Run Docs folder', + error: 'Safety check failed: not a playbooks folder', }; - expect(result.error).toBe('Safety check failed: not an Auto Run Docs folder'); + expect(result.error).toBe('Safety check failed: not a playbooks folder'); } }); }); diff --git a/src/__tests__/main/cue/cue-completion-chains.test.ts b/src/__tests__/main/cue/cue-completion-chains.test.ts new file mode 100644 index 0000000000..42bb10b62d --- /dev/null +++ b/src/__tests__/main/cue/cue-completion-chains.test.ts @@ -0,0 +1,592 @@ +/** + * Tests for Cue Engine completion chains (Phase 09). + * + * Tests cover: + * - Completion event emission after Cue runs + * - Completion data in event payloads + * - Session name matching (matching by name, not just ID) + * - Fan-out dispatch to multiple target sessions + * - Fan-in data tracking (output concatenation, session names) + * - Fan-in timeout handling (break and continue modes) + * - hasCompletionSubscribers check + * - clearFanInState cleanup + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/types'; + +// Mock the yaml loader +const mockLoadCueConfig = vi.fn<(projectRoot: string) => CueConfig | null>(); +const mockWatchCueYaml = vi.fn<(projectRoot: string, onChange: () => void) => () => void>(); +vi.mock('../../../main/cue/cue-yaml-loader', () => ({ + loadCueConfig: (...args: unknown[]) => mockLoadCueConfig(args[0] as string), + watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), +})); + +// Mock the file watcher +const mockCreateCueFileWatcher = vi.fn<(config: unknown) => () => void>(); +vi.mock('../../../main/cue/cue-file-watcher', () => ({ + createCueFileWatcher: (...args: unknown[]) => mockCreateCueFileWatcher(args[0]), +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), +})); + +import { CueEngine, type CueEngineDeps } from '../../../main/cue/cue-engine'; + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockConfig(overrides: Partial = {}): CueConfig { + return { + subscriptions: [], + settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 }, + ...overrides, + }; +} + +function createMockDeps(overrides: Partial = {}): CueEngineDeps { + return { + getSessions: vi.fn(() => [createMockSession()]), + onCueRun: vi.fn(async () => ({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'test', + event: {} as CueEvent, + status: 'completed' as const, + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + onLog: vi.fn(), + ...overrides, + }; +} + +describe('CueEngine completion chains', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockWatchCueYaml.mockReturnValue(vi.fn()); + mockCreateCueFileWatcher.mockReturnValue(vi.fn()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('completion data in event payload', () => { + it('includes completion data when provided', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'agent-a', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-a', { + sessionName: 'Agent A', + status: 'completed', + exitCode: 0, + durationMs: 5000, + stdout: 'test output', + triggeredBy: 'some-sub', + }); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'follow up', + expect.objectContaining({ + type: 'agent.completed', + payload: expect.objectContaining({ + sourceSession: 'Agent A', + sourceSessionId: 'agent-a', + status: 'completed', + exitCode: 0, + durationMs: 5000, + sourceOutput: 'test output', + triggeredBy: 'some-sub', + }), + }) + ); + + engine.stop(); + }); + + it('truncates sourceOutput to 5000 chars', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'agent-a', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + const longOutput = 'x'.repeat(10000); + engine.notifyAgentCompleted('agent-a', { stdout: longOutput }); + + const call = (deps.onCueRun as ReturnType).mock.calls[0]; + const event = call[2] as CueEvent; + expect((event.payload.sourceOutput as string).length).toBe(5000); + + engine.stop(); + }); + }); + + describe('session name matching', () => { + it('matches by session name when source_session uses name', () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Test Session' }), + createMockSession({ id: 'session-2', name: 'Agent Alpha' }), + ]; + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-alpha-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'Agent Alpha', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('session-2'); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'follow up', + expect.objectContaining({ + type: 'agent.completed', + triggerName: 'on-alpha-done', + }) + ); + + engine.stop(); + }); + }); + + describe('completion event emission (chaining)', () => { + it('emits completion event after Cue run finishes', async () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Source', projectRoot: '/proj1' }), + createMockSession({ id: 'session-2', name: 'Downstream', projectRoot: '/proj2' }), + ]; + + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'do work', + interval_minutes: 60, + }, + ], + }); + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'chain', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'Source', + }, + ], + }); + + mockLoadCueConfig.mockImplementation((projectRoot) => { + if (projectRoot === '/proj1') return config1; + if (projectRoot === '/proj2') return config2; + return null; + }); + + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(100); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'do work', + expect.objectContaining({ type: 'time.interval' }) + ); + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-2', + 'follow up', + expect.objectContaining({ type: 'agent.completed', triggerName: 'chain' }) + ); + + engine.stop(); + }); + }); + + describe('fan-out', () => { + it('dispatches to each fan_out target session', () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Orchestrator', projectRoot: '/projects/orch' }), + createMockSession({ id: 'session-2', name: 'Frontend', projectRoot: '/projects/fe' }), + createMockSession({ id: 'session-3', name: 'Backend', projectRoot: '/projects/be' }), + ]; + const config = createMockConfig({ + subscriptions: [ + { + name: 'deploy-all', + event: 'agent.completed', + enabled: true, + prompt: 'deploy', + source_session: 'trigger-session', + fan_out: ['Frontend', 'Backend'], + }, + ], + }); + // Only the orchestrator session owns the subscription + mockLoadCueConfig.mockImplementation((root: string) => + root === '/projects/orch' ? config : null + ); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('trigger-session'); + + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-2', + 'deploy', + expect.objectContaining({ + payload: expect.objectContaining({ fanOutSource: 'trigger-session', fanOutIndex: 0 }), + }) + ); + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-3', + 'deploy', + expect.objectContaining({ + payload: expect.objectContaining({ fanOutSource: 'trigger-session', fanOutIndex: 1 }), + }) + ); + + engine.stop(); + }); + + it('logs fan-out dispatch', () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Orchestrator', projectRoot: '/projects/orch' }), + createMockSession({ id: 'session-2', name: 'Frontend', projectRoot: '/projects/fe' }), + createMockSession({ id: 'session-3', name: 'Backend', projectRoot: '/projects/be' }), + ]; + const config = createMockConfig({ + subscriptions: [ + { + name: 'deploy-all', + event: 'agent.completed', + enabled: true, + prompt: 'deploy', + source_session: 'trigger-session', + fan_out: ['Frontend', 'Backend'], + }, + ], + }); + mockLoadCueConfig.mockImplementation((root: string) => + root === '/projects/orch' ? config : null + ); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('trigger-session'); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Fan-out: "deploy-all" → Frontend, Backend') + ); + + engine.stop(); + }); + + it('skips missing fan-out targets with log', () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Orchestrator', projectRoot: '/projects/orch' }), + createMockSession({ id: 'session-2', name: 'Frontend', projectRoot: '/projects/fe' }), + ]; + const config = createMockConfig({ + subscriptions: [ + { + name: 'deploy-all', + event: 'agent.completed', + enabled: true, + prompt: 'deploy', + source_session: 'trigger-session', + fan_out: ['Frontend', 'NonExistent'], + }, + ], + }); + mockLoadCueConfig.mockImplementation((root: string) => + root === '/projects/orch' ? config : null + ); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('trigger-session'); + + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Fan-out target not found: "NonExistent"') + ); + + engine.stop(); + }); + }); + + describe('fan-in data tracking', () => { + it('concatenates fan-in source outputs in event payload', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + + engine.notifyAgentCompleted('agent-a', { sessionName: 'Agent A', stdout: 'output-a' }); + engine.notifyAgentCompleted('agent-b', { sessionName: 'Agent B', stdout: 'output-b' }); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'aggregate', + expect.objectContaining({ + payload: expect.objectContaining({ + sourceOutput: 'output-a\n---\noutput-b', + sourceSession: 'Agent A, Agent B', + }), + }) + ); + + engine.stop(); + }); + + it('logs waiting message during fan-in', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b', 'agent-c'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-a'); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('waiting for 2 more session(s)') + ); + + engine.stop(); + }); + }); + + describe('fan-in timeout', () => { + it('clears tracker on timeout in break mode', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + settings: { timeout_minutes: 1, timeout_on_fail: 'break' }, + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-a'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1 * 60 * 1000 + 100); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('timed out (break mode)') + ); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-b'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('fires with partial data on timeout in continue mode', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + settings: { timeout_minutes: 1, timeout_on_fail: 'continue' }, + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-a', { stdout: 'partial-output' }); + + vi.advanceTimersByTime(1 * 60 * 1000 + 100); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'aggregate', + expect.objectContaining({ + payload: expect.objectContaining({ + partial: true, + timedOutSessions: expect.arrayContaining(['agent-b']), + }), + }) + ); + + engine.stop(); + }); + }); + + describe('hasCompletionSubscribers', () => { + it('returns true when subscribers exist for a session', () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Source' }), + createMockSession({ id: 'session-2', name: 'Listener' }), + ]; + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-source-done', + event: 'agent.completed', + enabled: true, + prompt: 'react', + source_session: 'Source', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + expect(engine.hasCompletionSubscribers('session-1')).toBe(true); + expect(engine.hasCompletionSubscribers('session-2')).toBe(false); + expect(engine.hasCompletionSubscribers('unknown')).toBe(false); + + engine.stop(); + }); + + it('returns false when engine is disabled', () => { + const engine = new CueEngine(createMockDeps()); + expect(engine.hasCompletionSubscribers('any')).toBe(false); + }); + }); + + describe('clearFanInState', () => { + it('clears fan-in trackers for a specific session', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + engine.notifyAgentCompleted('agent-a'); + vi.clearAllMocks(); + + engine.clearFanInState('session-1'); + + engine.notifyAgentCompleted('agent-b'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-concurrency.test.ts b/src/__tests__/main/cue/cue-concurrency.test.ts new file mode 100644 index 0000000000..cba31c8758 --- /dev/null +++ b/src/__tests__/main/cue/cue-concurrency.test.ts @@ -0,0 +1,636 @@ +/** + * Tests for per-session concurrency control and event queuing. + * + * Tests cover: + * - Concurrency limits (max_concurrent) gate event dispatch + * - Event queuing when at concurrency limit + * - Queue draining when slots free + * - Queue overflow (oldest entry dropped) + * - Stale event eviction during drain + * - Queue cleanup on stopAll, removeSession, and stop + * - getQueueStatus() and clearQueue() public API + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/types'; + +// Mock the yaml loader +const mockLoadCueConfig = vi.fn<(projectRoot: string) => CueConfig | null>(); +const mockWatchCueYaml = vi.fn<(projectRoot: string, onChange: () => void) => () => void>(); +vi.mock('../../../main/cue/cue-yaml-loader', () => ({ + loadCueConfig: (...args: unknown[]) => mockLoadCueConfig(args[0] as string), + watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), +})); + +// Mock the file watcher +const mockCreateCueFileWatcher = vi.fn<(config: unknown) => () => void>(); +vi.mock('../../../main/cue/cue-file-watcher', () => ({ + createCueFileWatcher: (...args: unknown[]) => mockCreateCueFileWatcher(args[0]), +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), +})); + +import { CueEngine, type CueEngineDeps } from '../../../main/cue/cue-engine'; + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockConfig(overrides: Partial = {}): CueConfig { + return { + subscriptions: [], + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + ...overrides, + }; +} + +function createMockDeps(overrides: Partial = {}): CueEngineDeps { + return { + getSessions: vi.fn(() => [createMockSession()]), + onCueRun: vi.fn(async () => ({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'test', + event: {} as CueEvent, + status: 'completed' as const, + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + onLog: vi.fn(), + ...overrides, + }; +} + +describe('CueEngine Concurrency Control', () => { + let yamlWatcherCleanup: ReturnType; + let fileWatcherCleanup: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + yamlWatcherCleanup = vi.fn(); + mockWatchCueYaml.mockReturnValue(yamlWatcherCleanup); + + fileWatcherCleanup = vi.fn(); + mockCreateCueFileWatcher.mockReturnValue(fileWatcherCleanup); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('max_concurrent enforcement', () => { + it('allows dispatching when below max_concurrent', async () => { + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 3, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Initial fire should dispatch (1/3 concurrent) + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + engine.stop(); + }); + + it('queues events when at max_concurrent limit', async () => { + // Create a never-resolving onCueRun to keep runs active + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + // Allow the initial fire to start (never completes) + await vi.advanceTimersByTimeAsync(10); + + // First call dispatched + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Trigger another interval — should be queued + vi.advanceTimersByTime(1 * 60 * 1000); + // Still only 1 call — the second was queued + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Verify queue has an entry + const queueStatus = engine.getQueueStatus(); + expect(queueStatus.get('session-1')).toBe(1); + + engine.stopAll(); + engine.stop(); + }); + + it('logs queue activity with correct format', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 5, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + + // Trigger another interval — should be queued + vi.advanceTimersByTime(1 * 60 * 1000); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Event queued for "Test Session"') + ); + expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('1/5 in queue')); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('queue draining', () => { + it('dequeues and dispatches when a slot frees up', async () => { + let resolveRun: ((val: CueRunResult) => void) | undefined; + const deps = createMockDeps({ + onCueRun: vi.fn( + () => + new Promise((resolve) => { + resolveRun = resolve; + }) + ), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Trigger another — should be queued + vi.advanceTimersByTime(1 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + // Complete the first run — should drain the queue + resolveRun!({ + runId: 'r1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'timer', + event: {} as CueEvent, + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + await vi.advanceTimersByTimeAsync(10); + + // The queued event should now be dispatched + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + // Queue should be empty + expect(engine.getQueueStatus().size).toBe(0); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('queue overflow', () => { + it('drops oldest entry when queue is full', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 2, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + + // Fill the queue (size 2) + vi.advanceTimersByTime(1 * 60 * 1000); // queued: 1 + vi.advanceTimersByTime(1 * 60 * 1000); // queued: 2 + + expect(engine.getQueueStatus().get('session-1')).toBe(2); + + // Overflow — should drop oldest + vi.advanceTimersByTime(1 * 60 * 1000); // queued: still 2, but oldest dropped + + expect(engine.getQueueStatus().get('session-1')).toBe(2); + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Queue full for "Test Session", dropping oldest event') + ); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('stale event eviction', () => { + it('drops stale events during drain', async () => { + let resolveRun: ((val: CueRunResult) => void) | undefined; + const deps = createMockDeps({ + onCueRun: vi.fn( + () => + new Promise((resolve) => { + resolveRun = resolve; + }) + ), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 1, // 1 minute timeout + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Queue an event + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + // Wait long enough for the queued event to become stale (> 1 minute) + vi.advanceTimersByTime(2 * 60 * 1000); + + // Complete the first run — drain should evict the stale event + resolveRun!({ + runId: 'r1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'timer', + event: {} as CueEvent, + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + await vi.advanceTimersByTimeAsync(10); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Dropping stale queued event') + ); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('queue cleanup', () => { + it('stopAll clears all queues', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + engine.stopAll(); + expect(engine.getQueueStatus().size).toBe(0); + engine.stop(); + }); + + it('removeSession clears queue for that session', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + engine.removeSession('session-1'); + expect(engine.getQueueStatus().size).toBe(0); + engine.stop(); + }); + + it('engine stop clears all queues', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + engine.stop(); + expect(engine.getQueueStatus().size).toBe(0); + }); + }); + + describe('clearQueue', () => { + it('clears queued events for a specific session', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + vi.advanceTimersByTime(1 * 60 * 1000); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(2); + + engine.clearQueue('session-1'); + expect(engine.getQueueStatus().size).toBe(0); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('getQueueStatus', () => { + it('returns empty map when no events are queued', () => { + mockLoadCueConfig.mockReturnValue(null); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(engine.getQueueStatus().size).toBe(0); + engine.stop(); + }); + + it('returns correct count per session', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + vi.advanceTimersByTime(1 * 60 * 1000); + vi.advanceTimersByTime(1 * 60 * 1000); + vi.advanceTimersByTime(1 * 60 * 1000); + + expect(engine.getQueueStatus().get('session-1')).toBe(3); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('multi-concurrent slots', () => { + it('allows multiple concurrent runs up to max_concurrent', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 3, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); // Initial fire + + // Trigger 2 more intervals — all should dispatch (3 slots) + vi.advanceTimersByTime(1 * 60 * 1000); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(3); + expect(engine.getQueueStatus().size).toBe(0); // Nothing queued + + // 4th trigger should be queued + vi.advanceTimersByTime(1 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(3); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + engine.stopAll(); + engine.stop(); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-db.test.ts b/src/__tests__/main/cue/cue-db.test.ts new file mode 100644 index 0000000000..2f98842334 --- /dev/null +++ b/src/__tests__/main/cue/cue-db.test.ts @@ -0,0 +1,420 @@ +/** + * Tests for the Cue Database module (cue-db.ts). + * + * Note: better-sqlite3 is a native module compiled for Electron's Node version. + * These tests use a mocked database to verify the logic without requiring the + * native module. The mock validates that the correct SQL statements and parameters + * are passed to better-sqlite3. + * + * Tests cover: + * - Database initialization and lifecycle + * - Event recording, status updates, and retrieval + * - Heartbeat write and read + * - Event pruning (housekeeping) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as path from 'path'; +import * as os from 'os'; + +// Store parameters passed to mock statement methods +const runCalls: unknown[][] = []; +const getCalls: unknown[][] = []; +const allCalls: unknown[][] = []; +let mockGetReturn: unknown = undefined; +let mockAllReturn: unknown[] = []; + +const mockStatement = { + run: vi.fn((...args: unknown[]) => { + runCalls.push(args); + return { changes: 1 }; + }), + get: vi.fn((...args: unknown[]) => { + getCalls.push(args); + return mockGetReturn; + }), + all: vi.fn((...args: unknown[]) => { + allCalls.push(args); + return mockAllReturn; + }), +}; + +const prepareCalls: string[] = []; + +const mockDb = { + pragma: vi.fn(), + prepare: vi.fn((sql: string) => { + prepareCalls.push(sql); + return mockStatement; + }), + close: vi.fn(), +}; + +vi.mock('better-sqlite3', () => ({ + default: class MockDatabase { + constructor() { + /* noop */ + } + pragma = mockDb.pragma; + prepare = mockDb.prepare; + close = mockDb.close; + }, +})); + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => os.tmpdir()), + }, +})); + +import { + initCueDb, + closeCueDb, + isCueDbReady, + recordCueEvent, + updateCueEventStatus, + getRecentCueEvents, + updateHeartbeat, + getLastHeartbeat, + pruneCueEvents, + isGitHubItemSeen, + markGitHubItemSeen, + hasAnyGitHubSeen, + pruneGitHubSeen, + clearGitHubSeenForSubscription, +} from '../../../main/cue/cue-db'; + +beforeEach(() => { + vi.clearAllMocks(); + runCalls.length = 0; + getCalls.length = 0; + allCalls.length = 0; + prepareCalls.length = 0; + mockGetReturn = undefined; + mockAllReturn = []; + + // Ensure the module's internal db is reset + closeCueDb(); +}); + +afterEach(() => { + closeCueDb(); +}); + +describe('cue-db lifecycle', () => { + it('should report ready after initialization', () => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + expect(isCueDbReady()).toBe(true); + }); + + it('should report not ready after close', () => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + closeCueDb(); + expect(isCueDbReady()).toBe(false); + }); + + it('should not double-initialize', () => { + const dbPath = path.join(os.tmpdir(), 'test-cue.db'); + initCueDb(undefined, dbPath); + const callCountAfterFirst = mockDb.pragma.mock.calls.length; + + initCueDb(undefined, dbPath); + // No new pragma calls because it short-circuited + expect(mockDb.pragma.mock.calls.length).toBe(callCountAfterFirst); + }); + + it('should set WAL mode on initialization', () => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + expect(mockDb.pragma).toHaveBeenCalledWith('journal_mode = WAL'); + }); + + it('should create tables and indexes on initialization', () => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + + // Should have prepared CREATE TABLE and CREATE INDEX statements + expect(prepareCalls.some((sql) => sql.includes('CREATE TABLE IF NOT EXISTS cue_events'))).toBe( + true + ); + expect( + prepareCalls.some((sql) => sql.includes('CREATE TABLE IF NOT EXISTS cue_heartbeat')) + ).toBe(true); + expect(prepareCalls.some((sql) => sql.includes('idx_cue_events_created'))).toBe(true); + expect(prepareCalls.some((sql) => sql.includes('idx_cue_events_session'))).toBe(true); + expect( + prepareCalls.some((sql) => sql.includes('CREATE TABLE IF NOT EXISTS cue_github_seen')) + ).toBe(true); + expect(prepareCalls.some((sql) => sql.includes('idx_cue_github_seen_at'))).toBe(true); + }); + + it('should throw when accessing before initialization', () => { + expect(() => + recordCueEvent({ + id: 'test-1', + type: 'time.interval', + triggerName: 'test', + sessionId: 'session-1', + subscriptionName: 'test-sub', + status: 'running', + }) + ).toThrow('Cue database not initialized'); + }); + + it('should close the database', () => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + closeCueDb(); + expect(mockDb.close).toHaveBeenCalled(); + }); +}); + +describe('cue-db event journal', () => { + beforeEach(() => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + vi.clearAllMocks(); + runCalls.length = 0; + prepareCalls.length = 0; + }); + + it('should record an event with correct parameters', () => { + recordCueEvent({ + id: 'evt-1', + type: 'time.interval', + triggerName: 'my-trigger', + sessionId: 'session-1', + subscriptionName: 'periodic-check', + status: 'running', + }); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR REPLACE INTO cue_events') + ); + expect(runCalls.length).toBeGreaterThan(0); + const lastRun = runCalls[runCalls.length - 1]; + expect(lastRun[0]).toBe('evt-1'); // id + expect(lastRun[1]).toBe('time.interval'); // type + expect(lastRun[2]).toBe('my-trigger'); // trigger_name + expect(lastRun[3]).toBe('session-1'); // session_id + expect(lastRun[4]).toBe('periodic-check'); // subscription_name + expect(lastRun[5]).toBe('running'); // status + expect(typeof lastRun[6]).toBe('number'); // created_at (timestamp) + expect(lastRun[7]).toBeNull(); // payload (null when not provided) + }); + + it('should record an event with payload', () => { + const payload = JSON.stringify({ reconciled: true, missedCount: 3 }); + recordCueEvent({ + id: 'evt-2', + type: 'time.interval', + triggerName: 'cron-trigger', + sessionId: 'session-2', + subscriptionName: 'cron-sub', + status: 'completed', + payload, + }); + + const lastRun = runCalls[runCalls.length - 1]; + expect(lastRun[7]).toBe(payload); + }); + + it('should update event status with completed_at timestamp', () => { + updateCueEventStatus('evt-3', 'completed'); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('UPDATE cue_events SET status') + ); + const lastRun = runCalls[runCalls.length - 1]; + expect(lastRun[0]).toBe('completed'); // status + expect(typeof lastRun[1]).toBe('number'); // completed_at + expect(lastRun[2]).toBe('evt-3'); // id + }); + + it('should query recent events with correct since parameter', () => { + const since = Date.now() - 1000; + getRecentCueEvents(since); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('FROM cue_events WHERE created_at >=') + ); + const lastAll = allCalls[allCalls.length - 1]; + expect(lastAll[0]).toBe(since); + }); + + it('should query recent events with limit', () => { + const since = Date.now() - 1000; + getRecentCueEvents(since, 10); + + expect(mockDb.prepare).toHaveBeenCalledWith(expect.stringContaining('LIMIT')); + const lastAll = allCalls[allCalls.length - 1]; + expect(lastAll[0]).toBe(since); + expect(lastAll[1]).toBe(10); + }); + + it('should map row data to CueEventRecord correctly', () => { + mockAllReturn = [ + { + id: 'evt-mapped', + type: 'file.changed', + trigger_name: 'file-trigger', + session_id: 'session-1', + subscription_name: 'file-sub', + status: 'completed', + created_at: 1000000, + completed_at: 1000500, + payload: '{"file":"test.ts"}', + }, + ]; + + const events = getRecentCueEvents(0); + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + id: 'evt-mapped', + type: 'file.changed', + triggerName: 'file-trigger', + sessionId: 'session-1', + subscriptionName: 'file-sub', + status: 'completed', + createdAt: 1000000, + completedAt: 1000500, + payload: '{"file":"test.ts"}', + }); + }); +}); + +describe('cue-db heartbeat', () => { + beforeEach(() => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + vi.clearAllMocks(); + runCalls.length = 0; + getCalls.length = 0; + prepareCalls.length = 0; + }); + + it('should write heartbeat with INSERT OR REPLACE', () => { + updateHeartbeat(); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR REPLACE INTO cue_heartbeat') + ); + const lastRun = runCalls[runCalls.length - 1]; + expect(typeof lastRun[0]).toBe('number'); // current timestamp + }); + + it('should return null when no heartbeat exists', () => { + mockGetReturn = undefined; + const result = getLastHeartbeat(); + expect(result).toBeNull(); + }); + + it('should return the last_seen value when heartbeat exists', () => { + mockGetReturn = { last_seen: 1234567890 }; + const result = getLastHeartbeat(); + expect(result).toBe(1234567890); + }); +}); + +describe('cue-db pruning', () => { + beforeEach(() => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + vi.clearAllMocks(); + runCalls.length = 0; + prepareCalls.length = 0; + }); + + it('should delete events older than specified age', () => { + const olderThanMs = 7 * 24 * 60 * 60 * 1000; + const before = Date.now(); + pruneCueEvents(olderThanMs); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM cue_events WHERE created_at < ?') + ); + const lastRun = runCalls[runCalls.length - 1]; + const cutoff = lastRun[0] as number; + // The cutoff should be approximately Date.now() - olderThanMs + expect(cutoff).toBeLessThanOrEqual(before); + expect(cutoff).toBeGreaterThan(before - olderThanMs - 1000); + }); +}); + +describe('cue-db github seen tracking', () => { + beforeEach(() => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + vi.clearAllMocks(); + runCalls.length = 0; + getCalls.length = 0; + prepareCalls.length = 0; + mockGetReturn = undefined; + }); + + it('isGitHubItemSeen should return false when item not found', () => { + mockGetReturn = undefined; + const result = isGitHubItemSeen('sub-1', 'pr:owner/repo:123'); + expect(result).toBe(false); + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining( + 'SELECT 1 FROM cue_github_seen WHERE subscription_id = ? AND item_key = ?' + ) + ); + const lastGet = getCalls[getCalls.length - 1]; + expect(lastGet[0]).toBe('sub-1'); + expect(lastGet[1]).toBe('pr:owner/repo:123'); + }); + + it('isGitHubItemSeen should return true when item exists', () => { + mockGetReturn = { '1': 1 }; + const result = isGitHubItemSeen('sub-1', 'pr:owner/repo:123'); + expect(result).toBe(true); + }); + + it('markGitHubItemSeen should INSERT OR IGNORE with correct parameters', () => { + markGitHubItemSeen('sub-1', 'pr:owner/repo:456'); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR IGNORE INTO cue_github_seen') + ); + const lastRun = runCalls[runCalls.length - 1]; + expect(lastRun[0]).toBe('sub-1'); + expect(lastRun[1]).toBe('pr:owner/repo:456'); + expect(typeof lastRun[2]).toBe('number'); // seen_at + }); + + it('hasAnyGitHubSeen should return false when no records exist', () => { + mockGetReturn = undefined; + const result = hasAnyGitHubSeen('sub-1'); + expect(result).toBe(false); + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('SELECT 1 FROM cue_github_seen WHERE subscription_id = ? LIMIT 1') + ); + const lastGet = getCalls[getCalls.length - 1]; + expect(lastGet[0]).toBe('sub-1'); + }); + + it('hasAnyGitHubSeen should return true when records exist', () => { + mockGetReturn = { '1': 1 }; + const result = hasAnyGitHubSeen('sub-1'); + expect(result).toBe(true); + }); + + it('pruneGitHubSeen should delete old records with correct cutoff', () => { + const olderThanMs = 30 * 24 * 60 * 60 * 1000; + const before = Date.now(); + pruneGitHubSeen(olderThanMs); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM cue_github_seen WHERE seen_at < ?') + ); + const lastRun = runCalls[runCalls.length - 1]; + const cutoff = lastRun[0] as number; + expect(cutoff).toBeLessThanOrEqual(before); + expect(cutoff).toBeGreaterThan(before - olderThanMs - 1000); + }); + + it('clearGitHubSeenForSubscription should delete all records for a subscription', () => { + clearGitHubSeenForSubscription('sub-1'); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM cue_github_seen WHERE subscription_id = ?') + ); + const lastRun = runCalls[runCalls.length - 1]; + expect(lastRun[0]).toBe('sub-1'); + }); +}); diff --git a/src/__tests__/main/cue/cue-engine.test.ts b/src/__tests__/main/cue/cue-engine.test.ts new file mode 100644 index 0000000000..1f1dbc6f2f --- /dev/null +++ b/src/__tests__/main/cue/cue-engine.test.ts @@ -0,0 +1,1470 @@ +/** + * Tests for the Cue Engine core. + * + * Tests cover: + * - Engine lifecycle (start, stop, isEnabled) + * - Session initialization from YAML configs + * - Timer-based subscriptions (time.interval) + * - File watcher subscriptions (file.changed) + * - Agent completion subscriptions (agent.completed) + * - Fan-in tracking for multi-source agent.completed + * - Active run tracking and stopping + * - Activity log ring buffer + * - Session refresh and removal + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/types'; + +// Mock the yaml loader +const mockLoadCueConfig = vi.fn<(projectRoot: string) => CueConfig | null>(); +const mockWatchCueYaml = vi.fn<(projectRoot: string, onChange: () => void) => () => void>(); +vi.mock('../../../main/cue/cue-yaml-loader', () => ({ + loadCueConfig: (...args: unknown[]) => mockLoadCueConfig(args[0] as string), + watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), +})); + +// Mock the file watcher +const mockCreateCueFileWatcher = vi.fn<(config: unknown) => () => void>(); +vi.mock('../../../main/cue/cue-file-watcher', () => ({ + createCueFileWatcher: (...args: unknown[]) => mockCreateCueFileWatcher(args[0]), +})); + +// Mock the GitHub poller +const mockCreateCueGitHubPoller = vi.fn<(config: unknown) => () => void>(); +vi.mock('../../../main/cue/cue-github-poller', () => ({ + createCueGitHubPoller: (...args: unknown[]) => mockCreateCueGitHubPoller(args[0]), +})); + +// Mock the task scanner +const mockCreateCueTaskScanner = vi.fn<(config: unknown) => () => void>(); +vi.mock('../../../main/cue/cue-task-scanner', () => ({ + createCueTaskScanner: (...args: unknown[]) => mockCreateCueTaskScanner(args[0]), +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), +})); + +import { CueEngine, type CueEngineDeps } from '../../../main/cue/cue-engine'; + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockConfig(overrides: Partial = {}): CueConfig { + return { + subscriptions: [], + settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 }, + ...overrides, + }; +} + +function createMockDeps(overrides: Partial = {}): CueEngineDeps { + return { + getSessions: vi.fn(() => [createMockSession()]), + onCueRun: vi.fn(async () => ({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'test', + event: {} as CueEvent, + status: 'completed' as const, + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + onLog: vi.fn(), + ...overrides, + }; +} + +describe('CueEngine', () => { + let yamlWatcherCleanup: ReturnType; + let fileWatcherCleanup: ReturnType; + + let gitHubPollerCleanup: ReturnType; + let taskScannerCleanup: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + yamlWatcherCleanup = vi.fn(); + mockWatchCueYaml.mockReturnValue(yamlWatcherCleanup); + + fileWatcherCleanup = vi.fn(); + mockCreateCueFileWatcher.mockReturnValue(fileWatcherCleanup); + + gitHubPollerCleanup = vi.fn(); + mockCreateCueGitHubPoller.mockReturnValue(gitHubPollerCleanup); + + taskScannerCleanup = vi.fn(); + mockCreateCueTaskScanner.mockReturnValue(taskScannerCleanup); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('lifecycle', () => { + it('starts as disabled', () => { + const engine = new CueEngine(createMockDeps()); + expect(engine.isEnabled()).toBe(false); + }); + + it('becomes enabled after start()', () => { + mockLoadCueConfig.mockReturnValue(null); + const engine = new CueEngine(createMockDeps()); + engine.start(); + expect(engine.isEnabled()).toBe(true); + }); + + it('becomes disabled after stop()', () => { + mockLoadCueConfig.mockReturnValue(null); + const engine = new CueEngine(createMockDeps()); + engine.start(); + engine.stop(); + expect(engine.isEnabled()).toBe(false); + }); + + it('logs start and stop events', () => { + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + engine.stop(); + + expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('started')); + expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('stopped')); + }); + }); + + describe('session initialization', () => { + it('scans all sessions on start', () => { + const sessions = [ + createMockSession({ id: 's1', projectRoot: '/proj1' }), + createMockSession({ id: 's2', projectRoot: '/proj2' }), + ]; + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockLoadCueConfig).toHaveBeenCalledWith('/proj1'); + expect(mockLoadCueConfig).toHaveBeenCalledWith('/proj2'); + }); + + it('skips sessions without a cue config', () => { + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(engine.getStatus()).toHaveLength(0); + }); + + it('initializes sessions with valid config', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 10, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].subscriptionCount).toBe(1); + }); + + it('sets up YAML file watcher for config changes', () => { + mockLoadCueConfig.mockReturnValue(createMockConfig()); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockWatchCueYaml).toHaveBeenCalled(); + }); + }); + + describe('time.interval subscriptions', () => { + it('fires immediately on setup', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.interval', + enabled: true, + prompt: 'Run check', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Should fire immediately + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'Run check', + expect.objectContaining({ type: 'time.interval', triggerName: 'periodic' }) + ); + }); + + it('fires on the interval', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.interval', + enabled: true, + prompt: 'Run check', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Flush microtasks to let the initial run complete and free the concurrency slot + await vi.advanceTimersByTimeAsync(0); + vi.clearAllMocks(); + + // Advance 5 minutes + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Advance another 5 minutes + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + + engine.stop(); + }); + + it('skips disabled subscriptions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'disabled', + event: 'time.interval', + enabled: false, + prompt: 'noop', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + engine.stop(); + }); + + it('clears timers on stop', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.stop(); + + vi.advanceTimersByTime(60 * 1000); + expect(deps.onCueRun).not.toHaveBeenCalled(); + }); + }); + + describe('file.changed subscriptions', () => { + it('creates a file watcher with correct config', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'watch-src', + event: 'file.changed', + enabled: true, + prompt: 'lint', + watch: 'src/**/*.ts', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueFileWatcher).toHaveBeenCalledWith( + expect.objectContaining({ + watchGlob: 'src/**/*.ts', + projectRoot: '/projects/test', + debounceMs: 5000, + triggerName: 'watch-src', + }) + ); + + engine.stop(); + }); + + it('cleans up file watcher on stop', () => { + const config = createMockConfig({ + subscriptions: [ + { name: 'watch', event: 'file.changed', enabled: true, prompt: 'test', watch: '**/*.ts' }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + engine.stop(); + + expect(fileWatcherCleanup).toHaveBeenCalled(); + }); + }); + + describe('agent.completed subscriptions', () => { + it('fires for single source_session match', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'agent-a', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-a'); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'follow up', + expect.objectContaining({ + type: 'agent.completed', + triggerName: 'on-done', + }) + ); + }); + + it('does not fire for non-matching session', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'agent-a', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-b'); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + }); + + it('tracks fan-in completions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + + // First completion — should not fire + engine.notifyAgentCompleted('agent-a'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + + // Second completion — should fire + engine.notifyAgentCompleted('agent-b'); + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'aggregate', + expect.objectContaining({ + type: 'agent.completed', + triggerName: 'all-done', + }) + ); + }); + + it('resets fan-in tracker after firing', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + + engine.notifyAgentCompleted('agent-a'); + engine.notifyAgentCompleted('agent-b'); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + vi.clearAllMocks(); + + // Start again — should need both to fire again + engine.notifyAgentCompleted('agent-a'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + }); + }); + + describe('session management', () => { + it('removeSession tears down subscriptions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + engine.removeSession('session-1'); + expect(engine.getStatus()).toHaveLength(0); + expect(yamlWatcherCleanup).toHaveBeenCalled(); + }); + + it('refreshSession re-reads config', () => { + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'old', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'new-1', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 10, + }, + { + name: 'new-2', + event: 'time.interval', + enabled: true, + prompt: 'test2', + interval_minutes: 15, + }, + ], + }); + mockLoadCueConfig.mockReturnValueOnce(config1).mockReturnValue(config2); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + engine.refreshSession('session-1', '/projects/test'); + + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].subscriptionCount).toBe(2); + }); + }); + + describe('YAML hot reload', () => { + it('logs "Config reloaded" with subscription count when config changes', () => { + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'old-sub', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'new-sub-1', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 10, + }, + { + name: 'new-sub-2', + event: 'time.interval', + enabled: true, + prompt: 'test2', + interval_minutes: 15, + }, + ], + }); + mockLoadCueConfig.mockReturnValueOnce(config1).mockReturnValue(config2); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.refreshSession('session-1', '/projects/test'); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Config reloaded for "Test Session" (2 subscriptions)'), + expect.objectContaining({ type: 'configReloaded', sessionId: 'session-1' }) + ); + }); + + it('passes data to onLog for IPC push on config reload', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.refreshSession('session-1', '/projects/test'); + + // Verify data parameter is passed (triggers cue:activityUpdate in main process) + const reloadCall = (deps.onLog as ReturnType).mock.calls.find( + (call: unknown[]) => typeof call[1] === 'string' && call[1].includes('Config reloaded') + ); + expect(reloadCall).toBeDefined(); + expect(reloadCall![2]).toEqual( + expect.objectContaining({ type: 'configReloaded', sessionId: 'session-1' }) + ); + + engine.stop(); + }); + + it('logs "Config removed" when YAML file is deleted', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + // First call returns config (initial load), second returns null (file deleted) + mockLoadCueConfig.mockReturnValueOnce(config).mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.refreshSession('session-1', '/projects/test'); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Config removed for "Test Session"'), + expect.objectContaining({ type: 'configRemoved', sessionId: 'session-1' }) + ); + expect(engine.getStatus()).toHaveLength(0); + }); + + it('sets up a pending yaml watcher after config deletion for re-creation', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValueOnce(config).mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const initialWatchCalls = mockWatchCueYaml.mock.calls.length; + engine.refreshSession('session-1', '/projects/test'); + + // A new yaml watcher should be created for watching re-creation + expect(mockWatchCueYaml.mock.calls.length).toBe(initialWatchCalls + 1); + }); + + it('recovers when config file is re-created after deletion', () => { + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'original', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'recreated', + event: 'time.interval', + enabled: true, + prompt: 'test2', + interval_minutes: 10, + }, + ], + }); + // First: initial config, second: null (deleted), third: new config (re-created) + mockLoadCueConfig + .mockReturnValueOnce(config1) + .mockReturnValueOnce(null) + .mockReturnValue(config2); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Delete config + engine.refreshSession('session-1', '/projects/test'); + expect(engine.getStatus()).toHaveLength(0); + + // Capture the pending yaml watcher callback + const lastWatchCall = mockWatchCueYaml.mock.calls[mockWatchCueYaml.mock.calls.length - 1]; + const pendingOnChange = lastWatchCall[1] as () => void; + + // Simulate file re-creation by invoking the watcher callback + pendingOnChange(); + + // Session should be re-initialized with the new config + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].subscriptionCount).toBe(1); + }); + + it('cleans up pending yaml watchers on engine stop', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + const pendingCleanup = vi.fn(); + mockLoadCueConfig.mockReturnValueOnce(config).mockReturnValue(null); + mockWatchCueYaml.mockReturnValueOnce(yamlWatcherCleanup).mockReturnValue(pendingCleanup); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Delete config — creates pending yaml watcher + engine.refreshSession('session-1', '/projects/test'); + + // Stop engine — should clean up pending watcher + engine.stop(); + expect(pendingCleanup).toHaveBeenCalled(); + }); + + it('cleans up pending yaml watchers on removeSession', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + const pendingCleanup = vi.fn(); + mockLoadCueConfig.mockReturnValueOnce(config).mockReturnValue(null); + mockWatchCueYaml.mockReturnValueOnce(yamlWatcherCleanup).mockReturnValue(pendingCleanup); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Delete config — creates pending yaml watcher + engine.refreshSession('session-1', '/projects/test'); + + // Remove session — should clean up pending watcher + engine.removeSession('session-1'); + expect(pendingCleanup).toHaveBeenCalled(); + }); + + it('triggers refresh via yaml watcher callback on file change', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Capture the yaml watcher callback + const watchCall = mockWatchCueYaml.mock.calls[0]; + const onChange = watchCall[1] as () => void; + + vi.clearAllMocks(); + mockLoadCueConfig.mockReturnValue(config); + mockWatchCueYaml.mockReturnValue(vi.fn()); + + // Simulate file change by invoking the watcher callback + onChange(); + + // refreshSession should have been called (loadCueConfig invoked for re-init) + expect(mockLoadCueConfig).toHaveBeenCalledWith('/projects/test'); + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Config reloaded'), + expect.any(Object) + ); + }); + + it('does not log "Config removed" when session never had config', () => { + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + // Session never had a config, so refreshSession with null should not log "Config removed" + engine.refreshSession('session-1', '/projects/test'); + + const removedCall = (deps.onLog as ReturnType).mock.calls.find( + (call: unknown[]) => typeof call[1] === 'string' && call[1].includes('Config removed') + ); + expect(removedCall).toBeUndefined(); + }); + }); + + describe('activity log', () => { + it('records completed runs', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Wait for the async run to complete + await vi.advanceTimersByTimeAsync(100); + + const log = engine.getActivityLog(); + expect(log.length).toBeGreaterThan(0); + expect(log[0].subscriptionName).toBe('periodic'); + }); + + it('respects limit parameter', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Run multiple intervals + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + + const limited = engine.getActivityLog(1); + expect(limited).toHaveLength(1); + + engine.stop(); + }); + }); + + describe('run management', () => { + it('stopRun returns false for non-existent run', () => { + const engine = new CueEngine(createMockDeps()); + expect(engine.stopRun('nonexistent')).toBe(false); + }); + + it('stopAll clears all active runs', async () => { + // Use a slow-resolving onCueRun to keep runs active + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), // Never resolves + }); + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + // Allow async execution to start + await vi.advanceTimersByTimeAsync(10); + + expect(engine.getActiveRuns().length).toBeGreaterThan(0); + engine.stopAll(); + expect(engine.getActiveRuns()).toHaveLength(0); + + engine.stop(); + }); + }); + + describe('github.pull_request / github.issue subscriptions', () => { + it('github.pull_request subscription creates a GitHub poller with correct config', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'pr-watcher', + event: 'github.pull_request', + enabled: true, + prompt: 'review PR', + repo: 'owner/repo', + poll_minutes: 10, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueGitHubPoller).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'github.pull_request', + repo: 'owner/repo', + pollMinutes: 10, + projectRoot: '/projects/test', + triggerName: 'pr-watcher', + subscriptionId: 'session-1:pr-watcher', + }) + ); + + engine.stop(); + }); + + it('github.issue subscription creates a GitHub poller', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'issue-watcher', + event: 'github.issue', + enabled: true, + prompt: 'triage issue', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueGitHubPoller).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'github.issue', + pollMinutes: 5, // default + triggerName: 'issue-watcher', + subscriptionId: 'session-1:issue-watcher', + }) + ); + + engine.stop(); + }); + + it('cleanup function is called on session teardown', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'pr-watcher', + event: 'github.pull_request', + enabled: true, + prompt: 'review', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + engine.removeSession('session-1'); + + expect(gitHubPollerCleanup).toHaveBeenCalled(); + }); + + it('disabled github subscription is skipped', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'pr-watcher', + event: 'github.pull_request', + enabled: false, + prompt: 'review', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueGitHubPoller).not.toHaveBeenCalled(); + + engine.stop(); + }); + }); + + describe('task.pending subscriptions', () => { + it('creates a task scanner with correct config', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + enabled: true, + prompt: 'process tasks', + watch: 'tasks/**/*.md', + poll_minutes: 2, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueTaskScanner).toHaveBeenCalledWith( + expect.objectContaining({ + watchGlob: 'tasks/**/*.md', + pollMinutes: 2, + projectRoot: '/projects/test', + triggerName: 'task-queue', + }) + ); + + engine.stop(); + }); + + it('defaults poll_minutes to 1 when not specified', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + enabled: true, + prompt: 'process tasks', + watch: 'tasks/**/*.md', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueTaskScanner).toHaveBeenCalledWith( + expect.objectContaining({ + pollMinutes: 1, + }) + ); + + engine.stop(); + }); + + it('cleanup function is called on session teardown', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + enabled: true, + prompt: 'process tasks', + watch: 'tasks/**/*.md', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + engine.removeSession('session-1'); + + expect(taskScannerCleanup).toHaveBeenCalled(); + }); + + it('disabled task.pending subscription is skipped', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + enabled: false, + prompt: 'process tasks', + watch: 'tasks/**/*.md', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueTaskScanner).not.toHaveBeenCalled(); + + engine.stop(); + }); + }); + + describe('getStatus', () => { + it('returns correct status for active sessions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + { + name: 'disabled', + event: 'time.interval', + enabled: false, + prompt: 'noop', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].sessionId).toBe('session-1'); + expect(status[0].sessionName).toBe('Test Session'); + expect(status[0].subscriptionCount).toBe(1); // Only enabled ones + expect(status[0].enabled).toBe(true); + + engine.stop(); + }); + + it('returns sessions with cue configs when engine is disabled', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + // Engine never started — getStatus should still find configs on disk + + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].sessionId).toBe('session-1'); + expect(status[0].sessionName).toBe('Test Session'); + expect(status[0].enabled).toBe(false); + expect(status[0].subscriptionCount).toBe(1); + expect(status[0].activeRuns).toBe(0); + }); + + it('returns sessions with enabled=false after engine is stopped', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // While running, enabled is true + expect(engine.getStatus()[0].enabled).toBe(true); + + engine.stop(); + + // After stopping, sessions should still appear but with enabled=false + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].enabled).toBe(false); + }); + }); + + describe('output_prompt execution', () => { + it('executes output prompt after successful main task', async () => { + const mainResult: CueRunResult = { + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'timer', + event: {} as CueEvent, + status: 'completed', + stdout: 'main task output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + const outputResult: CueRunResult = { + ...mainResult, + runId: 'run-2', + stdout: 'formatted output for downstream', + }; + const onCueRun = vi + .fn() + .mockResolvedValueOnce(mainResult) + .mockResolvedValueOnce(outputResult); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'do work', + output_prompt: 'format results', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps({ onCueRun }); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(100); + + // onCueRun called twice: main task + output prompt + expect(onCueRun).toHaveBeenCalledTimes(2); + + // First call is the main prompt + expect(onCueRun.mock.calls[0][1]).toBe('do work'); + + // Second call is the output prompt with context appended + expect(onCueRun.mock.calls[1][1]).toContain('format results'); + expect(onCueRun.mock.calls[1][1]).toContain('main task output'); + + // Activity log should have the output prompt's stdout + const log = engine.getActivityLog(); + expect(log[0].stdout).toBe('formatted output for downstream'); + + engine.stop(); + }); + + it('skips output prompt when main task fails', async () => { + const failedResult: CueRunResult = { + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'timer', + event: {} as CueEvent, + status: 'failed', + stdout: '', + stderr: 'error', + exitCode: 1, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + const onCueRun = vi.fn().mockResolvedValue(failedResult); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'do work', + output_prompt: 'format results', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps({ onCueRun }); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(100); + + // Only called once — output prompt skipped + expect(onCueRun).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it('falls back to main output when output prompt fails', async () => { + const mainResult: CueRunResult = { + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'timer', + event: {} as CueEvent, + status: 'completed', + stdout: 'main task output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + const failedOutputResult: CueRunResult = { + ...mainResult, + runId: 'run-2', + status: 'failed', + stdout: '', + stderr: 'output prompt error', + }; + const onCueRun = vi + .fn() + .mockResolvedValueOnce(mainResult) + .mockResolvedValueOnce(failedOutputResult); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'do work', + output_prompt: 'format results', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps({ onCueRun }); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(100); + + // Both calls made + expect(onCueRun).toHaveBeenCalledTimes(2); + + // Activity log should retain main task output (fallback) + const log = engine.getActivityLog(); + expect(log[0].stdout).toBe('main task output'); + + engine.stop(); + }); + + it('does not execute output prompt when none is configured', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'do work', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(100); + + // Only one call — no output prompt + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + }); + + describe('getGraphData', () => { + it('returns graph data for active sessions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const graph = engine.getGraphData(); + expect(graph).toHaveLength(1); + expect(graph[0].sessionId).toBe('session-1'); + expect(graph[0].subscriptions).toHaveLength(1); + + engine.stop(); + }); + + it('returns graph data from disk configs when engine is disabled', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + // Never started + + const graph = engine.getGraphData(); + expect(graph).toHaveLength(1); + expect(graph[0].sessionId).toBe('session-1'); + expect(graph[0].sessionName).toBe('Test Session'); + expect(graph[0].subscriptions).toHaveLength(1); + }); + + it('returns graph data after engine is stopped', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + engine.stop(); + + const graph = engine.getGraphData(); + expect(graph).toHaveLength(1); + expect(graph[0].sessionId).toBe('session-1'); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-executor.test.ts b/src/__tests__/main/cue/cue-executor.test.ts new file mode 100644 index 0000000000..09ae1553de --- /dev/null +++ b/src/__tests__/main/cue/cue-executor.test.ts @@ -0,0 +1,1017 @@ +/** + * Tests for the Cue executor module. + * + * Tests cover: + * - Prompt file resolution (absolute and relative paths) + * - Prompt file read failures + * - Template variable substitution with Cue event context + * - Agent argument building (follows process:spawn pattern) + * - Process spawning and stdout/stderr capture + * - Timeout enforcement with SIGTERM → SIGKILL escalation + * - Successful completion and failure detection + * - SSH remote execution wrapping + * - stopCueRun process termination + * - recordCueHistoryEntry construction + * - History entry field population and response truncation + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import type { ChildProcess } from 'child_process'; +import type { CueEvent, CueSubscription, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/types'; +import type { TemplateContext } from '../../../shared/templateVariables'; + +// --- Mocks --- + +// Mock fs +const mockReadFileSync = vi.fn(); +vi.mock('fs', () => ({ + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => 'test-uuid-1234'), +})); + +// Mock substituteTemplateVariables +const mockSubstitute = vi.fn((template: string) => `substituted: ${template}`); +vi.mock('../../../shared/templateVariables', () => ({ + substituteTemplateVariables: (...args: unknown[]) => mockSubstitute(args[0] as string, args[1]), +})); + +// Mock agents module +const mockGetAgentDefinition = vi.fn(); +const mockGetAgentCapabilities = vi.fn(() => ({ + supportsResume: true, + supportsReadOnlyMode: true, + supportsJsonOutput: true, + supportsSessionId: true, + supportsImageInput: false, + supportsImageInputOnResume: false, + supportsSlashCommands: true, + supportsSessionStorage: true, + supportsCostTracking: true, + supportsContextUsage: true, + supportsThinking: false, + supportsStdin: false, + supportsRawStdin: false, + supportsModelSelection: false, + supportsModelDiscovery: false, + supportsBatchMode: true, + supportsYoloMode: true, + supportsExitCodes: true, + supportsWorkingDir: false, +})); +vi.mock('../../../main/agents', () => ({ + getAgentDefinition: (...args: unknown[]) => mockGetAgentDefinition(...args), + getAgentCapabilities: (...args: unknown[]) => mockGetAgentCapabilities(...args), +})); + +// Mock buildAgentArgs and applyAgentConfigOverrides +const mockBuildAgentArgs = vi.fn((_agent: unknown, _opts: unknown) => [ + '--print', + '--verbose', + '--output-format', + 'stream-json', + '--dangerously-skip-permissions', + '--', + 'prompt-content', +]); +const mockApplyOverrides = vi.fn((_agent: unknown, args: string[], _overrides: unknown) => ({ + args, + effectiveCustomEnvVars: undefined, + customArgsSource: 'none' as const, + customEnvSource: 'none' as const, + modelSource: 'default' as const, +})); +vi.mock('../../../main/utils/agent-args', () => ({ + buildAgentArgs: (...args: unknown[]) => mockBuildAgentArgs(...args), + applyAgentConfigOverrides: (...args: unknown[]) => mockApplyOverrides(...args), +})); + +// Mock wrapSpawnWithSsh +const mockWrapSpawnWithSsh = vi.fn(); +vi.mock('../../../main/utils/ssh-spawn-wrapper', () => ({ + wrapSpawnWithSsh: (...args: unknown[]) => mockWrapSpawnWithSsh(...args), +})); + +// Mock child_process.spawn +class MockChildProcess extends EventEmitter { + stdin = { + write: vi.fn(), + end: vi.fn(), + }; + stdout = new EventEmitter(); + stderr = new EventEmitter(); + killed = false; + + kill(signal?: string) { + this.killed = true; + return true; + } + + constructor() { + super(); + // Set encoding methods on stdout/stderr + (this.stdout as any).setEncoding = vi.fn(); + (this.stderr as any).setEncoding = vi.fn(); + } +} + +let mockChild: MockChildProcess; +const mockSpawn = vi.fn(() => { + mockChild = new MockChildProcess(); + return mockChild as unknown as ChildProcess; +}); + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: (...args: unknown[]) => mockSpawn(...args), + default: { + ...actual, + spawn: (...args: unknown[]) => mockSpawn(...args), + }, + }; +}); + +// Must import after mocks +import { + executeCuePrompt, + stopCueRun, + getActiveProcesses, + recordCueHistoryEntry, + type CueExecutionConfig, +} from '../../../main/cue/cue-executor'; + +// --- Helpers --- + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockSubscription(overrides: Partial = {}): CueSubscription { + return { + name: 'Watch config', + event: 'file.changed', + enabled: true, + prompt: 'prompts/on-config-change.md', + watch: '**/*.yaml', + ...overrides, + }; +} + +function createMockEvent(overrides: Partial = {}): CueEvent { + return { + id: 'event-1', + type: 'file.changed', + timestamp: '2026-03-01T00:00:00.000Z', + triggerName: 'Watch config', + payload: { + path: '/projects/test/config.yaml', + filename: 'config.yaml', + directory: '/projects/test', + extension: '.yaml', + }, + ...overrides, + }; +} + +function createMockTemplateContext(): TemplateContext { + return { + session: { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + }, + }; +} + +function createExecutionConfig(overrides: Partial = {}): CueExecutionConfig { + return { + runId: 'run-1', + session: createMockSession(), + subscription: createMockSubscription(), + event: createMockEvent(), + promptPath: 'prompts/on-config-change.md', + toolType: 'claude-code', + projectRoot: '/projects/test', + templateContext: createMockTemplateContext(), + timeoutMs: 30000, + onLog: vi.fn(), + ...overrides, + }; +} + +const defaultAgentDef = { + id: 'claude-code', + name: 'Claude Code', + binaryName: 'claude', + command: 'claude', + args: [ + '--print', + '--verbose', + '--output-format', + 'stream-json', + '--dangerously-skip-permissions', + ], +}; + +// --- Tests --- + +describe('cue-executor', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + getActiveProcesses().clear(); + + // Default mock implementations + mockReadFileSync.mockReturnValue('Prompt content: check {{CUE_FILE_PATH}}'); + mockGetAgentDefinition.mockReturnValue(defaultAgentDef); + mockSubstitute.mockImplementation((template: string) => `substituted: ${template}`); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('executeCuePrompt', () => { + it('should resolve relative prompt paths against projectRoot', async () => { + const config = createExecutionConfig({ + promptPath: 'prompts/check.md', + projectRoot: '/projects/test', + }); + + const resultPromise = executeCuePrompt(config); + // Let spawn happen + await vi.advanceTimersByTimeAsync(0); + + expect(mockReadFileSync).toHaveBeenCalledWith('/projects/test/prompts/check.md', 'utf-8'); + + // Close the process to resolve + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should use absolute prompt paths directly', async () => { + const config = createExecutionConfig({ + promptPath: '/absolute/path/prompt.md', + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockReadFileSync).toHaveBeenCalledWith('/absolute/path/prompt.md', 'utf-8'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should return failed result when prompt file cannot be read', async () => { + mockReadFileSync.mockImplementation(() => { + throw new Error('ENOENT: no such file'); + }); + + const config = createExecutionConfig(); + const result = await executeCuePrompt(config); + + expect(result.status).toBe('failed'); + expect(result.stderr).toContain('Failed to read prompt file'); + expect(result.stderr).toContain('ENOENT'); + expect(result.exitCode).toBeNull(); + }); + + it('should populate Cue event data in template context', async () => { + const event = createMockEvent({ + type: 'file.changed', + payload: { + path: '/projects/test/src/app.ts', + filename: 'app.ts', + directory: '/projects/test/src', + extension: '.ts', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Verify template context was populated with cue data + expect(templateContext.cue).toEqual({ + eventType: 'file.changed', + eventTimestamp: event.timestamp, + triggerName: 'Watch config', + runId: 'run-1', + filePath: '/projects/test/src/app.ts', + fileName: 'app.ts', + fileDir: '/projects/test/src', + fileExt: '.ts', + fileChangeType: '', + sourceSession: '', + sourceOutput: '', + sourceStatus: '', + sourceExitCode: '', + sourceDuration: '', + sourceTriggeredBy: '', + }); + + // Verify substituteTemplateVariables was called + expect(mockSubstitute).toHaveBeenCalledWith( + 'Prompt content: check {{CUE_FILE_PATH}}', + templateContext + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should return failed result for unknown agent type', async () => { + mockGetAgentDefinition.mockReturnValue(undefined); + + const config = createExecutionConfig({ toolType: 'nonexistent' }); + const result = await executeCuePrompt(config); + + expect(result.status).toBe('failed'); + expect(result.stderr).toContain('Unknown agent type: nonexistent'); + }); + + it('should build agent args using the same pipeline as process:spawn', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Verify buildAgentArgs was called with proper params + expect(mockBuildAgentArgs).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'claude-code', + binaryName: 'claude', + command: 'claude', + }), + expect.objectContaining({ + baseArgs: defaultAgentDef.args, + cwd: '/projects/test', + yoloMode: true, + }) + ); + + // Verify applyAgentConfigOverrides was called + expect(mockApplyOverrides).toHaveBeenCalled(); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should spawn the process with correct command and args', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockSpawn).toHaveBeenCalledWith( + 'claude', + expect.any(Array), + expect.objectContaining({ + cwd: '/projects/test', + stdio: ['pipe', 'pipe', 'pipe'], + }) + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should capture stdout and stderr from the process', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Emit some output + mockChild.stdout.emit('data', 'Hello '); + mockChild.stdout.emit('data', 'world'); + mockChild.stderr.emit('data', 'Warning: something'); + + mockChild.emit('close', 0); + const result = await resultPromise; + + expect(result.stdout).toBe('Hello world'); + expect(result.stderr).toBe('Warning: something'); + }); + + it('should return completed status on exit code 0', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + mockChild.emit('close', 0); + const result = await resultPromise; + + expect(result.status).toBe('completed'); + expect(result.exitCode).toBe(0); + expect(result.runId).toBe('run-1'); + expect(result.sessionId).toBe('session-1'); + expect(result.sessionName).toBe('Test Session'); + expect(result.subscriptionName).toBe('Watch config'); + }); + + it('should return failed status on non-zero exit code', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + mockChild.emit('close', 1); + const result = await resultPromise; + + expect(result.status).toBe('failed'); + expect(result.exitCode).toBe(1); + }); + + it('should handle spawn errors gracefully', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + mockChild.emit('error', new Error('spawn ENOENT')); + const result = await resultPromise; + + expect(result.status).toBe('failed'); + expect(result.stderr).toContain('Spawn error: spawn ENOENT'); + expect(result.exitCode).toBeNull(); + }); + + it('should track the process in activeProcesses while running', async () => { + const config = createExecutionConfig({ runId: 'tracked-run' }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(getActiveProcesses().has('tracked-run')).toBe(true); + + mockChild.emit('close', 0); + await resultPromise; + + expect(getActiveProcesses().has('tracked-run')).toBe(false); + }); + + it('should use custom path when provided', async () => { + const config = createExecutionConfig({ + customPath: '/custom/claude', + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockSpawn).toHaveBeenCalledWith( + '/custom/claude', + expect.any(Array), + expect.any(Object) + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should close stdin for local execution', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // For local (non-SSH) execution, stdin should just be closed + expect(mockChild.stdin.end).toHaveBeenCalled(); + + mockChild.emit('close', 0); + await resultPromise; + }); + + describe('timeout enforcement', () => { + it('should send SIGTERM when timeout expires', async () => { + const config = createExecutionConfig({ timeoutMs: 5000 }); + const killSpy = vi.spyOn(mockChild, 'kill'); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Wait: re-spy after child is created + const childKill = vi.spyOn(mockChild, 'kill'); + + // Advance past timeout + await vi.advanceTimersByTimeAsync(5000); + + expect(childKill).toHaveBeenCalledWith('SIGTERM'); + + // Process exits after SIGTERM + mockChild.emit('close', null); + const result = await resultPromise; + + expect(result.status).toBe('timeout'); + }); + + it('should escalate to SIGKILL after SIGTERM + delay', async () => { + const config = createExecutionConfig({ timeoutMs: 5000 }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const childKill = vi.spyOn(mockChild, 'kill'); + + // Advance past timeout + await vi.advanceTimersByTimeAsync(5000); + expect(childKill).toHaveBeenCalledWith('SIGTERM'); + + // Reset to track SIGKILL — but killed is already true so SIGKILL won't fire + // since child.killed is true. That's correct behavior. + mockChild.killed = false; + + // Advance past SIGKILL delay + await vi.advanceTimersByTimeAsync(5000); + expect(childKill).toHaveBeenCalledWith('SIGKILL'); + + mockChild.emit('close', null); + await resultPromise; + }); + + it('should not timeout when timeoutMs is 0', async () => { + const config = createExecutionConfig({ timeoutMs: 0 }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const childKill = vi.spyOn(mockChild, 'kill'); + + // Advance a lot of time + await vi.advanceTimersByTimeAsync(60000); + expect(childKill).not.toHaveBeenCalled(); + + mockChild.emit('close', 0); + await resultPromise; + }); + }); + + describe('SSH remote execution', () => { + it('should call wrapSpawnWithSsh when SSH is enabled', async () => { + const mockSshStore = { getSshRemotes: vi.fn(() => []) }; + + mockWrapSpawnWithSsh.mockResolvedValue({ + command: 'ssh', + args: ['-o', 'BatchMode=yes', 'user@host', 'claude --print'], + cwd: '/Users/test', + customEnvVars: undefined, + prompt: undefined, + sshRemoteUsed: { id: 'remote-1', name: 'My Server', host: 'host.example.com' }, + }); + + const config = createExecutionConfig({ + sshRemoteConfig: { enabled: true, remoteId: 'remote-1' }, + sshStore: mockSshStore, + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockWrapSpawnWithSsh).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'claude', + agentBinaryName: 'claude', + }), + { enabled: true, remoteId: 'remote-1' }, + mockSshStore + ); + + expect(mockSpawn).toHaveBeenCalledWith( + 'ssh', + expect.arrayContaining(['-o', 'BatchMode=yes']), + expect.objectContaining({ cwd: '/Users/test' }) + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should write prompt to stdin for SSH large prompt mode', async () => { + const mockSshStore = { getSshRemotes: vi.fn(() => []) }; + + mockWrapSpawnWithSsh.mockResolvedValue({ + command: 'ssh', + args: ['user@host'], + cwd: '/Users/test', + customEnvVars: undefined, + prompt: 'large prompt content', // SSH returns prompt for stdin delivery + sshRemoteUsed: { id: 'remote-1', name: 'Server', host: 'host' }, + }); + + const config = createExecutionConfig({ + sshRemoteConfig: { enabled: true, remoteId: 'remote-1' }, + sshStore: mockSshStore, + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockChild.stdin.write).toHaveBeenCalledWith('large prompt content'); + expect(mockChild.stdin.end).toHaveBeenCalled(); + + mockChild.emit('close', 0); + await resultPromise; + }); + }); + + it('should pass custom model and args through config overrides', async () => { + const config = createExecutionConfig({ + customModel: 'claude-4-opus', + customArgs: '--max-tokens 1000', + customEnvVars: { API_KEY: 'test-key' }, + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockApplyOverrides).toHaveBeenCalledWith( + expect.anything(), + expect.any(Array), + expect.objectContaining({ + sessionCustomModel: 'claude-4-opus', + sessionCustomArgs: '--max-tokens 1000', + sessionCustomEnvVars: { API_KEY: 'test-key' }, + }) + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should include event duration in the result', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Advance some time + await vi.advanceTimersByTimeAsync(1500); + + mockChild.emit('close', 0); + const result = await resultPromise; + + expect(result.durationMs).toBeGreaterThanOrEqual(1500); + expect(result.startedAt).toBeTruthy(); + expect(result.endedAt).toBeTruthy(); + }); + + it('should populate github.pull_request event context correctly', async () => { + const subscription = createMockSubscription({ + name: 'PR watcher', + event: 'github.pull_request', + }); + const event = createMockEvent({ + type: 'github.pull_request', + triggerName: 'PR watcher', + payload: { + type: 'pull_request', + number: 42, + title: 'Add feature X', + author: 'octocat', + url: 'https://github.com/owner/repo/pull/42', + body: 'This PR adds feature X', + labels: 'enhancement,review-needed', + state: 'open', + repo: 'owner/repo', + head_branch: 'feature-x', + base_branch: 'main', + assignees: '', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, subscription, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(templateContext.cue?.ghType).toBe('pull_request'); + expect(templateContext.cue?.ghNumber).toBe('42'); + expect(templateContext.cue?.ghTitle).toBe('Add feature X'); + expect(templateContext.cue?.ghAuthor).toBe('octocat'); + expect(templateContext.cue?.ghUrl).toBe('https://github.com/owner/repo/pull/42'); + expect(templateContext.cue?.ghBranch).toBe('feature-x'); + expect(templateContext.cue?.ghBaseBranch).toBe('main'); + expect(templateContext.cue?.ghRepo).toBe('owner/repo'); + // Base cue fields should still be populated + expect(templateContext.cue?.eventType).toBe('github.pull_request'); + expect(templateContext.cue?.triggerName).toBe('PR watcher'); + expect(templateContext.cue?.runId).toBe('run-1'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should populate github.issue event context correctly', async () => { + const subscription = createMockSubscription({ + name: 'Issue watcher', + event: 'github.issue', + }); + const event = createMockEvent({ + type: 'github.issue', + triggerName: 'Issue watcher', + payload: { + type: 'issue', + number: 99, + title: 'Bug report', + author: 'user1', + url: 'https://github.com/owner/repo/issues/99', + body: 'Found a bug', + labels: 'bug', + state: 'open', + repo: 'owner/repo', + assignees: 'dev1,dev2', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, subscription, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(templateContext.cue?.ghType).toBe('issue'); + expect(templateContext.cue?.ghNumber).toBe('99'); + expect(templateContext.cue?.ghAssignees).toBe('dev1,dev2'); + // head_branch / base_branch not in payload → empty string + expect(templateContext.cue?.ghBranch).toBe(''); + expect(templateContext.cue?.ghBaseBranch).toBe(''); + // Base cue fields preserved + expect(templateContext.cue?.eventType).toBe('github.issue'); + expect(templateContext.cue?.triggerName).toBe('Issue watcher'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should populate file.changed changeType in template context', async () => { + const event = createMockEvent({ + type: 'file.changed', + payload: { + path: '/projects/test/new-file.ts', + filename: 'new-file.ts', + directory: '/projects/test', + extension: '.ts', + changeType: 'add', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(templateContext.cue?.fileChangeType).toBe('add'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should populate agent.completed event context correctly', async () => { + const event = createMockEvent({ + type: 'agent.completed', + triggerName: 'On agent done', + payload: { + sourceSession: 'builder-session', + sourceOutput: 'Build completed successfully', + status: 'completed', + exitCode: 0, + durationMs: 15000, + triggeredBy: 'lint-on-save', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(templateContext.cue?.sourceSession).toBe('builder-session'); + expect(templateContext.cue?.sourceOutput).toBe('Build completed successfully'); + expect(templateContext.cue?.sourceStatus).toBe('completed'); + expect(templateContext.cue?.sourceExitCode).toBe('0'); + expect(templateContext.cue?.sourceDuration).toBe('15000'); + expect(templateContext.cue?.sourceTriggeredBy).toBe('lint-on-save'); + + mockChild.emit('close', 0); + await resultPromise; + }); + }); + + describe('stopCueRun', () => { + it('should return false for unknown runId', () => { + expect(stopCueRun('nonexistent')).toBe(false); + }); + + it('should send SIGTERM to a running process', async () => { + const config = createExecutionConfig({ runId: 'stop-test-run' }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const childKill = vi.spyOn(mockChild, 'kill'); + + const stopped = stopCueRun('stop-test-run'); + expect(stopped).toBe(true); + expect(childKill).toHaveBeenCalledWith('SIGTERM'); + + mockChild.emit('close', null); + await resultPromise; + }); + }); + + describe('recordCueHistoryEntry', () => { + it('should construct a proper CUE history entry', () => { + const result: CueRunResult = { + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Watch config', + event: createMockEvent(), + status: 'completed', + stdout: 'Task completed successfully', + stderr: '', + exitCode: 0, + durationMs: 5000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:05.000Z', + }; + + const session = createMockSession(); + const entry = recordCueHistoryEntry(result, session); + + expect(entry.type).toBe('CUE'); + expect(entry.id).toBe('test-uuid-1234'); + expect(entry.summary).toBe('[CUE] "Watch config" (file.changed)'); + expect(entry.fullResponse).toBe('Task completed successfully'); + expect(entry.projectPath).toBe('/projects/test'); + expect(entry.sessionId).toBe('session-1'); + expect(entry.sessionName).toBe('Test Session'); + expect(entry.success).toBe(true); + expect(entry.elapsedTimeMs).toBe(5000); + expect(entry.cueTriggerName).toBe('Watch config'); + expect(entry.cueEventType).toBe('file.changed'); + }); + + it('should set success to false for failed runs', () => { + const result: CueRunResult = { + runId: 'run-2', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Periodic check', + event: createMockEvent({ type: 'time.interval' }), + status: 'failed', + stdout: '', + stderr: 'Error occurred', + exitCode: 1, + durationMs: 2000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:02.000Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.success).toBe(false); + expect(entry.summary).toBe('[CUE] "Periodic check" (time.interval)'); + }); + + it('should truncate long stdout in fullResponse', () => { + const longOutput = 'x'.repeat(15000); + const result: CueRunResult = { + runId: 'run-3', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Large output', + event: createMockEvent(), + status: 'completed', + stdout: longOutput, + stderr: '', + exitCode: 0, + durationMs: 1000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:01.000Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.fullResponse?.length).toBe(10000); + }); + + it('should set fullResponse to undefined when stdout is empty', () => { + const result: CueRunResult = { + runId: 'run-4', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Silent run', + event: createMockEvent(), + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 500, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:00.500Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.fullResponse).toBeUndefined(); + }); + + it('should populate cueSourceSession from agent.completed event payload', () => { + const result: CueRunResult = { + runId: 'run-5', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'On build done', + event: createMockEvent({ + type: 'agent.completed', + payload: { + sourceSession: 'builder-agent', + }, + }), + status: 'completed', + stdout: 'Done', + stderr: '', + exitCode: 0, + durationMs: 3000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:03.000Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.cueSourceSession).toBe('builder-agent'); + expect(entry.cueEventType).toBe('agent.completed'); + }); + + it('should set cueSourceSession to undefined when not present in payload', () => { + const result: CueRunResult = { + runId: 'run-6', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Timer check', + event: createMockEvent({ + type: 'time.interval', + payload: { interval_minutes: 5 }, + }), + status: 'completed', + stdout: 'OK', + stderr: '', + exitCode: 0, + durationMs: 1000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:01.000Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.cueSourceSession).toBeUndefined(); + }); + + it('should use projectRoot for projectPath, falling back to cwd', () => { + const session = createMockSession({ projectRoot: '', cwd: '/fallback/cwd' }); + const result: CueRunResult = { + runId: 'run-7', + sessionId: 'session-1', + sessionName: 'Test', + subscriptionName: 'Test', + event: createMockEvent(), + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:00.100Z', + }; + + const entry = recordCueHistoryEntry(result, session); + + // Empty string is falsy, so should fall back to cwd + expect(entry.projectPath).toBe('/fallback/cwd'); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-file-watcher.test.ts b/src/__tests__/main/cue/cue-file-watcher.test.ts new file mode 100644 index 0000000000..7d4d8e5d97 --- /dev/null +++ b/src/__tests__/main/cue/cue-file-watcher.test.ts @@ -0,0 +1,218 @@ +/** + * Tests for the Cue file watcher provider. + * + * Tests cover: + * - Chokidar watcher creation with correct options + * - Per-file debouncing of change events + * - CueEvent construction with correct payload + * - Cleanup of timers and watcher + * - Error handling + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock crypto.randomUUID +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => 'test-uuid-1234'), +})); + +// Mock chokidar +const mockOn = vi.fn().mockReturnThis(); +const mockClose = vi.fn(); +vi.mock('chokidar', () => ({ + watch: vi.fn(() => ({ + on: mockOn, + close: mockClose, + })), +})); + +import { createCueFileWatcher } from '../../../main/cue/cue-file-watcher'; +import type { CueEvent } from '../../../main/cue/cue-types'; +import * as chokidar from 'chokidar'; + +describe('cue-file-watcher', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates a chokidar watcher with correct options', () => { + createCueFileWatcher({ + watchGlob: 'src/**/*.ts', + projectRoot: '/projects/test', + debounceMs: 5000, + onEvent: vi.fn(), + triggerName: 'test-trigger', + }); + + expect(chokidar.watch).toHaveBeenCalledWith('src/**/*.ts', { + cwd: '/projects/test', + ignoreInitial: true, + persistent: true, + }); + }); + + it('registers change, add, and unlink handlers', () => { + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent: vi.fn(), + triggerName: 'test', + }); + + const registeredEvents = mockOn.mock.calls.map((call) => call[0]); + expect(registeredEvents).toContain('change'); + expect(registeredEvents).toContain('add'); + expect(registeredEvents).toContain('unlink'); + expect(registeredEvents).toContain('error'); + }); + + it('debounces events per file', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent, + triggerName: 'test', + }); + + const changeHandler = mockOn.mock.calls.find((call) => call[0] === 'change')?.[1]; + expect(changeHandler).toBeDefined(); + + // Rapid changes to the same file + changeHandler('src/index.ts'); + changeHandler('src/index.ts'); + changeHandler('src/index.ts'); + + vi.advanceTimersByTime(5000); + expect(onEvent).toHaveBeenCalledTimes(1); + }); + + it('does not coalesce events from different files', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent, + triggerName: 'test', + }); + + const changeHandler = mockOn.mock.calls.find((call) => call[0] === 'change')?.[1]; + + changeHandler('src/a.ts'); + changeHandler('src/b.ts'); + + vi.advanceTimersByTime(5000); + expect(onEvent).toHaveBeenCalledTimes(2); + }); + + it('constructs a CueEvent with correct payload for change events', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 100, + onEvent, + triggerName: 'my-trigger', + }); + + const changeHandler = mockOn.mock.calls.find((call) => call[0] === 'change')?.[1]; + changeHandler('src/index.ts'); + vi.advanceTimersByTime(100); + + expect(onEvent).toHaveBeenCalledTimes(1); + const event: CueEvent = onEvent.mock.calls[0][0]; + expect(event.id).toBe('test-uuid-1234'); + expect(event.type).toBe('file.changed'); + expect(event.triggerName).toBe('my-trigger'); + expect(event.payload.filename).toBe('index.ts'); + expect(event.payload.extension).toBe('.ts'); + expect(event.payload.changeType).toBe('change'); + }); + + it('reports correct changeType for add events', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 100, + onEvent, + triggerName: 'test', + }); + + const addHandler = mockOn.mock.calls.find((call) => call[0] === 'add')?.[1]; + addHandler('src/new.ts'); + vi.advanceTimersByTime(100); + + const event: CueEvent = onEvent.mock.calls[0][0]; + expect(event.payload.changeType).toBe('add'); + }); + + it('reports correct changeType for unlink events', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 100, + onEvent, + triggerName: 'test', + }); + + const unlinkHandler = mockOn.mock.calls.find((call) => call[0] === 'unlink')?.[1]; + unlinkHandler('src/deleted.ts'); + vi.advanceTimersByTime(100); + + const event: CueEvent = onEvent.mock.calls[0][0]; + expect(event.payload.changeType).toBe('unlink'); + }); + + it('cleanup function clears timers and closes watcher', () => { + const onEvent = vi.fn(); + const cleanup = createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent, + triggerName: 'test', + }); + + // Trigger a change to create a pending timer + const changeHandler = mockOn.mock.calls.find((call) => call[0] === 'change')?.[1]; + changeHandler('src/index.ts'); + + cleanup(); + + // Advance past debounce — event should NOT fire since cleanup was called + vi.advanceTimersByTime(5000); + expect(onEvent).not.toHaveBeenCalled(); + expect(mockClose).toHaveBeenCalled(); + }); + + it('handles watcher errors gracefully', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent: vi.fn(), + triggerName: 'test', + }); + + const errorHandler = mockOn.mock.calls.find((call) => call[0] === 'error')?.[1]; + expect(errorHandler).toBeDefined(); + + // Should not throw + errorHandler(new Error('Watch error')); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/main/cue/cue-filter.test.ts b/src/__tests__/main/cue/cue-filter.test.ts new file mode 100644 index 0000000000..6dad73aaf9 --- /dev/null +++ b/src/__tests__/main/cue/cue-filter.test.ts @@ -0,0 +1,242 @@ +/** + * Tests for the Cue filter matching engine. + * + * Tests cover: + * - Exact string matching + * - Negation (!value) + * - Numeric comparisons (>, <, >=, <=) + * - Glob pattern matching (*) + * - Boolean matching + * - Numeric equality + * - Dot-notation nested key access + * - AND logic (all conditions must pass) + * - Missing payload fields + * - describeFilter human-readable output + */ + +import { describe, it, expect } from 'vitest'; +import { matchesFilter, describeFilter } from '../../../main/cue/cue-filter'; + +describe('cue-filter', () => { + describe('matchesFilter', () => { + describe('exact string matching', () => { + it('matches exact string values', () => { + expect(matchesFilter({ extension: '.ts' }, { extension: '.ts' })).toBe(true); + }); + + it('rejects non-matching string values', () => { + expect(matchesFilter({ extension: '.js' }, { extension: '.ts' })).toBe(false); + }); + + it('coerces payload value to string for comparison', () => { + expect(matchesFilter({ count: 42 }, { count: '42' })).toBe(true); + }); + }); + + describe('negation (!value)', () => { + it('matches when value does not equal', () => { + expect(matchesFilter({ status: 'active' }, { status: '!archived' })).toBe(true); + }); + + it('rejects when value equals the negated term', () => { + expect(matchesFilter({ status: 'archived' }, { status: '!archived' })).toBe(false); + }); + }); + + describe('numeric comparisons', () => { + it('matches greater than', () => { + expect(matchesFilter({ size: 1500 }, { size: '>1000' })).toBe(true); + }); + + it('rejects not greater than', () => { + expect(matchesFilter({ size: 500 }, { size: '>1000' })).toBe(false); + }); + + it('rejects equal for greater than', () => { + expect(matchesFilter({ size: 1000 }, { size: '>1000' })).toBe(false); + }); + + it('matches less than', () => { + expect(matchesFilter({ priority: 3 }, { priority: '<5' })).toBe(true); + }); + + it('rejects not less than', () => { + expect(matchesFilter({ priority: 7 }, { priority: '<5' })).toBe(false); + }); + + it('matches greater than or equal', () => { + expect(matchesFilter({ score: 100 }, { score: '>=100' })).toBe(true); + expect(matchesFilter({ score: 101 }, { score: '>=100' })).toBe(true); + }); + + it('rejects less than for >=', () => { + expect(matchesFilter({ score: 99 }, { score: '>=100' })).toBe(false); + }); + + it('matches less than or equal', () => { + expect(matchesFilter({ count: 10 }, { count: '<=10' })).toBe(true); + expect(matchesFilter({ count: 9 }, { count: '<=10' })).toBe(true); + }); + + it('rejects greater than for <=', () => { + expect(matchesFilter({ count: 11 }, { count: '<=10' })).toBe(false); + }); + + it('handles string payload values with numeric comparison', () => { + expect(matchesFilter({ size: '1500' }, { size: '>1000' })).toBe(true); + }); + + it('rejects NaN payload values in numeric comparisons', () => { + expect(matchesFilter({ size: 'not-a-number' }, { size: '>1000' })).toBe(false); + expect(matchesFilter({ size: 'abc' }, { size: '<1000' })).toBe(false); + expect(matchesFilter({ size: 'xyz' }, { size: '>=100' })).toBe(false); + expect(matchesFilter({ size: '' }, { size: '<=100' })).toBe(false); + }); + + it('rejects NaN threshold values in numeric comparisons', () => { + expect(matchesFilter({ size: 500 }, { size: '>abc' })).toBe(false); + expect(matchesFilter({ size: 500 }, { size: '=foo' })).toBe(false); + expect(matchesFilter({ size: 500 }, { size: '<=bar' })).toBe(false); + }); + }); + + describe('glob pattern matching', () => { + it('matches simple glob patterns', () => { + expect(matchesFilter({ path: 'file.ts' }, { path: '*.ts' })).toBe(true); + }); + + it('rejects non-matching glob patterns', () => { + expect(matchesFilter({ path: 'file.js' }, { path: '*.ts' })).toBe(false); + }); + + it('matches complex glob patterns', () => { + expect(matchesFilter({ path: 'src/components/Button.tsx' }, { path: 'src/**/*.tsx' })).toBe( + true + ); + }); + + it('rejects non-matching complex patterns', () => { + expect(matchesFilter({ path: 'test/Button.tsx' }, { path: 'src/**/*.tsx' })).toBe(false); + }); + }); + + describe('boolean matching', () => { + it('matches true boolean', () => { + expect(matchesFilter({ active: true }, { active: true })).toBe(true); + }); + + it('rejects false when expecting true', () => { + expect(matchesFilter({ active: false }, { active: true })).toBe(false); + }); + + it('matches false boolean', () => { + expect(matchesFilter({ active: false }, { active: false })).toBe(true); + }); + + it('rejects true when expecting false', () => { + expect(matchesFilter({ active: true }, { active: false })).toBe(false); + }); + }); + + describe('numeric equality', () => { + it('matches exact numeric values', () => { + expect(matchesFilter({ exitCode: 0 }, { exitCode: 0 })).toBe(true); + }); + + it('rejects non-matching numeric values', () => { + expect(matchesFilter({ exitCode: 1 }, { exitCode: 0 })).toBe(false); + }); + }); + + describe('dot-notation nested access', () => { + it('resolves nested payload fields', () => { + const payload = { source: { status: 'completed' } }; + expect(matchesFilter(payload, { 'source.status': 'completed' })).toBe(true); + }); + + it('returns false for missing nested path', () => { + const payload = { source: {} }; + expect(matchesFilter(payload, { 'source.status': 'completed' })).toBe(false); + }); + + it('handles deeply nested access', () => { + const payload = { a: { b: { c: 'deep' } } }; + expect(matchesFilter(payload, { 'a.b.c': 'deep' })).toBe(true); + }); + }); + + describe('AND logic', () => { + it('requires all conditions to pass', () => { + const payload = { extension: '.ts', changeType: 'change', path: 'src/index.ts' }; + const filter = { extension: '.ts', changeType: 'change' }; + expect(matchesFilter(payload, filter)).toBe(true); + }); + + it('fails if any condition does not pass', () => { + const payload = { extension: '.js', changeType: 'change' }; + const filter = { extension: '.ts', changeType: 'change' }; + expect(matchesFilter(payload, filter)).toBe(false); + }); + }); + + describe('missing payload fields', () => { + it('fails when payload field is undefined', () => { + expect(matchesFilter({}, { extension: '.ts' })).toBe(false); + }); + + it('fails when nested payload field is undefined', () => { + expect(matchesFilter({ source: {} }, { 'source.missing': 'value' })).toBe(false); + }); + }); + + describe('empty filter', () => { + it('matches everything when filter is empty', () => { + expect(matchesFilter({ any: 'value' }, {})).toBe(true); + }); + }); + }); + + describe('describeFilter', () => { + it('describes exact string match', () => { + expect(describeFilter({ extension: '.ts' })).toBe('extension == ".ts"'); + }); + + it('describes negation', () => { + expect(describeFilter({ status: '!archived' })).toBe('status != archived'); + }); + + it('describes greater than', () => { + expect(describeFilter({ size: '>1000' })).toBe('size > 1000'); + }); + + it('describes less than', () => { + expect(describeFilter({ priority: '<5' })).toBe('priority < 5'); + }); + + it('describes greater than or equal', () => { + expect(describeFilter({ score: '>=100' })).toBe('score >= 100'); + }); + + it('describes less than or equal', () => { + expect(describeFilter({ count: '<=10' })).toBe('count <= 10'); + }); + + it('describes glob pattern', () => { + expect(describeFilter({ path: '*.ts' })).toBe('path matches *.ts'); + }); + + it('describes boolean', () => { + expect(describeFilter({ active: true })).toBe('active is true'); + }); + + it('describes numeric equality', () => { + expect(describeFilter({ exitCode: 0 })).toBe('exitCode == 0'); + }); + + it('joins multiple conditions with AND', () => { + const result = describeFilter({ extension: '.ts', status: '!archived' }); + expect(result).toBe('extension == ".ts" AND status != archived'); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-github-poller.test.ts b/src/__tests__/main/cue/cue-github-poller.test.ts new file mode 100644 index 0000000000..b7282b8d58 --- /dev/null +++ b/src/__tests__/main/cue/cue-github-poller.test.ts @@ -0,0 +1,604 @@ +/** + * Tests for the Cue GitHub poller provider. + * + * Tests cover: + * - gh CLI availability check + * - Repo auto-detection + * - PR and issue polling with event emission + * - Seen-item tracking and first-run seeding + * - CueEvent payload shapes + * - Body truncation + * - Cleanup and timer management + * - Error handling + * + * Note: The poller uses execFile (not exec) to avoid shell injection. + * The mock here simulates execFile's callback-based API via promisify. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Hoisted mock references (vi.hoisted runs before vi.mock hoisting) +const { + mockExecFile, + mockIsGitHubItemSeen, + mockMarkGitHubItemSeen, + mockHasAnyGitHubSeen, + mockPruneGitHubSeen, +} = vi.hoisted(() => ({ + mockExecFile: vi.fn(), + mockIsGitHubItemSeen: vi.fn<(subId: string, key: string) => boolean>().mockReturnValue(false), + mockMarkGitHubItemSeen: vi.fn<(subId: string, key: string) => void>(), + mockHasAnyGitHubSeen: vi.fn<(subId: string) => boolean>().mockReturnValue(true), + mockPruneGitHubSeen: vi.fn<(olderThanMs: number) => void>(), +})); + +// Mock crypto.randomUUID +let uuidCounter = 0; +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `test-uuid-${++uuidCounter}`), +})); + +// Mock child_process.execFile (safe — no shell injection risk) +vi.mock('child_process', () => ({ + default: { execFile: mockExecFile }, + execFile: mockExecFile, +})); + +// Mock cue-db functions +vi.mock('../../../main/cue/cue-db', () => ({ + isGitHubItemSeen: (subId: string, key: string) => mockIsGitHubItemSeen(subId, key), + markGitHubItemSeen: (subId: string, key: string) => mockMarkGitHubItemSeen(subId, key), + hasAnyGitHubSeen: (subId: string) => mockHasAnyGitHubSeen(subId), + pruneGitHubSeen: (olderThanMs: number) => mockPruneGitHubSeen(olderThanMs), +})); + +import { + createCueGitHubPoller, + type CueGitHubPollerConfig, +} from '../../../main/cue/cue-github-poller'; + +// Helper: make mockExecFile (callback-style) resolve/reject +function setupExecFile(responses: Record) { + mockExecFile.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + const key = `${cmd} ${args.join(' ')}`; + for (const [pattern, stdout] of Object.entries(responses)) { + if (key.includes(pattern)) { + cb(null, stdout, ''); + return; + } + } + cb(new Error(`Command not found: ${key}`), '', ''); + } + ); +} + +function setupExecFileReject(pattern: string, errorMsg: string) { + mockExecFile.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + const key = `${cmd} ${args.join(' ')}`; + if (key.includes(pattern)) { + cb(new Error(errorMsg), '', ''); + return; + } + cb(null, '', ''); + } + ); +} + +const samplePRs = [ + { + number: 1, + title: 'Add feature', + author: { login: 'alice' }, + url: 'https://github.com/owner/repo/pull/1', + body: 'Feature description', + state: 'OPEN', + isDraft: false, + labels: [{ name: 'enhancement' }], + headRefName: 'feature-branch', + baseRefName: 'main', + createdAt: '2026-03-01T00:00:00Z', + updatedAt: '2026-03-02T00:00:00Z', + }, + { + number: 2, + title: 'Fix bug', + author: { login: 'bob' }, + url: 'https://github.com/owner/repo/pull/2', + body: 'Bug fix', + state: 'OPEN', + isDraft: true, + labels: [{ name: 'bug' }, { name: 'urgent' }], + headRefName: 'fix-branch', + baseRefName: 'main', + createdAt: '2026-03-01T12:00:00Z', + updatedAt: '2026-03-02T12:00:00Z', + }, + { + number: 3, + title: 'Docs update', + author: { login: 'charlie' }, + url: 'https://github.com/owner/repo/pull/3', + body: null, + state: 'OPEN', + isDraft: false, + labels: [], + headRefName: 'docs', + baseRefName: 'main', + createdAt: '2026-03-02T00:00:00Z', + updatedAt: '2026-03-03T00:00:00Z', + }, +]; + +const sampleIssues = [ + { + number: 10, + title: 'Bug report', + author: { login: 'dave' }, + url: 'https://github.com/owner/repo/issues/10', + body: 'Something is broken', + state: 'OPEN', + labels: [{ name: 'bug' }], + assignees: [{ login: 'alice' }, { login: 'bob' }], + createdAt: '2026-03-01T00:00:00Z', + updatedAt: '2026-03-02T00:00:00Z', + }, + { + number: 11, + title: 'Feature request', + author: { login: 'eve' }, + url: 'https://github.com/owner/repo/issues/11', + body: 'Please add this', + state: 'OPEN', + labels: [], + assignees: [], + createdAt: '2026-03-02T00:00:00Z', + updatedAt: '2026-03-03T00:00:00Z', + }, +]; + +function makeConfig(overrides: Partial = {}): CueGitHubPollerConfig { + return { + eventType: 'github.pull_request', + repo: 'owner/repo', + pollMinutes: 5, + projectRoot: '/projects/test', + onEvent: vi.fn(), + onLog: vi.fn(), + triggerName: 'test-trigger', + subscriptionId: 'session-1:test-sub', + ...overrides, + }; +} + +describe('cue-github-poller', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + uuidCounter = 0; + mockIsGitHubItemSeen.mockReturnValue(false); + mockHasAnyGitHubSeen.mockReturnValue(true); // not first run by default + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('gh CLI not available — warning logged, no events fired, no crash', async () => { + const config = makeConfig(); + setupExecFileReject('--version', 'gh not found'); + + const cleanup = createCueGitHubPoller(config); + + // Advance past initial 2s delay + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onLog).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('GitHub CLI (gh) not found') + ); + expect(config.onEvent).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('repo auto-detection — resolves from gh repo view', async () => { + const config = makeConfig({ repo: undefined }); + setupExecFile({ + '--version': '2.0.0', + 'repo view': 'auto-owner/auto-repo\n', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + // Should have auto-detected repo and used it in pr list + expect(mockExecFile).toHaveBeenCalledWith( + 'gh', + expect.arrayContaining(['repo', 'view']), + expect.anything(), + expect.any(Function) + ); + + cleanup(); + }); + + it('repo auto-detection failure — warning logged, poll skipped', async () => { + const config = makeConfig({ repo: undefined }); + setupExecFile({ '--version': '2.0.0' }); + // repo view will hit the default reject + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onLog).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('Could not auto-detect repo') + ); + expect(config.onEvent).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('PR polling — new items fire events', async () => { + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onEvent).toHaveBeenCalledTimes(3); + + cleanup(); + }); + + it('PR polling — seen items are skipped', async () => { + mockIsGitHubItemSeen.mockImplementation(((_subId: string, itemKey: string) => { + return itemKey === 'pr:owner/repo:2'; // PR #2 already seen + }) as (subId: string, key: string) => boolean); + + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onEvent).toHaveBeenCalledTimes(2); + + cleanup(); + }); + + it('PR polling — marks items as seen with correct keys', async () => { + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(mockMarkGitHubItemSeen).toHaveBeenCalledWith('session-1:test-sub', 'pr:owner/repo:1'); + expect(mockMarkGitHubItemSeen).toHaveBeenCalledWith('session-1:test-sub', 'pr:owner/repo:2'); + expect(mockMarkGitHubItemSeen).toHaveBeenCalledWith('session-1:test-sub', 'pr:owner/repo:3'); + + cleanup(); + }); + + it('issue polling — new items fire events with assignees', async () => { + const config = makeConfig({ eventType: 'github.issue' }); + setupExecFile({ + '--version': '2.0.0', + 'issue list': JSON.stringify(sampleIssues), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onEvent).toHaveBeenCalledTimes(2); + const event = (config.onEvent as ReturnType).mock.calls[0][0]; + expect(event.payload.assignees).toBe('alice,bob'); + + cleanup(); + }); + + it('CueEvent payload shape for PRs', async () => { + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify([samplePRs[0]]), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + const event = (config.onEvent as ReturnType).mock.calls[0][0]; + expect(event.type).toBe('github.pull_request'); + expect(event.triggerName).toBe('test-trigger'); + expect(event.payload).toEqual({ + type: 'pull_request', + number: 1, + title: 'Add feature', + author: 'alice', + url: 'https://github.com/owner/repo/pull/1', + body: 'Feature description', + state: 'open', + draft: false, + labels: 'enhancement', + head_branch: 'feature-branch', + base_branch: 'main', + repo: 'owner/repo', + created_at: '2026-03-01T00:00:00Z', + updated_at: '2026-03-02T00:00:00Z', + }); + + cleanup(); + }); + + it('CueEvent payload shape for issues', async () => { + const config = makeConfig({ eventType: 'github.issue' }); + setupExecFile({ + '--version': '2.0.0', + 'issue list': JSON.stringify([sampleIssues[0]]), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + const event = (config.onEvent as ReturnType).mock.calls[0][0]; + expect(event.type).toBe('github.issue'); + expect(event.payload).toEqual({ + type: 'issue', + number: 10, + title: 'Bug report', + author: 'dave', + url: 'https://github.com/owner/repo/issues/10', + body: 'Something is broken', + state: 'open', + labels: 'bug', + assignees: 'alice,bob', + repo: 'owner/repo', + created_at: '2026-03-01T00:00:00Z', + updated_at: '2026-03-02T00:00:00Z', + }); + + cleanup(); + }); + + it('body truncation — body exceeding 5000 chars is truncated', async () => { + const longBody = 'x'.repeat(6000); + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify([{ ...samplePRs[0], body: longBody }]), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + const event = (config.onEvent as ReturnType).mock.calls[0][0]; + expect(event.payload.body).toHaveLength(5000); + + cleanup(); + }); + + it('first-run seeding — no events on first poll', async () => { + mockHasAnyGitHubSeen.mockReturnValue(false); // first run + + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onEvent).not.toHaveBeenCalled(); + expect(mockMarkGitHubItemSeen).toHaveBeenCalledTimes(3); + expect(config.onLog).toHaveBeenCalledWith( + 'info', + expect.stringContaining('seeded 3 existing pull_request(s)') + ); + + cleanup(); + }); + + it('second poll fires events after seeding', async () => { + // First poll: seeding (no seen records) + mockHasAnyGitHubSeen.mockReturnValueOnce(false); + // Second poll: has seen records now + mockHasAnyGitHubSeen.mockReturnValue(true); + + const newPR = { + ...samplePRs[0], + number: 99, + title: 'New PR', + }; + + const config = makeConfig({ pollMinutes: 1 }); + + let callCount = 0; + mockExecFile.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + const key = `${cmd} ${args.join(' ')}`; + if (key.includes('--version')) { + cb(null, '2.0.0', ''); + } else if (key.includes('pr list')) { + callCount++; + if (callCount === 1) { + cb(null, JSON.stringify(samplePRs), ''); + } else { + cb(null, JSON.stringify([newPR]), ''); + } + } else { + cb(new Error('not found'), '', ''); + } + } + ); + + const cleanup = createCueGitHubPoller(config); + + // First poll at 2s + await vi.advanceTimersByTimeAsync(2000); + expect(config.onEvent).not.toHaveBeenCalled(); // seeded + + // Second poll at 2s + 1min + await vi.advanceTimersByTimeAsync(60000); + expect(config.onEvent).toHaveBeenCalledTimes(1); + + cleanup(); + }); + + it('cleanup stops polling', async () => { + const config = makeConfig({ pollMinutes: 1 }); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + + // First poll + await vi.advanceTimersByTimeAsync(2000); + const callCountAfterFirst = (config.onEvent as ReturnType).mock.calls.length; + + cleanup(); + + // Advance past poll interval — no new polls should occur + await vi.advanceTimersByTimeAsync(600000); + expect((config.onEvent as ReturnType).mock.calls.length).toBe( + callCountAfterFirst + ); + }); + + it('initial poll delay — first poll at 2s, not immediately', async () => { + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + + // At 0ms, nothing should have happened + expect(mockExecFile).not.toHaveBeenCalled(); + + // At 1999ms, still nothing + await vi.advanceTimersByTimeAsync(1999); + expect(mockExecFile).not.toHaveBeenCalled(); + + // At 2000ms, poll starts + await vi.advanceTimersByTimeAsync(1); + expect(mockExecFile).toHaveBeenCalled(); + + cleanup(); + }); + + it('poll interval — subsequent polls at configured interval', async () => { + const config = makeConfig({ pollMinutes: 2 }); + let pollCount = 0; + mockExecFile.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + const key = `${cmd} ${args.join(' ')}`; + if (key.includes('--version')) { + cb(null, '2.0.0', ''); + } else if (key.includes('pr list')) { + pollCount++; + cb(null, JSON.stringify([]), ''); + } else { + cb(new Error('not found'), '', ''); + } + } + ); + + const cleanup = createCueGitHubPoller(config); + + // Initial poll at 2s + await vi.advanceTimersByTimeAsync(2000); + expect(pollCount).toBe(1); + + // Second poll at 2s + 2min + await vi.advanceTimersByTimeAsync(120000); + expect(pollCount).toBe(2); + + // Third poll at 2s + 4min + await vi.advanceTimersByTimeAsync(120000); + expect(pollCount).toBe(3); + + cleanup(); + }); + + it('gh parse error — invalid JSON from gh, error logged, no crash', async () => { + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': 'not valid json{{{', + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onLog).toHaveBeenCalledWith( + 'error', + expect.stringContaining('GitHub poll error') + ); + expect(config.onEvent).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('stopped during iteration — remaining items skipped', async () => { + const config = makeConfig(); + + // Track onEvent calls to call cleanup mid-iteration + let cleanupFn: (() => void) | null = null; + let eventCallCount = 0; + const originalOnEvent = vi.fn(() => { + eventCallCount++; + if (eventCallCount === 1 && cleanupFn) { + cleanupFn(); // Stop after first event + } + }); + config.onEvent = originalOnEvent; + + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + cleanupFn = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + // Should have fired 1 event then stopped (remaining 2 skipped) + expect(eventCallCount).toBe(1); + }); +}); diff --git a/src/__tests__/main/cue/cue-ipc-handlers.test.ts b/src/__tests__/main/cue/cue-ipc-handlers.test.ts new file mode 100644 index 0000000000..fcb5d8beb5 --- /dev/null +++ b/src/__tests__/main/cue/cue-ipc-handlers.test.ts @@ -0,0 +1,378 @@ +/** + * Tests for Cue IPC handlers. + * + * Tests cover: + * - Handler registration with ipcMain.handle + * - Delegation to CueEngine methods (getStatus, getActiveRuns, etc.) + * - YAML read/write/validate operations + * - Engine enable/disable controls + * - Error handling when engine is not initialized + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; + +// Track registered IPC handlers +const registeredHandlers = new Map unknown>(); + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn((name: string) => `/mock-user-data/${name}`), + }, + ipcMain: { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => { + registeredHandlers.set(channel, handler); + }), + }, +})); + +vi.mock('fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock('path', async () => { + const actual = await vi.importActual('path'); + return { + ...actual, + join: vi.fn((...args: string[]) => args.join('/')), + }; +}); + +vi.mock('js-yaml', () => ({ + load: vi.fn(), +})); + +vi.mock('../../../main/utils/ipcHandler', () => ({ + withIpcErrorLogging: vi.fn( + ( + _opts: unknown, + handler: (...args: unknown[]) => unknown + ): ((_event: unknown, ...args: unknown[]) => unknown) => { + return (_event: unknown, ...args: unknown[]) => handler(...args); + } + ), +})); + +vi.mock('../../../main/cue/cue-yaml-loader', () => ({ + validateCueConfig: vi.fn(), + resolveCueConfigPath: vi.fn(), +})); + +vi.mock('../../../main/cue/cue-types', () => ({ + CUE_YAML_FILENAME: 'maestro-cue.yaml', // legacy name kept in cue-types for compat +})); + +vi.mock('../../../shared/maestro-paths', () => ({ + CUE_CONFIG_PATH: '.maestro/cue.yaml', + MAESTRO_DIR: '.maestro', +})); + +import { registerCueHandlers } from '../../../main/ipc/handlers/cue'; +import { validateCueConfig, resolveCueConfigPath } from '../../../main/cue/cue-yaml-loader'; +import * as yaml from 'js-yaml'; + +// Create a mock CueEngine +function createMockEngine() { + return { + getStatus: vi.fn().mockReturnValue([]), + getActiveRuns: vi.fn().mockReturnValue([]), + getActivityLog: vi.fn().mockReturnValue([]), + start: vi.fn(), + stop: vi.fn(), + stopRun: vi.fn().mockReturnValue(true), + stopAll: vi.fn(), + refreshSession: vi.fn(), + isEnabled: vi.fn().mockReturnValue(false), + }; +} + +describe('Cue IPC Handlers', () => { + let mockEngine: ReturnType; + + beforeEach(() => { + registeredHandlers.clear(); + vi.clearAllMocks(); + mockEngine = createMockEngine(); + }); + + afterEach(() => { + registeredHandlers.clear(); + }); + + function registerAndGetHandler(channel: string) { + registerCueHandlers({ + getCueEngine: () => mockEngine as any, + }); + const handler = registeredHandlers.get(channel); + if (!handler) { + throw new Error(`Handler for channel "${channel}" not registered`); + } + return handler; + } + + describe('handler registration', () => { + it('should register all expected IPC channels', () => { + registerCueHandlers({ + getCueEngine: () => mockEngine as any, + }); + + const expectedChannels = [ + 'cue:getStatus', + 'cue:getActiveRuns', + 'cue:getActivityLog', + 'cue:enable', + 'cue:disable', + 'cue:stopRun', + 'cue:stopAll', + 'cue:refreshSession', + 'cue:readYaml', + 'cue:writeYaml', + 'cue:deleteYaml', + 'cue:validateYaml', + 'cue:savePipelineLayout', + 'cue:loadPipelineLayout', + ]; + + for (const channel of expectedChannels) { + expect(registeredHandlers.has(channel)).toBe(true); + } + }); + }); + + describe('engine not initialized', () => { + it('should throw when engine is null', async () => { + registerCueHandlers({ + getCueEngine: () => null, + }); + + const handler = registeredHandlers.get('cue:getStatus')!; + await expect(handler(null)).rejects.toThrow('Cue engine not initialized'); + }); + }); + + describe('cue:getStatus', () => { + it('should delegate to engine.getStatus()', async () => { + const mockStatus = [ + { + sessionId: 's1', + sessionName: 'Test', + toolType: 'claude-code', + enabled: true, + subscriptionCount: 2, + activeRuns: 0, + }, + ]; + mockEngine.getStatus.mockReturnValue(mockStatus); + + const handler = registerAndGetHandler('cue:getStatus'); + const result = await handler(null); + expect(result).toEqual(mockStatus); + expect(mockEngine.getStatus).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:getActiveRuns', () => { + it('should delegate to engine.getActiveRuns()', async () => { + const mockRuns = [{ runId: 'r1', status: 'running' }]; + mockEngine.getActiveRuns.mockReturnValue(mockRuns); + + const handler = registerAndGetHandler('cue:getActiveRuns'); + const result = await handler(null); + expect(result).toEqual(mockRuns); + expect(mockEngine.getActiveRuns).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:getActivityLog', () => { + it('should delegate to engine.getActivityLog() with limit', async () => { + const mockLog = [{ runId: 'r1', status: 'completed' }]; + mockEngine.getActivityLog.mockReturnValue(mockLog); + + const handler = registerAndGetHandler('cue:getActivityLog'); + const result = await handler(null, { limit: 10 }); + expect(result).toEqual(mockLog); + expect(mockEngine.getActivityLog).toHaveBeenCalledWith(10); + }); + + it('should pass undefined limit when not provided', async () => { + const handler = registerAndGetHandler('cue:getActivityLog'); + await handler(null, {}); + expect(mockEngine.getActivityLog).toHaveBeenCalledWith(undefined); + }); + }); + + describe('cue:enable', () => { + it('should call engine.start()', async () => { + const handler = registerAndGetHandler('cue:enable'); + await handler(null); + expect(mockEngine.start).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:disable', () => { + it('should call engine.stop()', async () => { + const handler = registerAndGetHandler('cue:disable'); + await handler(null); + expect(mockEngine.stop).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:stopRun', () => { + it('should delegate to engine.stopRun() with runId', async () => { + mockEngine.stopRun.mockReturnValue(true); + const handler = registerAndGetHandler('cue:stopRun'); + const result = await handler(null, { runId: 'run-123' }); + expect(result).toBe(true); + expect(mockEngine.stopRun).toHaveBeenCalledWith('run-123'); + }); + + it('should return false when run not found', async () => { + mockEngine.stopRun.mockReturnValue(false); + const handler = registerAndGetHandler('cue:stopRun'); + const result = await handler(null, { runId: 'nonexistent' }); + expect(result).toBe(false); + }); + }); + + describe('cue:stopAll', () => { + it('should call engine.stopAll()', async () => { + const handler = registerAndGetHandler('cue:stopAll'); + await handler(null); + expect(mockEngine.stopAll).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:refreshSession', () => { + it('should delegate to engine.refreshSession()', async () => { + const handler = registerAndGetHandler('cue:refreshSession'); + await handler(null, { sessionId: 's1', projectRoot: '/projects/test' }); + expect(mockEngine.refreshSession).toHaveBeenCalledWith('s1', '/projects/test'); + }); + }); + + describe('cue:readYaml', () => { + it('should return file content when file exists', async () => { + vi.mocked(resolveCueConfigPath).mockReturnValue('/projects/test/.maestro/cue.yaml'); + vi.mocked(fs.readFileSync).mockReturnValue('subscriptions: []'); + + const handler = registerAndGetHandler('cue:readYaml'); + const result = await handler(null, { projectRoot: '/projects/test' }); + expect(result).toBe('subscriptions: []'); + expect(resolveCueConfigPath).toHaveBeenCalledWith('/projects/test'); + expect(fs.readFileSync).toHaveBeenCalledWith('/projects/test/.maestro/cue.yaml', 'utf-8'); + }); + + it('should return null when file does not exist', async () => { + vi.mocked(resolveCueConfigPath).mockReturnValue(null); + + const handler = registerAndGetHandler('cue:readYaml'); + const result = await handler(null, { projectRoot: '/projects/test' }); + expect(result).toBeNull(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + }); + }); + + describe('cue:writeYaml', () => { + it('should write content to the correct file path', async () => { + const content = 'subscriptions:\n - name: test\n event: time.interval'; + vi.mocked(fs.existsSync).mockReturnValue(true); // .maestro dir exists + + const handler = registerAndGetHandler('cue:writeYaml'); + await handler(null, { projectRoot: '/projects/test', content }); + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/projects/test/.maestro/cue.yaml', + content, + 'utf-8' + ); + }); + }); + + describe('cue:validateYaml', () => { + it('should return valid result for valid YAML', async () => { + const content = 'subscriptions: []'; + vi.mocked(yaml.load).mockReturnValue({ subscriptions: [] }); + vi.mocked(validateCueConfig).mockReturnValue({ valid: true, errors: [] }); + + const handler = registerAndGetHandler('cue:validateYaml'); + const result = await handler(null, { content }); + expect(result).toEqual({ valid: true, errors: [] }); + expect(yaml.load).toHaveBeenCalledWith(content); + expect(validateCueConfig).toHaveBeenCalledWith({ subscriptions: [] }); + }); + + it('should return errors for invalid config', async () => { + const content = 'subscriptions: invalid'; + vi.mocked(yaml.load).mockReturnValue({ subscriptions: 'invalid' }); + vi.mocked(validateCueConfig).mockReturnValue({ + valid: false, + errors: ['Config must have a "subscriptions" array'], + }); + + const handler = registerAndGetHandler('cue:validateYaml'); + const result = await handler(null, { content }); + expect(result).toEqual({ + valid: false, + errors: ['Config must have a "subscriptions" array'], + }); + }); + + it('should return parse error for malformed YAML', async () => { + const content = '{{invalid yaml'; + vi.mocked(yaml.load).mockImplementation(() => { + throw new Error('bad indentation'); + }); + + const handler = registerAndGetHandler('cue:validateYaml'); + const result = await handler(null, { content }); + expect(result).toEqual({ + valid: false, + errors: ['YAML parse error: bad indentation'], + }); + }); + }); + + describe('cue:savePipelineLayout', () => { + it('should write layout to JSON file', async () => { + const layout = { + pipelines: [{ id: 'p1', name: 'Pipeline 1', color: '#06b6d4', nodes: [], edges: [] }], + selectedPipelineId: 'p1', + viewport: { x: 0, y: 0, zoom: 1 }, + }; + + const handler = registerAndGetHandler('cue:savePipelineLayout'); + await handler(null, { layout }); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('cue-pipeline-layout.json'), + JSON.stringify(layout, null, 2), + 'utf-8' + ); + }); + }); + + describe('cue:loadPipelineLayout', () => { + it('should return layout when file exists', async () => { + const layout = { + pipelines: [{ id: 'p1', name: 'Pipeline 1', color: '#06b6d4', nodes: [], edges: [] }], + selectedPipelineId: 'p1', + viewport: { x: 100, y: 200, zoom: 1.5 }, + }; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(layout)); + + const handler = registerAndGetHandler('cue:loadPipelineLayout'); + const result = await handler(null); + expect(result).toEqual(layout); + }); + + it('should return null when file does not exist', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const handler = registerAndGetHandler('cue:loadPipelineLayout'); + const result = await handler(null); + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-reconciler.test.ts b/src/__tests__/main/cue/cue-reconciler.test.ts new file mode 100644 index 0000000000..7720919878 --- /dev/null +++ b/src/__tests__/main/cue/cue-reconciler.test.ts @@ -0,0 +1,393 @@ +/** + * Tests for the Cue Time Event Reconciler (cue-reconciler.ts). + * + * Tests cover: + * - Missed interval calculation + * - Single catch-up event per subscription (no flooding) + * - Skipping file.changed and agent.completed events + * - Skipping disabled subscriptions + * - Reconciled payload metadata (reconciled: true, missedCount) + * - Zero-gap and negative-gap edge cases + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), +})); + +import { reconcileMissedTimeEvents } from '../../../main/cue/cue-reconciler'; +import type { ReconcileConfig, ReconcileSessionInfo } from '../../../main/cue/cue-reconciler'; +import type { CueConfig, CueEvent, CueSubscription } from '../../../main/cue/cue-types'; + +function createConfig(subscriptions: CueSubscription[]): CueConfig { + return { + subscriptions, + settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 }, + }; +} + +describe('reconcileMissedTimeEvents', () => { + let dispatched: Array<{ sessionId: string; sub: CueSubscription; event: CueEvent }>; + let logged: Array<{ level: string; message: string }>; + + beforeEach(() => { + dispatched = []; + logged = []; + }); + + function makeConfig(overrides: Partial = {}): ReconcileConfig { + return { + sleepStartMs: Date.now() - 60 * 60 * 1000, // 1 hour ago + wakeTimeMs: Date.now(), + sessions: new Map(), + onDispatch: (sessionId, sub, event) => { + dispatched.push({ sessionId, sub, event }); + }, + onLog: (level, message) => { + logged.push({ level, message }); + }, + ...overrides, + }; + } + + it('should fire one catch-up event for a missed interval', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'every-15m', + event: 'time.interval', + enabled: true, + prompt: 'check status', + interval_minutes: 15, + }, + ]), + sessionName: 'Test Session', + }); + + // Sleep for 1 hour means 4 intervals of 15m were missed + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + // Should fire exactly one catch-up event (not 4) + expect(dispatched).toHaveLength(1); + expect(dispatched[0].sessionId).toBe('session-1'); + expect(dispatched[0].event.type).toBe('time.interval'); + expect(dispatched[0].event.triggerName).toBe('every-15m'); + expect(dispatched[0].event.payload.reconciled).toBe(true); + expect(dispatched[0].event.payload.missedCount).toBe(4); + }); + + it('should skip when no intervals were missed', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'every-2h', + event: 'time.interval', + enabled: true, + prompt: 'long check', + interval_minutes: 120, + }, + ]), + sessionName: 'Test Session', + }); + + // Sleep for 30 minutes — interval is 2 hours, so 0 missed + const config = makeConfig({ + sleepStartMs: Date.now() - 30 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should not reconcile file.changed subscriptions', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'file-watcher', + event: 'file.changed', + enabled: true, + prompt: 'check files', + watch: 'src/**/*.ts', + }, + ]), + sessionName: 'Test Session', + }); + + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should not reconcile agent.completed subscriptions', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'chain-reaction', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'other-agent', + }, + ]), + sessionName: 'Test Session', + }); + + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should skip disabled subscriptions', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'disabled-timer', + event: 'time.interval', + enabled: false, + prompt: 'disabled', + interval_minutes: 5, + }, + ]), + sessionName: 'Test Session', + }); + + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should handle multiple sessions with multiple subscriptions', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'fast-timer', + event: 'time.interval', + enabled: true, + prompt: 'fast check', + interval_minutes: 10, + }, + { + name: 'slow-timer', + event: 'time.interval', + enabled: true, + prompt: 'slow check', + interval_minutes: 60, + }, + { + name: 'file-watcher', + event: 'file.changed', + enabled: true, + prompt: 'watch files', + watch: '*.ts', + }, + ]), + sessionName: 'Session A', + }); + sessions.set('session-2', { + config: createConfig([ + { + name: 'another-timer', + event: 'time.interval', + enabled: true, + prompt: 'another check', + interval_minutes: 30, + }, + ]), + sessionName: 'Session B', + }); + + // 90 minutes of sleep + const config = makeConfig({ + sleepStartMs: Date.now() - 90 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + // fast-timer: 90/10 = 9 missed → 1 catch-up + // slow-timer: 90/60 = 1 missed → 1 catch-up + // file-watcher: skipped (not time.interval) + // another-timer: 90/30 = 3 missed → 1 catch-up + expect(dispatched).toHaveLength(3); + + const fastTimer = dispatched.find((d) => d.event.triggerName === 'fast-timer'); + expect(fastTimer?.event.payload.missedCount).toBe(9); + + const slowTimer = dispatched.find((d) => d.event.triggerName === 'slow-timer'); + expect(slowTimer?.event.payload.missedCount).toBe(1); + + const anotherTimer = dispatched.find((d) => d.event.triggerName === 'another-timer'); + expect(anotherTimer?.event.payload.missedCount).toBe(3); + expect(anotherTimer?.sessionId).toBe('session-2'); + }); + + it('should include sleepDurationMs in the event payload', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'check', + interval_minutes: 5, + }, + ]), + sessionName: 'Test', + }); + + const sleepDuration = 60 * 60 * 1000; // 1 hour + const config = makeConfig({ + sleepStartMs: Date.now() - sleepDuration, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched[0].event.payload.sleepDurationMs).toBe(sleepDuration); + }); + + it('should do nothing with zero gap', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'check', + interval_minutes: 5, + }, + ]), + sessionName: 'Test', + }); + + const now = Date.now(); + const config = makeConfig({ + sleepStartMs: now, + wakeTimeMs: now, + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should do nothing with negative gap', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'check', + interval_minutes: 5, + }, + ]), + sessionName: 'Test', + }); + + const now = Date.now(); + const config = makeConfig({ + sleepStartMs: now, + wakeTimeMs: now - 1000, // Wake before sleep (shouldn't happen, but edge case) + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should log reconciliation for each fired catch-up', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'my-timer', + event: 'time.interval', + enabled: true, + prompt: 'check', + interval_minutes: 10, + }, + ]), + sessionName: 'Test', + }); + + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(logged.some((l) => l.message.includes('Reconciling "my-timer"'))).toBe(true); + expect(logged.some((l) => l.message.includes('6 interval(s) missed'))).toBe(true); + }); + + it('should skip subscriptions with zero interval_minutes', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'zero-interval', + event: 'time.interval', + enabled: true, + prompt: 'check', + interval_minutes: 0, + }, + ]), + sessionName: 'Test', + }); + + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); +}); diff --git a/src/__tests__/main/cue/cue-sleep-wake.test.ts b/src/__tests__/main/cue/cue-sleep-wake.test.ts new file mode 100644 index 0000000000..4071f8a27d --- /dev/null +++ b/src/__tests__/main/cue/cue-sleep-wake.test.ts @@ -0,0 +1,308 @@ +/** + * Tests for the CueEngine sleep/wake detection and reconciliation. + * + * Tests cover: + * - Heartbeat starts on engine.start() and stops on engine.stop() + * - Sleep detection triggers reconciler when gap >= 2 minutes + * - No reconciliation when gap < 2 minutes + * - Database pruning on start + * - Graceful handling of missing/uninitialized database + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/types'; + +// Track cue-db calls +const mockInitCueDb = vi.fn(); +const mockCloseCueDb = vi.fn(); +const mockUpdateHeartbeat = vi.fn(); +const mockGetLastHeartbeat = vi.fn<() => number | null>(); +const mockPruneCueEvents = vi.fn(); + +vi.mock('../../../main/cue/cue-db', () => ({ + initCueDb: (...args: unknown[]) => mockInitCueDb(...args), + closeCueDb: () => mockCloseCueDb(), + updateHeartbeat: () => mockUpdateHeartbeat(), + getLastHeartbeat: () => mockGetLastHeartbeat(), + pruneCueEvents: (...args: unknown[]) => mockPruneCueEvents(...args), +})); + +// Track reconciler calls +const mockReconcileMissedTimeEvents = vi.fn(); +vi.mock('../../../main/cue/cue-reconciler', () => ({ + reconcileMissedTimeEvents: (...args: unknown[]) => mockReconcileMissedTimeEvents(...args), +})); + +// Mock the yaml loader +const mockLoadCueConfig = vi.fn<(projectRoot: string) => CueConfig | null>(); +const mockWatchCueYaml = vi.fn<(projectRoot: string, onChange: () => void) => () => void>(); +vi.mock('../../../main/cue/cue-yaml-loader', () => ({ + loadCueConfig: (...args: unknown[]) => mockLoadCueConfig(args[0] as string), + watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), +})); + +// Mock the file watcher +vi.mock('../../../main/cue/cue-file-watcher', () => ({ + createCueFileWatcher: vi.fn(() => vi.fn()), +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), +})); + +import { CueEngine, type CueEngineDeps } from '../../../main/cue/cue-engine'; + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockConfig(overrides: Partial = {}): CueConfig { + return { + subscriptions: [ + { + name: 'timer-sub', + event: 'time.interval', + enabled: true, + prompt: 'check status', + interval_minutes: 15, + }, + ], + settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 }, + ...overrides, + }; +} + +function createMockDeps(overrides: Partial = {}): CueEngineDeps { + return { + getSessions: vi.fn(() => [createMockSession()]), + onCueRun: vi.fn(async () => ({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'test', + event: {} as CueEvent, + status: 'completed' as const, + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + onLog: vi.fn(), + ...overrides, + }; +} + +describe('CueEngine sleep/wake detection', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockWatchCueYaml.mockReturnValue(vi.fn()); + mockLoadCueConfig.mockReturnValue(createMockConfig()); + mockGetLastHeartbeat.mockReturnValue(null); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should initialize the Cue database on start', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockInitCueDb).toHaveBeenCalledTimes(1); + expect(mockInitCueDb).toHaveBeenCalledWith(expect.any(Function)); + + engine.stop(); + }); + + it('should prune old events on start', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockPruneCueEvents).toHaveBeenCalledTimes(1); + // 7 days in milliseconds + expect(mockPruneCueEvents).toHaveBeenCalledWith(7 * 24 * 60 * 60 * 1000); + + engine.stop(); + }); + + it('should write heartbeat immediately on start', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockUpdateHeartbeat).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it('should write heartbeat every 30 seconds', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Initial call + expect(mockUpdateHeartbeat).toHaveBeenCalledTimes(1); + + // Advance 30 seconds + vi.advanceTimersByTime(30_000); + expect(mockUpdateHeartbeat).toHaveBeenCalledTimes(2); + + // Advance another 30 seconds + vi.advanceTimersByTime(30_000); + expect(mockUpdateHeartbeat).toHaveBeenCalledTimes(3); + + engine.stop(); + }); + + it('should stop heartbeat on engine stop', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const callCount = mockUpdateHeartbeat.mock.calls.length; + engine.stop(); + + // Advance time — no more heartbeats should fire + vi.advanceTimersByTime(60_000); + expect(mockUpdateHeartbeat).toHaveBeenCalledTimes(callCount); + }); + + it('should close the database on stop', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + engine.stop(); + + expect(mockCloseCueDb).toHaveBeenCalledTimes(1); + }); + + it('should not reconcile on first start (no previous heartbeat)', () => { + mockGetLastHeartbeat.mockReturnValue(null); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockReconcileMissedTimeEvents).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('should not reconcile when gap is less than 2 minutes', () => { + // Last heartbeat was 60 seconds ago (below 120s threshold) + mockGetLastHeartbeat.mockReturnValue(Date.now() - 60_000); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockReconcileMissedTimeEvents).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('should reconcile when gap exceeds 2 minutes', () => { + // Last heartbeat was 10 minutes ago + const tenMinutesAgo = Date.now() - 10 * 60 * 1000; + mockGetLastHeartbeat.mockReturnValue(tenMinutesAgo); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockReconcileMissedTimeEvents).toHaveBeenCalledTimes(1); + const reconcileArgs = mockReconcileMissedTimeEvents.mock.calls[0][0]; + expect(reconcileArgs.sleepStartMs).toBe(tenMinutesAgo); + expect(reconcileArgs.sessions).toBeInstanceOf(Map); + expect(typeof reconcileArgs.onDispatch).toBe('function'); + expect(typeof reconcileArgs.onLog).toBe('function'); + + engine.stop(); + }); + + it('should log sleep detection with gap duration', () => { + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + mockGetLastHeartbeat.mockReturnValue(fiveMinutesAgo); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Sleep detected (gap: 5m)') + ); + + engine.stop(); + }); + + it('should handle database initialization failure gracefully', () => { + mockInitCueDb.mockImplementation(() => { + throw new Error('DB init failed'); + }); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + + // Should not throw + expect(() => engine.start()).not.toThrow(); + + // Should log the warning + expect(deps.onLog).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('Failed to initialize Cue database') + ); + + engine.stop(); + }); + + it('should handle heartbeat read failure gracefully during sleep detection', () => { + mockGetLastHeartbeat.mockImplementation(() => { + throw new Error('DB read failed'); + }); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + + // Should not throw + expect(() => engine.start()).not.toThrow(); + + engine.stop(); + }); + + it('should pass session info to the reconciler', () => { + const tenMinutesAgo = Date.now() - 10 * 60 * 1000; + mockGetLastHeartbeat.mockReturnValue(tenMinutesAgo); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const reconcileArgs = mockReconcileMissedTimeEvents.mock.calls[0][0]; + const sessions = reconcileArgs.sessions as Map; + + // Should contain the session from our mock + expect(sessions.size).toBe(1); + expect(sessions.has('session-1')).toBe(true); + + const sessionInfo = sessions.get('session-1') as { config: CueConfig; sessionName: string }; + expect(sessionInfo.sessionName).toBe('Test Session'); + expect(sessionInfo.config.subscriptions).toHaveLength(1); + + engine.stop(); + }); +}); diff --git a/src/__tests__/main/cue/cue-task-scanner.test.ts b/src/__tests__/main/cue/cue-task-scanner.test.ts new file mode 100644 index 0000000000..1446fa6cb9 --- /dev/null +++ b/src/__tests__/main/cue/cue-task-scanner.test.ts @@ -0,0 +1,305 @@ +/** + * Tests for the Cue task scanner module. + * + * Tests cover: + * - extractPendingTasks: parsing markdown for unchecked tasks + * - createCueTaskScanner: polling lifecycle, hash tracking, event emission + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock fs +const mockReadFileSync = vi.fn(); +const mockReaddirSync = vi.fn(); +vi.mock('fs', () => ({ + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), + readdirSync: (...args: unknown[]) => mockReaddirSync(...args), +})); + +// Mock picomatch +vi.mock('picomatch', () => ({ + default: (pattern: string) => { + // Simple mock: match files ending in .md for "**/*.md" pattern + if (pattern === '**/*.md' || pattern === 'tasks/**/*.md') { + return (file: string) => file.endsWith('.md'); + } + return () => true; + }, +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), + createHash: () => ({ + update: (content: string) => ({ + digest: () => `hash-${content.length}`, + }), + }), +})); + +import { extractPendingTasks, createCueTaskScanner } from '../../../main/cue/cue-task-scanner'; + +describe('cue-task-scanner', () => { + describe('extractPendingTasks', () => { + it('extracts unchecked tasks from markdown', () => { + const content = `# Tasks +- [ ] First task +- [x] Completed task +- [ ] Second task +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(2); + expect(tasks[0]).toEqual({ line: 2, text: 'First task' }); + expect(tasks[1]).toEqual({ line: 4, text: 'Second task' }); + }); + + it('handles indented tasks', () => { + const content = `# Project + - [ ] Nested task + - [ ] Deeply nested +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(2); + expect(tasks[0].text).toBe('Nested task'); + expect(tasks[1].text).toBe('Deeply nested'); + }); + + it('handles different list markers', () => { + const content = `- [ ] Dash task +* [ ] Star task ++ [ ] Plus task +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(3); + }); + + it('returns empty array for no pending tasks', () => { + const content = `# Done +- [x] All done +- [x] Also done +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(0); + }); + + it('returns empty array for empty content', () => { + const tasks = extractPendingTasks(''); + expect(tasks).toHaveLength(0); + }); + + it('skips tasks with empty text', () => { + const content = `- [ ] +- [ ] Real task +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(1); + expect(tasks[0].text).toBe('Real task'); + }); + + it('does not match checked tasks', () => { + const content = `- [x] Done +- [X] Also done +- [ ] Not done +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(1); + expect(tasks[0].text).toBe('Not done'); + }); + }); + + describe('createCueTaskScanner', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns a cleanup function', () => { + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent: vi.fn(), + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + expect(typeof cleanup).toBe('function'); + cleanup(); + }); + + it('cleanup stops polling', () => { + const onEvent = vi.fn(); + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent, + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + + cleanup(); + + // Advance past initial delay + vi.advanceTimersByTime(3000); + expect(onEvent).not.toHaveBeenCalled(); + }); + + it('seeds hashes on first scan without firing events', async () => { + const onEvent = vi.fn(); + + // Mock directory walk: one .md file with pending tasks + mockReaddirSync.mockImplementation((_dir: string, opts: { withFileTypes: boolean }) => { + if (opts?.withFileTypes) { + return [{ name: 'task.md', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + mockReadFileSync.mockReturnValue('- [ ] Pending task\n'); + + createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent, + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + + // Advance past initial delay + await vi.advanceTimersByTimeAsync(3000); + + // First scan seeds hashes — should NOT fire events + expect(onEvent).not.toHaveBeenCalled(); + }); + + it('fires event on second scan when content has changed and has pending tasks', async () => { + const onEvent = vi.fn(); + + mockReaddirSync.mockImplementation((_dir: string, opts: { withFileTypes: boolean }) => { + if (opts?.withFileTypes) { + return [{ name: 'task.md', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + + // First scan: seed with initial content + mockReadFileSync.mockReturnValueOnce('- [ ] Initial task\n'); + + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent, + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + + // First scan (seed) + await vi.advanceTimersByTimeAsync(3000); + expect(onEvent).not.toHaveBeenCalled(); + + // Second scan: content changed, has pending tasks + mockReadFileSync.mockReturnValue('- [ ] Initial task\n- [ ] New task\n'); + await vi.advanceTimersByTimeAsync(60 * 1000); + + expect(onEvent).toHaveBeenCalledTimes(1); + const event = onEvent.mock.calls[0][0]; + expect(event.type).toBe('task.pending'); + expect(event.triggerName).toBe('test-scanner'); + expect(event.payload.taskCount).toBe(2); + expect(event.payload.filename).toBe('task.md'); + + cleanup(); + }); + + it('does not fire when content unchanged', async () => { + const onEvent = vi.fn(); + + mockReaddirSync.mockImplementation((_dir: string, opts: { withFileTypes: boolean }) => { + if (opts?.withFileTypes) { + return [{ name: 'task.md', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + + // Same content every scan + mockReadFileSync.mockReturnValue('- [ ] Same task\n'); + + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent, + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + + // First scan + second scan + await vi.advanceTimersByTimeAsync(3000); + await vi.advanceTimersByTimeAsync(60 * 1000); + + expect(onEvent).not.toHaveBeenCalled(); + cleanup(); + }); + + it('does not fire when content changed but no pending tasks', async () => { + const onEvent = vi.fn(); + + mockReaddirSync.mockImplementation((_dir: string, opts: { withFileTypes: boolean }) => { + if (opts?.withFileTypes) { + return [{ name: 'task.md', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + + // First scan: has pending tasks + mockReadFileSync.mockReturnValueOnce('- [ ] Task\n'); + + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent, + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + + // Seed + await vi.advanceTimersByTimeAsync(3000); + + // Second scan: all tasks completed + mockReadFileSync.mockReturnValue('- [x] Task\n'); + await vi.advanceTimersByTimeAsync(60 * 1000); + + expect(onEvent).not.toHaveBeenCalled(); + cleanup(); + }); + + it('logs error when scan fails', async () => { + const onLog = vi.fn(); + + mockReaddirSync.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent: vi.fn(), + onLog, + triggerName: 'test-scanner', + }); + + await vi.advanceTimersByTimeAsync(3000); + + expect(onLog).toHaveBeenCalledWith('error', expect.stringContaining('Task scan error')); + + cleanup(); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-yaml-loader.test.ts b/src/__tests__/main/cue/cue-yaml-loader.test.ts new file mode 100644 index 0000000000..f281dd0cc6 --- /dev/null +++ b/src/__tests__/main/cue/cue-yaml-loader.test.ts @@ -0,0 +1,820 @@ +/** + * Tests for the Cue YAML loader module. + * + * Tests cover: + * - Loading and parsing maestro-cue.yaml files + * - Handling missing files + * - Merging with default settings + * - Validation of subscription fields per event type + * - YAML file watching with debounce + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock chokidar +const mockChokidarOn = vi.fn().mockReturnThis(); +const mockChokidarClose = vi.fn(); +vi.mock('chokidar', () => ({ + watch: vi.fn(() => ({ + on: mockChokidarOn, + close: mockChokidarClose, + })), +})); + +// Mock fs +const mockExistsSync = vi.fn(); +const mockReadFileSync = vi.fn(); +vi.mock('fs', () => ({ + existsSync: (...args: unknown[]) => mockExistsSync(...args), + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), +})); + +// Must import after mocks +import { loadCueConfig, watchCueYaml, validateCueConfig } from '../../../main/cue/cue-yaml-loader'; +import * as chokidar from 'chokidar'; + +describe('cue-yaml-loader', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('loadCueConfig', () => { + it('returns null when neither canonical nor legacy file exists', () => { + mockExistsSync.mockReturnValue(false); + const result = loadCueConfig('/projects/test'); + expect(result).toBeNull(); + }); + + it('loads from canonical .maestro/cue.yaml path first', () => { + // Canonical path exists + mockExistsSync.mockImplementation((p: string) => String(p).includes('.maestro/cue.yaml')); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: canonical-sub + event: time.interval + prompt: From canonical + interval_minutes: 5 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].name).toBe('canonical-sub'); + }); + + it('falls back to legacy maestro-cue.yaml when canonical does not exist', () => { + // Only legacy path exists + mockExistsSync.mockImplementation( + (p: string) => String(p).includes('maestro-cue.yaml') && !String(p).includes('.maestro/') + ); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: legacy-sub + event: time.interval + prompt: From legacy + interval_minutes: 5 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].name).toBe('legacy-sub'); + }); + + it('parses a valid YAML config with subscriptions and settings', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: daily-check + event: time.interval + enabled: true + prompt: Check all tests + interval_minutes: 60 + - name: watch-src + event: file.changed + enabled: true + prompt: Run lint + watch: "src/**/*.ts" +settings: + timeout_minutes: 15 + timeout_on_fail: continue +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions).toHaveLength(2); + expect(result!.subscriptions[0].name).toBe('daily-check'); + expect(result!.subscriptions[0].event).toBe('time.interval'); + expect(result!.subscriptions[0].interval_minutes).toBe(60); + expect(result!.subscriptions[1].name).toBe('watch-src'); + expect(result!.subscriptions[1].watch).toBe('src/**/*.ts'); + expect(result!.settings.timeout_minutes).toBe(15); + expect(result!.settings.timeout_on_fail).toBe('continue'); + }); + + it('uses default settings when settings section is missing', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: test-sub + event: time.interval + prompt: Do stuff + interval_minutes: 5 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.settings.timeout_minutes).toBe(30); + expect(result!.settings.timeout_on_fail).toBe('break'); + expect(result!.settings.max_concurrent).toBe(1); + expect(result!.settings.queue_size).toBe(10); + }); + + it('defaults enabled to true when not specified', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: test-sub + event: time.interval + prompt: Do stuff + interval_minutes: 10 +`); + + const result = loadCueConfig('/projects/test'); + expect(result!.subscriptions[0].enabled).toBe(true); + }); + + it('respects enabled: false', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: disabled-sub + event: time.interval + enabled: false + prompt: Do stuff + interval_minutes: 10 +`); + + const result = loadCueConfig('/projects/test'); + expect(result!.subscriptions[0].enabled).toBe(false); + }); + + it('returns null for empty YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(''); + const result = loadCueConfig('/projects/test'); + expect(result).toBeNull(); + }); + + it('throws on malformed YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('{ invalid yaml ['); + expect(() => loadCueConfig('/projects/test')).toThrow(); + }); + + it('resolves prompt_file to prompt content when prompt is empty', () => { + // First call: existsSync for config file (true), then for prompt file path (true) + let readCallCount = 0; + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation((p: string) => { + readCallCount++; + if (String(p).endsWith('.maestro/prompts/worker-pipeline.md')) { + return 'Prompt from external file'; + } + return ` +subscriptions: + - name: test-sub + event: time.interval + prompt_file: .maestro/prompts/worker-pipeline.md + interval_minutes: 5 +`; + }); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].prompt).toBe('Prompt from external file'); + expect(result!.subscriptions[0].prompt_file).toBe('.maestro/prompts/worker-pipeline.md'); + }); + + it('keeps inline prompt when both prompt and prompt_file exist', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: test-sub + event: time.interval + prompt: Inline prompt text + prompt_file: .maestro/prompts/should-be-ignored.md + interval_minutes: 5 +`); + + const result = loadCueConfig('/projects/test'); + expect(result!.subscriptions[0].prompt).toBe('Inline prompt text'); + }); + + it('handles agent.completed with source_session array', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: fan-in-trigger + event: agent.completed + prompt: All agents done + source_session: + - agent-1 + - agent-2 +`); + + const result = loadCueConfig('/projects/test'); + expect(result!.subscriptions[0].source_session).toEqual(['agent-1', 'agent-2']); + }); + }); + + describe('watchCueYaml', () => { + it('watches both canonical and legacy file paths', () => { + watchCueYaml('/projects/test', vi.fn()); + // Should watch both .maestro/cue.yaml (canonical) and maestro-cue.yaml (legacy) + expect(chokidar.watch).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.stringContaining('.maestro/cue.yaml'), + expect.stringContaining('maestro-cue.yaml'), + ]), + expect.objectContaining({ persistent: true, ignoreInitial: true }) + ); + }); + + it('calls onChange with debounce on file change', () => { + const onChange = vi.fn(); + watchCueYaml('/projects/test', onChange); + + // Simulate a 'change' event via the mock's on handler + const changeHandler = mockChokidarOn.mock.calls.find( + (call: unknown[]) => call[0] === 'change' + )?.[1]; + expect(changeHandler).toBeDefined(); + + changeHandler!(); + expect(onChange).not.toHaveBeenCalled(); // Not yet — debounced + + vi.advanceTimersByTime(1000); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('debounces multiple rapid changes', () => { + const onChange = vi.fn(); + watchCueYaml('/projects/test', onChange); + + const changeHandler = mockChokidarOn.mock.calls.find( + (call: unknown[]) => call[0] === 'change' + )?.[1]; + + changeHandler!(); + vi.advanceTimersByTime(500); + changeHandler!(); + vi.advanceTimersByTime(500); + changeHandler!(); + vi.advanceTimersByTime(1000); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('cleanup function closes watcher', () => { + const cleanup = watchCueYaml('/projects/test', vi.fn()); + cleanup(); + expect(mockChokidarClose).toHaveBeenCalled(); + }); + + it('registers handlers for add, change, and unlink events', () => { + watchCueYaml('/projects/test', vi.fn()); + const registeredEvents = mockChokidarOn.mock.calls.map((call: unknown[]) => call[0]); + expect(registeredEvents).toContain('add'); + expect(registeredEvents).toContain('change'); + expect(registeredEvents).toContain('unlink'); + }); + }); + + describe('validateCueConfig', () => { + it('returns valid for a correct config', () => { + const result = validateCueConfig({ + subscriptions: [ + { name: 'test', event: 'time.interval', prompt: 'Do it', interval_minutes: 5 }, + ], + settings: { timeout_minutes: 30, timeout_on_fail: 'break' }, + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('rejects non-object config', () => { + const result = validateCueConfig(null); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('non-null object'); + }); + + it('requires subscriptions array', () => { + const result = validateCueConfig({ settings: {} }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('subscriptions'); + }); + + it('requires name on subscriptions', () => { + const result = validateCueConfig({ + subscriptions: [{ event: 'time.interval', prompt: 'Test', interval_minutes: 5 }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('"name"')])); + }); + + it('requires interval_minutes for time.interval', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'time.interval', prompt: 'Do it' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('interval_minutes')]) + ); + }); + + it('requires watch for file.changed', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'file.changed', prompt: 'Do it' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('watch')])); + }); + + it('requires source_session for agent.completed', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'agent.completed', prompt: 'Do it' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('source_session')]) + ); + }); + + it('accepts prompt_file as alternative to prompt', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'time.interval', + prompt_file: '.maestro/prompts/test.md', + interval_minutes: 5, + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('rejects subscription with neither prompt nor prompt_file', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'time.interval', interval_minutes: 5 }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"prompt" or "prompt_file"')]) + ); + }); + + it('rejects invalid timeout_on_fail value', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { timeout_on_fail: 'invalid' }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('timeout_on_fail')]) + ); + }); + + it('accepts valid timeout_on_fail values', () => { + const breakResult = validateCueConfig({ + subscriptions: [], + settings: { timeout_on_fail: 'break' }, + }); + expect(breakResult.valid).toBe(true); + + const continueResult = validateCueConfig({ + subscriptions: [], + settings: { timeout_on_fail: 'continue' }, + }); + expect(continueResult.valid).toBe(true); + }); + + it('rejects invalid max_concurrent value', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { max_concurrent: 0 }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('max_concurrent')]) + ); + }); + + it('rejects max_concurrent above 10', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { max_concurrent: 11 }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('max_concurrent')]) + ); + }); + + it('rejects non-integer max_concurrent', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { max_concurrent: 1.5 }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('max_concurrent')]) + ); + }); + + it('accepts valid max_concurrent values', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { max_concurrent: 5 }, + }); + expect(result.valid).toBe(true); + }); + + it('rejects negative queue_size', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { queue_size: -1 }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('queue_size')]) + ); + }); + + it('rejects queue_size above 50', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { queue_size: 51 }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('queue_size')]) + ); + }); + + it('accepts valid queue_size values including 0', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { queue_size: 0 }, + }); + expect(result.valid).toBe(true); + }); + + it('requires prompt to be a non-empty string', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'time.interval', interval_minutes: 5 }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('"prompt"')])); + }); + + it('accepts valid filter with string/number/boolean values', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'file.changed', + prompt: 'Do it', + watch: 'src/**', + filter: { extension: '.ts', active: true, priority: 5 }, + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('rejects filter with nested object values', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'file.changed', + prompt: 'Do it', + watch: 'src/**', + filter: { nested: { deep: 'value' } }, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('filter key "nested"')]) + ); + }); + + it('rejects filter that is an array', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'file.changed', + prompt: 'Do it', + watch: 'src/**', + filter: ['not', 'valid'], + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"filter" must be a plain object')]) + ); + }); + + it('rejects filter with null value', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'file.changed', + prompt: 'Do it', + watch: 'src/**', + filter: null, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"filter" must be a plain object')]) + ); + }); + }); + + describe('loadCueConfig with GitHub events', () => { + it('parses repo and poll_minutes from YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: pr-watch + event: github.pull_request + prompt: Review the PR + repo: owner/repo + poll_minutes: 10 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].repo).toBe('owner/repo'); + expect(result!.subscriptions[0].poll_minutes).toBe(10); + }); + + it('defaults poll_minutes to undefined when not specified', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: issue-watch + event: github.issue + prompt: Triage issue +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].poll_minutes).toBeUndefined(); + expect(result!.subscriptions[0].repo).toBeUndefined(); + }); + }); + + describe('validateCueConfig for GitHub events', () => { + it('accepts valid github.pull_request subscription', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'pr-watch', event: 'github.pull_request', prompt: 'Review it' }], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('accepts github.pull_request with repo and poll_minutes', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'pr-watch', + event: 'github.pull_request', + prompt: 'Review it', + repo: 'owner/repo', + poll_minutes: 10, + }, + ], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('rejects github.pull_request with poll_minutes < 1', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'pr-watch', + event: 'github.pull_request', + prompt: 'Review', + poll_minutes: 0.5, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('poll_minutes')]) + ); + }); + + it('rejects github.pull_request with poll_minutes = 0', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'pr-watch', + event: 'github.pull_request', + prompt: 'Review', + poll_minutes: 0, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('poll_minutes')]) + ); + }); + + it('rejects github.issue with non-string repo', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'issue-watch', + event: 'github.issue', + prompt: 'Triage', + repo: 123, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"repo" must be a string')]) + ); + }); + + it('accepts github.issue with filter', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'issue-watch', + event: 'github.issue', + prompt: 'Triage', + filter: { author: 'octocat', labels: 'bug' }, + }, + ], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe('validateCueConfig for task.pending events', () => { + it('accepts valid task.pending subscription', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + prompt: 'Process tasks', + watch: 'tasks/**/*.md', + }, + ], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('requires watch for task.pending', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'task-queue', event: 'task.pending', prompt: 'Process tasks' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('watch')])); + }); + + it('accepts task.pending with poll_minutes', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + prompt: 'Process', + watch: 'tasks/**/*.md', + poll_minutes: 5, + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('rejects task.pending with poll_minutes < 1', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + prompt: 'Process', + watch: 'tasks/**/*.md', + poll_minutes: 0, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('poll_minutes')]) + ); + }); + }); + + describe('loadCueConfig with task.pending', () => { + it('parses watch and poll_minutes from YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: task-queue + event: task.pending + prompt: Process the tasks + watch: "tasks/**/*.md" + poll_minutes: 2 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].event).toBe('task.pending'); + expect(result!.subscriptions[0].watch).toBe('tasks/**/*.md'); + expect(result!.subscriptions[0].poll_minutes).toBe(2); + }); + }); + + describe('loadCueConfig with filter', () => { + it('parses filter field from YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: ts-only + event: file.changed + prompt: Review it + watch: "src/**/*" + filter: + extension: ".ts" + path: "!*.test.ts" +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].filter).toEqual({ + extension: '.ts', + path: '!*.test.ts', + }); + }); + + it('parses filter with boolean and numeric values', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: filtered + event: agent.completed + prompt: Do it + source_session: agent-1 + filter: + active: true + exitCode: 0 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].filter).toEqual({ + active: true, + exitCode: 0, + }); + }); + + it('ignores filter with invalid nested values', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: bad-filter + event: file.changed + prompt: Do it + watch: "src/**" + filter: + nested: + deep: value +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].filter).toBeUndefined(); + }); + }); +}); diff --git a/src/__tests__/main/deep-links.test.ts b/src/__tests__/main/deep-links.test.ts new file mode 100644 index 0000000000..25b373897f --- /dev/null +++ b/src/__tests__/main/deep-links.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for deep link URL parsing + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron before importing the module under test +vi.mock('electron', () => ({ + app: { + isPackaged: false, + setAsDefaultProtocolClient: vi.fn(), + requestSingleInstanceLock: vi.fn().mockReturnValue(true), + on: vi.fn(), + quit: vi.fn(), + }, + BrowserWindow: { + getAllWindows: vi.fn().mockReturnValue([]), + }, +})); + +vi.mock('../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../../main/utils/safe-send', () => ({ + isWebContentsAvailable: vi.fn().mockReturnValue(true), +})); + +import { parseDeepLink } from '../../main/deep-links'; + +describe('parseDeepLink', () => { + describe('focus action', () => { + it('should parse maestro://focus', () => { + expect(parseDeepLink('maestro://focus')).toEqual({ action: 'focus' }); + }); + + it('should parse empty path as focus', () => { + expect(parseDeepLink('maestro://')).toEqual({ action: 'focus' }); + }); + + it('should parse protocol-only as focus', () => { + expect(parseDeepLink('maestro:')).toEqual({ action: 'focus' }); + }); + }); + + describe('session action', () => { + it('should parse session URL', () => { + expect(parseDeepLink('maestro://session/abc123')).toEqual({ + action: 'session', + sessionId: 'abc123', + }); + }); + + it('should parse session URL with tab', () => { + expect(parseDeepLink('maestro://session/abc123/tab/tab456')).toEqual({ + action: 'session', + sessionId: 'abc123', + tabId: 'tab456', + }); + }); + + it('should decode URI-encoded session IDs', () => { + expect(parseDeepLink('maestro://session/session%20with%20space')).toEqual({ + action: 'session', + sessionId: 'session with space', + }); + }); + + it('should decode URI-encoded tab IDs', () => { + expect(parseDeepLink('maestro://session/abc/tab/tab%2Fslash')).toEqual({ + action: 'session', + sessionId: 'abc', + tabId: 'tab/slash', + }); + }); + + it('should return null for session without ID', () => { + expect(parseDeepLink('maestro://session')).toBeNull(); + expect(parseDeepLink('maestro://session/')).toBeNull(); + }); + + it('should ignore extra path segments after tab ID', () => { + const result = parseDeepLink('maestro://session/abc/tab/tab1/extra/stuff'); + expect(result).toEqual({ + action: 'session', + sessionId: 'abc', + tabId: 'tab1', + }); + }); + }); + + describe('group action', () => { + it('should parse group URL', () => { + expect(parseDeepLink('maestro://group/grp789')).toEqual({ + action: 'group', + groupId: 'grp789', + }); + }); + + it('should decode URI-encoded group IDs', () => { + expect(parseDeepLink('maestro://group/group%20name')).toEqual({ + action: 'group', + groupId: 'group name', + }); + }); + + it('should return null for group without ID', () => { + expect(parseDeepLink('maestro://group')).toBeNull(); + expect(parseDeepLink('maestro://group/')).toBeNull(); + }); + }); + + describe('Windows compatibility', () => { + it('should handle Windows maestro: prefix (no double slash)', () => { + expect(parseDeepLink('maestro:session/abc123')).toEqual({ + action: 'session', + sessionId: 'abc123', + }); + }); + + it('should handle Windows focus without double slash', () => { + expect(parseDeepLink('maestro:focus')).toEqual({ action: 'focus' }); + }); + }); + + describe('error handling', () => { + it('should return null for unrecognized resource', () => { + expect(parseDeepLink('maestro://unknown/abc')).toBeNull(); + }); + + it('should return null for completely malformed URLs', () => { + // parseDeepLink is tolerant of most inputs, but unrecognized resources return null + expect(parseDeepLink('maestro://settings')).toBeNull(); + }); + }); +}); diff --git a/src/__tests__/main/group-chat/group-chat-agent.test.ts b/src/__tests__/main/group-chat/group-chat-agent.test.ts index 30784bee9e..00a4f332e4 100644 --- a/src/__tests__/main/group-chat/group-chat-agent.test.ts +++ b/src/__tests__/main/group-chat/group-chat-agent.test.ts @@ -38,6 +38,12 @@ vi.mock('electron-store', () => { }; }); +// Mock wrapSpawnWithSsh so we can verify SSH path normalization +const mockWrapSpawnWithSsh = vi.fn(); +vi.mock('../../../main/utils/ssh-spawn-wrapper', () => ({ + wrapSpawnWithSsh: (...args: unknown[]) => mockWrapSpawnWithSsh(...args), +})); + import { addParticipant, sendToParticipant, @@ -59,6 +65,7 @@ import { loadGroupChat, } from '../../../main/group-chat/group-chat-storage'; import { readLog } from '../../../main/group-chat/group-chat-log'; +import { AgentDetector } from '../../../main/agents'; describe('group-chat-agent', () => { let mockProcessManager: IProcessManager; @@ -202,6 +209,22 @@ describe('group-chat-agent', () => { ).rejects.toThrow(/Failed to spawn participant/); }); + it('sanitizes special characters in participant name for session ID', async () => { + const chat = await createTestChatWithModerator('Sanitize Test'); + + const participant = await addParticipant( + chat.id, + 'My Agent (v2.0)', + 'claude-code', + mockProcessManager + ); + + // Session ID should contain sanitized name with special chars replaced by underscores + expect(participant.sessionId).toContain('participant-My_Agent__v2_0_-'); + // Should NOT contain raw special characters + expect(participant.sessionId).not.toMatch(/[() .]/); + }); + it('throws when moderator is not active', async () => { const chat = await createTestChat('No Moderator Test'); // Don't spawn moderator @@ -523,5 +546,165 @@ describe('group-chat-agent', () => { expect(updated?.participants[0].agentId).toBe('claude-code'); expect(updated?.participants[1].agentId).toBe('opencode'); }); + + it('kills spawned process when addParticipantToChat rejects (race condition)', async () => { + const chat = await createTestChatWithModerator('Race Condition Test'); + + // Launch two concurrent addParticipant calls with the same name. + // Both will read the initial state (0 participants), both pass the + // early duplicate check, both spawn a process. The serialized + // addParticipantToChat write queue ensures one succeeds and the + // other rejects with "already exists". The losing call must kill + // its spawned process to avoid orphans. + const [result1, result2] = await Promise.allSettled([ + addParticipant(chat.id, 'Client', 'claude-code', mockProcessManager), + addParticipant(chat.id, 'Client', 'claude-code', mockProcessManager), + ]); + + const successes = [result1, result2].filter((r) => r.status === 'fulfilled'); + const failures = [result1, result2].filter((r) => r.status === 'rejected'); + + expect(successes).toHaveLength(1); + expect(failures).toHaveLength(1); + + // Both calls spawned a process + expect(mockProcessManager.spawn).toHaveBeenCalledTimes(2); + // The failed call should have killed its orphaned process + expect(mockProcessManager.kill).toHaveBeenCalledTimes(1); + + // Only one participant should exist in storage + const updated = await loadGroupChat(chat.id); + expect(updated?.participants).toHaveLength(1); + expect(updated?.participants[0].name).toBe('Client'); + + // Only one active session should remain + expect(isParticipantActive(chat.id, 'Client')).toBe(true); + expect(getActiveParticipants(chat.id)).toHaveLength(1); + }); + }); + + // =========================================================================== + // Test: SSH path normalization for Gemini CLI --include-directories + // =========================================================================== + describe('SSH path normalization for Gemini CLI', () => { + const sshRemoteConfig = { + enabled: true, + remoteId: 'remote-1', + }; + + const mockSshStore = { + getSshRemotes: vi + .fn() + .mockReturnValue([ + { id: 'remote-1', name: 'RemoteHost', host: 'remote.local', user: 'user' }, + ]), + }; + + const geminiAgentDetector = { + getAgent: vi.fn().mockResolvedValue({ + id: 'gemini-cli', + name: 'Gemini CLI', + binaryName: 'gemini', + command: 'gemini', + args: ['-y', '--output-format', 'stream-json'], + available: true, + path: '/usr/local/bin/gemini', + capabilities: {}, + workingDirArgs: (dir: string) => ['--include-directories', dir], + promptArgs: (prompt: string) => ['-p', prompt], + }), + detectAgents: vi.fn().mockResolvedValue([]), + clearCache: vi.fn(), + setCustomPaths: vi.fn(), + getCustomPaths: vi.fn().mockReturnValue({}), + discoverModels: vi.fn().mockResolvedValue([]), + clearModelCache: vi.fn(), + } as unknown as AgentDetector; + + beforeEach(() => { + mockWrapSpawnWithSsh.mockResolvedValue({ + command: 'ssh', + args: ['-t', 'user@remote.local', 'gemini'], + cwd: '/home/remoteuser/project', + prompt: 'test', + customEnvVars: {}, + sshRemoteUsed: { name: 'RemoteHost' }, + }); + }); + + afterEach(() => { + mockWrapSpawnWithSsh.mockReset(); + }); + + it('excludes local-only paths from --include-directories when SSH is configured', async () => { + const chat = await createTestChatWithModerator('SSH Gemini Dir Test'); + + await addParticipant( + chat.id, + 'GeminiRemote', + 'gemini-cli', + mockProcessManager, + '/home/remoteuser/project', + geminiAgentDetector, + {}, + undefined, + { sshRemoteName: 'RemoteHost', sshRemoteConfig }, + mockSshStore + ); + + // wrapSpawnWithSsh should have been called + expect(mockWrapSpawnWithSsh).toHaveBeenCalled(); + + // Check the args passed to wrapSpawnWithSsh + const sshCallArgs = mockWrapSpawnWithSsh.mock.calls[0][0].args as string[]; + const includeDirIndices: number[] = []; + sshCallArgs.forEach((arg: string, i: number) => { + if (arg === '--include-directories') includeDirIndices.push(i); + }); + + // All --include-directories paths should be the remote cwd only + // (buildAgentArgs adds one, buildGeminiWorkspaceDirArgs adds another for cwd) + expect(includeDirIndices.length).toBeGreaterThan(0); + const allDirPaths = includeDirIndices.map((i: number) => sshCallArgs[i + 1]); + expect(allDirPaths.every((p: string) => p === '/home/remoteuser/project')).toBe(true); + // Should NOT contain local home directory or local config paths + expect(allDirPaths).not.toContain(os.homedir()); + }); + + it('includes all workspace paths when SSH is not configured', async () => { + const chat = await createTestChatWithModerator('Local Gemini Dir Test'); + + await addParticipant( + chat.id, + 'GeminiLocal', + 'gemini-cli', + mockProcessManager, + '/Users/dev/project', + geminiAgentDetector, + {} + // No sessionOverrides, no sshStore + ); + + // wrapSpawnWithSsh should NOT have been called (no SSH) + expect(mockWrapSpawnWithSsh).not.toHaveBeenCalled(); + + // Check the args passed to processManager.spawn + const spawnCall = (mockProcessManager.spawn as ReturnType).mock.calls; + // The last spawn call is for our participant (moderator uses different spawn flow) + const lastCall = spawnCall[spawnCall.length - 1][0]; + const spawnArgs = lastCall.args as string[]; + const includeDirIndices: number[] = []; + spawnArgs.forEach((arg: string, i: number) => { + if (arg === '--include-directories') includeDirIndices.push(i); + }); + + // Should have 4 --include-directories entries: + // 1 from buildAgentArgs(cwd) + 3 from buildGeminiWorkspaceDirArgs(cwd, groupChatFolder, homedir) + expect(includeDirIndices.length).toBe(4); + const allDirPaths = includeDirIndices.map((i: number) => spawnArgs[i + 1]); + // Should contain cwd and os.homedir() + expect(allDirPaths).toContain('/Users/dev/project'); + expect(allDirPaths).toContain(os.homedir()); + }); }); }); diff --git a/src/__tests__/main/group-chat/group-chat-log.test.ts b/src/__tests__/main/group-chat/group-chat-log.test.ts index fbb88905f2..c7f4678214 100644 --- a/src/__tests__/main/group-chat/group-chat-log.test.ts +++ b/src/__tests__/main/group-chat/group-chat-log.test.ts @@ -177,6 +177,20 @@ describe('group-chat-log', () => { expect(content).toContain('Line1\\nLine2\\|Data'); }); + it('appends with image filenames', async () => { + const logPath = path.join(testDir, 'image-append.log'); + await appendToLog(logPath, 'user', 'Check this', false, ['img-001.png', 'img-002.jpg']); + const content = await fs.readFile(logPath, 'utf-8'); + expect(content).toContain('|images:img-001.png,img-002.jpg'); + }); + + it('appends with readOnly and image filenames', async () => { + const logPath = path.join(testDir, 'ro-image.log'); + await appendToLog(logPath, 'user', 'Read only with images', true, ['screenshot.png']); + const content = await fs.readFile(logPath, 'utf-8'); + expect(content).toContain('|readOnly|images:screenshot.png'); + }); + it('uses ISO 8601 timestamp format', async () => { const logPath = path.join(testDir, 'timestamp-chat.log'); const beforeTime = new Date().toISOString(); @@ -277,6 +291,39 @@ describe('group-chat-log', () => { expect(messages).toHaveLength(2); }); + it('parses image filenames from log', async () => { + const logPath = path.join(testDir, 'images-parse.log'); + await fs.writeFile( + logPath, + '2024-01-15T10:30:00.000Z|user|Check this|images:img-001.png,img-002.jpg\n' + ); + const messages = await readLog(logPath); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('Check this'); + expect(messages[0].images).toEqual(['img-001.png', 'img-002.jpg']); + }); + + it('parses readOnly and images together', async () => { + const logPath = path.join(testDir, 'ro-images.log'); + await fs.writeFile( + logPath, + '2024-01-15T10:30:00.000Z|user|Hello|readOnly|images:screenshot.png\n' + ); + const messages = await readLog(logPath); + expect(messages).toHaveLength(1); + expect(messages[0].readOnly).toBe(true); + expect(messages[0].images).toEqual(['screenshot.png']); + }); + + it('round-trips with appendToLog including images', async () => { + const logPath = path.join(testDir, 'round-trip-images.log'); + await appendToLog(logPath, 'user', 'With images', false, ['img.png']); + const messages = await readLog(logPath); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('With images'); + expect(messages[0].images).toEqual(['img.png']); + }); + it('round-trips with appendToLog', async () => { const logPath = path.join(testDir, 'round-trip.log'); const testContent = 'Hello\nWorld|Test'; 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..5c6ffcda23 100644 --- a/src/__tests__/main/group-chat/group-chat-moderator.test.ts +++ b/src/__tests__/main/group-chat/group-chat-moderator.test.ts @@ -49,6 +49,8 @@ import { getModeratorSessionId, clearAllModeratorSessions, getModeratorSystemPrompt, + getModeratorSynthesisPrompt, + isModeratorActive, type IProcessManager, } from '../../../main/group-chat/group-chat-moderator'; import { @@ -289,6 +291,105 @@ describe('group-chat-moderator', () => { }); }); + // =========================================================================== + // Test 3.6: getModeratorSynthesisPrompt returns synthesis instructions + // =========================================================================== + describe('getModeratorSynthesisPrompt', () => { + it('returns a non-empty synthesis prompt string', () => { + const prompt = getModeratorSynthesisPrompt(); + expect(typeof prompt).toBe('string'); + expect(prompt.length).toBeGreaterThan(0); + }); + + it('contains synthesis-related instructions', () => { + const prompt = getModeratorSynthesisPrompt(); + // The synthesis prompt should instruct the moderator about reviewing responses + // and deciding next steps (synthesize or follow-up) + expect(prompt.toLowerCase()).toMatch(/synthe|review|response|summar/); + }); + + it('is distinct from the system prompt', () => { + const systemPrompt = getModeratorSystemPrompt(); + const synthesisPrompt = getModeratorSynthesisPrompt(); + expect(synthesisPrompt).not.toBe(systemPrompt); + }); + }); + + // =========================================================================== + // Test 3.7: Moderator lifecycle — spawn → send → kill → cleanup + // =========================================================================== + describe('moderator lifecycle', () => { + it('full lifecycle: spawn → sendToModerator → killModerator', async () => { + const chat = await createTestChat('Lifecycle Test', 'claude-code'); + + // 1. Spawn moderator + const sessionId = await spawnModerator(chat, mockProcessManager); + expect(sessionId).toBeTruthy(); + expect(isModeratorActive(chat.id)).toBe(true); + expect(getModeratorSessionId(chat.id)).toBe(sessionId); + + // 2. Send message to moderator + await sendToModerator(chat.id, 'Plan the architecture', mockProcessManager); + expect(mockProcessManager.write).toHaveBeenCalledWith(sessionId, 'Plan the architecture\n'); + + // Verify message was logged + const messages = await readLog(chat.logPath); + expect(messages.some((m) => m.from === 'user' && m.content === 'Plan the architecture')).toBe( + true + ); + + // 3. Kill moderator + await killModerator(chat.id, mockProcessManager); + expect(mockProcessManager.kill).toHaveBeenCalledWith(sessionId); + expect(isModeratorActive(chat.id)).toBe(false); + expect(getModeratorSessionId(chat.id)).toBeUndefined(); + + // 4. Verify storage was cleaned up + const updated = await loadGroupChat(chat.id); + expect(updated?.moderatorSessionId).toBe(''); + }); + + it('spawn replaces previous session mapping for same chat', async () => { + const chat = await createTestChat('Replace Test', 'claude-code'); + + const sessionId1 = await spawnModerator(chat, mockProcessManager); + expect(getModeratorSessionId(chat.id)).toBe(sessionId1); + + // Spawn again for the same chat — should replace the mapping + const sessionId2 = await spawnModerator(chat, mockProcessManager); + expect(getModeratorSessionId(chat.id)).toBe(sessionId2); + // Session IDs are based on chat.id so they will be the same prefix + expect(sessionId1).toBe(sessionId2); + }); + + it('kill after kill is idempotent', async () => { + const chat = await createTestChat('Double Kill Test', 'claude-code'); + await spawnModerator(chat, mockProcessManager); + + await killModerator(chat.id, mockProcessManager); + expect(isModeratorActive(chat.id)).toBe(false); + + // Second kill should not throw + await expect(killModerator(chat.id, mockProcessManager)).resolves.not.toThrow(); + // Process manager kill should only be called once (first kill had a session) + expect(mockProcessManager.kill).toHaveBeenCalledTimes(1); + }); + + it('sendToModerator without spawn logs but does not write to process', async () => { + const chat = await createTestChat('No Spawn Send Test', 'claude-code'); + + // Send without spawning moderator first + await sendToModerator(chat.id, 'Hello without moderator', mockProcessManager); + + // Message should be logged + const messages = await readLog(chat.logPath); + expect(messages.some((m) => m.content === 'Hello without moderator')).toBe(true); + + // But write should not be called (no active session) + expect(mockProcessManager.write).not.toHaveBeenCalled(); + }); + }); + // =========================================================================== // Additional tests for edge cases // =========================================================================== diff --git a/src/__tests__/main/group-chat/group-chat-router.test.ts b/src/__tests__/main/group-chat/group-chat-router.test.ts index e8a0e82b6e..f6b758e1cd 100644 --- a/src/__tests__/main/group-chat/group-chat-router.test.ts +++ b/src/__tests__/main/group-chat/group-chat-router.test.ts @@ -52,12 +52,20 @@ import { routeModeratorResponse, routeAgentResponse, getGroupChatReadOnlyState, + getPendingParticipants, + markParticipantResponded, + clearPendingParticipants, setGetSessionsCallback, setSshStore, + spawnModeratorSynthesis, + setPendingParticipantTimeout, type SessionInfo, } from '../../../main/group-chat/group-chat-router'; import { spawnModerator, + killModerator, + isModeratorActive, + getModeratorSessionId, clearAllModeratorSessions, type IProcessManager, } from '../../../main/group-chat/group-chat-moderator'; @@ -879,6 +887,206 @@ describe('group-chat-router', () => { ); }); + it('excludes local-only paths from --include-directories for SSH Gemini CLI participants', async () => { + const chat = await createTestChatWithModerator('SSH Gemini Path Test'); + + // Configure agent detector to return a Gemini CLI agent with workingDirArgs + const geminiAgent = { + id: 'gemini-cli', + name: 'Gemini CLI', + binaryName: 'gemini', + command: 'gemini', + args: ['-y', '--output-format', 'stream-json'], + available: true, + path: '/usr/local/bin/gemini', + capabilities: {}, + workingDirArgs: (dir: string) => ['--include-directories', dir], + promptArgs: (prompt: string) => ['-p', prompt], + }; + (mockAgentDetector.getAgent as ReturnType).mockResolvedValue(geminiAgent); + + // Set up a session with SSH config + const sshSession: SessionInfo = { + id: 'ses-ssh-gemini', + name: 'GeminiRemote', + toolType: 'gemini-cli', + cwd: '/home/remoteuser/project', + sshRemoteName: 'PedTome', + sshRemoteConfig, + }; + setGetSessionsCallback(() => [sshSession]); + setSshStore(mockSshStore); + + // Add participant and trigger spawn + await addParticipant( + chat.id, + 'GeminiRemote', + 'gemini-cli', + mockProcessManager, + '/home/remoteuser/project', + mockAgentDetector, + {}, + undefined, + { sshRemoteName: 'PedTome', sshRemoteConfig }, + mockSshStore + ); + + mockWrapSpawnWithSsh.mockClear(); + + // Moderator mentions the SSH Gemini participant + await routeModeratorResponse( + chat.id, + '@GeminiRemote: implement the feature', + mockProcessManager, + mockAgentDetector + ); + + // Verify wrapSpawnWithSsh was called with args that include --include-directories + // for the remote cwd, but NOT for local groupChatFolder or os.homedir() + expect(mockWrapSpawnWithSsh).toHaveBeenCalled(); + const sshCallArgs = mockWrapSpawnWithSsh.mock.calls[0][0].args as string[]; + const includeDirIndices: number[] = []; + sshCallArgs.forEach((arg: string, i: number) => { + if (arg === '--include-directories') includeDirIndices.push(i); + }); + + // All --include-directories paths should be the remote cwd only + // (buildAgentArgs adds one, buildGeminiWorkspaceDirArgs adds another for cwd) + expect(includeDirIndices.length).toBeGreaterThan(0); + const allDirPaths = includeDirIndices.map((i: number) => sshCallArgs[i + 1]); + expect(allDirPaths.every((p: string) => p === '/home/remoteuser/project')).toBe(true); + // Should NOT contain the local home directory or local config paths + expect(allDirPaths).not.toContain(os.homedir()); + }); + + it('includes all workspace paths for local Gemini CLI participants', async () => { + const chat = await createTestChatWithModerator('Local Gemini Path Test'); + + // Configure agent detector to return a Gemini CLI agent + const geminiAgent = { + id: 'gemini-cli', + name: 'Gemini CLI', + binaryName: 'gemini', + command: 'gemini', + args: ['-y', '--output-format', 'stream-json'], + available: true, + path: '/usr/local/bin/gemini', + capabilities: {}, + workingDirArgs: (dir: string) => ['--include-directories', dir], + promptArgs: (prompt: string) => ['-p', prompt], + }; + (mockAgentDetector.getAgent as ReturnType).mockResolvedValue(geminiAgent); + + // Session without SSH config (local) + const localSession: SessionInfo = { + id: 'ses-local-gemini', + name: 'GeminiLocal', + toolType: 'gemini-cli', + cwd: '/Users/dev/project', + }; + setGetSessionsCallback(() => [localSession]); + + // Add participant locally (no SSH) + await addParticipant( + chat.id, + 'GeminiLocal', + 'gemini-cli', + mockProcessManager, + '/Users/dev/project', + mockAgentDetector, + {} + ); + + mockProcessManager.spawn = vi.fn().mockReturnValue({ pid: 12345, success: true }); + + // Moderator mentions the local Gemini participant + await routeModeratorResponse( + chat.id, + '@GeminiLocal: implement the feature', + mockProcessManager, + mockAgentDetector + ); + + // For local sessions, spawn should include --include-directories for all paths + // including groupChatFolder and os.homedir() + const spawnCall = (mockProcessManager.spawn as ReturnType).mock.calls[0][0]; + const spawnArgs = spawnCall.args as string[]; + const includeDirIndices: number[] = []; + spawnArgs.forEach((arg: string, i: number) => { + if (arg === '--include-directories') includeDirIndices.push(i); + }); + + // Should have 4 --include-directories entries: + // 1 from buildAgentArgs(cwd) + 3 from buildGeminiWorkspaceDirArgs(cwd, groupChatFolder, homedir) + expect(includeDirIndices.length).toBe(4); + const allDirPaths = includeDirIndices.map((i: number) => spawnArgs[i + 1]); + // Should contain the cwd and os.homedir() + expect(allDirPaths).toContain('/Users/dev/project'); + expect(allDirPaths).toContain(os.homedir()); + }); + + it('spawnModeratorSynthesis applies SSH wrapping when moderator has SSH config', async () => { + // Create a chat with SSH-enabled moderator config + const chat = await createTestChat('SSH Synthesis Test', 'claude-code'); + const sshModeratorConfig = { + sshRemoteConfig: { + enabled: true, + remoteId: 'remote-1', + workingDirOverride: '/home/user/project', + }, + }; + + // Update the chat to have moderator config with SSH + // We need to create the chat with moderator config, then spawn moderator + const chatWithSsh = await createGroupChat( + 'SSH Synthesis Test 2', + 'claude-code', + sshModeratorConfig + ); + createdChats.push(chatWithSsh.id); + await spawnModerator(chatWithSsh, mockProcessManager); + + // Add a participant so the synthesis prompt includes participant context + await addParticipant(chatWithSsh.id, 'Worker', 'claude-code', mockProcessManager); + + // Set up SSH store + setSshStore(mockSshStore); + mockWrapSpawnWithSsh.mockClear(); + + // Spawn synthesis — should apply SSH wrapping + await spawnModeratorSynthesis(chatWithSsh.id, mockProcessManager, mockAgentDetector); + + // Verify SSH wrapping was applied + expect(mockWrapSpawnWithSsh).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.any(String), + args: expect.any(Array), + agentBinaryName: 'claude', + }), + sshModeratorConfig.sshRemoteConfig, + mockSshStore + ); + + // Verify spawn used the SSH-wrapped config + const spawnCall = (mockProcessManager.spawn as ReturnType).mock.calls; + const synthesisSpawn = spawnCall.find((call: any[]) => call[0]?.command === 'ssh'); + expect(synthesisSpawn).toBeDefined(); + }); + + it('spawnModeratorSynthesis does NOT apply SSH wrapping when no SSH config', async () => { + // Create a chat without SSH config + const chat = await createTestChatWithModerator('No SSH Synthesis Test'); + await addParticipant(chat.id, 'Worker', 'claude-code', mockProcessManager); + + setSshStore(mockSshStore); + mockWrapSpawnWithSsh.mockClear(); + + await spawnModeratorSynthesis(chat.id, mockProcessManager, mockAgentDetector); + + // SSH wrapping should NOT be called since chat has no moderatorConfig.sshRemoteConfig + expect(mockWrapSpawnWithSsh).not.toHaveBeenCalled(); + }); + it('does not apply SSH wrapping for non-SSH sessions', async () => { const chat = await createTestChatWithModerator('No SSH Test'); @@ -903,4 +1111,298 @@ describe('group-chat-router', () => { expect(mockWrapSpawnWithSsh).not.toHaveBeenCalled(); }); }); + + // =========================================================================== + // Test: Pending participant timeout + // =========================================================================== + describe('pending participant timeout', () => { + const SHORT_TIMEOUT = 100; // ms — short timeout for testing with real timers + + beforeEach(() => { + setPendingParticipantTimeout(SHORT_TIMEOUT); + }); + + afterEach(() => { + // Restore default timeout + setPendingParticipantTimeout(5 * 60 * 1000); + }); + + /** Helper: wait for a duration plus buffer */ + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + it('triggers synthesis after timeout when participants have not responded', async () => { + const chat = await createTestChatWithModerator('Timeout Test'); + await addParticipant(chat.id, 'Worker', 'claude-code', mockProcessManager); + + const workerSession: SessionInfo = { + id: 'ses-worker-1', + name: 'Worker', + toolType: 'claude-code', + cwd: '/Users/dev/project', + }; + setGetSessionsCallback(() => [workerSession]); + + await routeModeratorResponse( + chat.id, + '@Worker: Please implement this feature', + mockProcessManager, + mockAgentDetector + ); + + // Participant should be pending + const pending = getPendingParticipants(chat.id); + expect(pending.size).toBe(1); + expect(pending.has('Worker')).toBe(true); + + const spawnCountBefore = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + + // Wait for timeout + buffer for async operations + await wait(SHORT_TIMEOUT + 200); + + // Pending should be cleared + expect(getPendingParticipants(chat.id).size).toBe(0); + + // Synthesis should have been spawned (one additional spawn call) + const spawnCountAfter = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + expect(spawnCountAfter).toBeGreaterThan(spawnCountBefore); + }); + + it('clears timeout when last participant responds', async () => { + const chat = await createTestChatWithModerator('Clear On Response Test'); + await addParticipant(chat.id, 'Worker', 'claude-code', mockProcessManager); + + const workerSession: SessionInfo = { + id: 'ses-worker-1', + name: 'Worker', + toolType: 'claude-code', + cwd: '/Users/dev/project', + }; + setGetSessionsCallback(() => [workerSession]); + + await routeModeratorResponse( + chat.id, + '@Worker: Please implement this', + mockProcessManager, + mockAgentDetector + ); + + expect(getPendingParticipants(chat.id).size).toBe(1); + + // Mark participant as responded (last one) + const isLast = markParticipantResponded(chat.id, 'Worker'); + expect(isLast).toBe(true); + + const spawnCountBefore = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + + // Wait past timeout — should NOT trigger synthesis since participant already responded + await wait(SHORT_TIMEOUT + 200); + + const spawnCountAfter = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + expect(spawnCountAfter).toBe(spawnCountBefore); + }); + + it('clears timeout when clearPendingParticipants is called', async () => { + const chat = await createTestChatWithModerator('Clear Pending Test'); + await addParticipant(chat.id, 'Worker', 'claude-code', mockProcessManager); + + const workerSession: SessionInfo = { + id: 'ses-worker-1', + name: 'Worker', + toolType: 'claude-code', + cwd: '/Users/dev/project', + }; + setGetSessionsCallback(() => [workerSession]); + + await routeModeratorResponse( + chat.id, + '@Worker: Do the thing', + mockProcessManager, + mockAgentDetector + ); + + expect(getPendingParticipants(chat.id).size).toBe(1); + + // Explicitly clear pending + clearPendingParticipants(chat.id); + + const spawnCountBefore = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + + // Wait past timeout — should NOT trigger synthesis + await wait(SHORT_TIMEOUT + 200); + + const spawnCountAfter = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + expect(spawnCountAfter).toBe(spawnCountBefore); + }); + + it('clears timeout when a new user message arrives', async () => { + const chat = await createTestChatWithModerator('Clear On User Msg Test'); + await addParticipant(chat.id, 'Worker', 'claude-code', mockProcessManager); + + const workerSession: SessionInfo = { + id: 'ses-worker-1', + name: 'Worker', + toolType: 'claude-code', + cwd: '/Users/dev/project', + }; + setGetSessionsCallback(() => [workerSession]); + + await routeModeratorResponse( + chat.id, + '@Worker: Implement this', + mockProcessManager, + mockAgentDetector + ); + + expect(getPendingParticipants(chat.id).size).toBe(1); + + const spawnCountBefore = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + + // Send a new user message — this should clear the timeout + await routeUserMessage( + chat.id, + 'Actually, never mind. Do something else.', + mockProcessManager, + mockAgentDetector + ); + + const spawnCountAfterUserMsg = (mockProcessManager.spawn as ReturnType).mock + .calls.length; + + // Wait past timeout — should NOT trigger synthesis from the old timeout + await wait(SHORT_TIMEOUT + 200); + + const spawnCountAfter = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + // No additional spawns beyond what routeUserMessage already did + expect(spawnCountAfter).toBe(spawnCountAfterUserMsg); + }); + }); + + // =========================================================================== + // Test: Moderator synthesis pipeline lifecycle + // (spawn → synthesis → cleanup) + // =========================================================================== + describe('moderator synthesis pipeline lifecycle', () => { + it('spawn → spawnModeratorSynthesis → killModerator full lifecycle', async () => { + const chat = await createTestChatWithModerator('Synthesis Lifecycle'); + await addParticipant(chat.id, 'Coder', 'claude-code', mockProcessManager); + + // Verify moderator is active after spawn + expect(isModeratorActive(chat.id)).toBe(true); + const sessionId = getModeratorSessionId(chat.id); + expect(sessionId).toBeTruthy(); + + // Trigger synthesis — should spawn a process + const spawnCountBefore = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + await spawnModeratorSynthesis(chat.id, mockProcessManager, mockAgentDetector); + const spawnCountAfter = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + expect(spawnCountAfter).toBe(spawnCountBefore + 1); + + // Verify synthesis spawn used a session ID derived from the moderator prefix + const spawnCall = (mockProcessManager.spawn as ReturnType).mock.calls[ + spawnCountAfter - 1 + ][0]; + expect(spawnCall.sessionId).toContain('moderator'); + expect(spawnCall.readOnlyMode).toBe(true); + + // Kill moderator — cleanup + await killModerator(chat.id, mockProcessManager); + expect(isModeratorActive(chat.id)).toBe(false); + expect(getModeratorSessionId(chat.id)).toBeUndefined(); + }); + + it('spawnModeratorSynthesis returns early if chat not found', async () => { + // Use a non-existent chat ID — should not throw, just return early + const spawnCountBefore = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + + await spawnModeratorSynthesis('non-existent-chat', mockProcessManager, mockAgentDetector); + + const spawnCountAfter = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + expect(spawnCountAfter).toBe(spawnCountBefore); + }); + + it('spawnModeratorSynthesis returns early if moderator not active', async () => { + const chat = await createTestChat('No Moderator Synth'); + // Do NOT spawn moderator — isModeratorActive will return false + + const spawnCountBefore = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + + await spawnModeratorSynthesis(chat.id, mockProcessManager, mockAgentDetector); + + const spawnCountAfter = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + expect(spawnCountAfter).toBe(spawnCountBefore); + }); + + it('spawnModeratorSynthesis returns early if agent unavailable', async () => { + const chat = await createTestChatWithModerator('Unavailable Agent Synth'); + await addParticipant(chat.id, 'Worker', 'claude-code', mockProcessManager); + + // Override agent detector to return unavailable agent + const unavailableDetector = { + ...mockAgentDetector, + getAgent: vi.fn().mockResolvedValue({ + id: 'claude-code', + name: 'Claude Code', + command: 'claude', + args: [], + available: false, + capabilities: {}, + }), + } as unknown as AgentDetector; + + const spawnCountBefore = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + + await spawnModeratorSynthesis(chat.id, mockProcessManager, unavailableDetector); + + const spawnCountAfter = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + expect(spawnCountAfter).toBe(spawnCountBefore); + }); + + it('synthesis prompt includes participant context and chat history', async () => { + const chat = await createTestChatWithModerator('Prompt Content Synth'); + await addParticipant(chat.id, 'Architect', 'claude-code', mockProcessManager); + + await spawnModeratorSynthesis(chat.id, mockProcessManager, mockAgentDetector); + + // Check the prompt passed to spawn contains participant info + const spawnCalls = (mockProcessManager.spawn as ReturnType).mock.calls; + const lastSpawn = spawnCalls[spawnCalls.length - 1][0]; + expect(lastSpawn.prompt).toBeDefined(); + expect(lastSpawn.prompt).toContain('Architect'); + expect(lastSpawn.prompt).toContain('Current Participants'); + }); + + it('synthesis after kill does not spawn', async () => { + const chat = await createTestChatWithModerator('Kill Then Synth'); + await addParticipant(chat.id, 'Worker', 'claude-code', mockProcessManager); + + // Kill moderator first + await killModerator(chat.id, mockProcessManager); + + const spawnCountBefore = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + + // Attempt synthesis after kill — should not spawn + await spawnModeratorSynthesis(chat.id, mockProcessManager, mockAgentDetector); + + const spawnCountAfter = (mockProcessManager.spawn as ReturnType).mock.calls + .length; + expect(spawnCountAfter).toBe(spawnCountBefore); + }); + }); }); diff --git a/src/__tests__/main/group-chat/output-parser.test.ts b/src/__tests__/main/group-chat/output-parser.test.ts index 8894dc39d1..e72be8ec2a 100644 --- a/src/__tests__/main/group-chat/output-parser.test.ts +++ b/src/__tests__/main/group-chat/output-parser.test.ts @@ -251,4 +251,652 @@ describe('group-chat/output-parser', () => { expect(getOutputParser).not.toHaveBeenCalled(); }); }); + + // ========================================================================= + // Agent-specific NDJSON format tests + // ========================================================================= + + describe('Gemini NDJSON extraction', () => { + function geminiParser() { + return { + parseJsonLine: vi.fn((line: string) => { + try { + const msg = JSON.parse(line); + if (msg.type === 'init') { + return { type: 'init' as const, sessionId: msg.session_id, text: '' }; + } + if (msg.type === 'message' && msg.role === 'assistant') { + return { + type: 'text' as const, + text: msg.content || '', + isPartial: !!msg.delta, + }; + } + if (msg.type === 'tool_use') { + return { + type: 'tool_use' as const, + toolName: msg.tool_name, + toolState: { id: msg.tool_id, status: 'running', input: msg.parameters }, + }; + } + if (msg.type === 'tool_result') { + return { + type: 'tool_use' as const, + toolName: msg.tool_id, + toolState: { id: msg.tool_id, status: msg.status, output: msg.output }, + }; + } + if (msg.type === 'error') { + return { type: 'error' as const, text: msg.message }; + } + if (msg.type === 'result') { + return { + type: 'result' as const, + text: msg.text, + usage: msg.usage + ? { + inputTokens: msg.usage.input_tokens || 0, + outputTokens: msg.usage.output_tokens || 0, + } + : undefined, + }; + } + return null; + } catch { + return null; + } + }), + }; + } + + it('should extract text from Gemini assistant message events', () => { + vi.mocked(getOutputParser).mockReturnValue(geminiParser() as any); + const ndjson = [ + '{"type":"init","session_id":"gem-123"}', + '{"type":"message","role":"assistant","content":"Hello from Gemini"}', + ].join('\n'); + expect(extractTextFromAgentOutput(ndjson, 'gemini-cli')).toBe('Hello from Gemini'); + }); + + it('should extract result from Gemini result event over text events', () => { + vi.mocked(getOutputParser).mockReturnValue(geminiParser() as any); + const ndjson = [ + '{"type":"message","role":"assistant","content":"Streaming chunk","delta":true}', + '{"type":"message","role":"assistant","content":"Another chunk","delta":true}', + '{"type":"result","text":"Complete Gemini response","usage":{"input_tokens":100,"output_tokens":50}}', + ].join('\n'); + expect(extractTextFromAgentOutput(ndjson, 'gemini-cli')).toBe('Complete Gemini response'); + }); + + it('should concatenate partial Gemini text events when no result', () => { + vi.mocked(getOutputParser).mockReturnValue(geminiParser() as any); + const ndjson = [ + '{"type":"message","role":"assistant","content":"Part A","delta":true}', + '{"type":"message","role":"assistant","content":"Part B","delta":true}', + ].join('\n'); + expect(extractTextFromAgentOutput(ndjson, 'gemini-cli')).toBe('Part A\nPart B'); + }); + + it('should skip Gemini tool_use and tool_result events (no text)', () => { + vi.mocked(getOutputParser).mockReturnValue(geminiParser() as any); + const ndjson = [ + '{"type":"message","role":"assistant","content":"Before tool"}', + '{"type":"tool_use","tool_name":"read_file","tool_id":"t1","parameters":{"path":"foo.ts"}}', + '{"type":"tool_result","tool_id":"t1","status":"success","output":"file contents"}', + '{"type":"message","role":"assistant","content":"After tool"}', + ].join('\n'); + expect(extractTextFromAgentOutput(ndjson, 'gemini-cli')).toBe('Before tool\nAfter tool'); + }); + }); + + describe('Claude Code output', () => { + function claudeParser() { + return { + parseJsonLine: vi.fn((line: string) => { + try { + const msg = JSON.parse(line); + if (msg.type === 'system' && msg.subtype === 'init') { + return { + type: 'init' as const, + sessionId: msg.session_id, + slashCommands: msg.slash_commands, + }; + } + if (msg.type === 'assistant') { + const content = msg.message?.content; + if (typeof content === 'string') { + return { type: 'text' as const, text: content, isPartial: true }; + } + if (Array.isArray(content)) { + const textBlocks = content + .filter((b: any) => b.type === 'text') + .map((b: any) => b.text); + return { + type: 'text' as const, + text: textBlocks.join(''), + isPartial: true, + }; + } + return null; + } + if (msg.type === 'result') { + return { + type: 'result' as const, + text: msg.result, + usage: msg.usage + ? { + inputTokens: msg.usage.input_tokens || 0, + outputTokens: msg.usage.output_tokens || 0, + } + : undefined, + }; + } + return null; + } catch { + return null; + } + }), + }; + } + + it('should extract text from Claude assistant message with string content', () => { + vi.mocked(getOutputParser).mockReturnValue(claudeParser() as any); + const jsonl = [ + '{"type":"system","subtype":"init","session_id":"s-abc"}', + '{"type":"assistant","message":{"content":"Claude says hello"}}', + ].join('\n'); + expect(extractTextFromAgentOutput(jsonl, 'claude-code')).toBe('Claude says hello'); + }); + + it('should extract text from Claude assistant content blocks', () => { + vi.mocked(getOutputParser).mockReturnValue(claudeParser() as any); + const jsonl = [ + '{"type":"assistant","message":{"content":[{"type":"text","text":"Block 1"},{"type":"text","text":" Block 2"}]}}', + ].join('\n'); + expect(extractTextFromAgentOutput(jsonl, 'claude-code')).toBe('Block 1 Block 2'); + }); + + it('should prefer Claude result over streaming assistant events', () => { + vi.mocked(getOutputParser).mockReturnValue(claudeParser() as any); + const jsonl = [ + '{"type":"assistant","message":{"content":"Partial..."}}', + '{"type":"result","result":"Final Claude result","usage":{"input_tokens":200,"output_tokens":80}}', + ].join('\n'); + expect(extractTextFromAgentOutput(jsonl, 'claude-code')).toBe('Final Claude result'); + }); + }); + + describe('Codex output', () => { + function codexParser() { + return { + parseJsonLine: vi.fn((line: string) => { + try { + const msg = JSON.parse(line); + if (msg.type === 'thread.started') { + return { type: 'init' as const, sessionId: msg.thread_id }; + } + if (msg.type === 'item.completed' && msg.item) { + if (msg.item.type === 'agent_message') { + return { type: 'result' as const, text: msg.item.text }; + } + if (msg.item.type === 'reasoning') { + return { + type: 'text' as const, + text: msg.item.text, + isPartial: true, + }; + } + if (msg.item.type === 'tool_call') { + return { + type: 'tool_use' as const, + toolName: msg.item.tool, + toolState: { status: 'running', input: msg.item.args }, + }; + } + } + if (msg.type === 'turn.completed' && msg.usage) { + return { + type: 'usage' as const, + usage: { + inputTokens: msg.usage.input_tokens || 0, + outputTokens: msg.usage.output_tokens || 0, + }, + }; + } + if (msg.type === 'turn.failed') { + return { type: 'error' as const, text: msg.error || 'Turn failed' }; + } + return null; + } catch { + return null; + } + }), + }; + } + + it('should extract text from Codex agent_message item', () => { + vi.mocked(getOutputParser).mockReturnValue(codexParser() as any); + const jsonl = [ + '{"type":"thread.started","thread_id":"t-xyz"}', + '{"type":"item.completed","item":{"type":"agent_message","text":"Codex response"}}', + ].join('\n'); + expect(extractTextFromAgentOutput(jsonl, 'codex')).toBe('Codex response'); + }); + + it('should skip Codex tool_call items and extract agent_message', () => { + vi.mocked(getOutputParser).mockReturnValue(codexParser() as any); + const jsonl = [ + '{"type":"item.completed","item":{"type":"tool_call","tool":"shell","args":"ls"}}', + '{"type":"item.completed","item":{"type":"agent_message","text":"Done listing files"}}', + ].join('\n'); + expect(extractTextFromAgentOutput(jsonl, 'codex')).toBe('Done listing files'); + }); + + it('should concatenate Codex reasoning items when no agent_message', () => { + vi.mocked(getOutputParser).mockReturnValue(codexParser() as any); + const jsonl = [ + '{"type":"item.completed","item":{"type":"reasoning","text":"Thinking step 1"}}', + '{"type":"item.completed","item":{"type":"reasoning","text":"Thinking step 2"}}', + ].join('\n'); + expect(extractTextFromAgentOutput(jsonl, 'codex')).toBe('Thinking step 1\nThinking step 2'); + }); + }); + + describe('empty output', () => { + it('should return empty string for empty input', () => { + expect(extractTextGeneric('')).toBe(''); + }); + + it('should return empty string for whitespace-only input', () => { + // Whitespace-only lines: first non-empty line is undefined (all trim to ''), + // so the JSONL check sees no first line starting with '{' → returns raw. + // But actually all lines trim() to '' so firstNonEmptyLine is undefined, + // and the condition !firstNonEmptyLine.trim().startsWith('{') is skipped. + // Lines iterate: all trim to empty → skipped → empty result + expect(extractTextGeneric(' \n \n ')).toBe(''); + }); + + it('should return empty string for parser with empty JSONL input', () => { + const mockParser = { parseJsonLine: vi.fn().mockReturnValue(null) }; + vi.mocked(getOutputParser).mockReturnValue(mockParser as any); + expect(extractTextFromAgentOutput('', 'claude-code')).toBe(''); + }); + + it('should return empty when parser produces no text or result events', () => { + const mockParser = { + parseJsonLine: vi.fn().mockReturnValue({ type: 'system' as const }), + }; + vi.mocked(getOutputParser).mockReturnValue(mockParser as any); + const jsonl = '{"type":"system","data":"init"}'; + expect(extractTextFromAgentOutput(jsonl, 'claude-code')).toBe(''); + }); + + it('should return empty for JSONL lines with only empty text values', () => { + const mockParser = { + parseJsonLine: vi.fn((line: string) => { + try { + const msg = JSON.parse(line); + return { type: 'text' as const, text: msg.text }; + } catch { + return null; + } + }), + }; + vi.mocked(getOutputParser).mockReturnValue(mockParser as any); + // text fields are empty strings — empty string is falsy, so not pushed + const jsonl = ['{"text":""}', '{"text":""}'].join('\n'); + expect(extractTextFromAgentOutput(jsonl, 'test-agent')).toBe(''); + }); + }); + + describe('malformed JSON', () => { + it('should handle truncated JSON lines via generic parser', () => { + const malformed = [ + '{"text": "Valid line"}', + '{"text": "Truncated', + '{"text": "After truncation"}', + ].join('\n'); + const result = extractTextGeneric(malformed); + expect(result).toContain('Valid line'); + expect(result).toContain('After truncation'); + }); + + it('should handle completely garbled JSON via generic parser', () => { + const garbled = ['{not json at all}', '{{double braces}}', '{"]bad["}'].join('\n'); + // First line starts with '{' so JSONL mode is entered; + // all fail to parse and start with '{' so they are skipped + expect(extractTextGeneric(garbled)).toBe(''); + }); + + it('should handle malformed lines gracefully with agent parser', () => { + const mockParser = { + parseJsonLine: vi.fn().mockReturnValue(null), + }; + vi.mocked(getOutputParser).mockReturnValue(mockParser as any); + const garbled = '{"broken": json}\n{also bad}\n{"type":"text"'; + const result = extractTextFromAgentOutput(garbled, 'claude-code'); + // Parser returns null for all lines, so empty + expect(result).toBe(''); + expect(mockParser.parseJsonLine).toHaveBeenCalledTimes(3); + }); + + it('should recover from interleaved valid and malformed JSON', () => { + const mockParser = { + parseJsonLine: vi.fn((line: string) => { + try { + const msg = JSON.parse(line); + if (msg.type === 'text') return { type: 'text' as const, text: msg.text }; + } catch { + return null; + } + return null; + }), + }; + vi.mocked(getOutputParser).mockReturnValue(mockParser as any); + const mixed = [ + '{"type":"text","text":"Good 1"}', + '{broken json', + '{"type":"text","text":"Good 2"}', + 'totally not json', + '{"type":"text","text":"Good 3"}', + ].join('\n'); + expect(extractTextFromAgentOutput(mixed, 'claude-code')).toBe('Good 1\nGood 2\nGood 3'); + }); + }); + + describe('mixed content types', () => { + it('should extract text while ignoring tool_use, usage, and system events', () => { + const mockParser = { + parseJsonLine: vi.fn((line: string) => { + try { + const msg = JSON.parse(line); + const typeMap: Record = { + init: 'init', + text: 'text', + tool_use: 'tool_use', + usage: 'usage', + system: 'system', + result: 'result', + }; + const mapped = typeMap[msg.type] || 'system'; + return { type: mapped as any, text: msg.text, toolName: msg.tool }; + } catch { + return null; + } + }), + }; + vi.mocked(getOutputParser).mockReturnValue(mockParser as any); + const jsonl = [ + '{"type":"init","text":""}', + '{"type":"text","text":"Intro paragraph"}', + '{"type":"tool_use","tool":"read_file","text":""}', + '{"type":"system","text":""}', + '{"type":"text","text":"Explanation"}', + '{"type":"usage","text":""}', + '{"type":"result","text":"Final answer"}', + ].join('\n'); + // result takes precedence + expect(extractTextFromAgentOutput(jsonl, 'test-agent')).toBe('Final answer'); + }); + + it('should concatenate multiple text events when mixed with non-text (no result)', () => { + const mockParser = { + parseJsonLine: vi.fn((line: string) => { + try { + const msg = JSON.parse(line); + return { type: msg.type as any, text: msg.text || undefined }; + } catch { + return null; + } + }), + }; + vi.mocked(getOutputParser).mockReturnValue(mockParser as any); + const jsonl = [ + '{"type":"text","text":"Step 1"}', + '{"type":"tool_use","text":""}', + '{"type":"text","text":"Step 2"}', + '{"type":"system","text":""}', + '{"type":"text","text":"Step 3"}', + ].join('\n'); + expect(extractTextFromAgentOutput(jsonl, 'test-agent')).toBe('Step 1\nStep 2\nStep 3'); + }); + }); + + describe('tool call output', () => { + it('should not include tool_use events in extracted text', () => { + const mockParser = { + parseJsonLine: vi.fn((line: string) => { + try { + const msg = JSON.parse(line); + if (msg.type === 'tool_use') { + return { + type: 'tool_use' as const, + toolName: msg.name, + toolState: { status: msg.status, input: msg.input }, + }; + } + if (msg.type === 'text') return { type: 'text' as const, text: msg.text }; + return null; + } catch { + return null; + } + }), + }; + vi.mocked(getOutputParser).mockReturnValue(mockParser as any); + const jsonl = [ + '{"type":"tool_use","name":"execute_shell","status":"running","input":"ls"}', + '{"type":"tool_use","name":"execute_shell","status":"complete","input":"ls"}', + '{"type":"text","text":"The directory contains 5 files."}', + ].join('\n'); + expect(extractTextFromAgentOutput(jsonl, 'test-agent')).toBe( + 'The directory contains 5 files.' + ); + }); + + it('should return empty when output contains only tool calls', () => { + const mockParser = { + parseJsonLine: vi.fn((line: string) => { + try { + const msg = JSON.parse(line); + return { + type: 'tool_use' as const, + toolName: msg.name, + toolState: { status: 'complete' }, + }; + } catch { + return null; + } + }), + }; + vi.mocked(getOutputParser).mockReturnValue(mockParser as any); + const jsonl = [ + '{"name":"read_file","path":"foo.ts"}', + '{"name":"write_file","path":"bar.ts"}', + ].join('\n'); + expect(extractTextFromAgentOutput(jsonl, 'test-agent')).toBe(''); + }); + }); + + describe('partial streaming', () => { + it('should concatenate streaming text chunks without result', () => { + const mockParser = { + parseJsonLine: vi.fn((line: string) => { + try { + const msg = JSON.parse(line); + if (msg.type === 'text') { + return { type: 'text' as const, text: msg.text, isPartial: true }; + } + return null; + } catch { + return null; + } + }), + }; + vi.mocked(getOutputParser).mockReturnValue(mockParser as any); + const chunks = [ + '{"type":"text","text":"Hello "}', + '{"type":"text","text":"world, "}', + '{"type":"text","text":"how are you?"}', + ].join('\n'); + expect(extractTextFromAgentOutput(chunks, 'test-agent')).toBe( + 'Hello \nworld, \nhow are you?' + ); + }); + + it('should override streaming chunks when final result arrives', () => { + const mockParser = { + parseJsonLine: vi.fn((line: string) => { + try { + const msg = JSON.parse(line); + if (msg.type === 'text') + return { type: 'text' as const, text: msg.text, isPartial: true }; + if (msg.type === 'result') return { type: 'result' as const, text: msg.text }; + return null; + } catch { + return null; + } + }), + }; + vi.mocked(getOutputParser).mockReturnValue(mockParser as any); + const jsonl = [ + '{"type":"text","text":"Chunk A"}', + '{"type":"text","text":"Chunk B"}', + '{"type":"result","text":"Complete: Chunk A Chunk B"}', + ].join('\n'); + expect(extractTextFromAgentOutput(jsonl, 'test-agent')).toBe('Complete: Chunk A Chunk B'); + }); + + it('should handle single-chunk partial stream', () => { + const mockParser = { + parseJsonLine: vi.fn((line: string) => { + try { + const msg = JSON.parse(line); + return { type: 'text' as const, text: msg.text, isPartial: true }; + } catch { + return null; + } + }), + }; + vi.mocked(getOutputParser).mockReturnValue(mockParser as any); + const jsonl = '{"type":"text","text":"Only chunk"}'; + expect(extractTextFromAgentOutput(jsonl, 'test-agent')).toBe('Only chunk'); + }); + }); + + describe('error events', () => { + it('should not extract error event text as output text', () => { + const mockParser = { + parseJsonLine: vi.fn((line: string) => { + try { + const msg = JSON.parse(line); + if (msg.type === 'error') return { type: 'error' as const, text: msg.message }; + if (msg.type === 'text') return { type: 'text' as const, text: msg.text }; + return null; + } catch { + return null; + } + }), + }; + vi.mocked(getOutputParser).mockReturnValue(mockParser as any); + const jsonl = [ + '{"type":"text","text":"Some output"}', + '{"type":"error","message":"Rate limit exceeded"}', + ].join('\n'); + // error events have type 'error', not 'text' or 'result', so excluded + expect(extractTextFromAgentOutput(jsonl, 'test-agent')).toBe('Some output'); + }); + + it('should return empty when output contains only error events', () => { + const mockParser = { + parseJsonLine: vi.fn((line: string) => { + try { + const msg = JSON.parse(line); + return { type: 'error' as const, text: msg.message }; + } catch { + return null; + } + }), + }; + vi.mocked(getOutputParser).mockReturnValue(mockParser as any); + const jsonl = [ + '{"type":"error","message":"Connection failed"}', + '{"type":"error","message":"Retry exhausted"}', + ].join('\n'); + expect(extractTextFromAgentOutput(jsonl, 'test-agent')).toBe(''); + }); + + it('should handle error events via generic parser (no text/result fields)', () => { + const jsonl = ['{"type":"error","message":"Something broke","severity":"fatal"}'].join('\n'); + // Generic parser checks text, part.text, message.content, result — none match + expect(extractTextGeneric(jsonl)).toBe(''); + }); + }); + + describe('very long output', () => { + it('should handle output with many NDJSON lines', () => { + const mockParser = { + parseJsonLine: vi.fn((line: string) => { + try { + const msg = JSON.parse(line); + if (msg.type === 'text') return { type: 'text' as const, text: msg.text }; + if (msg.type === 'result') return { type: 'result' as const, text: msg.text }; + return null; + } catch { + return null; + } + }), + }; + vi.mocked(getOutputParser).mockReturnValue(mockParser as any); + + const lines: string[] = []; + for (let i = 0; i < 1000; i++) { + lines.push(`{"type":"text","text":"Line ${i}"}`); + } + lines.push('{"type":"result","text":"Final result after 1000 lines"}'); + const result = extractTextFromAgentOutput(lines.join('\n'), 'test-agent'); + expect(result).toBe('Final result after 1000 lines'); + }); + + it('should concatenate many text chunks without result', () => { + const mockParser = { + parseJsonLine: vi.fn((line: string) => { + try { + const msg = JSON.parse(line); + return { type: 'text' as const, text: msg.text }; + } catch { + return null; + } + }), + }; + vi.mocked(getOutputParser).mockReturnValue(mockParser as any); + + const lines: string[] = []; + for (let i = 0; i < 500; i++) { + lines.push(`{"text":"chunk-${i}"}`); + } + const result = extractTextFromAgentOutput(lines.join('\n'), 'test-agent'); + const parts = result.split('\n'); + expect(parts).toHaveLength(500); + expect(parts[0]).toBe('chunk-0'); + expect(parts[499]).toBe('chunk-499'); + }); + + it('should handle very long single JSON line via generic parser', () => { + const longText = 'A'.repeat(100_000); + const jsonl = `{"result": "${longText}"}`; + expect(extractTextGeneric(jsonl)).toBe(longText); + }); + + it('should handle many lines via generic parser', () => { + const lines: string[] = []; + for (let i = 0; i < 200; i++) { + lines.push(`{"text":"generic-${i}"}`); + } + const result = extractTextGeneric(lines.join('\n')); + const parts = result.split('\n'); + expect(parts).toHaveLength(200); + expect(parts[0]).toBe('generic-0'); + expect(parts[199]).toBe('generic-199'); + }); + }); }); diff --git a/src/__tests__/main/group-chat/session-recovery.test.ts b/src/__tests__/main/group-chat/session-recovery.test.ts index d60771e38c..450d59d34d 100644 --- a/src/__tests__/main/group-chat/session-recovery.test.ts +++ b/src/__tests__/main/group-chat/session-recovery.test.ts @@ -694,4 +694,122 @@ describe('group-chat/session-recovery', () => { expect(result).toBe(false); }); }); + + // ======================================================================== + // Corrupted state scenarios + // ======================================================================== + + describe('corrupted state handling', () => { + it('buildRecoveryContext should propagate error when readLog throws (corrupted log)', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/corrupted.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + mockedReadLog.mockRejectedValue(new Error('ENOENT: no such file or directory')); + + await expect(buildRecoveryContext('chat-1', 'Alice')).rejects.toThrow('ENOENT'); + }); + + it('buildRecoveryContext should throw when messages have undefined content (corrupted data)', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + // Simulate corrupted messages where content is undefined + mockedReadLog.mockResolvedValue([ + { + timestamp: '2025-01-15T10:00:00.000Z', + from: 'Alice', + content: undefined as unknown as string, + }, + { timestamp: '2025-01-15T10:01:00.000Z', from: 'Bob', content: 'Normal message' }, + ]); + + // substring() on undefined throws TypeError — corrupted data propagates + await expect(buildRecoveryContext('chat-1', 'Alice')).rejects.toThrow(TypeError); + }); + + it('buildRecoveryContext should handle messages with empty from field', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + mockedReadLog.mockResolvedValue([ + { timestamp: '2025-01-15T10:00:00.000Z', from: '', content: 'Orphaned message' }, + { timestamp: '2025-01-15T10:01:00.000Z', from: 'Alice', content: 'My message' }, + ]); + + const result = await buildRecoveryContext('chat-1', 'Alice'); + // Empty-from message should appear in history but not be categorized as Alice's + expect(result).toContain('Orphaned message'); + expect(result).toContain('My message'); + expect(result).toContain('**YOU (Alice):**'); + }); + + it('buildRecoveryContext should handle invalid timestamps in messages', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + mockedReadLog.mockResolvedValue([ + { timestamp: 'not-a-date', from: 'Alice', content: 'Message with bad timestamp' }, + ]); + + // Should not throw — new Date('not-a-date').toLocaleTimeString() returns 'Invalid Date' + const result = await buildRecoveryContext('chat-1', 'Alice'); + expect(result).toContain('Message with bad timestamp'); + }); + + it('initiateSessionRecovery should return false when storage is corrupted', async () => { + mockedUpdateParticipant.mockRejectedValue(new Error('Unexpected end of JSON input')); + + const result = await initiateSessionRecovery('chat-1', 'Alice'); + expect(result).toBe(false); + }); + + it('initiateSessionRecovery should return false when chat metadata file is missing', async () => { + mockedUpdateParticipant.mockRejectedValue( + new Error("ENOENT: no such file or directory, open '/tmp/gc/chat-1/metadata.json'") + ); + + const result = await initiateSessionRecovery('chat-1', 'Alice'); + expect(result).toBe(false); + }); + + it('detectSessionNotFoundError should handle output with null bytes gracefully', () => { + mockedGetErrorPatterns.mockReturnValue({}); + mockedMatchErrorPattern.mockReturnValue(null); + + const output = 'Normal text\x00with null bytes\x00and session not found error'; + expect(detectSessionNotFoundError(output)).toBe(true); + }); + }); }); diff --git a/src/__tests__/main/ipc/handlers/agentSessions.test.ts b/src/__tests__/main/ipc/handlers/agentSessions.test.ts index baccd997ef..3e2c881102 100644 --- a/src/__tests__/main/ipc/handlers/agentSessions.test.ts +++ b/src/__tests__/main/ipc/handlers/agentSessions.test.ts @@ -7,8 +7,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ipcMain } from 'electron'; -import { registerAgentSessionsHandlers } from '../../../../main/ipc/handlers/agentSessions'; +import { + registerAgentSessionsHandlers, + getGeminiStatsStore, + parseGeminiSessionContent, + countGeminiMessages, +} from '../../../../main/ipc/handlers/agentSessions'; import * as agentSessionStorage from '../../../../main/agents'; +import { GEMINI_SESSION_STATS_DEFAULTS } from '../../../../main/stores/defaults'; +import type { + GeminiSessionStatsData, + GeminiSessionTokenStats, +} from '../../../../main/stores/types'; // Mock electron's ipcMain vi.mock('electron', () => ({ @@ -25,6 +35,14 @@ vi.mock('../../../../main/agents', () => ({ getAllSessionStorages: vi.fn(), })); +// Mock Sentry utilities +const mockCaptureException = vi.fn(); +vi.mock('../../../../main/utils/sentry', () => ({ + captureException: (...args: unknown[]) => mockCaptureException(...args), + captureMessage: vi.fn(), + addBreadcrumb: vi.fn(), +})); + // Mock the logger vi.mock('../../../../main/utils/logger', () => ({ logger: { @@ -67,6 +85,7 @@ describe('agentSessions IPC handlers', () => { 'agentSessions:deleteMessagePair', 'agentSessions:hasStorage', 'agentSessions:getAvailableStorages', + 'agentSessions:getAllNamedSessions', ]; for (const channel of expectedChannels) { @@ -466,4 +485,488 @@ describe('agentSessions IPC handlers', () => { expect(result).toEqual(['claude-code', 'opencode']); }); }); + + describe('agentSessions:getAllNamedSessions', () => { + it('should aggregate named sessions from all storages that support getAllNamedSessions', async () => { + const mockGeminiStorage = { + agentId: 'gemini-cli', + getAllNamedSessions: vi.fn().mockResolvedValue([ + { + agentSessionId: 'gem-1', + projectPath: '/project', + sessionName: 'Gemini Chat', + starred: true, + }, + ]), + }; + + const mockClaudeStorage = { + agentId: 'claude-code', + getAllNamedSessions: vi.fn().mockResolvedValue([ + { agentSessionId: 'claude-1', projectPath: '/project', sessionName: 'Claude Chat' }, + { + agentSessionId: 'claude-2', + projectPath: '/other', + sessionName: 'Claude Debug', + starred: false, + }, + ]), + }; + + // A storage without getAllNamedSessions (e.g., terminal) + const mockTerminalStorage = { + agentId: 'terminal', + }; + + vi.mocked(agentSessionStorage.getAllSessionStorages).mockReturnValue([ + mockGeminiStorage, + mockClaudeStorage, + mockTerminalStorage, + ] as unknown as agentSessionStorage.AgentSessionStorage[]); + + const handler = handlers.get('agentSessions:getAllNamedSessions'); + const result = await handler!({} as any); + + // Should have 3 total (1 gemini + 2 claude), terminal excluded + expect(result).toHaveLength(3); + + // Verify agentId is added to each session + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agentId: 'gemini-cli', + agentSessionId: 'gem-1', + sessionName: 'Gemini Chat', + starred: true, + }), + expect.objectContaining({ + agentId: 'claude-code', + agentSessionId: 'claude-1', + sessionName: 'Claude Chat', + }), + expect.objectContaining({ + agentId: 'claude-code', + agentSessionId: 'claude-2', + sessionName: 'Claude Debug', + starred: false, + }), + ]) + ); + }); + + it('should return empty array when no storages support getAllNamedSessions', async () => { + const mockStorage = { + agentId: 'terminal', + // No getAllNamedSessions method + }; + + vi.mocked(agentSessionStorage.getAllSessionStorages).mockReturnValue([ + mockStorage, + ] as unknown as agentSessionStorage.AgentSessionStorage[]); + + const handler = handlers.get('agentSessions:getAllNamedSessions'); + const result = await handler!({} as any); + + expect(result).toEqual([]); + }); + + it('should continue aggregating if one storage throws an error', async () => { + const mockFailingStorage = { + agentId: 'codex', + getAllNamedSessions: vi.fn().mockRejectedValue(new Error('Storage error')), + }; + + const mockWorkingStorage = { + agentId: 'gemini-cli', + getAllNamedSessions: vi + .fn() + .mockResolvedValue([ + { agentSessionId: 'gem-1', projectPath: '/project', sessionName: 'Gemini Session' }, + ]), + }; + + vi.mocked(agentSessionStorage.getAllSessionStorages).mockReturnValue([ + mockFailingStorage, + mockWorkingStorage, + ] as unknown as agentSessionStorage.AgentSessionStorage[]); + + const handler = handlers.get('agentSessions:getAllNamedSessions'); + const result = await handler!({} as any); + + // Should still return the working storage's sessions + expect(result).toHaveLength(1); + expect(result[0].agentId).toBe('gemini-cli'); + expect(result[0].agentSessionId).toBe('gem-1'); + }); + }); + + describe('gemini session stats store', () => { + it('should return undefined when no store is provided', () => { + // Default registration (no deps) should leave gemini stats store undefined + expect(getGeminiStatsStore()).toBeUndefined(); + }); + + it('should store reference when geminiSessionStatsStore is provided via deps', () => { + const mockStore = { + get: vi.fn(), + set: vi.fn(), + store: { stats: {} }, + }; + + // Re-register with the mock store + registerAgentSessionsHandlers({ + getMainWindow: () => null, + geminiSessionStatsStore: mockStore as any, + }); + + expect(getGeminiStatsStore()).toBe(mockStore); + }); + + it('should have correct schema defaults with empty stats record', () => { + // Verify the store defaults match the expected GeminiSessionStatsData shape + expect(GEMINI_SESSION_STATS_DEFAULTS).toEqual({ stats: {} }); + expect(GEMINI_SESSION_STATS_DEFAULTS.stats).toEqual({}); + }); + + it('should accept GeminiSessionTokenStats entries keyed by session UUID', () => { + // Verify the store schema supports the expected data shape + const entry: GeminiSessionTokenStats = { + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 10, + reasoningTokens: 5, + lastUpdatedMs: Date.now(), + }; + const storeData: GeminiSessionStatsData = { + stats: { 'gemini-uuid-abc': entry }, + }; + expect(storeData.stats['gemini-uuid-abc']).toMatchObject({ + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 10, + reasoningTokens: 5, + }); + expect(storeData.stats['gemini-uuid-abc'].lastUpdatedMs).toBeGreaterThan(0); + }); + }); + + describe('parseGeminiSessionContent', () => { + it('should parse messages and return zeroed tokens when no token data in session', () => { + const content = JSON.stringify({ + messages: [{ type: 'user' }, { type: 'gemini' }, { type: 'user' }, { type: 'gemini' }], + }); + const result = parseGeminiSessionContent(content, 1024); + expect(result.messages).toBe(4); + expect(result.inputTokens).toBe(0); + expect(result.outputTokens).toBe(0); + expect(result.cachedInputTokens).toBe(0); + expect(result.sizeBytes).toBe(1024); + }); + + it('should fall back to persistedStats when message-level tokens are 0', () => { + const content = JSON.stringify({ + messages: [{ type: 'user' }, { type: 'gemini' }], + }); + const persistedStats = { + inputTokens: 500, + outputTokens: 1200, + cacheReadTokens: 100, + reasoningTokens: 50, + }; + const result = parseGeminiSessionContent(content, 2048, persistedStats); + expect(result.messages).toBe(2); + expect(result.inputTokens).toBe(500); + expect(result.outputTokens).toBe(1200); + expect(result.cachedInputTokens).toBe(100); + expect(result.sizeBytes).toBe(2048); + }); + + it('should NOT fall back to persistedStats when message-level tokens are non-zero', () => { + // Hypothetical: if Gemini ever adds token data to messages + const content = JSON.stringify({ + messages: [{ type: 'user', tokens: { input: 10, output: 20 } }], + }); + const persistedStats = { + inputTokens: 500, + outputTokens: 1200, + cacheReadTokens: 100, + reasoningTokens: 50, + }; + const result = parseGeminiSessionContent(content, 512, persistedStats); + // Should use the message-level data, not the persisted fallback + expect(result.inputTokens).toBe(10); + expect(result.outputTokens).toBe(20); + }); + + it('should handle empty/invalid JSON gracefully with persistedStats fallback', () => { + const persistedStats = { + inputTokens: 300, + outputTokens: 600, + cacheReadTokens: 50, + reasoningTokens: 0, + }; + const result = parseGeminiSessionContent('not valid json', 100, persistedStats); + expect(result.messages).toBe(0); + // Parse failed, tokens are 0, so persisted stats should be used + expect(result.inputTokens).toBe(300); + expect(result.outputTokens).toBe(600); + expect(result.cachedInputTokens).toBe(50); + }); + + it('should report corrupted session JSON to Sentry', () => { + parseGeminiSessionContent('not valid json', 256); + expect(mockCaptureException).toHaveBeenCalledWith(expect.any(SyntaxError), { + context: 'parseGeminiSessionContent', + sizeBytes: 256, + }); + }); + + it('should handle missing messages array', () => { + const content = JSON.stringify({ sessionId: 'abc-123' }); + const result = parseGeminiSessionContent(content, 50); + expect(result.messages).toBe(0); + expect(result.inputTokens).toBe(0); + expect(result.outputTokens).toBe(0); + }); + + it('should not use persistedStats when undefined', () => { + const content = JSON.stringify({ + messages: [{ type: 'user' }], + }); + const result = parseGeminiSessionContent(content, 100); + expect(result.inputTokens).toBe(0); + expect(result.outputTokens).toBe(0); + expect(result.cachedInputTokens).toBe(0); + }); + + it('should sum mixed input and prompt token fields on the same object', () => { + const content = JSON.stringify({ + messages: [{ type: 'user', tokens: { input: 10, prompt: 5, output: 3 } }], + }); + const result = parseGeminiSessionContent(content, 256); + expect(result.inputTokens).toBe(15); + expect(result.outputTokens).toBe(3); + }); + + it('should use first-match semantics to avoid double-counting tokens across sources', () => { + // When tokens appear in multiple locations (e.g., msg.tokens AND msg.metadata.tokens), + // only the first source with non-zero values should be used + const content = JSON.stringify({ + messages: [ + { + type: 'gemini', + tokens: { input: 100, output: 200 }, + tokenUsage: { input: 100, output: 200 }, + metadata: { tokens: { input: 100, output: 200 } }, + }, + ], + }); + const result = parseGeminiSessionContent(content, 512); + // Should be 100/200, NOT 300/600 from triple-counting + expect(result.inputTokens).toBe(100); + expect(result.outputTokens).toBe(200); + }); + + it('should fall through to next token source when earlier sources are empty', () => { + const content = JSON.stringify({ + messages: [ + { + type: 'gemini', + tokens: {}, + tokenUsage: { input: 50, output: 75, cached: 10 }, + }, + ], + }); + const result = parseGeminiSessionContent(content, 256); + expect(result.inputTokens).toBe(50); + expect(result.outputTokens).toBe(75); + expect(result.cachedInputTokens).toBe(10); + }); + }); + + describe('sessionId extraction regex (used in getGlobalStats)', () => { + // This regex is used in getGlobalStats() to extract the sessionId from + // Gemini session JSON files and look up persisted token stats by UUID. + const SESSION_ID_REGEX = /"sessionId"\s*:\s*"([^"]+)"/; + + it('should extract sessionId from a realistic Gemini session JSON', () => { + const content = JSON.stringify({ + sessionId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + messages: [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi there' }, + ], + startTime: '2026-02-21T10:00:00Z', + lastUpdated: '2026-02-21T10:05:00Z', + }); + const match = content.match(SESSION_ID_REGEX); + expect(match).not.toBeNull(); + expect(match![1]).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + }); + + it('should match the same UUID format emitted by init events', () => { + // The init event emits session_id (snake_case), parser maps to sessionId (camelCase). + // The session file stores sessionId (camelCase). Both should contain the same UUID. + const uuid = 'abc-123-def-456'; + const sessionFile = JSON.stringify({ sessionId: uuid, messages: [] }); + const match = sessionFile.match(SESSION_ID_REGEX); + expect(match).not.toBeNull(); + expect(match![1]).toBe(uuid); + }); + + it('should not match when sessionId field is absent', () => { + const content = JSON.stringify({ messages: [{ type: 'user' }] }); + const match = content.match(SESSION_ID_REGEX); + expect(match).toBeNull(); + }); + + it('should handle whitespace variations in JSON formatting', () => { + // JSON.stringify uses no spaces, but manually-formatted JSON might + const content = '{"sessionId" : "spaced-uuid-123", "messages": []}'; + const match = content.match(SESSION_ID_REGEX); + expect(match).not.toBeNull(); + expect(match![1]).toBe('spaced-uuid-123'); + }); + + it('should enable correct persisted stats lookup', () => { + // End-to-end: extract sessionId from file, look up in persisted stats, pass to parser + const uuid = 'live-session-uuid-789'; + const sessionContent = JSON.stringify({ + sessionId: uuid, + messages: [{ type: 'user' }, { type: 'gemini' }], + }); + + const allPersistedStats: Record< + string, + { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + reasoningTokens: number; + } + > = { + [uuid]: { + inputTokens: 1000, + outputTokens: 2000, + cacheReadTokens: 300, + reasoningTokens: 100, + }, + 'other-uuid': { inputTokens: 50, outputTokens: 50, cacheReadTokens: 0, reasoningTokens: 0 }, + }; + + // Extract sessionId (mirrors getGlobalStats logic) + const match = sessionContent.match(SESSION_ID_REGEX); + const persistedStats = match?.[1] ? allPersistedStats[match[1]] : undefined; + + // Pass to parser (mirrors getGlobalStats logic) + const result = parseGeminiSessionContent(sessionContent, 512, persistedStats); + expect(result.inputTokens).toBe(1000); + expect(result.outputTokens).toBe(2000); + expect(result.cachedInputTokens).toBe(300); + }); + }); + + describe('countGeminiMessages', () => { + it('should count user and gemini message types', () => { + const content = JSON.stringify({ + messages: [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi' }, + { type: 'user', content: 'How are you?' }, + { type: 'gemini', content: 'Good' }, + ], + }); + expect(countGeminiMessages(content)).toBe(4); + }); + + it('should count assistant and human message types', () => { + const content = JSON.stringify({ + messages: [ + { type: 'human', content: 'Hello' }, + { type: 'assistant', content: 'Hi' }, + ], + }); + expect(countGeminiMessages(content)).toBe(2); + }); + + it('should return 0 for content with no matching message types', () => { + const content = JSON.stringify({ + messages: [ + { type: 'system', content: 'config' }, + { type: 'tool', content: 'result' }, + ], + }); + expect(countGeminiMessages(content)).toBe(0); + }); + + it('should return 0 for empty content', () => { + expect(countGeminiMessages('')).toBe(0); + }); + + it('should return 0 for invalid JSON', () => { + expect(countGeminiMessages('not valid json')).toBe(0); + }); + + it('should handle whitespace variations in JSON formatting', () => { + const content = '{"messages": [{"type" : "user"}, {"type":"gemini"}]}'; + expect(countGeminiMessages(content)).toBe(2); + }); + }); + + describe('getGlobalStats lightweight Gemini path (sizeBytes + persistedStats skip)', () => { + it('should produce same stats via lightweight path as full parse when persistedStats exist', () => { + // Simulate the lightweight path used in getGlobalStats when persistedStats are available + const content = JSON.stringify({ + sessionId: 'test-uuid', + messages: [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi' }, + { type: 'user', content: 'Question' }, + { type: 'gemini', content: 'Answer' }, + ], + }); + const persistedStats = { + inputTokens: 500, + outputTokens: 1200, + cacheReadTokens: 100, + reasoningTokens: 50, + }; + const sizeBytes = Buffer.byteLength(content, 'utf-8'); + + // Lightweight path (used when persistedStats exist) + const messageCount = countGeminiMessages(content); + const lightweightStats = { + messages: messageCount, + inputTokens: persistedStats.inputTokens, + outputTokens: persistedStats.outputTokens, + cacheReadTokens: 0, + cacheCreationTokens: 0, + cachedInputTokens: persistedStats.cacheReadTokens, + sizeBytes, + }; + + // Full parse path (used when persistedStats don't exist, with fallback) + const fullParseStats = parseGeminiSessionContent(content, sizeBytes, persistedStats); + + // Both paths should produce identical results + expect(lightweightStats.messages).toBe(fullParseStats.messages); + expect(lightweightStats.inputTokens).toBe(fullParseStats.inputTokens); + expect(lightweightStats.outputTokens).toBe(fullParseStats.outputTokens); + expect(lightweightStats.cachedInputTokens).toBe(fullParseStats.cachedInputTokens); + expect(lightweightStats.sizeBytes).toBe(fullParseStats.sizeBytes); + }); + + it('should fall back to full parse when no persistedStats exist', () => { + const content = JSON.stringify({ + messages: [{ type: 'user', tokens: { input: 10, output: 20 } }, { type: 'gemini' }], + }); + const sizeBytes = 512; + + // Without persistedStats, full parse is used and extracts token data from messages + const result = parseGeminiSessionContent(content, sizeBytes); + expect(result.messages).toBe(2); + expect(result.inputTokens).toBe(10); + expect(result.outputTokens).toBe(20); + }); + }); }); diff --git a/src/__tests__/main/ipc/handlers/autorun.test.ts b/src/__tests__/main/ipc/handlers/autorun.test.ts index ab5dd2133d..48d5e35f84 100644 --- a/src/__tests__/main/ipc/handlers/autorun.test.ts +++ b/src/__tests__/main/ipc/handlers/autorun.test.ts @@ -664,7 +664,7 @@ describe('autorun IPC handlers', () => { }); describe('autorun:deleteFolder', () => { - it('should remove the Auto Run Docs folder', async () => { + it('should remove the playbooks folder', async () => { vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => true, } as any); @@ -674,7 +674,7 @@ describe('autorun IPC handlers', () => { const result = await handler!({} as any, '/test/project'); expect(result.success).toBe(true); - expect(fs.rm).toHaveBeenCalledWith(path.join('/test/project', 'Auto Run Docs'), { + expect(fs.rm).toHaveBeenCalledWith(path.join('/test/project', '.maestro/playbooks'), { recursive: true, force: true, }); @@ -691,7 +691,7 @@ describe('autorun IPC handlers', () => { expect(fs.rm).not.toHaveBeenCalled(); }); - it('should return error if path is not a directory', async () => { + it('should skip non-directory paths without error', async () => { vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => false, } as any); @@ -699,8 +699,9 @@ describe('autorun IPC handlers', () => { const handler = handlers.get('autorun:deleteFolder'); const result = await handler!({} as any, '/test/project'); - expect(result.success).toBe(false); - expect(result.error).toContain('Auto Run Docs path is not a directory'); + // Both canonical and legacy are non-directories, so nothing to delete + expect(result.success).toBe(true); + expect(fs.rm).not.toHaveBeenCalled(); }); it('should return error for invalid project path', async () => { @@ -1389,14 +1390,14 @@ describe('autorun IPC handlers', () => { const result = await handler!({} as any, '/remote/folder', 'doc1', 1, 'ssh-remote-1'); expect(result.success).toBe(true); - expect(result.workingCopyPath).toMatch(/^Runs\/doc1-\d+-loop-1$/); + expect(result.workingCopyPath).toMatch(/^runs\/doc1-\d+-loop-1$/); expect(result.originalPath).toBe('doc1'); // Verify remote operations were called expect(mockReadFileRemote).toHaveBeenCalledWith('/remote/folder/doc1.md', sampleSshRemote); - expect(mockMkdirRemote).toHaveBeenCalledWith('/remote/folder/Runs', sampleSshRemote, true); + expect(mockMkdirRemote).toHaveBeenCalledWith('/remote/folder/runs', sampleSshRemote, true); expect(mockWriteFileRemote).toHaveBeenCalledWith( - expect.stringContaining('/remote/folder/Runs/doc1-'), + expect.stringContaining('/remote/folder/runs/doc1-'), '# Source Content', sampleSshRemote ); @@ -1425,12 +1426,12 @@ describe('autorun IPC handlers', () => { ); expect(result.success).toBe(true); - expect(result.workingCopyPath).toMatch(/^Runs\/subdir\/nested-doc-\d+-loop-2$/); + expect(result.workingCopyPath).toMatch(/^runs\/subdir\/nested-doc-\d+-loop-2$/); expect(result.originalPath).toBe('subdir/nested-doc'); // Verify remote mkdir creates the correct subdirectory expect(mockMkdirRemote).toHaveBeenCalledWith( - '/remote/folder/Runs/subdir', + '/remote/folder/runs/subdir', sampleSshRemote, true ); diff --git a/src/__tests__/main/ipc/handlers/director-notes.test.ts b/src/__tests__/main/ipc/handlers/director-notes.test.ts index ae1cf96e37..db5301e960 100644 --- a/src/__tests__/main/ipc/handlers/director-notes.test.ts +++ b/src/__tests__/main/ipc/handlers/director-notes.test.ts @@ -245,6 +245,37 @@ describe('director-notes IPC handlers', () => { expect(result.stats.totalCount).toBe(3); }); + it('should only count agents with entries in lookback window for agentCount', async () => { + const now = Date.now(); + const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000; + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + // 3 sessions on disk, but only 2 have entries within 7-day lookback + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue([ + 'session-1', + 'session-2', + 'session-3', + ]); + + vi.mocked(mockHistoryManager.getEntries) + .mockReturnValueOnce([ + createMockEntry({ id: 'e1', timestamp: twoDaysAgo, agentSessionId: 'as-1' }), + ]) + .mockReturnValueOnce([ + // session-2 only has old entries outside lookback + createMockEntry({ id: 'e2', timestamp: tenDaysAgo, agentSessionId: 'as-2' }), + ]) + .mockReturnValueOnce([ + createMockEntry({ id: 'e3', timestamp: twoDaysAgo, agentSessionId: 'as-3' }), + ]); + + const handler = handlers.get('director-notes:getUnifiedHistory'); + const result = await handler!({} as any, { lookbackDays: 7 }); + + expect(result.stats.agentCount).toBe(2); // Only 2 agents had entries in window + expect(result.entries).toHaveLength(2); + }); + it('should filter by lookbackDays', async () => { const now = Date.now(); const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000; diff --git a/src/__tests__/main/ipc/handlers/groupChat.test.ts b/src/__tests__/main/ipc/handlers/groupChat.test.ts index 4d3fffc46d..83c9bf4d40 100644 --- a/src/__tests__/main/ipc/handlers/groupChat.test.ts +++ b/src/__tests__/main/ipc/handlers/groupChat.test.ts @@ -688,7 +688,8 @@ describe('groupChat IPC handlers', () => { 'Hello moderator', mockProcessManager, mockAgentDetector, - false + false, + undefined ); }); @@ -703,7 +704,8 @@ describe('groupChat IPC handlers', () => { 'Analyze this', mockProcessManager, mockAgentDetector, - true + true, + undefined ); }); }); diff --git a/src/__tests__/main/ipc/handlers/history.test.ts b/src/__tests__/main/ipc/handlers/history.test.ts index e612489d77..ff1216e06f 100644 --- a/src/__tests__/main/ipc/handlers/history.test.ts +++ b/src/__tests__/main/ipc/handlers/history.test.ts @@ -38,6 +38,7 @@ vi.mock('../../../../main/utils/logger', () => ({ describe('history IPC handlers', () => { let handlers: Map; let mockHistoryManager: Partial; + let mockSafeSend: ReturnType; // Sample history entries for testing const createMockEntry = (overrides: Partial = {}): HistoryEntry => ({ @@ -54,6 +55,8 @@ describe('history IPC handlers', () => { // Clear mocks vi.clearAllMocks(); + mockSafeSend = vi.fn(); + // Create mock history manager mockHistoryManager = { getEntries: vi.fn().mockReturnValue([]), @@ -101,8 +104,8 @@ describe('history IPC handlers', () => { handlers.set(channel, handler); }); - // Register handlers - registerHistoryHandlers(); + // Register handlers with mock safeSend + registerHistoryHandlers({ safeSend: mockSafeSend }); }); afterEach(() => { @@ -282,6 +285,15 @@ describe('history IPC handlers', () => { expect(result).toBe(true); }); + it('should broadcast entry via safeSend after adding', async () => { + const entry = createMockEntry({ sessionId: 'session-1', projectPath: '/test' }); + + const handler = handlers.get('history:add'); + await handler!({} as any, entry); + + expect(mockSafeSend).toHaveBeenCalledWith('history:entryAdded', entry, 'session-1'); + }); + it('should use orphaned session ID when sessionId is missing', async () => { const entry = createMockEntry({ sessionId: undefined, projectPath: '/test' }); diff --git a/src/__tests__/main/ipc/handlers/notifications.test.ts b/src/__tests__/main/ipc/handlers/notifications.test.ts index add55b37c8..a1ed411db1 100644 --- a/src/__tests__/main/ipc/handlers/notifications.test.ts +++ b/src/__tests__/main/ipc/handlers/notifications.test.ts @@ -17,6 +17,7 @@ import { ipcMain } from 'electron'; const mocks = vi.hoisted(() => ({ mockNotificationShow: vi.fn(), mockNotificationIsSupported: vi.fn().mockReturnValue(true), + mockNotificationOn: vi.fn(), })); // Mock electron with a proper class for Notification @@ -29,6 +30,9 @@ vi.mock('electron', () => { show() { mocks.mockNotificationShow(); } + on(event: string, handler: () => void) { + mocks.mockNotificationOn(event, handler); + } static isSupported() { return mocks.mockNotificationIsSupported(); } @@ -55,6 +59,15 @@ vi.mock('../../../../main/utils/logger', () => ({ }, })); +// Mock deep-links module (used by notification click handler) +vi.mock('../../../../main/deep-links', () => ({ + parseDeepLink: vi.fn((url: string) => { + if (url.includes('session/')) return { action: 'session', sessionId: 'test-session' }; + return { action: 'focus' }; + }), + dispatchDeepLink: vi.fn(), +})); + // Mock child_process - must include default export vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); @@ -99,6 +112,8 @@ import { describe('Notification IPC Handlers', () => { let handlers: Map; + const mockGetMainWindow = vi.fn().mockReturnValue(null); + beforeEach(() => { vi.clearAllMocks(); resetNotificationState(); @@ -107,13 +122,14 @@ describe('Notification IPC Handlers', () => { // Reset mocks mocks.mockNotificationIsSupported.mockReturnValue(true); mocks.mockNotificationShow.mockClear(); + mocks.mockNotificationOn.mockClear(); // Capture registered handlers vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Function) => { handlers.set(channel, handler); }); - registerNotificationsHandlers(); + registerNotificationsHandlers({ getMainWindow: mockGetMainWindow }); }); afterEach(() => { @@ -186,6 +202,50 @@ describe('Notification IPC Handlers', () => { }); }); + describe('notification:show click-to-navigate', () => { + it('should register click handler when sessionId is provided', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body', 'session-123'); + + expect(mocks.mockNotificationOn).toHaveBeenCalledWith('click', expect.any(Function)); + }); + + it('should register click handler when sessionId and tabId are provided', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body', 'session-123', 'tab-456'); + + expect(mocks.mockNotificationOn).toHaveBeenCalledWith('click', expect.any(Function)); + }); + + it('should URI-encode sessionId and tabId in deep link URL', async () => { + const { parseDeepLink } = await import('../../../../main/deep-links'); + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body', 'id/with/slashes', 'tab?special'); + + // Trigger the click handler + const clickHandler = mocks.mockNotificationOn.mock.calls[0][1]; + clickHandler(); + + expect(parseDeepLink).toHaveBeenCalledWith( + `maestro://session/${encodeURIComponent('id/with/slashes')}/tab/${encodeURIComponent('tab?special')}` + ); + }); + + it('should not register click handler when sessionId is not provided', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body'); + + expect(mocks.mockNotificationOn).not.toHaveBeenCalled(); + }); + + it('should not register click handler when sessionId is undefined', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body', undefined, undefined); + + expect(mocks.mockNotificationOn).not.toHaveBeenCalled(); + }); + }); + describe('notification:stopSpeak', () => { it('should return error when no active notification process', async () => { const handler = handlers.get('notification:stopSpeak')!; diff --git a/src/__tests__/main/ipc/handlers/process.test.ts b/src/__tests__/main/ipc/handlers/process.test.ts index 29b01fefc7..9d8499bdc0 100644 --- a/src/__tests__/main/ipc/handlers/process.test.ts +++ b/src/__tests__/main/ipc/handlers/process.test.ts @@ -200,6 +200,7 @@ describe('process IPC handlers', () => { resize: ReturnType; getAll: ReturnType; runCommand: ReturnType; + spawnTerminalTab: ReturnType; }; let mockAgentDetector: { getAgent: ReturnType; @@ -227,6 +228,7 @@ describe('process IPC handlers', () => { resize: vi.fn(), getAll: vi.fn(), runCommand: vi.fn(), + spawnTerminalTab: vi.fn(), }; // Create mock agent detector @@ -287,6 +289,7 @@ describe('process IPC handlers', () => { 'process:kill', 'process:resize', 'process:getActiveProcesses', + 'process:spawnTerminalTab', 'process:runCommand', ]; @@ -976,7 +979,181 @@ describe('process IPC handlers', () => { }); }); - describe('SSH remote execution (session-level only)', () => { + describe('process:spawnTerminalTab', () => { + const mockSshRemoteForTerminal = { + id: 'remote-1', + name: 'Dev Server', + host: 'dev.example.com', + port: 22, + username: 'devuser', + privateKeyPath: '~/.ssh/id_ed25519', + enabled: true, + }; + + it('should spawn local terminal when no SSH config is provided', async () => { + mockProcessManager.spawnTerminalTab.mockReturnValue({ pid: 5000, success: true }); + + const handler = handlers.get('process:spawnTerminalTab'); + const result = await handler!({} as any, { + sessionId: 'session-1-terminal-tab-1', + cwd: '/local/project', + }); + + expect(mockProcessManager.spawnTerminalTab).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-1-terminal-tab-1', + cwd: '/local/project', + }) + ); + expect(mockProcessManager.spawn).not.toHaveBeenCalled(); + expect(result).toEqual({ pid: 5000, success: true }); + }); + + it('should spawn SSH session when sessionSshRemoteConfig is enabled', async () => { + mockSettingsStore.get.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'sshRemotes') return [mockSshRemoteForTerminal]; + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 5001, success: true }); + + const handler = handlers.get('process:spawnTerminalTab'); + const result = await handler!({} as any, { + sessionId: 'session-1-terminal-tab-1', + cwd: '/local/project', + sessionSshRemoteConfig: { + enabled: true, + remoteId: 'remote-1', + }, + }); + + expect(mockProcessManager.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'ssh', + args: expect.arrayContaining(['devuser@dev.example.com']), + toolType: 'terminal', + }) + ); + expect(mockProcessManager.spawnTerminalTab).not.toHaveBeenCalled(); + expect(result).toEqual({ pid: 5001, success: true }); + }); + + it('should add -t flag and remote cd command when workingDirOverride is set', async () => { + mockSettingsStore.get.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'sshRemotes') return [mockSshRemoteForTerminal]; + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 5002, success: true }); + + const handler = handlers.get('process:spawnTerminalTab'); + await handler!({} as any, { + sessionId: 'session-1-terminal-tab-1', + cwd: '/local/project', + sessionSshRemoteConfig: { + enabled: true, + remoteId: 'remote-1', + workingDirOverride: '/remote/project', + }, + }); + + const spawnCall = mockProcessManager.spawn.mock.calls[0][0]; + expect(spawnCall.command).toBe('ssh'); + // -t must appear before the host in the args + const tIndex = spawnCall.args.indexOf('-t'); + const hostIndex = spawnCall.args.indexOf('devuser@dev.example.com'); + expect(tIndex).toBeGreaterThanOrEqual(0); + expect(tIndex).toBeLessThan(hostIndex); + // Remote command to cd and exec shell must be the last arg + const lastArg = spawnCall.args[spawnCall.args.length - 1]; + expect(lastArg).toContain('/remote/project'); + expect(lastArg).toContain('exec $SHELL'); + }); + + it('should include port flag for non-default SSH port', async () => { + const remoteWithPort = { ...mockSshRemoteForTerminal, port: 2222 }; + mockSettingsStore.get.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'sshRemotes') return [remoteWithPort]; + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 5003, success: true }); + + const handler = handlers.get('process:spawnTerminalTab'); + await handler!({} as any, { + sessionId: 'session-1-terminal-tab-1', + cwd: '/local/project', + sessionSshRemoteConfig: { enabled: true, remoteId: 'remote-1' }, + }); + + const spawnCall = mockProcessManager.spawn.mock.calls[0][0]; + const portIndex = spawnCall.args.indexOf('-p'); + expect(portIndex).toBeGreaterThanOrEqual(0); + expect(spawnCall.args[portIndex + 1]).toBe('2222'); + }); + + it('should include identity file flag when privateKeyPath is set', async () => { + mockSettingsStore.get.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'sshRemotes') return [mockSshRemoteForTerminal]; + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 5004, success: true }); + + const handler = handlers.get('process:spawnTerminalTab'); + await handler!({} as any, { + sessionId: 'session-1-terminal-tab-1', + cwd: '/local/project', + sessionSshRemoteConfig: { enabled: true, remoteId: 'remote-1' }, + }); + + const spawnCall = mockProcessManager.spawn.mock.calls[0][0]; + const keyIndex = spawnCall.args.indexOf('-i'); + expect(keyIndex).toBeGreaterThanOrEqual(0); + expect(spawnCall.args[keyIndex + 1]).toBe('~/.ssh/id_ed25519'); + }); + + it('should return failure when SSH is enabled but remote config not found', async () => { + mockSettingsStore.get.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'sshRemotes') return []; // No remotes configured + return defaultValue; + }); + + const handler = handlers.get('process:spawnTerminalTab'); + const result = await handler!({} as any, { + sessionId: 'session-1-terminal-tab-1', + cwd: '/local/project', + sessionSshRemoteConfig: { + enabled: true, + remoteId: 'nonexistent-remote', + }, + }); + + // Must NOT silently fall through to local spawn + expect(mockProcessManager.spawnTerminalTab).not.toHaveBeenCalled(); + expect(mockProcessManager.spawn).not.toHaveBeenCalled(); + expect(result).toEqual({ success: false, pid: 0 }); + }); + + it('should spawn local terminal when SSH config is present but disabled', async () => { + mockSettingsStore.get.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'sshRemotes') return [mockSshRemoteForTerminal]; + return defaultValue; + }); + mockProcessManager.spawnTerminalTab.mockReturnValue({ pid: 5005, success: true }); + + const handler = handlers.get('process:spawnTerminalTab'); + await handler!({} as any, { + sessionId: 'session-1-terminal-tab-1', + cwd: '/local/project', + sessionSshRemoteConfig: { + enabled: false, // Explicitly disabled + remoteId: 'remote-1', + }, + }); + + expect(mockProcessManager.spawnTerminalTab).toHaveBeenCalled(); + expect(mockProcessManager.spawn).not.toHaveBeenCalled(); + }); + }); + + describe('SSH remote execution (session-level only)', () => { // SSH is SESSION-LEVEL ONLY - no agent-level or global defaults const mockSshRemote = { id: 'remote-1', diff --git a/src/__tests__/main/ipc/handlers/symphony.test.ts b/src/__tests__/main/ipc/handlers/symphony.test.ts index 623b84a78b..cd328fff9c 100644 --- a/src/__tests__/main/ipc/handlers/symphony.test.ts +++ b/src/__tests__/main/ipc/handlers/symphony.test.ts @@ -95,11 +95,18 @@ describe('Symphony IPC handlers', () => { set: vi.fn(), }; + // Setup mock settings store + const mockSettingsStore = { + get: vi.fn().mockReturnValue([]), + set: vi.fn(), + }; + // Setup dependencies mockDeps = { app: mockApp, getMainWindow: () => mockMainWindow, sessionsStore: mockSessionsStore as any, + settingsStore: mockSettingsStore as any, }; // Default mock for fs operations @@ -1066,7 +1073,9 @@ describe('Symphony IPC handlers', () => { const result = await handler!({} as any, false); expect(result.fromCache).toBe(false); - expect(result.registry).toEqual(freshRegistry); + expect(result.registry).toEqual( + expect.objectContaining({ repositories: freshRegistry.repositories }) + ); }); it('should fetch fresh data when forceRefresh is true', async () => { @@ -1089,7 +1098,9 @@ describe('Symphony IPC handlers', () => { const result = await handler!({} as any, true); // forceRefresh = true expect(result.fromCache).toBe(false); - expect(result.registry).toEqual(freshRegistry); + expect(result.registry).toEqual( + expect.objectContaining({ repositories: freshRegistry.repositories }) + ); }); it('should update cache after fresh fetch', async () => { @@ -1107,7 +1118,9 @@ describe('Symphony IPC handlers', () => { expect(fs.writeFile).toHaveBeenCalled(); const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; const writtenData = JSON.parse(writeCall[1] as string); - expect(writtenData.registry.data).toEqual(freshRegistry); + expect(writtenData.registry.data).toEqual( + expect.objectContaining({ repositories: freshRegistry.repositories }) + ); }); it('should handle network errors gracefully', async () => { @@ -1120,7 +1133,7 @@ describe('Symphony IPC handlers', () => { // The IPC handler wrapper catches errors and returns success: false expect(result.success).toBe(false); - expect(result.error).toContain('Network error'); + expect(result.error).toContain('Failed to fetch registry'); }); }); diff --git a/src/__tests__/main/ipc/handlers/system.test.ts b/src/__tests__/main/ipc/handlers/system.test.ts index a826e6f5dd..a990398816 100644 --- a/src/__tests__/main/ipc/handlers/system.test.ts +++ b/src/__tests__/main/ipc/handlers/system.test.ts @@ -34,6 +34,7 @@ vi.mock('electron', () => ({ openExternal: vi.fn(), openPath: vi.fn(), showItemInFolder: vi.fn(), + trashItem: vi.fn(), }, BrowserWindow: { getFocusedWindow: vi.fn(), @@ -612,6 +613,46 @@ describe('system IPC handlers', () => { }); }); + describe('shell:trashItem', () => { + it('should trash item successfully', async () => { + vi.mocked(fsSync.existsSync).mockReturnValue(true); + vi.mocked(shell.trashItem).mockResolvedValue(); + + const handler = handlers.get('shell:trashItem'); + await handler!({} as any, '/path/to/file.txt'); + + expect(shell.trashItem).toHaveBeenCalledWith('/path/to/file.txt'); + }); + + it('should throw error for empty path', async () => { + const handler = handlers.get('shell:trashItem'); + await expect(handler!({} as any, '')).rejects.toThrow('Invalid path'); + }); + + it('should throw error for non-existent path', async () => { + vi.mocked(fsSync.existsSync).mockReturnValue(false); + const handler = handlers.get('shell:trashItem'); + await expect(handler!({} as any, '/non/existent/path')).rejects.toThrow('Path does not exist'); + }); + + it('should handle aborted operation gracefully', async () => { + vi.mocked(fsSync.existsSync).mockReturnValue(true); + vi.mocked(shell.trashItem).mockRejectedValue(new Error('Operation was aborted')); + + const handler = handlers.get('shell:trashItem'); + // Should not throw — aborted operations are expected + await expect(handler!({} as any, '/path/to/file.txt')).resolves.toBeUndefined(); + }); + + it('should rethrow unexpected errors', async () => { + vi.mocked(fsSync.existsSync).mockReturnValue(true); + vi.mocked(shell.trashItem).mockRejectedValue(new Error('Permission denied')); + + const handler = handlers.get('shell:trashItem'); + await expect(handler!({} as any, '/path/to/file.txt')).rejects.toThrow('Permission denied'); + }); + }); + describe('shell:openPath', () => { it('should open file in default application', async () => { vi.mocked(fsSync.existsSync).mockReturnValue(true); @@ -629,25 +670,21 @@ describe('system IPC handlers', () => { await expect(handler!({} as any, '')).rejects.toThrow('Invalid path'); }); - it('should throw error for non-existent path', async () => { + it('should return gracefully for non-existent path', async () => { vi.mocked(fsSync.existsSync).mockReturnValue(false); const handler = handlers.get('shell:openPath'); - - await expect(handler!({} as any, '/non/existent/path')).rejects.toThrow( - 'Path does not exist' - ); + // Should not throw — logs warning and returns gracefully + await expect(handler!({} as any, '/non/existent/path')).resolves.toBeUndefined(); }); - it('should throw error when shell.openPath returns error message', async () => { + it('should log warning when shell.openPath returns error message', async () => { vi.mocked(fsSync.existsSync).mockReturnValue(true); vi.mocked(shell.openPath).mockResolvedValue('No application found'); const handler = handlers.get('shell:openPath'); - - await expect(handler!({} as any, '/path/to/file.xyz')).rejects.toThrow( - 'No application found' - ); + // Should not throw — logs warning instead + await expect(handler!({} as any, '/path/to/file.xyz')).resolves.toBeUndefined(); }); }); diff --git a/src/__tests__/main/parsers/error-patterns.test.ts b/src/__tests__/main/parsers/error-patterns.test.ts index c822992573..ee9ac7c14c 100644 --- a/src/__tests__/main/parsers/error-patterns.test.ts +++ b/src/__tests__/main/parsers/error-patterns.test.ts @@ -16,6 +16,7 @@ import { CLAUDE_ERROR_PATTERNS, OPENCODE_ERROR_PATTERNS, CODEX_ERROR_PATTERNS, + GEMINI_ERROR_PATTERNS, SSH_ERROR_PATTERNS, type AgentErrorPatterns, } from '../../../main/parsers/error-patterns'; @@ -173,6 +174,105 @@ describe('error-patterns', () => { }); }); + describe('GEMINI_ERROR_PATTERNS', () => { + it('should define auth_expired patterns', () => { + expect(GEMINI_ERROR_PATTERNS).toBeDefined(); + expect(GEMINI_ERROR_PATTERNS.auth_expired).toBeDefined(); + expect(GEMINI_ERROR_PATTERNS.auth_expired?.length).toBeGreaterThan(0); + }); + + it('should match credentials expired text', () => { + const result = matchErrorPattern(GEMINI_ERROR_PATTERNS, 'credentials expired'); + expect(result).not.toBeNull(); + expect(result?.type).toBe('auth_expired'); + }); + + it('should match rate limit errors', () => { + const result = matchErrorPattern(GEMINI_ERROR_PATTERNS, 'rate limit exceeded'); + expect(result).not.toBeNull(); + expect(result?.type).toBe('rate_limited'); + }); + + it('should match turn limit exhaustion errors', () => { + const result = matchErrorPattern(GEMINI_ERROR_PATTERNS, 'FatalTurnLimitedError'); + expect(result).not.toBeNull(); + expect(result?.type).toBe('token_exhaustion'); + }); + + it('should match fatal input errors as crashes', () => { + const result = matchErrorPattern(GEMINI_ERROR_PATTERNS, 'FatalInputError'); + expect(result).not.toBeNull(); + expect(result?.type).toBe('agent_crashed'); + }); + + it('should match capacity unavailable with model name', () => { + const result = matchErrorPattern( + GEMINI_ERROR_PATTERNS, + 'No capacity available for model gemini-3-flash-preview on the server' + ); + expect(result).not.toBeNull(); + expect(result?.type).toBe('rate_limited'); + expect(result?.message).toContain('gemini-3-flash-preview'); + expect(result?.message).toContain('different model'); + expect(result?.recoverable).toBe(true); + }); + + it('should match max attempts reached with model name', () => { + const result = matchErrorPattern( + GEMINI_ERROR_PATTERNS, + 'Max attempts reached for model gemini-3-flash-preview' + ); + expect(result).not.toBeNull(); + expect(result?.type).toBe('rate_limited'); + expect(result?.message).toContain('gemini-3-flash-preview'); + expect(result?.message).toContain('retry limit'); + }); + + it('should match max attempts reached without model name', () => { + const result = matchErrorPattern( + GEMINI_ERROR_PATTERNS, + 'Max attempts reached' + ); + expect(result).not.toBeNull(); + expect(result?.type).toBe('rate_limited'); + expect(result?.message).toContain('retry limit'); + expect(result?.message).toContain('different model'); + }); + + it('should match RetryableQuotaError with model name', () => { + const result = matchErrorPattern( + GEMINI_ERROR_PATTERNS, + 'RetryableQuotaError: No capacity available for model gemini-3-flash-preview on the server' + ); + expect(result).not.toBeNull(); + expect(result?.type).toBe('rate_limited'); + expect(result?.message).toContain('gemini-3-flash-preview'); + }); + + it('should still match generic rate limit text', () => { + const result = matchErrorPattern(GEMINI_ERROR_PATTERNS, 'rate limit exceeded'); + expect(result).not.toBeNull(); + expect(result?.type).toBe('rate_limited'); + }); + + it('should still match generic 429 errors', () => { + const result = matchErrorPattern(GEMINI_ERROR_PATTERNS, 'HTTP 429 Too Many Requests'); + expect(result).not.toBeNull(); + expect(result?.type).toBe('rate_limited'); + }); + + it('should match streamGenerateContent API error with model path', () => { + const result = matchErrorPattern( + GEMINI_ERROR_PATTERNS, + 'streamGenerateContent failed for models/gemini-2.5-pro error 500' + ); + expect(result).not.toBeNull(); + expect(result?.type).toBe('agent_crashed'); + expect(result?.message).toContain('gemini-2.5-pro'); + expect(result?.recoverable).toBe(true); + }); + }); + describe('getErrorPatterns', () => { it('should return claude-code patterns', () => { const patterns = getErrorPatterns('claude-code'); diff --git a/src/__tests__/main/parsers/gemini-output-parser.test.ts b/src/__tests__/main/parsers/gemini-output-parser.test.ts new file mode 100644 index 0000000000..7479f20d89 --- /dev/null +++ b/src/__tests__/main/parsers/gemini-output-parser.test.ts @@ -0,0 +1,575 @@ +import { describe, it, expect } from 'vitest'; +import { GeminiOutputParser } from '../../../main/parsers/gemini-output-parser'; + +describe('GeminiOutputParser', () => { + const parser = new GeminiOutputParser(); + + describe('agentId', () => { + it('should be gemini-cli', () => { + expect(parser.agentId).toBe('gemini-cli'); + }); + }); + + describe('parseJsonLine', () => { + it('should return null for empty lines', () => { + expect(parser.parseJsonLine('')).toBeNull(); + expect(parser.parseJsonLine(' ')).toBeNull(); + expect(parser.parseJsonLine('\n')).toBeNull(); + }); + + it('should return null for non-JSON lines', () => { + expect(parser.parseJsonLine('not json')).toBeNull(); + expect(parser.parseJsonLine('Loading...')).toBeNull(); + }); + + it('should return null for lines not starting with {', () => { + expect(parser.parseJsonLine('[1,2,3]')).toBeNull(); + expect(parser.parseJsonLine('[]')).toBeNull(); + expect(parser.parseJsonLine('"hello"')).toBeNull(); + }); + + it('should return null for JSON without type field', () => { + expect(parser.parseJsonLine('{"data":"test"}')).toBeNull(); + }); + + describe('init events', () => { + it('should parse init event with session_id and model', () => { + const line = JSON.stringify({ + type: 'init', + timestamp: '2025-01-15T10:30:00Z', + session_id: 'abc-123', + model: 'gemini-2.5-flash', + }); + + const event = parser.parseJsonLine(line); + expect(event).not.toBeNull(); + expect(event?.type).toBe('init'); + expect(event?.sessionId).toBe('abc-123'); + expect(event?.text).toBe('Gemini CLI session started (model: gemini-2.5-flash)'); + }); + + it('should handle init without model', () => { + const line = JSON.stringify({ + type: 'init', + session_id: 'abc123', + }); + + const event = parser.parseJsonLine(line); + expect(event?.text).toBe('Gemini CLI session started (model: unknown)'); + }); + }); + + describe('message events', () => { + it('should parse assistant message with delta', () => { + const line = JSON.stringify({ + type: 'message', + role: 'assistant', + content: 'Hello world', + delta: true, + }); + + const event = parser.parseJsonLine(line); + expect(event).not.toBeNull(); + expect(event?.type).toBe('text'); + expect(event?.text).toBe('Hello world'); + expect(event?.isPartial).toBe(true); + }); + + it('should parse assistant message without delta', () => { + const line = JSON.stringify({ + type: 'message', + role: 'assistant', + content: 'Done!', + }); + + const event = parser.parseJsonLine(line); + expect(event?.type).toBe('text'); + expect(event?.isPartial).toBe(false); + }); + + it('should skip user messages', () => { + const line = JSON.stringify({ + type: 'message', + role: 'user', + content: 'Do something', + }); + + expect(parser.parseJsonLine(line)).toBeNull(); + }); + }); + + describe('tool_use events', () => { + it('should parse tool_use event', () => { + const line = JSON.stringify({ + type: 'tool_use', + tool_name: 'read_file', + tool_id: 'tool_123', + parameters: { path: 'README.md' }, + }); + + const event = parser.parseJsonLine(line); + expect(event).not.toBeNull(); + expect(event?.type).toBe('tool_use'); + expect(event?.toolName).toBe('read_file'); + expect(event?.toolState).toEqual({ + id: 'tool_123', + name: 'read_file', + input: { path: 'README.md' }, + status: 'running', + }); + }); + }); + + describe('tool_result events', () => { + it('should parse successful tool_result', () => { + const line = JSON.stringify({ + type: 'tool_result', + tool_id: 'tool_123', + status: 'success', + output: 'file contents', + }); + + const event = parser.parseJsonLine(line); + expect(event).not.toBeNull(); + expect(event?.type).toBe('tool_use'); + expect(event?.toolState).toEqual({ + id: 'tool_123', + status: 'success', + output: 'file contents', + error: undefined, + }); + expect(event?.text).toBeUndefined(); + }); + + it('should parse error tool_result with error text', () => { + const line = JSON.stringify({ + type: 'tool_result', + tool_id: 'tool_456', + status: 'error', + error: { type: 'io_error', message: 'Failed to open file' }, + }); + + const event = parser.parseJsonLine(line); + expect(event?.type).toBe('tool_use'); + expect(event?.text).toBe('Tool error: Failed to open file'); + expect(event?.toolState).toEqual({ + id: 'tool_456', + status: 'error', + output: undefined, + error: { type: 'io_error', message: 'Failed to open file' }, + }); + }); + + it('should handle error tool_result without error message', () => { + const line = JSON.stringify({ + type: 'tool_result', + tool_id: 'tool_789', + status: 'error', + }); + + const event = parser.parseJsonLine(line); + expect(event?.text).toBe('Tool error: Unknown tool error'); + }); + }); + + describe('error events (mid-stream)', () => { + it('should parse warning error event', () => { + const line = JSON.stringify({ + type: 'error', + severity: 'warning', + message: 'Loop detected, stopping execution', + }); + + const event = parser.parseJsonLine(line); + expect(event).not.toBeNull(); + expect(event?.type).toBe('error'); + expect(event?.text).toBe('Loop detected, stopping execution'); + }); + + it('should parse error severity event', () => { + const line = JSON.stringify({ + type: 'error', + severity: 'error', + message: 'Maximum session turns exceeded', + }); + + const event = parser.parseJsonLine(line); + expect(event?.type).toBe('error'); + expect(event?.text).toBe('Maximum session turns exceeded'); + }); + }); + + describe('result events', () => { + it('should parse successful result with flat stats', () => { + const line = JSON.stringify({ + type: 'result', + status: 'success', + stats: { + input_tokens: 500, + output_tokens: 1000, + cached: 50, + duration_ms: 3200, + tool_calls: 1, + }, + }); + + const event = parser.parseJsonLine(line); + expect(event).not.toBeNull(); + expect(event?.type).toBe('result'); + expect(event?.text).toBe(''); + expect(event?.usage).toEqual({ + inputTokens: 500, + outputTokens: 1000, + cacheReadTokens: 50, + reasoningTokens: 0, + }); + }); + + it('should parse successful result with nested model stats', () => { + const line = JSON.stringify({ + type: 'result', + status: 'success', + stats: { + models: { + 'gemini-2.5-flash': { + tokens: { + input: 200, + prompt: 300, + candidates: 800, + total: 800, + cached: 25, + thoughts: 50, + }, + }, + }, + }, + }); + + const event = parser.parseJsonLine(line); + expect(event?.type).toBe('result'); + expect(event?.usage).toEqual({ + inputTokens: 500, // input + prompt + outputTokens: 800, // candidates + cacheReadTokens: 25, + reasoningTokens: 50, + }); + }); + + it('should parse successful result without stats', () => { + const line = JSON.stringify({ + type: 'result', + status: 'success', + }); + + const event = parser.parseJsonLine(line); + expect(event?.type).toBe('result'); + expect(event?.usage).toBeUndefined(); + }); + + it('should parse error result', () => { + const line = JSON.stringify({ + type: 'result', + status: 'error', + error: { type: 'auth_error', message: 'Token expired' }, + }); + + const event = parser.parseJsonLine(line); + expect(event?.type).toBe('error'); + expect(event?.text).toBe('Token expired'); + }); + + it('should parse error result without error message', () => { + const line = JSON.stringify({ + type: 'result', + status: 'error', + }); + + const event = parser.parseJsonLine(line); + expect(event?.type).toBe('error'); + expect(event?.text).toBe('Gemini CLI error'); + }); + }); + + describe('unknown events', () => { + it('should return null for unknown event types', () => { + const line = JSON.stringify({ + type: 'unknown_type', + data: 'something', + }); + + expect(parser.parseJsonLine(line)).toBeNull(); + }); + }); + + it('should preserve raw event data', () => { + const original = { + type: 'init', + session_id: 'test-123', + model: 'gemini-2.5-pro', + }; + const event = parser.parseJsonLine(JSON.stringify(original)); + expect(event?.raw).toEqual(original); + }); + }); + + describe('isResultMessage', () => { + it('should return true for result events', () => { + const event = parser.parseJsonLine( + JSON.stringify({ type: 'result', status: 'success' }) + ); + expect(parser.isResultMessage(event!)).toBe(true); + }); + + it('should return true for error result events', () => { + const event = parser.parseJsonLine( + JSON.stringify({ type: 'result', status: 'error', error: { message: 'fail' } }) + ); + // Error results are emitted as type: 'error', but raw.type is 'result' + expect(parser.isResultMessage(event!)).toBe(true); + }); + + it('should return false for mid-stream error events', () => { + const event = parser.parseJsonLine( + JSON.stringify({ type: 'error', severity: 'warning', message: 'Loop detected' }) + ); + expect(parser.isResultMessage(event!)).toBe(false); + }); + + it('should return false for non-result events', () => { + const initEvent = parser.parseJsonLine( + JSON.stringify({ type: 'init', session_id: 'test' }) + ); + expect(parser.isResultMessage(initEvent!)).toBe(false); + + const textEvent = parser.parseJsonLine( + JSON.stringify({ type: 'message', role: 'assistant', content: 'hi' }) + ); + expect(parser.isResultMessage(textEvent!)).toBe(false); + }); + }); + + describe('extractSessionId', () => { + it('should extract session ID from init event', () => { + const event = parser.parseJsonLine( + JSON.stringify({ type: 'init', session_id: 'gem-abc' }) + ); + expect(parser.extractSessionId(event!)).toBe('gem-abc'); + }); + + it('should extract session ID from sessionId field', () => { + const event = { type: 'init' as const, sessionId: 'custom-id' }; + expect(parser.extractSessionId(event)).toBe('custom-id'); + }); + + it('should return null when no session ID', () => { + const event = parser.parseJsonLine( + JSON.stringify({ type: 'message', role: 'assistant', content: 'hi' }) + ); + expect(parser.extractSessionId(event!)).toBeNull(); + }); + }); + + describe('extractUsage', () => { + it('should extract usage from result event', () => { + const event = parser.parseJsonLine( + JSON.stringify({ + type: 'result', + status: 'success', + stats: { input_tokens: 100, output_tokens: 200 }, + }) + ); + + const usage = parser.extractUsage(event!); + expect(usage).not.toBeNull(); + expect(usage?.inputTokens).toBe(100); + expect(usage?.outputTokens).toBe(200); + }); + + it('should return null for events without usage', () => { + const event = parser.parseJsonLine( + JSON.stringify({ type: 'init', session_id: 'test' }) + ); + expect(parser.extractUsage(event!)).toBeNull(); + }); + }); + + describe('extractSlashCommands', () => { + it('should return null - Gemini CLI does not expose slash commands', () => { + const event = parser.parseJsonLine( + JSON.stringify({ type: 'init', session_id: 'test' }) + ); + expect(parser.extractSlashCommands(event!)).toBeNull(); + }); + }); + + describe('detectErrorFromLine', () => { + it('should return null for empty lines', () => { + expect(parser.detectErrorFromLine('')).toBeNull(); + expect(parser.detectErrorFromLine(' ')).toBeNull(); + }); + + it('should detect errors from JSON error events', () => { + const line = JSON.stringify({ + type: 'error', + severity: 'error', + message: 'Maximum session turns exceeded', + }); + const error = parser.detectErrorFromLine(line); + expect(error).not.toBeNull(); + expect(error?.type).toBe('token_exhaustion'); + expect(error?.agentId).toBe('gemini-cli'); + }); + + it('should detect errors from result error events', () => { + const line = JSON.stringify({ + type: 'result', + status: 'error', + error: { type: 'auth', message: 'credentials expired please login' }, + }); + const error = parser.detectErrorFromLine(line); + expect(error).not.toBeNull(); + expect(error?.type).toBe('auth_expired'); + }); + + it('should NOT detect errors from plain text', () => { + expect(parser.detectErrorFromLine('credentials expired')).toBeNull(); + expect(parser.detectErrorFromLine('rate limit')).toBeNull(); + }); + + it('should return null for non-error JSON', () => { + const line = JSON.stringify({ + type: 'message', + role: 'assistant', + content: 'Hello', + }); + expect(parser.detectErrorFromLine(line)).toBeNull(); + }); + }); + + describe('detectErrorFromExit', () => { + it('should return null for exit code 0', () => { + expect(parser.detectErrorFromExit(0, '', '')).toBeNull(); + }); + + it('should map exit code 41 to auth_expired', () => { + const error = parser.detectErrorFromExit(41, '', ''); + expect(error).not.toBeNull(); + expect(error?.type).toBe('auth_expired'); + expect(error?.message).toContain('gemini login'); + expect(error?.recoverable).toBe(true); + }); + + it('should map exit code 42 to unknown (input error)', () => { + const error = parser.detectErrorFromExit(42, '', ''); + expect(error).not.toBeNull(); + expect(error?.type).toBe('unknown'); + expect(error?.message).toContain('Invalid input'); + expect(error?.recoverable).toBe(false); + }); + + it('should map exit code 52 to unknown (config error)', () => { + const error = parser.detectErrorFromExit(52, '', ''); + expect(error).not.toBeNull(); + expect(error?.type).toBe('unknown'); + expect(error?.message).toContain('configuration error'); + }); + + it('should map exit code 53 to token_exhaustion', () => { + const error = parser.detectErrorFromExit(53, '', ''); + expect(error).not.toBeNull(); + expect(error?.type).toBe('token_exhaustion'); + expect(error?.message).toContain('turn limit'); + expect(error?.recoverable).toBe(false); + }); + + it('should map exit code 130 to unknown (user cancelled)', () => { + const error = parser.detectErrorFromExit(130, '', ''); + expect(error).not.toBeNull(); + expect(error?.type).toBe('unknown'); + expect(error?.message).toContain('cancelled'); + expect(error?.recoverable).toBe(true); + }); + + it('should detect errors from stderr before using exit code mapping', () => { + const error = parser.detectErrorFromExit(1, 'rate limit exceeded', ''); + expect(error).not.toBeNull(); + expect(error?.type).toBe('rate_limited'); + }); + + it('should return agent_crashed for unknown non-zero exit', () => { + const error = parser.detectErrorFromExit(137, '', ''); + expect(error).not.toBeNull(); + expect(error?.type).toBe('agent_crashed'); + expect(error?.message).toContain('137'); + expect(error?.recoverable).toBe(false); + }); + + it('should include raw exit info', () => { + const error = parser.detectErrorFromExit(42, 'some error', ''); + expect(error?.raw).toEqual({ exitCode: 42, stderr: 'some error' }); + }); + }); + + describe('usage extraction edge cases', () => { + it('should handle nested stats with multiple models', () => { + const line = JSON.stringify({ + type: 'result', + status: 'success', + stats: { + models: { + 'gemini-2.5-flash': { + tokens: { input: 100, prompt: 50, candidates: 200, cached: 10, thoughts: 5 }, + }, + 'gemini-2.5-pro': { + tokens: { input: 200, prompt: 100, candidates: 300, cached: 20, thoughts: 10 }, + }, + }, + }, + }); + + const event = parser.parseJsonLine(line); + expect(event?.usage).toEqual({ + inputTokens: 450, // (100+50) + (200+100) + outputTokens: 500, // 200 + 300 + cacheReadTokens: 30, // 10 + 20 + reasoningTokens: 15, // 5 + 10 + }); + }); + + it('should prefer flat stats over nested', () => { + const line = JSON.stringify({ + type: 'result', + status: 'success', + stats: { + input_tokens: 999, + output_tokens: 888, + models: { + 'gemini-2.5-flash': { + tokens: { input: 1, candidates: 2 }, + }, + }, + }, + }); + + const event = parser.parseJsonLine(line); + // Flat fields take priority + expect(event?.usage?.inputTokens).toBe(999); + expect(event?.usage?.outputTokens).toBe(888); + }); + + it('should handle stats with thoughts_tokens in flat format', () => { + const line = JSON.stringify({ + type: 'result', + status: 'success', + stats: { + input_tokens: 100, + output_tokens: 200, + thoughts_tokens: 50, + }, + }); + + const event = parser.parseJsonLine(line); + expect(event?.usage?.reasoningTokens).toBe(50); + }); + }); +}); diff --git a/src/__tests__/main/parsers/index.test.ts b/src/__tests__/main/parsers/index.test.ts index 792f404fea..1f49c94ce0 100644 --- a/src/__tests__/main/parsers/index.test.ts +++ b/src/__tests__/main/parsers/index.test.ts @@ -9,6 +9,7 @@ import { ClaudeOutputParser, OpenCodeOutputParser, CodexOutputParser, + GeminiOutputParser, } from '../../../main/parsers'; describe('parsers/index', () => { @@ -49,21 +50,29 @@ describe('parsers/index', () => { expect(hasOutputParser('factory-droid')).toBe(true); }); - it('should register exactly 4 parsers', () => { + it('should register Gemini CLI parser', () => { + expect(hasOutputParser('gemini-cli')).toBe(false); + + initializeOutputParsers(); + + expect(hasOutputParser('gemini-cli')).toBe(true); + }); + + it('should register exactly 5 parsers', () => { initializeOutputParsers(); const parsers = getAllOutputParsers(); - expect(parsers.length).toBe(4); // Claude, OpenCode, Codex, Factory Droid + expect(parsers.length).toBe(5); // Claude, OpenCode, Codex, Factory Droid, Gemini CLI }); it('should clear existing parsers before registering', () => { // First initialization initializeOutputParsers(); - expect(getAllOutputParsers().length).toBe(4); + expect(getAllOutputParsers().length).toBe(5); - // Second initialization should still have exactly 4 + // Second initialization should still have exactly 5 initializeOutputParsers(); - expect(getAllOutputParsers().length).toBe(4); + expect(getAllOutputParsers().length).toBe(5); }); }); @@ -73,7 +82,7 @@ describe('parsers/index', () => { ensureParsersInitialized(); - expect(getAllOutputParsers().length).toBe(4); + expect(getAllOutputParsers().length).toBe(5); }); it('should be idempotent after first call', () => { @@ -136,6 +145,11 @@ describe('parsers/index', () => { const parser = new CodexOutputParser(); expect(parser.agentId).toBe('codex'); }); + + it('should export GeminiOutputParser class', () => { + const parser = new GeminiOutputParser(); + expect(parser.agentId).toBe('gemini-cli'); + }); }); describe('integration', () => { @@ -177,5 +191,18 @@ describe('parsers/index', () => { expect(event?.type).toBe('init'); expect(event?.sessionId).toBe('cdx-456'); }); + + it('should correctly parse Gemini CLI output after initialization', () => { + initializeOutputParsers(); + + const parser = getOutputParser('gemini-cli'); + const event = parser?.parseJsonLine( + JSON.stringify({ type: 'init', session_id: 'gem-789', model: 'gemini-2.5-flash' }) + ); + + expect(event?.type).toBe('init'); + expect(event?.sessionId).toBe('gem-789'); + expect(event?.text).toContain('gemini-2.5-flash'); + }); }); }); diff --git a/src/__tests__/main/preload/notifications.test.ts b/src/__tests__/main/preload/notifications.test.ts index 093eb33683..4de6284b77 100644 --- a/src/__tests__/main/preload/notifications.test.ts +++ b/src/__tests__/main/preload/notifications.test.ts @@ -36,7 +36,13 @@ describe('Notification Preload API', () => { const result = await api.show('Test Title', 'Test Body'); - expect(mockInvoke).toHaveBeenCalledWith('notification:show', 'Test Title', 'Test Body'); + expect(mockInvoke).toHaveBeenCalledWith( + 'notification:show', + 'Test Title', + 'Test Body', + undefined, + undefined + ); expect(result).toEqual({ success: true }); }); diff --git a/src/__tests__/main/process-listeners/exit-listener.test.ts b/src/__tests__/main/process-listeners/exit-listener.test.ts index 7988edeeba..d45b9f1044 100644 --- a/src/__tests__/main/process-listeners/exit-listener.test.ts +++ b/src/__tests__/main/process-listeners/exit-listener.test.ts @@ -350,6 +350,102 @@ describe('Exit Listener', () => { }); }); + describe('Cue Completion Notification', () => { + it('should notify Cue engine on regular session exit when enabled', () => { + const mockCueEngine = { + hasCompletionSubscribers: vi.fn().mockReturnValue(true), + notifyAgentCompleted: vi.fn(), + }; + mockDeps.getCueEngine = () => mockCueEngine as any; + mockDeps.isCueEnabled = () => true; + + setupListener(); + const handler = eventHandlers.get('exit'); + + handler?.('regular-session-123', 0); + + expect(mockCueEngine.hasCompletionSubscribers).toHaveBeenCalledWith('regular-session-123'); + expect(mockCueEngine.notifyAgentCompleted).toHaveBeenCalledWith('regular-session-123', { + status: 'completed', + exitCode: 0, + }); + }); + + it('should pass failed status when exit code is non-zero', () => { + const mockCueEngine = { + hasCompletionSubscribers: vi.fn().mockReturnValue(true), + notifyAgentCompleted: vi.fn(), + }; + mockDeps.getCueEngine = () => mockCueEngine as any; + mockDeps.isCueEnabled = () => true; + + setupListener(); + const handler = eventHandlers.get('exit'); + + handler?.('regular-session-123', 1); + + expect(mockCueEngine.notifyAgentCompleted).toHaveBeenCalledWith('regular-session-123', { + status: 'failed', + exitCode: 1, + }); + }); + + it('should not notify when Cue feature is disabled', () => { + const mockCueEngine = { + hasCompletionSubscribers: vi.fn().mockReturnValue(true), + notifyAgentCompleted: vi.fn(), + }; + mockDeps.getCueEngine = () => mockCueEngine as any; + mockDeps.isCueEnabled = () => false; + + setupListener(); + const handler = eventHandlers.get('exit'); + + handler?.('regular-session-123', 0); + + expect(mockCueEngine.notifyAgentCompleted).not.toHaveBeenCalled(); + }); + + it('should not notify when no completion subscribers exist', () => { + const mockCueEngine = { + hasCompletionSubscribers: vi.fn().mockReturnValue(false), + notifyAgentCompleted: vi.fn(), + }; + mockDeps.getCueEngine = () => mockCueEngine as any; + mockDeps.isCueEnabled = () => true; + + setupListener(); + const handler = eventHandlers.get('exit'); + + handler?.('regular-session-123', 0); + + expect(mockCueEngine.hasCompletionSubscribers).toHaveBeenCalledWith('regular-session-123'); + expect(mockCueEngine.notifyAgentCompleted).not.toHaveBeenCalled(); + }); + + it('should not notify for group chat sessions', async () => { + const mockCueEngine = { + hasCompletionSubscribers: vi.fn().mockReturnValue(true), + notifyAgentCompleted: vi.fn(), + }; + mockDeps.getCueEngine = () => mockCueEngine as any; + mockDeps.isCueEnabled = () => true; + + setupListener(); + const handler = eventHandlers.get('exit'); + + // Moderator session + handler?.('group-chat-test-chat-123-moderator-1234567890', 0); + + await vi.waitFor(() => { + expect(mockDeps.groupChatRouter.routeModeratorResponse).toHaveBeenCalled(); + }); + + // Moderator exits return early before reaching Cue notification + expect(mockCueEngine.notifyAgentCompleted).not.toHaveBeenCalled(); + }); + }); + describe('Error Handling', () => { beforeEach(() => { mockDeps.outputParser.parseParticipantSessionId = vi.fn().mockReturnValue({ diff --git a/src/__tests__/main/process-listeners/gemini-stats-listener.test.ts b/src/__tests__/main/process-listeners/gemini-stats-listener.test.ts new file mode 100644 index 0000000000..f9cd6ad584 --- /dev/null +++ b/src/__tests__/main/process-listeners/gemini-stats-listener.test.ts @@ -0,0 +1,539 @@ +/** + * Tests for gemini-stats-listener. + * Verifies that per-turn Gemini token usage is accumulated in memory, + * debounced to disk, flushed on exit/shutdown, and pruned on startup. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { setupGeminiStatsListener } from '../../../main/process-listeners/gemini-stats-listener'; +import type { ProcessManager } from '../../../main/process-manager'; +import type { GeminiSessionStatsEvent } from '../../../main/process-manager/types'; +import type Store from 'electron-store'; +import type { GeminiSessionStatsData } from '../../../main/stores/types'; + +describe('Gemini Stats Listener', () => { + let mockProcessManager: ProcessManager; + let mockStore: Store; + let eventHandlers: Map void>; + let storeData: GeminiSessionStatsData; + + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + eventHandlers = new Map(); + storeData = { stats: {} }; + + mockProcessManager = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + eventHandlers.set(event, handler); + }), + } as unknown as ProcessManager; + + mockStore = { + get: vi.fn((key: string, defaultValue?: unknown) => { + if (key === 'stats') return storeData.stats; + return defaultValue; + }), + set: vi.fn((key: string, value: unknown) => { + if (key === 'stats') { + storeData.stats = value as GeminiSessionStatsData['stats']; + } + }), + } as unknown as Store; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should register session-id, gemini-session-stats, and exit listeners', () => { + setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + expect(mockProcessManager.on).toHaveBeenCalledWith('session-id', expect.any(Function)); + expect(mockProcessManager.on).toHaveBeenCalledWith( + 'gemini-session-stats', + expect.any(Function) + ); + expect(mockProcessManager.on).toHaveBeenCalledWith('exit', expect.any(Function)); + }); + + it('should not register listeners when store is undefined', () => { + const result = setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, undefined); + + expect(mockProcessManager.on).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('should return a handle with flushAll when store is provided', () => { + const handle = setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + expect(handle).toBeDefined(); + expect(typeof handle!.flushAll).toBe('function'); + }); + + // ---- Accumulation (debounced, NOT immediate) ---- + + it('should NOT write to store immediately on stats event', () => { + setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + const sessionIdHandler = eventHandlers.get('session-id')!; + const statsHandler = eventHandlers.get('gemini-session-stats')!; + + sessionIdHandler('maestro-1', 'gemini-uuid-abc'); + statsHandler('maestro-1', { + sessionId: 'maestro-1', + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 10, + reasoningTokens: 5, + } as GeminiSessionStatsEvent); + + // Store.set should NOT have been called (only .get for pruning on setup) + const setCalls = (mockStore.set as ReturnType).mock.calls.filter( + (c: unknown[]) => c[0] === 'stats' && Object.keys(c[1] as Record).length > 0 + ); + expect(setCalls.length).toBe(0); + }); + + it('should flush accumulated stats to store after debounce interval', () => { + setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + const sessionIdHandler = eventHandlers.get('session-id')!; + const statsHandler = eventHandlers.get('gemini-session-stats')!; + + sessionIdHandler('maestro-1', 'gemini-uuid-abc'); + + statsHandler('maestro-1', { + sessionId: 'maestro-1', + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 10, + reasoningTokens: 5, + } as GeminiSessionStatsEvent); + + statsHandler('maestro-1', { + sessionId: 'maestro-1', + inputTokens: 200, + outputTokens: 80, + cacheReadTokens: 20, + reasoningTokens: 10, + } as GeminiSessionStatsEvent); + + // Advance past debounce interval (5s) + vi.advanceTimersByTime(5_000); + + expect(storeData.stats['gemini-uuid-abc']).toMatchObject({ + inputTokens: 300, + outputTokens: 130, + cacheReadTokens: 30, + reasoningTokens: 15, + }); + expect(storeData.stats['gemini-uuid-abc'].lastUpdatedMs).toBeGreaterThan(0); + }); + + it('should accumulate multiple events into a single store write', () => { + setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + const sessionIdHandler = eventHandlers.get('session-id')!; + const statsHandler = eventHandlers.get('gemini-session-stats')!; + + sessionIdHandler('maestro-1', 'gemini-uuid-abc'); + + // Send 5 rapid stats events + for (let i = 0; i < 5; i++) { + statsHandler('maestro-1', { + sessionId: 'maestro-1', + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 1, + reasoningTokens: 0, + } as GeminiSessionStatsEvent); + } + + // Advance past debounce + vi.advanceTimersByTime(5_000); + + // Should be exactly 1 store.set call for the batch (not counting pruning) + const statSetCalls = (mockStore.set as ReturnType).mock.calls.filter( + (c: unknown[]) => c[0] === 'stats' && Object.keys(c[1] as Record).length > 0 + ); + expect(statSetCalls.length).toBe(1); + + expect(storeData.stats['gemini-uuid-abc']).toMatchObject({ + inputTokens: 50, + outputTokens: 25, + cacheReadTokens: 5, + reasoningTokens: 0, + }); + }); + + it('should accumulate stats for multiple sessions in a single flush', () => { + setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + const sessionIdHandler = eventHandlers.get('session-id')!; + const statsHandler = eventHandlers.get('gemini-session-stats')!; + + sessionIdHandler('maestro-1', 'gemini-uuid-aaa'); + sessionIdHandler('maestro-2', 'gemini-uuid-bbb'); + + statsHandler('maestro-1', { + sessionId: 'maestro-1', + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + reasoningTokens: 0, + } as GeminiSessionStatsEvent); + + statsHandler('maestro-2', { + sessionId: 'maestro-2', + inputTokens: 200, + outputTokens: 100, + cacheReadTokens: 0, + reasoningTokens: 0, + } as GeminiSessionStatsEvent); + + vi.advanceTimersByTime(5_000); + + expect(storeData.stats['gemini-uuid-aaa']).toMatchObject({ + inputTokens: 100, + outputTokens: 50, + }); + expect(storeData.stats['gemini-uuid-bbb']).toMatchObject({ + inputTokens: 200, + outputTokens: 100, + }); + }); + + // ---- Buffering (stats before session-id) ---- + + it('should buffer stats when session-id is not yet known and flush on session-id + debounce', () => { + setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + const sessionIdHandler = eventHandlers.get('session-id')!; + const statsHandler = eventHandlers.get('gemini-session-stats')!; + + // Stats arrive BEFORE session-id + statsHandler('maestro-2', { + sessionId: 'maestro-2', + inputTokens: 150, + outputTokens: 60, + cacheReadTokens: 5, + reasoningTokens: 0, + } as GeminiSessionStatsEvent); + + // Not written yet + vi.advanceTimersByTime(5_000); + expect(storeData.stats['gemini-uuid-def']).toBeUndefined(); + + // Session-id arrives → buffered stats moved to accumulator + sessionIdHandler('maestro-2', 'gemini-uuid-def'); + + // Advance past debounce + vi.advanceTimersByTime(5_000); + + expect(storeData.stats['gemini-uuid-def']).toMatchObject({ + inputTokens: 150, + outputTokens: 60, + cacheReadTokens: 5, + reasoningTokens: 0, + }); + }); + + it('should accumulate multiple buffered turns before session-id arrives', () => { + setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + const sessionIdHandler = eventHandlers.get('session-id')!; + const statsHandler = eventHandlers.get('gemini-session-stats')!; + + statsHandler('maestro-3', { + sessionId: 'maestro-3', + inputTokens: 50, + outputTokens: 25, + cacheReadTokens: 0, + reasoningTokens: 0, + } as GeminiSessionStatsEvent); + + statsHandler('maestro-3', { + sessionId: 'maestro-3', + inputTokens: 75, + outputTokens: 35, + cacheReadTokens: 5, + reasoningTokens: 2, + } as GeminiSessionStatsEvent); + + sessionIdHandler('maestro-3', 'gemini-uuid-ghi'); + vi.advanceTimersByTime(5_000); + + expect(storeData.stats['gemini-uuid-ghi']).toMatchObject({ + inputTokens: 125, + outputTokens: 60, + cacheReadTokens: 5, + reasoningTokens: 2, + }); + }); + + // ---- Flush on exit ---- + + it('should flush accumulated stats to store on process exit', () => { + setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + const sessionIdHandler = eventHandlers.get('session-id')!; + const statsHandler = eventHandlers.get('gemini-session-stats')!; + const exitHandler = eventHandlers.get('exit')!; + + sessionIdHandler('maestro-4', 'gemini-uuid-jkl'); + statsHandler('maestro-4', { + sessionId: 'maestro-4', + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + reasoningTokens: 0, + } as GeminiSessionStatsEvent); + + // Exit triggers immediate flush (no need to wait for debounce) + exitHandler('maestro-4'); + + expect(storeData.stats['gemini-uuid-jkl']).toMatchObject({ + inputTokens: 100, + outputTokens: 50, + }); + }); + + it('should clean up mappings on exit but preserve persisted stats', () => { + setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + const sessionIdHandler = eventHandlers.get('session-id')!; + const statsHandler = eventHandlers.get('gemini-session-stats')!; + const exitHandler = eventHandlers.get('exit')!; + + sessionIdHandler('maestro-4', 'gemini-uuid-jkl'); + statsHandler('maestro-4', { + sessionId: 'maestro-4', + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + reasoningTokens: 0, + } as GeminiSessionStatsEvent); + + exitHandler('maestro-4'); + + // Stats persisted + expect(storeData.stats['gemini-uuid-jkl']).toBeDefined(); + expect(storeData.stats['gemini-uuid-jkl'].inputTokens).toBe(100); + }); + + // ---- Flush via handle (shutdown) ---- + + it('should flush all accumulated stats when flushAll() is called', () => { + const handle = setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + const sessionIdHandler = eventHandlers.get('session-id')!; + const statsHandler = eventHandlers.get('gemini-session-stats')!; + + sessionIdHandler('maestro-5', 'gemini-uuid-mno'); + statsHandler('maestro-5', { + sessionId: 'maestro-5', + inputTokens: 500, + outputTokens: 250, + cacheReadTokens: 50, + reasoningTokens: 25, + } as GeminiSessionStatsEvent); + + // Call flushAll without waiting for timer + handle!.flushAll(); + + expect(storeData.stats['gemini-uuid-mno']).toMatchObject({ + inputTokens: 500, + outputTokens: 250, + cacheReadTokens: 50, + reasoningTokens: 25, + }); + }); + + it('should be safe to call flushAll() when accumulator is empty', () => { + const handle = setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + // Should not throw + expect(() => handle!.flushAll()).not.toThrow(); + }); + + // ---- Merging with existing store data ---- + + it('should merge accumulated stats with existing store entries on flush', () => { + // Pre-populate store with existing stats + storeData.stats['gemini-uuid-existing'] = { + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 100, + reasoningTokens: 50, + lastUpdatedMs: Date.now() - 60_000, + }; + + const handle = setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + const sessionIdHandler = eventHandlers.get('session-id')!; + const statsHandler = eventHandlers.get('gemini-session-stats')!; + + sessionIdHandler('maestro-6', 'gemini-uuid-existing'); + statsHandler('maestro-6', { + sessionId: 'maestro-6', + inputTokens: 200, + outputTokens: 100, + cacheReadTokens: 20, + reasoningTokens: 10, + } as GeminiSessionStatsEvent); + + handle!.flushAll(); + + expect(storeData.stats['gemini-uuid-existing']).toMatchObject({ + inputTokens: 1200, + outputTokens: 600, + cacheReadTokens: 120, + reasoningTokens: 60, + }); + }); + + // ---- Pruning ---- + + it('should prune stats entries older than 90 days on startup', () => { + const now = Date.now(); + const ninetyOneDaysAgo = now - 91 * 24 * 60 * 60 * 1_000; + const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1_000; + + storeData.stats = { + 'old-session': { + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + reasoningTokens: 0, + lastUpdatedMs: ninetyOneDaysAgo, + }, + 'recent-session': { + inputTokens: 200, + outputTokens: 100, + cacheReadTokens: 10, + reasoningTokens: 5, + lastUpdatedMs: thirtyDaysAgo, + }, + }; + + setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + // Old entry pruned, recent entry preserved + expect(storeData.stats['old-session']).toBeUndefined(); + expect(storeData.stats['recent-session']).toBeDefined(); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('Pruned 1 stale'), + 'GeminiStats' + ); + }); + + it('should not write to store if no entries need pruning', () => { + const recentTime = Date.now() - 10 * 24 * 60 * 60 * 1_000; + storeData.stats = { + 'session-a': { + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + reasoningTokens: 0, + lastUpdatedMs: recentTime, + }, + }; + + // Clear set calls from store setup + (mockStore.set as ReturnType).mockClear(); + + setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + // No store.set for pruning (nothing to prune) + expect(mockStore.set).not.toHaveBeenCalled(); + }); + + it('should prune all stale entries when all are old', () => { + const oldTime = Date.now() - 100 * 24 * 60 * 60 * 1_000; + + storeData.stats = { + 'old-1': { + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + reasoningTokens: 0, + lastUpdatedMs: oldTime, + }, + 'old-2': { + inputTokens: 20, + outputTokens: 10, + cacheReadTokens: 0, + reasoningTokens: 0, + lastUpdatedMs: oldTime, + }, + 'old-3': { + inputTokens: 30, + outputTokens: 15, + cacheReadTokens: 0, + reasoningTokens: 0, + lastUpdatedMs: oldTime, + }, + }; + + setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + expect(Object.keys(storeData.stats)).toHaveLength(0); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('Pruned 3 stale'), + 'GeminiStats' + ); + }); + + // ---- Debounce timer behavior ---- + + it('should not trigger multiple flushes for rapid events within debounce window', () => { + setupGeminiStatsListener(mockProcessManager, { logger: mockLogger }, mockStore); + + const sessionIdHandler = eventHandlers.get('session-id')!; + const statsHandler = eventHandlers.get('gemini-session-stats')!; + + sessionIdHandler('maestro-1', 'gemini-uuid-rapid'); + + // 10 rapid events within 1 second + for (let i = 0; i < 10; i++) { + statsHandler('maestro-1', { + sessionId: 'maestro-1', + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + reasoningTokens: 0, + } as GeminiSessionStatsEvent); + vi.advanceTimersByTime(100); + } + + // Total time elapsed: 1000ms (< 5000ms debounce) + // No flush yet — clear the mock to isolate + const setCallsBefore = (mockStore.set as ReturnType).mock.calls.filter( + (c: unknown[]) => c[0] === 'stats' && Object.keys(c[1] as Record).length > 0 + ).length; + + // Advance remaining time to trigger flush + vi.advanceTimersByTime(4_000); + + const setCallsAfter = (mockStore.set as ReturnType).mock.calls.filter( + (c: unknown[]) => c[0] === 'stats' && Object.keys(c[1] as Record).length > 0 + ).length; + + // Exactly 1 new set call for the batch + expect(setCallsAfter - setCallsBefore).toBe(1); + + expect(storeData.stats['gemini-uuid-rapid']).toMatchObject({ + inputTokens: 100, + outputTokens: 50, + }); + }); +}); diff --git a/src/__tests__/main/process-manager/handlers/StderrHandler.test.ts b/src/__tests__/main/process-manager/handlers/StderrHandler.test.ts index b152887609..09c6a1c75b 100644 --- a/src/__tests__/main/process-manager/handlers/StderrHandler.test.ts +++ b/src/__tests__/main/process-manager/handlers/StderrHandler.test.ts @@ -277,4 +277,93 @@ describe('StderrHandler', () => { expect(proc.stderrBuffer).toContain('Error 2'); }); }); + + describe('Gemini CLI informational message filtering', () => { + it('should suppress YOLO mode and extension loading messages', () => { + const { handler, emitter, sessionId } = createTestContext({ + toolType: 'gemini-cli' as any, + }); + + const dataSpy = vi.fn(); + const stderrSpy = vi.fn(); + emitter.on('data', dataSpy); + emitter.on('stderr', stderrSpy); + + handler.handleData( + sessionId, + 'YOLO mode is enabled. All tool calls will be automatically approved.' + ); + + expect(dataSpy).not.toHaveBeenCalled(); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it('should suppress extension and hook lifecycle messages', () => { + const { handler, emitter, sessionId } = createTestContext({ + toolType: 'gemini-cli' as any, + }); + + const dataSpy = vi.fn(); + const stderrSpy = vi.fn(); + emitter.on('data', dataSpy); + emitter.on('stderr', stderrSpy); + + handler.handleData(sessionId, 'Loading extension: gemini-cli-vibes'); + handler.handleData(sessionId, 'Hook execution for SessionStart: success'); + handler.handleData(sessionId, 'Created execution plan for BeforeAgent: 2 hooks'); + + expect(dataSpy).not.toHaveBeenCalled(); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it('should re-emit non-info Gemini content as data (not stderr)', () => { + const { handler, emitter, sessionId } = createTestContext({ + toolType: 'gemini-cli' as any, + }); + + const dataSpy = vi.fn(); + const stderrSpy = vi.fn(); + emitter.on('data', dataSpy); + emitter.on('stderr', stderrSpy); + + handler.handleData(sessionId, 'Here is the response to your question.'); + + // Non-info lines are re-emitted as 'data', not 'stderr' + expect(dataSpy).toHaveBeenCalledWith(sessionId, 'Here is the response to your question.'); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it('should suppress Axios dumps but surface actual API errors', () => { + const { handler, emitter, sessionId } = createTestContext({ + toolType: 'gemini-cli' as any, + }); + + const stderrSpy = vi.fn(); + emitter.on('stderr', stderrSpy); + + // Pure Axios noise — should be fully suppressed + handler.handleData(sessionId, '[Function: serialize] paramsSerializer validateStatus'); + + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it('should emit capacity/quota errors as agent-error events', () => { + const { handler, emitter, sessionId } = createTestContext({ + toolType: 'gemini-cli' as any, + }); + + const agentErrorSpy = vi.fn(); + emitter.on('agent-error', agentErrorSpy); + + handler.handleData(sessionId, 'no capacity available for model gemini-2.5-pro'); + + expect(agentErrorSpy).toHaveBeenCalledWith( + sessionId, + expect.objectContaining({ + type: 'rate_limited', + recoverable: true, + }) + ); + }); + }); }); diff --git a/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts b/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts index 38c50e1e1f..0756e109eb 100644 --- a/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts +++ b/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts @@ -47,7 +47,13 @@ vi.mock('../../../../main/parsers/error-patterns', () => ({ // ── Imports (after mocks) ────────────────────────────────────────────────── -import { StdoutHandler } from '../../../../main/process-manager/handlers/StdoutHandler'; +import { + StdoutHandler, + extractDeniedPath, + MAX_BUFFER_SIZE, + MAX_BATCH_BUFFER_SIZE, +} from '../../../../main/process-manager/handlers/StdoutHandler'; +import { getStreamedText } from '../../../../main/process-manager/types'; import type { ManagedProcess } from '../../../../main/process-manager/types'; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -407,11 +413,30 @@ describe('StdoutHandler', () => { } return { type: 'system' }; }), + parseJsonObject: vi.fn((parsed: any) => { + if (parsed.type === 'agent') { + return { type: 'result', text: parsed.text }; + } + if (parsed.type === 'done') { + return { + type: 'usage', + usage: { + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheCreationTokens: 0, + contextWindow: 400000, + }, + }; + } + return { type: 'system' }; + }), extractUsage: vi.fn((event: any) => event.usage || null), extractSessionId: vi.fn(() => null), extractSlashCommands: vi.fn(() => null), isResultMessage: vi.fn((event: any) => event.type === 'result' && !!event.text), detectErrorFromLine: vi.fn(() => null), + detectErrorFromParsed: vi.fn(() => null), }; const { handler, bufferManager, sessionId, proc } = createTestContext({ @@ -445,6 +470,244 @@ describe('StdoutHandler', () => { }); }); + // ── Gemini text routing ─────────────────────────────────────────────── + + describe('Gemini text routing', () => { + function createGeminiParser() { + return { + agentId: 'gemini-cli', + parseJsonLine: vi.fn((line: string) => { + try { + const parsed = JSON.parse(line); + if (parsed.type === 'message' && parsed.role === 'assistant') { + return { + type: 'text' as const, + text: parsed.content, + isPartial: parsed.delta === true, + }; + } + return null; + } catch { + return null; + } + }), + parseJsonObject: vi.fn((parsed: any) => { + if (parsed.type === 'message' && parsed.role === 'assistant') { + return { + type: 'text' as const, + text: parsed.content, + isPartial: parsed.delta === true, + }; + } + return null; + }), + extractUsage: vi.fn(() => null), + extractSessionId: vi.fn(() => null), + extractSlashCommands: vi.fn(() => null), + isResultMessage: vi.fn(() => false), + detectErrorFromLine: vi.fn(() => null), + detectErrorFromParsed: vi.fn(() => null), + }; + } + + it('should route non-partial Gemini text through data path for immediate display', () => { + const parser = createGeminiParser(); + const { handler, emitter, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'gemini-cli', + outputParser: parser as any, + }); + + const thinkingSpy = vi.fn(); + emitter.on('thinking-chunk', thinkingSpy); + + sendJsonLine(handler, sessionId, { + type: 'message', + role: 'assistant', + content: 'Hello from Gemini!', + // no delta field => isPartial = false + }); + + // Non-partial text should go through BOTH thinking-chunk AND data path + expect(thinkingSpy).toHaveBeenCalledWith(sessionId, 'Hello from Gemini!'); + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'Hello from Gemini!'); + // Non-partial text should NOT accumulate in streamedChunks + expect(getStreamedText(proc)).toBe(''); + }); + + it('should route partial/delta Gemini text through thinking-chunk only', () => { + const parser = createGeminiParser(); + const { handler, emitter, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'gemini-cli', + outputParser: parser as any, + }); + + const thinkingSpy = vi.fn(); + emitter.on('thinking-chunk', thinkingSpy); + + sendJsonLine(handler, sessionId, { + type: 'message', + role: 'assistant', + content: 'streaming...', + delta: true, + }); + + // Partial text should go through thinking-chunk only, NOT data + expect(thinkingSpy).toHaveBeenCalledWith(sessionId, 'streaming...'); + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + // Should accumulate in streamedChunks for result-time emission + expect(getStreamedText(proc)).toBe('streaming...'); + }); + + it('should use accumulated streamedText as fallback in result event after partial streaming', () => { + // Parser that returns partial text events, then a result event + const parser = { + agentId: 'gemini-cli', + parseJsonLine: vi.fn((line: string) => { + try { + const parsed = JSON.parse(line); + if (parsed.type === 'result') { + return { type: 'result' as const, text: '' }; // empty text on result + } + if (parsed.type === 'message' && parsed.role === 'assistant') { + return { + type: 'text' as const, + text: parsed.content, + isPartial: parsed.delta === true, + }; + } + return null; + } catch { + return null; + } + }), + parseJsonObject: vi.fn((parsed: any) => { + if (parsed.type === 'result') { + return { type: 'result' as const, text: '' }; // empty text on result + } + if (parsed.type === 'message' && parsed.role === 'assistant') { + return { + type: 'text' as const, + text: parsed.content, + isPartial: parsed.delta === true, + }; + } + return null; + }), + extractUsage: vi.fn(() => null), + extractSessionId: vi.fn(() => null), + extractSlashCommands: vi.fn(() => null), + isResultMessage: vi.fn((event: any) => event.type === 'result'), + detectErrorFromLine: vi.fn(() => null), + detectErrorFromParsed: vi.fn(() => null), + }; + + const { handler, emitter, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'gemini-cli', + outputParser: parser as any, + }); + + const thinkingSpy = vi.fn(); + emitter.on('thinking-chunk', thinkingSpy); + + // Send partial streaming chunks + sendJsonLine(handler, sessionId, { + type: 'message', + role: 'assistant', + content: 'Hello ', + delta: true, + }); + sendJsonLine(handler, sessionId, { + type: 'message', + role: 'assistant', + content: 'from Gemini!', + delta: true, + }); + + // Verify partial chunks accumulated + expect(getStreamedText(proc)).toBe('Hello from Gemini!'); + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + + // Send result event with empty text — should fall back to streamedChunks + sendJsonLine(handler, sessionId, { type: 'result' }); + + expect(proc.resultEmitted).toBe(true); + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'Hello from Gemini!'); + }); + + it('should not affect Claude Code partial text routing (non-Gemini agent unaffected)', () => { + const parser = { + agentId: 'claude-code', + parseJsonLine: vi.fn((line: string) => { + try { + const parsed = JSON.parse(line); + if (parsed.type === 'assistant' && parsed.content) { + return { + type: 'text' as const, + text: parsed.content, + isPartial: parsed.partial === true, + }; + } + return null; + } catch { + return null; + } + }), + parseJsonObject: vi.fn((parsed: any) => { + if (parsed.type === 'assistant' && parsed.content) { + return { + type: 'text' as const, + text: parsed.content, + isPartial: parsed.partial === true, + }; + } + return null; + }), + extractUsage: vi.fn(() => null), + extractSessionId: vi.fn(() => null), + extractSlashCommands: vi.fn(() => null), + isResultMessage: vi.fn(() => false), + detectErrorFromLine: vi.fn(() => null), + detectErrorFromParsed: vi.fn(() => null), + }; + + const { handler, emitter, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'claude-code', + outputParser: parser as any, + }); + + const thinkingSpy = vi.fn(); + emitter.on('thinking-chunk', thinkingSpy); + + // Send partial text for Claude Code + sendJsonLine(handler, sessionId, { + type: 'assistant', + content: 'thinking about your question...', + partial: true, + }); + + // Partial text should still go through thinking-chunk and accumulate in streamedChunks + expect(thinkingSpy).toHaveBeenCalledWith(sessionId, 'thinking about your question...'); + expect(getStreamedText(proc)).toBe('thinking about your question...'); + // Partial text should NOT be emitted via data path + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + + // Send non-partial text for Claude Code + sendJsonLine(handler, sessionId, { + type: 'assistant', + content: 'Here is my answer.', + partial: false, + }); + + // Non-partial Claude text also goes through thinking-chunk AND data path + expect(thinkingSpy).toHaveBeenCalledWith(sessionId, 'Here is my answer.'); + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'Here is my answer.'); + }); + }); + // ── normalizeUsageToDelta (tested via outputParser path) ─────────────── describe('normalizeUsageToDelta (via outputParser stream-JSON path)', () => { @@ -481,11 +744,19 @@ describe('StdoutHandler', () => { return null; } }), + parseJsonObject: vi.fn((parsed: any) => { + return { + type: parsed.type || 'message', + text: parsed.text, + isPartial: false, + }; + }), extractUsage: vi.fn(() => usageReturn), extractSessionId: vi.fn(() => null), extractSlashCommands: vi.fn(() => null), isResultMessage: vi.fn(() => false), detectErrorFromLine: vi.fn(() => null), + detectErrorFromParsed: vi.fn(() => null), }; } @@ -1160,6 +1431,92 @@ describe('StdoutHandler', () => { // ── Edge cases ───────────────────────────────────────────────────────── + describe('Gemini CLI non-partial text events', () => { + function createGeminiParser() { + return { + agentId: 'gemini-cli', + parseJsonLine: vi.fn(() => null), + parseJsonObject: vi.fn((parsed: any) => { + if (parsed.type === 'message' && parsed.role === 'assistant') { + return { + type: 'text', + text: parsed.content, + isPartial: parsed.delta === true, + raw: parsed, + }; + } + if (parsed.type === 'result') { + return { type: 'result', text: '', raw: parsed }; + } + return null; + }), + extractUsage: vi.fn(() => null), + extractSessionId: vi.fn(() => null), + extractSlashCommands: vi.fn(() => null), + isResultMessage: vi.fn((event: any) => event?.raw?.type === 'result'), + detectErrorFromLine: vi.fn(() => null), + detectErrorFromParsed: vi.fn(() => null), + detectErrorFromExit: vi.fn(() => null), + }; + } + + it('should emit complete (delta:false) message events as data immediately', () => { + const parser = createGeminiParser(); + const { handler, bufferManager, sessionId, proc, emitter } = createTestContext({ + toolType: 'gemini-cli' as any, + isStreamJsonMode: true, + outputParser: parser as any, + }); + + const thinkingSpy = vi.fn(); + emitter.on('thinking-chunk', thinkingSpy); + + // Gemini sends a complete message (delta: false or absent) + sendJsonLine(handler, sessionId, { + type: 'message', + role: 'assistant', + content: 'Hello! How can I help you?', + }); + + // Should be emitted immediately as data, not accumulated + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith( + sessionId, + 'Hello! How can I help you?' + ); + // Should also emit thinking-chunk for live display + expect(thinkingSpy).toHaveBeenCalledWith(sessionId, 'Hello! How can I help you?'); + // Should NOT be accumulated in streamedChunks + expect(getStreamedText(proc)).toBe(''); + }); + + it('should accumulate partial (delta:true) message events in streamedChunks', () => { + const parser = createGeminiParser(); + const { handler, bufferManager, sessionId, proc, emitter } = createTestContext({ + toolType: 'gemini-cli' as any, + isStreamJsonMode: true, + outputParser: parser as any, + }); + + const thinkingSpy = vi.fn(); + emitter.on('thinking-chunk', thinkingSpy); + + // Gemini sends streaming delta + sendJsonLine(handler, sessionId, { + type: 'message', + role: 'assistant', + content: 'Hello', + delta: true, + }); + + // Should accumulate in streamedChunks + expect(getStreamedText(proc)).toBe('Hello'); + // Should emit thinking-chunk + expect(thinkingSpy).toHaveBeenCalledWith(sessionId, 'Hello'); + // Should NOT emit via emitDataBuffered (deferred to result) + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + }); + }); + describe('edge cases', () => { it('should emit non-JSON lines via bufferManager in stream JSON mode', () => { const { handler, bufferManager, sessionId } = createTestContext({ @@ -1250,11 +1607,27 @@ describe('StdoutHandler', () => { } return { type: 'system' }; }), + parseJsonObject: vi.fn((parsed: any) => { + if (parsed.type === 'step_start') { + return { type: 'init', sessionId: parsed.sessionID }; + } + if (parsed.type === 'text') { + return { type: 'result', text: parsed.part?.text, sessionId: parsed.sessionID }; + } + if (parsed.type === 'tool_use') { + return { type: 'tool_use', toolName: parsed.part?.tool, sessionId: parsed.sessionID }; + } + if (parsed.type === 'step_finish') { + return { type: 'system', sessionId: parsed.sessionID }; + } + return { type: 'system' }; + }), extractUsage: vi.fn(() => null), extractSessionId: vi.fn((event: any) => event.sessionId || null), extractSlashCommands: vi.fn(() => null), isResultMessage: vi.fn((event: any) => event.type === 'result'), detectErrorFromLine: vi.fn(() => null), + detectErrorFromParsed: vi.fn(() => null), }; } @@ -1355,11 +1728,17 @@ describe('StdoutHandler', () => { if (parsed.type === 'text') return { type: 'result', text: parsed.text }; return { type: 'system' }; }), + parseJsonObject: vi.fn((parsed: any) => { + if (parsed.type === 'step_start') return { type: 'init', sessionId: 'x' }; + if (parsed.type === 'text') return { type: 'result', text: parsed.text }; + return { type: 'system' }; + }), extractUsage: vi.fn(() => null), extractSessionId: vi.fn(() => null), extractSlashCommands: vi.fn(() => null), isResultMessage: vi.fn((event: any) => event.type === 'result'), detectErrorFromLine: vi.fn(() => null), + detectErrorFromParsed: vi.fn(() => null), }; const { handler, proc, sessionId } = createTestContext({ @@ -1400,10 +1779,417 @@ function createMinimalOutputParser(usageReturn: { return null; } }), + parseJsonObject: vi.fn((parsed: any) => { + return { type: parsed.type || 'message', text: parsed.text, isPartial: false }; + }), extractUsage: vi.fn(() => usageReturn), extractSessionId: vi.fn(() => null), extractSlashCommands: vi.fn(() => null), isResultMessage: vi.fn(() => false), detectErrorFromLine: vi.fn(() => null), + detectErrorFromParsed: vi.fn(() => null), }; } + +// ── extractDeniedPath tests ───────────────────────────────────────────────── + +describe('extractDeniedPath', () => { + it('extracts directory from path with single quotes and "not in workspace"', () => { + const result = extractDeniedPath("path '/home/user/project/src' not in workspace"); + expect(result).toBe('/home/user/project/src'); + }); + + it('extracts parent directory when path is a file', () => { + const result = extractDeniedPath("path '/home/user/project/src/main.ts' not in workspace"); + expect(result).toBe('/home/user/project/src'); + }); + + it('extracts directory from double-quoted path', () => { + const result = extractDeniedPath('path "/home/user/project" not in workspace'); + expect(result).toBe('/home/user/project'); + }); + + it('extracts directory from "is outside" pattern', () => { + const result = extractDeniedPath("'/tmp/data' is outside the allowed workspace"); + expect(result).toBe('/tmp/data'); + }); + + it('rejects system-critical paths (permission denied on /etc)', () => { + const result = extractDeniedPath("'/etc/config.json' permission denied"); + expect(result).toBeNull(); // /etc is a system path + }); + + it('rejects system-critical paths (bare /usr/local/bin)', () => { + const result = extractDeniedPath('/usr/local/bin not in workspace'); + expect(result).toBeNull(); // /usr is a system path + }); + + it('rejects system-critical paths (bare file under /usr)', () => { + const result = extractDeniedPath('/usr/local/bin/tool.py not in workspace'); + expect(result).toBeNull(); // /usr is a system path + }); + + it('returns null when no path pattern matches', () => { + const result = extractDeniedPath('some random error message'); + expect(result).toBeNull(); + }); + + it('returns null for empty string', () => { + const result = extractDeniedPath(''); + expect(result).toBeNull(); + }); + + it('handles tilde paths', () => { + const result = extractDeniedPath("'~/projects/foo' not in workspace"); + expect(result).toBe('~/projects/foo'); + }); + + // Windows path patterns + it('extracts Windows quoted directory path', () => { + const result = extractDeniedPath("'C:\\Users\\dev\\project' not in workspace"); + expect(result).toBe('C:\\Users\\dev\\project'); + }); + + it('extracts parent directory from Windows file path', () => { + const result = extractDeniedPath("'C:\\Users\\dev\\project\\main.ts' not in workspace"); + expect(result).toBe('C:\\Users\\dev\\project'); + }); + + it('extracts Windows path with "is outside" pattern', () => { + const result = extractDeniedPath("'D:\\workspace\\data' is outside the allowed workspace"); + expect(result).toBe('D:\\workspace\\data'); + }); + + it('rejects Windows system path (C:\\Windows)', () => { + const result = extractDeniedPath("'C:\\Windows\\config.json' permission denied"); + expect(result).toBeNull(); // C:\Windows is a system path + }); + + it('extracts bare Windows path without quotes', () => { + const result = extractDeniedPath('C:\\Users\\dev\\project not in workspace'); + expect(result).toBe('C:\\Users\\dev\\project'); + }); + + it('extracts parent dir for bare Windows file path', () => { + const result = extractDeniedPath('C:\\Users\\dev\\project\\index.js not in workspace'); + expect(result).toBe('C:\\Users\\dev\\project'); + }); + + it('extracts Windows path with forward slashes', () => { + const result = extractDeniedPath("'C:/Users/dev/project' not in workspace"); + expect(result).toBe('C:/Users/dev/project'); + }); + + it('extracts Windows path from generic "path" prefix', () => { + const result = extractDeniedPath("path 'C:\\Users\\dev\\src' not in workspace"); + expect(result).toBe('C:\\Users\\dev\\src'); + }); + + // Tests with projectCwd normalization + it('normalizes relative traversal paths when projectCwd is provided', () => { + const result = extractDeniedPath("path '../../sibling' not in workspace", '/home/user/project'); + expect(result).toBe('/home/sibling'); + }); + + it('resolves tilde paths when projectCwd is provided', () => { + const result = extractDeniedPath("'~/projects/foo' not in workspace", '/tmp/cwd'); + const os = require('os'); + expect(result).toBe(`${os.homedir()}/projects/foo`); + }); + + it('rejects root path from traversal with projectCwd', () => { + const result = extractDeniedPath( + "path '../../../../..' not in workspace", + '/home/user/project' + ); + expect(result).toBeNull(); // resolves to / which is a system path + }); +}); + +// ── Performance: single JSON.parse per NDJSON line ────────────────────── + +describe('StdoutHandler — single JSON parse per line', () => { + it('parses JSON exactly once per NDJSON line (output parser path)', () => { + // Instrument JSON.parse to count calls + const originalParse = JSON.parse; + let parseCount = 0; + const countingParse = vi.fn((...args: Parameters) => { + parseCount++; + return originalParse.apply(JSON, args); + }); + JSON.parse = countingParse; + + try { + const mockParser = { + agentId: 'gemini-cli', + parseJsonLine: vi.fn(() => ({ + type: 'text' as const, + text: 'hello', + isPartial: true, + raw: {}, + })), + parseJsonObject: vi.fn((parsed: unknown) => ({ + type: 'text' as const, + text: 'hello', + isPartial: true, + raw: parsed, + })), + isResultMessage: vi.fn(() => false), + extractSessionId: vi.fn(() => null), + extractUsage: vi.fn(() => null), + extractSlashCommands: vi.fn(() => null), + detectErrorFromLine: vi.fn(() => null), + detectErrorFromParsed: vi.fn(() => null), + detectErrorFromExit: vi.fn(() => null), + }; + + const { handler, sessionId } = createTestContext({ + isStreamJsonMode: true, + toolType: 'gemini-cli' as any, + outputParser: mockParser as any, + }); + + // Send a valid JSON line + const jsonLine = JSON.stringify({ + type: 'message', + role: 'assistant', + content: 'hi', + delta: true, + }); + parseCount = 0; // reset after the stringify parse above + + handler.handleData(sessionId, jsonLine + '\n'); + + // Should parse exactly once (in processLine), not 3× as before + expect(parseCount).toBe(1); + + // parseJsonObject should be called with pre-parsed object (not parseJsonLine) + expect(mockParser.parseJsonObject).toHaveBeenCalledTimes(1); + expect(mockParser.parseJsonLine).not.toHaveBeenCalled(); + + // detectErrorFromParsed should be called (not detectErrorFromLine) + expect(mockParser.detectErrorFromParsed).toHaveBeenCalledTimes(1); + expect(mockParser.detectErrorFromLine).not.toHaveBeenCalled(); + } finally { + JSON.parse = originalParse; + } + }); + + it('falls back to detectErrorFromLine for non-JSON lines', () => { + const mockParser = { + agentId: 'claude-code', + parseJsonLine: vi.fn(() => null), + parseJsonObject: vi.fn(() => null), + isResultMessage: vi.fn(() => false), + extractSessionId: vi.fn(() => null), + extractUsage: vi.fn(() => null), + extractSlashCommands: vi.fn(() => null), + detectErrorFromLine: vi.fn(() => null), + detectErrorFromParsed: vi.fn(() => null), + detectErrorFromExit: vi.fn(() => null), + }; + + const { handler, sessionId } = createTestContext({ + isStreamJsonMode: true, + toolType: 'claude-code' as any, + outputParser: mockParser as any, + }); + + // Send a non-JSON line (e.g., stderr with embedded JSON) + handler.handleData(sessionId, 'Error streaming: 400 {"type":"error"}\n'); + + // Should fall back to line-based detection since JSON.parse fails + expect(mockParser.detectErrorFromLine).toHaveBeenCalledTimes(1); + expect(mockParser.detectErrorFromParsed).not.toHaveBeenCalled(); + }); +}); + +// ── Buffer size guards ────────────────────────────────────────────────── + +describe('StdoutHandler — buffer size guards', () => { + it('should truncate batch mode jsonBuffer when it exceeds MAX_BATCH_BUFFER_SIZE', () => { + const { handler, sessionId, proc } = createTestContext({ + isBatchMode: true, + isStreamJsonMode: false, + }); + + // Fill buffer close to limit + const bigChunk = 'x'.repeat(MAX_BATCH_BUFFER_SIZE - 100); + handler.handleData(sessionId, bigChunk); + expect(proc.jsonBuffer).toBe(bigChunk); + + // Send more data that exceeds the limit + const overflow = 'y'.repeat(200); + handler.handleData(sessionId, overflow); + + // Buffer should be capped at MAX_BATCH_BUFFER_SIZE + expect((proc.jsonBuffer || '').length).toBe(MAX_BATCH_BUFFER_SIZE); + // Overflow portion should be truncated + expect(proc.jsonBuffer!.endsWith('y'.repeat(100))).toBe(true); + }); + + it('should not truncate batch mode jsonBuffer within limits', () => { + const { handler, sessionId, proc } = createTestContext({ + isBatchMode: true, + isStreamJsonMode: false, + }); + + const chunk = 'x'.repeat(1000); + handler.handleData(sessionId, chunk); + expect(proc.jsonBuffer).toBe(chunk); + }); + + it('should clear stream-JSON jsonBuffer when it exceeds MAX_BUFFER_SIZE', () => { + const { handler, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + }); + + // Send a massive chunk without a newline (simulates stuck partial line) + const hugePartial = 'x'.repeat(MAX_BUFFER_SIZE + 100); + handler.handleData(sessionId, hugePartial); + + // Buffer should be reset to incoming data (cleared old + fresh start) + // The buffer holds whatever remains after split('\n').pop() + expect((proc.jsonBuffer || '').length).toBe(hugePartial.length); + }); + + it('should clear stream-JSON buffer and keep incoming data functional', () => { + const { handler, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + }); + + // Fill the buffer close to limit with a partial line + const nearLimit = 'x'.repeat(MAX_BUFFER_SIZE - 10); + handler.handleData(sessionId, nearLimit); + expect(proc.jsonBuffer).toBe(nearLimit); + + // Now send data that pushes over the limit — buffer is cleared and replaced + const newData = '{"type":"result"}\n'; + handler.handleData(sessionId, newData); + + // After clear + split on newline, the complete JSON line should be processed + // and jsonBuffer should hold the remainder (empty after newline) + expect(proc.jsonBuffer).toBe(''); + }); +}); + +// ── streamedChunks array accumulation ──────────────────────────────────── + +describe('StdoutHandler — streamedChunks accumulation', () => { + function createSimpleParser() { + return { + agentId: 'gemini-cli', + parseJsonLine: vi.fn((line: string) => { + const parsed = JSON.parse(line); + return { + type: parsed.type || 'text', + text: parsed.content || parsed.text, + isPartial: !!parsed.delta, + }; + }), + parseJsonObject: vi.fn((parsed: any) => ({ + type: parsed.type || 'text', + text: parsed.content || parsed.text, + isPartial: !!parsed.delta, + })), + extractUsage: vi.fn(() => null), + extractSessionId: vi.fn(() => null), + extractSlashCommands: vi.fn(() => null), + isResultMessage: vi.fn((event: any) => event.type === 'result'), + detectErrorFromLine: vi.fn(() => null), + detectErrorFromParsed: vi.fn(() => null), + }; + } + + it('should accumulate partial text in streamedChunks array (not string concatenation)', () => { + const parser = createSimpleParser(); + const { handler, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'gemini-cli', + outputParser: parser as any, + }); + + sendJsonLine(handler, sessionId, { type: 'text', content: 'Hello', delta: true }); + sendJsonLine(handler, sessionId, { type: 'text', content: ' world', delta: true }); + sendJsonLine(handler, sessionId, { type: 'text', content: '!', delta: true }); + + // Should be stored as separate chunks + expect(proc.streamedChunks).toEqual(['Hello', ' world', '!']); + // getStreamedText joins them + expect(getStreamedText(proc)).toBe('Hello world!'); + }); + + it('should reset streamedChunks on OpenCode init event', () => { + const parser = createSimpleParser(); + // Override parseJsonObject for init detection + parser.parseJsonObject = vi.fn((parsed: any) => { + if (parsed.type === 'init') return { type: 'init' }; + return { + type: parsed.type || 'text', + text: parsed.content || parsed.text, + isPartial: !!parsed.delta, + }; + }); + + const { handler, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'opencode', + outputParser: parser as any, + }); + + // Accumulate some chunks + sendJsonLine(handler, sessionId, { type: 'text', content: 'old text', delta: true }); + expect(proc.streamedChunks).toEqual(['old text']); + + // Send init — should reset + sendJsonLine(handler, sessionId, { type: 'init' }); + expect(proc.streamedChunks).toEqual([]); + expect(getStreamedText(proc)).toBe(''); + }); + + it('should replace streamedChunks for Codex result messages', () => { + const parser = createSimpleParser(); + parser.isResultMessage = vi.fn((event: any) => event.type === 'result'); + parser.parseJsonObject = vi.fn((parsed: any) => ({ + type: parsed.type || 'text', + text: parsed.content || parsed.text, + isPartial: false, + })); + + const { handler, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'codex', + outputParser: parser as any, + }); + + sendJsonLine(handler, sessionId, { type: 'result', content: 'Final answer' }); + + // Codex result should set chunks to single element + expect(proc.streamedChunks).toEqual(['Final answer']); + expect(getStreamedText(proc)).toBe('Final answer'); + }); + + it('getStreamedText should fall back to legacy streamedText field', () => { + const proc = createMockProcess({ + streamedText: 'legacy text', + streamedChunks: undefined, + }); + expect(getStreamedText(proc)).toBe('legacy text'); + }); + + it('getStreamedText should prefer streamedChunks over streamedText', () => { + const proc = createMockProcess({ + streamedText: 'old legacy', + streamedChunks: ['new', ' chunks'], + }); + expect(getStreamedText(proc)).toBe('new chunks'); + }); + + it('getStreamedText should return empty string when both are empty', () => { + const proc = createMockProcess({ + streamedText: '', + streamedChunks: [], + }); + expect(getStreamedText(proc)).toBe(''); + }); +}); diff --git a/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts b/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts index 83d84e3ecf..48c3d4a96c 100644 --- a/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts +++ b/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts @@ -23,6 +23,7 @@ function createMockChildProcess() { stderr: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }), stdin: { write: vi.fn(), end: vi.fn(), on: vi.fn() }, on: vi.fn(), + kill: vi.fn(), killed: false, exitCode: null, }; @@ -54,11 +55,14 @@ vi.mock('../../../../main/parsers', () => ({ getOutputParser: vi.fn(() => ({ agentId: 'claude-code', parseJsonLine: vi.fn(), + parseJsonObject: vi.fn(), extractUsage: vi.fn(), extractSessionId: vi.fn(), extractSlashCommands: vi.fn(), isResultMessage: vi.fn(), detectErrorFromLine: vi.fn(), + detectErrorFromParsed: vi.fn(), + detectErrorFromExit: vi.fn(() => null), })), })); @@ -726,4 +730,143 @@ describe('ChildProcessSpawner', () => { expect(spawnArgs).toContain('/tmp/maestro-image-0.png'); }); }); + + describe('process watchdog timers', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should set a batch watchdog timer when prompt is provided (batch mode)', () => { + const { processes, spawner } = createTestContext(); + + spawner.spawn(createBaseConfig({ prompt: 'test prompt' })); + + const proc = processes.get('test-session'); + expect(proc?.watchdogTimer).toBeDefined(); + // Interactive timers should not be set + expect(proc?.inactivityTimer).toBeUndefined(); + expect(proc?.lastActivityMs).toBeUndefined(); + }); + + it('should kill process after batch timeout expires', () => { + const { spawner } = createTestContext(); + + spawner.spawn(createBaseConfig({ prompt: 'test prompt', timeout: 5000 })); + + expect(mockChildProcess.killed).toBe(false); + + // Advance time past the timeout + vi.advanceTimersByTime(5001); + + // Process should have been killed + expect(mockChildProcess.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('should use default 10-minute timeout when no timeout specified in batch mode', () => { + const { processes, spawner } = createTestContext(); + + spawner.spawn(createBaseConfig({ prompt: 'test prompt' })); + + // Should NOT kill before 10 minutes + vi.advanceTimersByTime(9 * 60 * 1000); + expect(mockChildProcess.kill).not.toHaveBeenCalledWith('SIGTERM'); + + // Should kill after 10 minutes + vi.advanceTimersByTime(2 * 60 * 1000); + expect(mockChildProcess.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('should clear batch watchdog timer on process close', () => { + const { processes, spawner } = createTestContext(); + + spawner.spawn(createBaseConfig({ prompt: 'test prompt', timeout: 5000 })); + + const proc = processes.get('test-session'); + expect(proc?.watchdogTimer).toBeDefined(); + + // Simulate process close + const onCalls = mockChildProcess.on.mock.calls as [string, Function][]; + const closeHandler = onCalls.find(([event]) => event === 'close')?.[1]; + closeHandler?.(0); + + expect(proc?.watchdogTimer).toBeUndefined(); + }); + + it('should NOT kill process if already killed when batch watchdog fires', () => { + const { spawner } = createTestContext(); + + spawner.spawn(createBaseConfig({ prompt: 'test prompt', timeout: 5000 })); + + // Mark process as already killed + mockChildProcess.killed = true; + + vi.advanceTimersByTime(5001); + + // kill() should NOT have been called since process is already killed + expect(mockChildProcess.kill).not.toHaveBeenCalled(); + }); + + it('should set inactivity timer for interactive sessions (no prompt)', () => { + const { processes, spawner } = createTestContext(); + + spawner.spawn(createBaseConfig({ prompt: undefined })); + + const proc = processes.get('test-session'); + expect(proc?.inactivityTimer).toBeDefined(); + expect(proc?.lastActivityMs).toBeDefined(); + // Batch watchdog should not be set + expect(proc?.watchdogTimer).toBeUndefined(); + }); + + it('should update lastActivityMs on stdout data', () => { + const { processes, spawner } = createTestContext(); + + spawner.spawn(createBaseConfig({ prompt: undefined })); + + const proc = processes.get('test-session'); + const initialActivity = proc?.lastActivityMs; + + // Advance time a bit + vi.advanceTimersByTime(1000); + + // Simulate stdout data + mockChildProcess.stdout.emit('data', 'some output'); + + expect(proc?.lastActivityMs).toBeGreaterThan(initialActivity!); + }); + + it('should clear inactivity timer on process close', () => { + const { processes, spawner } = createTestContext(); + + spawner.spawn(createBaseConfig({ prompt: undefined })); + + const proc = processes.get('test-session'); + expect(proc?.inactivityTimer).toBeDefined(); + + // Simulate process close + const onCalls = mockChildProcess.on.mock.calls as [string, Function][]; + const closeHandler = onCalls.find(([event]) => event === 'close')?.[1]; + closeHandler?.(0); + + expect(proc?.inactivityTimer).toBeUndefined(); + }); + + it('should use custom inactivity timeout when specified', () => { + const { processes, spawner } = createTestContext(); + + spawner.spawn( + createBaseConfig({ + prompt: undefined, + inactivityTimeout: 60000, // 1 minute + }) + ); + + const proc = processes.get('test-session'); + expect(proc?.inactivityTimeout).toBe(60000); + }); + }); }); diff --git a/src/__tests__/main/process-manager/spawners/PtySpawner.test.ts b/src/__tests__/main/process-manager/spawners/PtySpawner.test.ts new file mode 100644 index 0000000000..de136176ab --- /dev/null +++ b/src/__tests__/main/process-manager/spawners/PtySpawner.test.ts @@ -0,0 +1,238 @@ +/** + * Tests for src/main/process-manager/spawners/PtySpawner.ts + * + * Key behaviors verified: + * - Shell terminal: uses `shell` field with -l/-i flags (login+interactive) + * - SSH terminal: when no `shell` is provided, uses `command`/`args` directly + * (this is the fix for SSH terminal tabs connecting to remote hosts) + * - AI agent PTY: uses `command`/`args` directly (toolType !== 'terminal') + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; + +// ── Mocks ────────────────────────────────────────────────────────────────── + +const mockPtySpawn = vi.fn(); +const mockPtyProcess = { + pid: 99999, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), +}; + +vi.mock('node-pty', () => ({ + spawn: (...args: unknown[]) => { + mockPtySpawn(...args); + return mockPtyProcess; + }, +})); + +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../../../../main/utils/terminalFilter', () => ({ + stripControlSequences: vi.fn((data: string) => data), +})); + +vi.mock('../../../../main/process-manager/utils/envBuilder', () => ({ + buildPtyTerminalEnv: vi.fn(() => ({ TERM: 'xterm-256color' })), + buildChildProcessEnv: vi.fn(() => ({ PATH: '/usr/bin' })), +})); + +vi.mock('../../../../shared/platformDetection', () => ({ + isWindows: vi.fn(() => false), +})); + +// ── Imports (after mocks) ────────────────────────────────────────────────── + +import { PtySpawner } from '../../../../main/process-manager/spawners/PtySpawner'; +import type { ManagedProcess, ProcessConfig } from '../../../../main/process-manager/types'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function createTestContext() { + const processes = new Map(); + const emitter = new EventEmitter(); + const bufferManager = { + emitDataBuffered: vi.fn(), + flushDataBuffer: vi.fn(), + }; + const spawner = new PtySpawner(processes, emitter, bufferManager as any); + return { processes, emitter, bufferManager, spawner }; +} + +function createBaseConfig(overrides: Partial = {}): ProcessConfig { + return { + sessionId: 'test-session', + toolType: 'terminal', + cwd: '/home/user', + command: 'zsh', + args: [], + shell: 'zsh', + ...overrides, + }; +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('PtySpawner', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPtyProcess.onData.mockImplementation(() => {}); + mockPtyProcess.onExit.mockImplementation(() => {}); + }); + + describe('shell terminal (toolType=terminal, shell provided)', () => { + it('spawns the shell with -l -i flags', () => { + const { spawner } = createTestContext(); + spawner.spawn(createBaseConfig({ shell: 'zsh' })); + + expect(mockPtySpawn).toHaveBeenCalledWith( + 'zsh', + ['-l', '-i'], + expect.objectContaining({ name: 'xterm-256color' }) + ); + }); + + it('appends custom shellArgs after -l -i', () => { + const { spawner } = createTestContext(); + spawner.spawn(createBaseConfig({ shell: 'zsh', shellArgs: '--login --no-rcs' })); + + const [, args] = mockPtySpawn.mock.calls[0]; + expect(args[0]).toBe('-l'); + expect(args[1]).toBe('-i'); + expect(args).toContain('--login'); + expect(args).toContain('--no-rcs'); + }); + + it('returns success with pid from PTY process', () => { + const { spawner } = createTestContext(); + const result = spawner.spawn(createBaseConfig({ shell: 'bash' })); + + expect(result.success).toBe(true); + expect(result.pid).toBe(99999); + }); + }); + + describe('SSH terminal (toolType=terminal, no shell provided)', () => { + it('uses command and args directly without -l/-i flags', () => { + const { spawner } = createTestContext(); + spawner.spawn( + createBaseConfig({ + shell: undefined, + command: 'ssh', + args: ['pedram@pedtome.example.com'], + }) + ); + + expect(mockPtySpawn).toHaveBeenCalledWith( + 'ssh', + ['pedram@pedtome.example.com'], + expect.objectContaining({ name: 'xterm-256color' }) + ); + }); + + it('passes through ssh args including -t flag and remote command', () => { + const { spawner } = createTestContext(); + const sshArgs = ['-t', 'pedram@pedtome.example.com', 'cd "/project" && exec $SHELL']; + spawner.spawn( + createBaseConfig({ + shell: undefined, + command: 'ssh', + args: sshArgs, + }) + ); + + expect(mockPtySpawn).toHaveBeenCalledWith( + 'ssh', + sshArgs, + expect.objectContaining({ name: 'xterm-256color' }) + ); + }); + + it('passes through ssh args with -i and -p flags', () => { + const { spawner } = createTestContext(); + const sshArgs = ['-i', '/home/user/.ssh/id_rsa', '-p', '2222', 'pedram@pedtome.example.com']; + spawner.spawn( + createBaseConfig({ + shell: undefined, + command: 'ssh', + args: sshArgs, + }) + ); + + const [cmd, args] = mockPtySpawn.mock.calls[0]; + expect(cmd).toBe('ssh'); + expect(args).toEqual(sshArgs); + // Must NOT contain -l or -i (shell flags) + expect(args).not.toContain('-l'); + }); + + it('returns success with pid from PTY process', () => { + const { spawner } = createTestContext(); + const result = spawner.spawn( + createBaseConfig({ + shell: undefined, + command: 'ssh', + args: ['user@remote.example.com'], + }) + ); + + expect(result.success).toBe(true); + expect(result.pid).toBe(99999); + }); + }); + + describe('AI agent PTY (toolType !== terminal)', () => { + it('uses command and args directly regardless of shell field', () => { + const { spawner } = createTestContext(); + spawner.spawn( + createBaseConfig({ + toolType: 'claude-code', + command: 'claude', + args: ['--print'], + shell: 'zsh', + }) + ); + + expect(mockPtySpawn).toHaveBeenCalledWith( + 'claude', + ['--print'], + expect.objectContaining({ name: 'xterm-256color' }) + ); + }); + }); + + describe('process registration', () => { + it('registers the managed process by sessionId', () => { + const { spawner, processes } = createTestContext(); + spawner.spawn(createBaseConfig({ sessionId: 'my-session', shell: 'zsh' })); + + expect(processes.has('my-session')).toBe(true); + expect(processes.get('my-session')?.pid).toBe(99999); + }); + + it('sets isTerminal=true for all PTY processes', () => { + const { spawner, processes } = createTestContext(); + + // Shell terminal + spawner.spawn(createBaseConfig({ sessionId: 'shell-session', shell: 'zsh' })); + expect(processes.get('shell-session')?.isTerminal).toBe(true); + + // SSH terminal + spawner.spawn( + createBaseConfig({ sessionId: 'ssh-session', shell: undefined, command: 'ssh', args: ['host'] }) + ); + expect(processes.get('ssh-session')?.isTerminal).toBe(true); + }); + }); +}); diff --git a/src/__tests__/main/process-manager/utils/shellEscape.test.ts b/src/__tests__/main/process-manager/utils/shellEscape.test.ts index 2722056cef..238075eb1b 100644 --- a/src/__tests__/main/process-manager/utils/shellEscape.test.ts +++ b/src/__tests__/main/process-manager/utils/shellEscape.test.ts @@ -6,6 +6,7 @@ import { escapePowerShellArgs, isPowerShellShell, escapeArgsForShell, + canRunWithoutShell, getWindowsShellForAgentExecution, } from '../../../../main/process-manager/utils/shellEscape'; @@ -22,30 +23,110 @@ describe('shellEscape', () => { expect(escapeCmdArg('path with spaces')).toBe('"path with spaces"'); }); - it('should escape arguments with special characters', () => { + // ── Per-metacharacter tests (S-03 audit) ──────────────────────── + + it('should escape & (command separator)', () => { expect(escapeCmdArg('foo&bar')).toBe('"foo&bar"'); + expect(escapeCmdArg('a && b')).toBe('"a && b"'); + }); + + it('should escape | (pipe)', () => { expect(escapeCmdArg('foo|bar')).toBe('"foo|bar"'); - expect(escapeCmdArg('foo { + expect(escapeCmdArg('foo;bar')).toBe('"foo;bar"'); + }); + + it('should escape > (redirect out)', () => { expect(escapeCmdArg('foo>bar')).toBe('"foo>bar"'); + expect(escapeCmdArg('a >> b')).toBe('"a >> b"'); }); - it('should escape double quotes by doubling them', () => { - expect(escapeCmdArg('say "hello"')).toBe('"say ""hello"""'); + it('should escape < (redirect in)', () => { + expect(escapeCmdArg('foo { + it('should escape ^ (caret/escape char) by doubling', () => { expect(escapeCmdArg('foo^bar')).toBe('"foo^^bar"'); + expect(escapeCmdArg('a^^b')).toBe('"a^^^^b"'); + }); + + it('should escape % (env var expansion) by doubling', () => { + expect(escapeCmdArg('foo%PATH%bar')).toBe('"foo%%PATH%%bar"'); + expect(escapeCmdArg('%USERPROFILE%')).toBe('"%%USERPROFILE%%"'); + expect(escapeCmdArg('100%')).toBe('"100%%"'); + }); + + it('should escape ! (delayed expansion)', () => { + // ! triggers quoting; inside double quotes it is literal unless delayed expansion is enabled + expect(escapeCmdArg('hello!')).toBe('"hello!"'); + expect(escapeCmdArg('!PATH!')).toBe('"!PATH!"'); }); - it('should escape arguments with newlines', () => { + it('should escape ( and ) (grouping)', () => { + expect(escapeCmdArg('foo(bar)')).toBe('"foo(bar)"'); + }); + + it('should escape backticks', () => { + expect(escapeCmdArg('foo`bar')).toBe('"foo`bar"'); + }); + + it('should escape $ (dollar sign)', () => { + expect(escapeCmdArg('foo$bar')).toBe('"foo$bar"'); + expect(escapeCmdArg('$HOME')).toBe('"$HOME"'); + }); + + it('should escape \\n (newline)', () => { expect(escapeCmdArg('line1\nline2')).toBe('"line1\nline2"'); + }); + + it('should escape \\r (carriage return)', () => { + expect(escapeCmdArg('line1\rline2')).toBe('"line1\rline2"'); expect(escapeCmdArg('line1\r\nline2')).toBe('"line1\r\nline2"'); }); + it('should escape double quotes by doubling them', () => { + expect(escapeCmdArg('say "hello"')).toBe('"say ""hello"""'); + }); + it('should escape long arguments', () => { const longArg = 'a'.repeat(150); expect(escapeCmdArg(longArg)).toBe(`"${longArg}"`); }); + + // ── Combined metacharacter tests ──────────────────────────────── + + it('should handle combined ^ and % correctly', () => { + // ^ is doubled by ^ replacement, % is doubled by % replacement (independent) + expect(escapeCmdArg('^%PATH%')).toBe('"^^%%PATH%%"'); + }); + + it('should handle prompt-like content with multiple metacharacters', () => { + const prompt = 'Run: cmd /c "echo %PATH%" & dir > output.txt'; + const escaped = escapeCmdArg(prompt); + // Should be wrapped in double quotes with " doubled and % doubled + expect(escaped).toContain('%%PATH%%'); + expect(escaped).toContain('""echo'); + expect(escaped.startsWith('"')).toBe(true); + expect(escaped.endsWith('"')).toBe(true); + }); + + it('should handle empty string', () => { + // Empty string has no special chars and is short, returned as-is + expect(escapeCmdArg('')).toBe(''); + }); + + it('should handle all metacharacters in a single string', () => { + const allMeta = '& | ; > < ^ % ! ( ) ` $ " \n \r'; + const result = escapeCmdArg(allMeta); + // Should be quoted and have " doubled, ^ doubled, % doubled + expect(result.startsWith('"')).toBe(true); + expect(result.endsWith('"')).toBe(true); + expect(result).toContain('""'); // doubled quotes + expect(result).toContain('^^'); // doubled carets + expect(result).toContain('%%'); // doubled percents + }); }); describe('escapePowerShellArg', () => { @@ -59,10 +140,45 @@ describe('shellEscape', () => { expect(escapePowerShellArg('hello world')).toBe("'hello world'"); }); - it('should escape arguments with special characters', () => { + // ── Per-metacharacter tests for PowerShell ────────────────────── + + it('should escape & (call operator)', () => { expect(escapePowerShellArg('foo&bar')).toBe("'foo&bar'"); + }); + + it('should escape | (pipe)', () => { + expect(escapePowerShellArg('foo|bar')).toBe("'foo|bar'"); + }); + + it('should escape ; (statement separator)', () => { + expect(escapePowerShellArg('foo;bar')).toBe("'foo;bar'"); + }); + + it('should escape > and < (redirects)', () => { + expect(escapePowerShellArg('foo>bar')).toBe("'foo>bar'"); + expect(escapePowerShellArg('foo { expect(escapePowerShellArg('foo$bar')).toBe("'foo$bar'"); + expect(escapePowerShellArg('$env:PATH')).toBe("'$env:PATH'"); + }); + + it('should escape ` (backtick/escape char)', () => { expect(escapePowerShellArg('foo`bar')).toBe("'foo`bar'"); + expect(escapePowerShellArg('`n')).toBe("'`n'"); + }); + + it('should escape @ (array/splatting)', () => { + expect(escapePowerShellArg('foo@bar')).toBe("'foo@bar'"); + }); + + it('should escape { } (script blocks)', () => { + expect(escapePowerShellArg('foo{bar}')).toBe("'foo{bar}'"); + }); + + it('should escape [ ] (type literals)', () => { + expect(escapePowerShellArg('foo[bar]')).toBe("'foo[bar]'"); }); it('should escape single quotes by doubling them', () => { @@ -70,10 +186,8 @@ describe('shellEscape', () => { expect(escapePowerShellArg("say 'hello'")).toBe("'say ''hello'''"); }); - it('should escape arguments with PowerShell-specific characters', () => { - expect(escapePowerShellArg('foo@bar')).toBe("'foo@bar'"); - expect(escapePowerShellArg('foo{bar}')).toBe("'foo{bar}'"); - expect(escapePowerShellArg('foo[bar]')).toBe("'foo[bar]'"); + it('should escape , (comma/array element separator)', () => { + expect(escapePowerShellArg('a,b,c')).toBe("'a,b,c'"); }); it('should escape long arguments', () => { @@ -87,6 +201,11 @@ describe('shellEscape', () => { const result = escapeCmdArgs(['simple', 'with space', 'say "hi"']); expect(result).toEqual(['simple', '"with space"', '"say ""hi"""']); }); + + it('should escape % in multiple arguments', () => { + const result = escapeCmdArgs(['--flag', '%PATH%', 'normal']); + expect(result).toEqual(['--flag', '"%%PATH%%"', 'normal']); + }); }); describe('escapePowerShellArgs', () => { @@ -137,6 +256,45 @@ describe('shellEscape', () => { const result = escapeArgsForShell(['with space', 'say "hi"']); expect(result).toEqual(['"with space"', '"say ""hi"""']); }); + + it('should escape % for cmd.exe but not PowerShell (uses single quotes)', () => { + const cmdResult = escapeArgsForShell(['%PATH%'], 'cmd.exe'); + expect(cmdResult).toEqual(['"%%PATH%%"']); + + const psResult = escapeArgsForShell(['%PATH%'], 'powershell.exe'); + expect(psResult).toEqual(["'%PATH%'"]); + }); + }); + + describe('canRunWithoutShell', () => { + it('should return true for fully-resolved .exe paths', () => { + expect(canRunWithoutShell('C:\\Program Files\\Node\\node.exe')).toBe(true); + expect(canRunWithoutShell('C:\\tools\\gemini.exe')).toBe(true); + expect(canRunWithoutShell('/usr/local/bin/agent.exe')).toBe(true); + }); + + it('should return false for .cmd files (require shell)', () => { + expect(canRunWithoutShell('C:\\Users\\user\\AppData\\Roaming\\npm\\gemini.cmd')).toBe(false); + }); + + it('should return false for .bat files (require shell)', () => { + expect(canRunWithoutShell('C:\\scripts\\setup.bat')).toBe(false); + }); + + it('should return false for bare basenames without path', () => { + expect(canRunWithoutShell('gemini.exe')).toBe(false); + expect(canRunWithoutShell('node.exe')).toBe(false); + }); + + it('should return false for extensionless basenames', () => { + expect(canRunWithoutShell('gemini')).toBe(false); + expect(canRunWithoutShell('claude')).toBe(false); + }); + + it('should return false for extensionless paths (may be scripts)', () => { + expect(canRunWithoutShell('/usr/local/bin/gemini')).toBe(false); + expect(canRunWithoutShell('C:\\tools\\opencode')).toBe(false); + }); }); describe('getWindowsShellForAgentExecution', () => { diff --git a/src/__tests__/main/services/symphony-runner.test.ts b/src/__tests__/main/services/symphony-runner.test.ts index b3c88c0f1a..88ad436a10 100644 --- a/src/__tests__/main/services/symphony-runner.test.ts +++ b/src/__tests__/main/services/symphony-runner.test.ts @@ -699,7 +699,7 @@ describe('Symphony Runner Service', () => { expect(mockFetch).toHaveBeenCalledWith('https://example.com/doc.md'); expect(fs.writeFile).toHaveBeenCalledWith( - '/tmp/test-repo/Auto Run Docs/doc.md', + '/tmp/test-repo/.maestro/playbooks/doc.md', expect.any(Buffer) ); }); @@ -780,11 +780,11 @@ describe('Symphony Runner Service', () => { }); // ============================================================================ - // Setup Auto Run Docs Tests + // Setup .maestro/playbooks Tests // ============================================================================ describe('setupAutoRunDocs', () => { - it('creates Auto Run Docs directory', async () => { + it('creates .maestro/playbooks directory', async () => { mockSuccessfulWorkflow(); await startContribution({ @@ -798,7 +798,9 @@ describe('Symphony Runner Service', () => { branchName: 'symphony/test-branch', }); - expect(fs.mkdir).toHaveBeenCalledWith('/tmp/test-repo/Auto Run Docs', { recursive: true }); + expect(fs.mkdir).toHaveBeenCalledWith('/tmp/test-repo/.maestro/playbooks', { + recursive: true, + }); }); it('downloads external documents (isExternal: true)', async () => { @@ -847,7 +849,7 @@ describe('Symphony Runner Service', () => { expect(fs.copyFile).toHaveBeenCalledWith( '/tmp/test-repo/docs/internal.md', - '/tmp/test-repo/Auto Run Docs/internal.md' + '/tmp/test-repo/.maestro/playbooks/internal.md' ); }); @@ -901,7 +903,7 @@ describe('Symphony Runner Service', () => { ); }); - it('returns path to Auto Run Docs directory', async () => { + it('returns path to .maestro/playbooks directory', async () => { mockSuccessfulWorkflow(); const result = await startContribution({ @@ -915,7 +917,7 @@ describe('Symphony Runner Service', () => { branchName: 'symphony/test-branch', }); - expect(result.autoRunPath).toBe('/tmp/test-repo/Auto Run Docs'); + expect(result.autoRunPath).toBe('/tmp/test-repo/.maestro/playbooks'); }); }); @@ -1101,7 +1103,7 @@ describe('Symphony Runner Service', () => { expect(result.success).toBe(true); expect(result.draftPrUrl).toBe('https://github.com/owner/repo/pull/42'); expect(result.draftPrNumber).toBe(42); - expect(result.autoRunPath).toBe('/tmp/test-repo/Auto Run Docs'); + expect(result.autoRunPath).toBe('/tmp/test-repo/.maestro/playbooks'); }); it('handles unexpected errors gracefully', async () => { diff --git a/src/__tests__/main/stats/auto-run.test.ts b/src/__tests__/main/stats/auto-run.test.ts index 51ab6f4174..bc1ef483a9 100644 --- a/src/__tests__/main/stats/auto-run.test.ts +++ b/src/__tests__/main/stats/auto-run.test.ts @@ -296,7 +296,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { const sessionId = db.insertAutoRunSession({ sessionId: 'maestro-session-123', agentType: 'claude-code', - documentPath: 'Auto Run Docs/PHASE-1.md', + documentPath: '.maestro/playbooks/PHASE-1.md', startTime, duration: 0, // Duration is 0 at start tasksTotal: 10, @@ -314,7 +314,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { // INSERT parameters: id, session_id, agent_type, document_path, start_time, duration, tasks_total, tasks_completed, project_path expect(lastCall[1]).toBe('maestro-session-123'); // session_id expect(lastCall[2]).toBe('claude-code'); // agent_type - expect(lastCall[3]).toBe('Auto Run Docs/PHASE-1.md'); // document_path + expect(lastCall[3]).toBe('.maestro/playbooks/PHASE-1.md'); // document_path expect(lastCall[4]).toBe(startTime); // start_time expect(lastCall[5]).toBe(0); // duration (0 at start) expect(lastCall[6]).toBe(10); // tasks_total diff --git a/src/__tests__/main/storage/gemini-session-storage.test.ts b/src/__tests__/main/storage/gemini-session-storage.test.ts new file mode 100644 index 0000000000..9ed7ade7e1 --- /dev/null +++ b/src/__tests__/main/storage/gemini-session-storage.test.ts @@ -0,0 +1,1856 @@ +/** + * Tests for GeminiSessionStorage + * + * Verifies: + * - deleteMessagePair: index-based and content-based message deletion + * - readSessionMessages: UUID uses original array index + * - getAllNamedSessions: named session aggregation from origins store + * - listSessions: origin metadata enrichment (names, stars) + * - Edge cases: missing file, missing message, no paired response, backup/restore + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GeminiSessionStorage } from '../../../main/storage/gemini-session-storage'; +import fs from 'fs/promises'; + +// Mock logger +vi.mock('../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock sentry +vi.mock('../../../main/utils/sentry', () => ({ + captureException: vi.fn(), +})); + +// Mock fs/promises +vi.mock('fs/promises', () => ({ + default: { + access: vi.fn(), + readdir: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + rename: vi.fn(), + stat: vi.fn(), + unlink: vi.fn(), + copyFile: vi.fn(), + }, +})); + +// Mock os.homedir +vi.mock('os', () => ({ + default: { + homedir: () => '/mock-home', + }, +})); + +/** + * Helper to build a Gemini session JSON string + */ +function buildSessionJson( + messages: Array<{ type: string; content: string; toolCalls?: unknown[] }>, + sessionId = 'test-session-id' +) { + return JSON.stringify( + { + sessionId, + messages, + startTime: '2026-01-01T00:00:00.000Z', + lastUpdated: '2026-01-01T01:00:00.000Z', + }, + null, + 2 + ); +} + +describe('GeminiSessionStorage', () => { + let storage: GeminiSessionStorage; + + beforeEach(() => { + vi.clearAllMocks(); + storage = new GeminiSessionStorage(); + }); + + /** + * Helper: set up mocks so findSessionFile succeeds for a given session + */ + function mockFindSessionFile(sessionContent: string) { + // getHistoryDir: access succeeds for basename path, readFile for .project_root + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + return Promise.resolve(sessionContent); + }); + (fs.readdir as ReturnType).mockResolvedValue([ + 'session-123-test-session-id.json', + ]); + (fs.stat as ReturnType).mockResolvedValue({ + size: 1000, + mtimeMs: Date.now(), + isDirectory: () => true, + }); + (fs.writeFile as ReturnType).mockResolvedValue(undefined); + (fs.rename as ReturnType).mockResolvedValue(undefined); + (fs.unlink as ReturnType).mockResolvedValue(undefined); + (fs.copyFile as ReturnType).mockResolvedValue(undefined); + } + + describe('readSessionMessages', () => { + it('should set uuid to stringified original array index', async () => { + const sessionContent = buildSessionJson([ + { type: 'user', content: 'Hello' }, + { type: 'info', content: 'Processing...' }, + { type: 'gemini', content: 'Hi there!' }, + { type: 'user', content: 'Second question' }, + { type: 'gemini', content: 'Second answer' }, + ]); + + mockFindSessionFile(sessionContent); + + const result = await storage.readSessionMessages('/test/project', 'test-session-id', { + limit: 100, + }); + + // Should only include conversation messages (user + gemini), skip info + expect(result.messages.length).toBe(4); + + // UUIDs should be the original array indices (not filtered indices) + expect(result.messages[0].uuid).toBe('0'); // user at index 0 + expect(result.messages[1].uuid).toBe('2'); // gemini at index 2 (index 1 is info, skipped) + expect(result.messages[2].uuid).toBe('3'); // user at index 3 + expect(result.messages[3].uuid).toBe('4'); // gemini at index 4 + }); + + it('should skip info/error/warning messages but preserve their indices', async () => { + const sessionContent = buildSessionJson([ + { type: 'user', content: 'Hello' }, + { type: 'warning', content: 'Be careful' }, + { type: 'error', content: 'Oops' }, + { type: 'gemini', content: 'Response' }, + ]); + + mockFindSessionFile(sessionContent); + + const result = await storage.readSessionMessages('/test/project', 'test-session-id', { + limit: 100, + }); + + expect(result.messages.length).toBe(2); + expect(result.messages[0].uuid).toBe('0'); // user at original index 0 + expect(result.messages[1].uuid).toBe('3'); // gemini at original index 3 + }); + }); + + describe('deleteMessagePair', () => { + it('should delete user message and paired gemini response by index', async () => { + const messages = [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi there!' }, + { type: 'user', content: 'Second question' }, + { type: 'gemini', content: 'Second answer' }, + ]; + const sessionContent = buildSessionJson(messages); + mockFindSessionFile(sessionContent); + + const result = await storage.deleteMessagePair('/test/project', 'test-session-id', '0'); + + expect(result.success).toBe(true); + expect(result.linesRemoved).toBe(2); + + // Verify the written content + const writeCall = (fs.writeFile as ReturnType).mock.calls.find( + (call: unknown[]) => !(call[0] as string).endsWith('.bak') + ); + expect(writeCall).toBeDefined(); + const writtenSession = JSON.parse(writeCall![1] as string); + expect(writtenSession.messages.length).toBe(2); + expect(writtenSession.messages[0].content).toBe('Second question'); + expect(writtenSession.messages[1].content).toBe('Second answer'); + }); + + it('should remove intermediate info/error/warning messages between user and gemini', async () => { + const messages = [ + { type: 'user', content: 'Hello' }, + { type: 'info', content: 'Processing...' }, + { type: 'warning', content: 'Slow response' }, + { type: 'gemini', content: 'Response' }, + { type: 'user', content: 'Next' }, + { type: 'gemini', content: 'Next response' }, + ]; + const sessionContent = buildSessionJson(messages); + mockFindSessionFile(sessionContent); + + const result = await storage.deleteMessagePair('/test/project', 'test-session-id', '0'); + + expect(result.success).toBe(true); + expect(result.linesRemoved).toBe(4); // user + info + warning + gemini + + const writeCall = (fs.writeFile as ReturnType).mock.calls.find( + (call: unknown[]) => !(call[0] as string).endsWith('.bak') + ); + const writtenSession = JSON.parse(writeCall![1] as string); + expect(writtenSession.messages.length).toBe(2); + expect(writtenSession.messages[0].content).toBe('Next'); + }); + + it('should delete only user message when no paired gemini response exists', async () => { + const messages = [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi there!' }, + { type: 'user', content: 'Last question with no response' }, + ]; + const sessionContent = buildSessionJson(messages); + mockFindSessionFile(sessionContent); + + const result = await storage.deleteMessagePair('/test/project', 'test-session-id', '2'); + + expect(result.success).toBe(true); + expect(result.linesRemoved).toBe(1); + + const writeCall = (fs.writeFile as ReturnType).mock.calls.find( + (call: unknown[]) => !(call[0] as string).endsWith('.bak') + ); + const writtenSession = JSON.parse(writeCall![1] as string); + expect(writtenSession.messages.length).toBe(2); + expect(writtenSession.messages[0].content).toBe('Hello'); + expect(writtenSession.messages[1].content).toBe('Hi there!'); + }); + + it('should fall back to content match when index does not match a user message', async () => { + const messages = [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi there!' }, + { type: 'user', content: 'Target message' }, + { type: 'gemini', content: 'Target response' }, + ]; + const sessionContent = buildSessionJson(messages); + mockFindSessionFile(sessionContent); + + // Pass an invalid index but valid fallback content + const result = await storage.deleteMessagePair( + '/test/project', + 'test-session-id', + '99', + 'Target message' + ); + + expect(result.success).toBe(true); + expect(result.linesRemoved).toBe(2); + + const writeCall = (fs.writeFile as ReturnType).mock.calls.find( + (call: unknown[]) => !(call[0] as string).endsWith('.bak') + ); + const writtenSession = JSON.parse(writeCall![1] as string); + expect(writtenSession.messages.length).toBe(2); + expect(writtenSession.messages[0].content).toBe('Hello'); + }); + + it('should return error when session file is not found', async () => { + // Set up mocks to simulate no session file found + (fs.access as ReturnType).mockRejectedValue(new Error('ENOENT')); + (fs.readdir as ReturnType).mockRejectedValue(new Error('ENOENT')); + + const result = await storage.deleteMessagePair('/test/project', 'nonexistent', '0'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Session file not found'); + }); + + it('should return error when message is not found', async () => { + const messages = [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi there!' }, + ]; + const sessionContent = buildSessionJson(messages); + mockFindSessionFile(sessionContent); + + const result = await storage.deleteMessagePair('/test/project', 'test-session-id', '99'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Message not found'); + }); + + it('should not match index pointing to a non-user message', async () => { + const messages = [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi there!' }, + ]; + const sessionContent = buildSessionJson(messages); + mockFindSessionFile(sessionContent); + + // Index 1 is a gemini message, not user + const result = await storage.deleteMessagePair('/test/project', 'test-session-id', '1'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Message not found'); + }); + + it('should return error for SSH remote sessions', async () => { + const result = await storage.deleteMessagePair( + '/test/project', + 'test-session-id', + '0', + undefined, + { enabled: true, host: 'example.com' } as never + ); + + expect(result.success).toBe(false); + expect(result.error).toBe('Delete not supported for remote sessions'); + }); + + it('should create backup before writing and clean up on success', async () => { + const messages = [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Response' }, + ]; + const sessionContent = buildSessionJson(messages); + mockFindSessionFile(sessionContent); + + await storage.deleteMessagePair('/test/project', 'test-session-id', '0'); + + // Verify backup was created (first writeFile call should be the .bak) + const writeCalls = (fs.writeFile as ReturnType).mock.calls; + const backupCall = writeCalls.find((call: unknown[]) => (call[0] as string).endsWith('.bak')); + expect(backupCall).toBeDefined(); + expect(backupCall![1]).toBe(sessionContent); + + // Verify atomic write: content written to .tmp, then renamed + const tmpCall = writeCalls.find((call: unknown[]) => (call[0] as string).endsWith('.tmp')); + expect(tmpCall).toBeDefined(); + expect(fs.rename).toHaveBeenCalledWith( + expect.stringContaining('.tmp'), + expect.stringContaining('session-123-test-session-id.json') + ); + + // Verify backup cleanup was attempted + expect(fs.unlink).toHaveBeenCalled(); + }); + + it('should restore from backup on write failure', async () => { + const messages = [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Response' }, + ]; + const sessionContent = buildSessionJson(messages); + mockFindSessionFile(sessionContent); + + // Make the second writeFile (the actual session write) fail + let writeCount = 0; + (fs.writeFile as ReturnType).mockImplementation(() => { + writeCount++; + if (writeCount === 2) { + return Promise.reject(new Error('Disk full')); + } + return Promise.resolve(undefined); + }); + + const result = await storage.deleteMessagePair('/test/project', 'test-session-id', '0'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to write session file'); + + // Verify copyFile was called to restore backup + expect(fs.copyFile).toHaveBeenCalled(); + }); + + it('should handle deletion of messages with toolCalls embedded', async () => { + const messages = [ + { type: 'user', content: 'Run a command' }, + { + type: 'gemini', + content: 'Running...', + toolCalls: [{ id: 'tc-1', name: 'execute', status: 'success' }], + }, + { type: 'user', content: 'Next' }, + { type: 'gemini', content: 'Done' }, + ]; + const sessionContent = buildSessionJson(messages); + mockFindSessionFile(sessionContent); + + const result = await storage.deleteMessagePair('/test/project', 'test-session-id', '0'); + + expect(result.success).toBe(true); + expect(result.linesRemoved).toBe(2); + + const writeCall = (fs.writeFile as ReturnType).mock.calls.find( + (call: unknown[]) => !(call[0] as string).endsWith('.bak') + ); + const writtenSession = JSON.parse(writeCall![1] as string); + expect(writtenSession.messages.length).toBe(2); + // toolCalls are embedded in the gemini message, so removing the message removes them + expect(writtenSession.messages[0].content).toBe('Next'); + }); + + it('should include intermediates when no gemini response exists (no orphans)', async () => { + const messages = [ + { type: 'user', content: 'Hello' }, + { type: 'info', content: 'Processing...' }, + { type: 'warning', content: 'Slow response' }, + { type: 'user', content: 'Gave up waiting' }, + { type: 'gemini', content: 'Response to second' }, + ]; + const sessionContent = buildSessionJson(messages); + mockFindSessionFile(sessionContent); + + const result = await storage.deleteMessagePair('/test/project', 'test-session-id', '0'); + + expect(result.success).toBe(true); + // Should remove user + info + warning = 3 (not just the user message) + expect(result.linesRemoved).toBe(3); + + const writeCall = (fs.writeFile as ReturnType).mock.calls.find( + (call: unknown[]) => !(call[0] as string).endsWith('.bak') + ); + const writtenSession = JSON.parse(writeCall![1] as string); + expect(writtenSession.messages.length).toBe(2); + expect(writtenSession.messages[0].content).toBe('Gave up waiting'); + expect(writtenSession.messages[1].content).toBe('Response to second'); + }); + + it('should include trailing intermediates after gemini response (no orphans)', async () => { + const messages = [ + { type: 'user', content: 'Run something' }, + { type: 'gemini', content: 'Done' }, + { type: 'info', content: 'Tool completed' }, + { type: 'warning', content: 'Cleanup note' }, + { type: 'user', content: 'Next question' }, + { type: 'gemini', content: 'Next answer' }, + ]; + const sessionContent = buildSessionJson(messages); + mockFindSessionFile(sessionContent); + + const result = await storage.deleteMessagePair('/test/project', 'test-session-id', '0'); + + expect(result.success).toBe(true); + // Should remove user + gemini + info + warning = 4 + expect(result.linesRemoved).toBe(4); + + const writeCall = (fs.writeFile as ReturnType).mock.calls.find( + (call: unknown[]) => !(call[0] as string).endsWith('.bak') + ); + const writtenSession = JSON.parse(writeCall![1] as string); + expect(writtenSession.messages.length).toBe(2); + expect(writtenSession.messages[0].content).toBe('Next question'); + expect(writtenSession.messages[1].content).toBe('Next answer'); + }); + + it('should include both leading and trailing intermediates around gemini response', async () => { + const messages = [ + { type: 'user', content: 'Do task' }, + { type: 'info', content: 'Starting tool...' }, + { type: 'gemini', content: 'Task done' }, + { type: 'info', content: 'Tool finished' }, + { type: 'user', content: 'Thanks' }, + { type: 'gemini', content: 'Welcome' }, + ]; + const sessionContent = buildSessionJson(messages); + mockFindSessionFile(sessionContent); + + const result = await storage.deleteMessagePair('/test/project', 'test-session-id', '0'); + + expect(result.success).toBe(true); + // Should remove user + info + gemini + info = 4 + expect(result.linesRemoved).toBe(4); + + const writeCall = (fs.writeFile as ReturnType).mock.calls.find( + (call: unknown[]) => !(call[0] as string).endsWith('.bak') + ); + const writtenSession = JSON.parse(writeCall![1] as string); + expect(writtenSession.messages.length).toBe(2); + expect(writtenSession.messages[0].content).toBe('Thanks'); + expect(writtenSession.messages[1].content).toBe('Welcome'); + }); + + it('should update lastUpdated timestamp after deletion', async () => { + const messages = [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Response' }, + ]; + const sessionContent = buildSessionJson(messages); + mockFindSessionFile(sessionContent); + + await storage.deleteMessagePair('/test/project', 'test-session-id', '0'); + + const writeCall = (fs.writeFile as ReturnType).mock.calls.find( + (call: unknown[]) => !(call[0] as string).endsWith('.bak') + ); + const writtenSession = JSON.parse(writeCall![1] as string); + // lastUpdated should be updated to a recent timestamp + expect(writtenSession.lastUpdated).toBeDefined(); + expect(new Date(writtenSession.lastUpdated).getTime()).toBeGreaterThan( + new Date('2026-01-01T01:00:00.000Z').getTime() + ); + }); + + it('should serialize concurrent deletes on the same session file', async () => { + const messages = [ + { type: 'user', content: 'First' }, + { type: 'gemini', content: 'Response 1' }, + { type: 'user', content: 'Second' }, + { type: 'gemini', content: 'Response 2' }, + ]; + + // Track the file content state so the second delete sees the first's changes + let currentContent = buildSessionJson(messages); + const operationOrder: string[] = []; + let firstWriteResolve: (() => void) | null = null; + + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readdir as ReturnType).mockResolvedValue([ + 'session-123-test-session-id.json', + ]); + (fs.stat as ReturnType).mockResolvedValue({ + size: 1000, + mtimeMs: Date.now(), + isDirectory: () => true, + }); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + return Promise.resolve(currentContent); + }); + (fs.unlink as ReturnType).mockResolvedValue(undefined); + (fs.copyFile as ReturnType).mockResolvedValue(undefined); + + let tmpWriteCount = 0; + (fs.writeFile as ReturnType).mockImplementation(async (_filePath: string) => { + if ((_filePath as string).endsWith('.tmp')) { + tmpWriteCount++; + const n = tmpWriteCount; + operationOrder.push(`write-start-${n}`); + if (n === 1) { + // First atomic write blocks to prove serialization + await new Promise((r) => { + firstWriteResolve = r; + }); + } + operationOrder.push(`write-end-${n}`); + } + }); + (fs.rename as ReturnType).mockImplementation(async (tmpPath: string) => { + // Simulate atomic rename: update currentContent from the .tmp write + const tmpWriteCall = (fs.writeFile as ReturnType).mock.calls.find( + (call: unknown[]) => call[0] === tmpPath + ); + if (tmpWriteCall) { + currentContent = tmpWriteCall[1] as string; + } + }); + + // Launch two concurrent deletes + const p1 = storage.deleteMessagePair('/test/project', 'test-session-id', '0'); + // Give p1 time to start and block on its write + await new Promise((r) => setTimeout(r, 50)); + const p2 = storage.deleteMessagePair('/test/project', 'test-session-id', '0'); + + // Release the first write + await new Promise((r) => setTimeout(r, 10)); + firstWriteResolve!(); + + const [result1, result2] = await Promise.all([p1, p2]); + + // First delete succeeds: removes "First" + "Response 1" + expect(result1.success).toBe(true); + expect(result1.linesRemoved).toBe(2); + + // Second delete runs after first completes and sees updated file. + // Index 0 is now "Second" (after first delete removed "First"+"Response 1") + expect(result2.success).toBe(true); + expect(result2.linesRemoved).toBe(2); + + // Verify serialization: second write started after first write ended + const writeEnd1 = operationOrder.indexOf('write-end-1'); + const writeStart2 = operationOrder.indexOf('write-start-2'); + expect(writeEnd1).toBeGreaterThanOrEqual(0); + expect(writeStart2).toBeGreaterThanOrEqual(0); + expect(writeEnd1).toBeLessThan(writeStart2); + }); + + it('should use atomic write-then-rename for session file updates', async () => { + const messages = [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Response' }, + ]; + const sessionContent = buildSessionJson(messages); + mockFindSessionFile(sessionContent); + + await storage.deleteMessagePair('/test/project', 'test-session-id', '0'); + + // Verify write went to .tmp file + const writeCalls = (fs.writeFile as ReturnType).mock.calls; + const tmpWrite = writeCalls.find((call: unknown[]) => (call[0] as string).endsWith('.tmp')); + expect(tmpWrite).toBeDefined(); + + // Verify rename was called from .tmp to .json + const renameCalls = (fs.rename as ReturnType).mock.calls; + expect(renameCalls).toHaveLength(1); + expect(renameCalls[0][0]).toMatch(/\.tmp$/); + expect(renameCalls[0][1]).toMatch(/\.json$/); + + // Verify NO direct write to the session .json file (only .bak and .tmp) + const directWrites = writeCalls.filter( + (call: unknown[]) => + (call[0] as string).endsWith('.json') && !(call[0] as string).endsWith('.bak') + ); + expect(directWrites).toHaveLength(0); + }); + + it('should clean up orphaned temp file on write failure', async () => { + const messages = [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Response' }, + ]; + const sessionContent = buildSessionJson(messages); + mockFindSessionFile(sessionContent); + + // Make rename fail (simulates atomic write failure) + (fs.rename as ReturnType).mockRejectedValue(new Error('rename failed')); + + const result = await storage.deleteMessagePair('/test/project', 'test-session-id', '0'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to write session file'); + + // Verify backup restore was attempted + expect(fs.copyFile).toHaveBeenCalled(); + + // Verify orphaned .tmp cleanup was attempted + const unlinkCalls = (fs.unlink as ReturnType).mock.calls; + const tmpUnlink = unlinkCalls.find((call: unknown[]) => (call[0] as string).endsWith('.tmp')); + expect(tmpUnlink).toBeDefined(); + }); + }); + + describe('getAllNamedSessions', () => { + function createMockOriginsStore(data: Record = {}) { + return { + get: vi.fn().mockReturnValue(data), + set: vi.fn(), + } as never; + } + + it('should return empty array when no origins store is provided', async () => { + const storageNoStore = new GeminiSessionStorage(); + const result = await storageNoStore.getAllNamedSessions(); + expect(result).toEqual([]); + }); + + it('should return empty array when no gemini-cli origins exist', async () => { + const store = createMockOriginsStore({ origins: {} }); + const storageWithStore = new GeminiSessionStorage(store); + const result = await storageWithStore.getAllNamedSessions(); + expect(result).toEqual([]); + }); + + it('should return named sessions from origins store', async () => { + const store = createMockOriginsStore({ + 'gemini-cli': { + '/test/project': { + 'session-1': { sessionName: 'My Session', starred: true }, + 'session-2': { sessionName: 'Other Session' }, + 'session-3': { origin: 'auto' }, // no sessionName — should be skipped + }, + }, + }); + // Mock findSessionFile to return null (no file on disk) + (fs.access as ReturnType).mockRejectedValue(new Error('ENOENT')); + (fs.readdir as ReturnType).mockRejectedValue(new Error('ENOENT')); + + const storageWithStore = new GeminiSessionStorage(store); + const result = await storageWithStore.getAllNamedSessions(); + + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agentSessionId: 'session-1', + projectPath: '/test/project', + sessionName: 'My Session', + starred: true, + }), + expect.objectContaining({ + agentSessionId: 'session-2', + projectPath: '/test/project', + sessionName: 'Other Session', + }), + ]) + ); + }); + + it('should NOT include sessions from other agents (e.g., codex)', async () => { + const store = createMockOriginsStore({ + 'gemini-cli': { + '/test/project': { + 'gemini-session-1': { sessionName: 'Gemini Session' }, + }, + }, + codex: { + '/test/project': { + 'codex-session-1': { sessionName: 'Codex Session' }, + }, + }, + 'claude-code': { + '/other/project': { + 'claude-session-1': { sessionName: 'Claude Session' }, + }, + }, + }); + // Mock findSessionFile to fail (no files on disk) + (fs.access as ReturnType).mockRejectedValue(new Error('ENOENT')); + (fs.readdir as ReturnType).mockRejectedValue(new Error('ENOENT')); + + const storageWithStore = new GeminiSessionStorage(store); + const result = await storageWithStore.getAllNamedSessions(); + + expect(result).toHaveLength(1); + expect(result[0].agentSessionId).toBe('gemini-session-1'); + expect(result[0].sessionName).toBe('Gemini Session'); + // Ensure no codex or claude sessions leak through + expect(result.find((s) => s.agentSessionId === 'codex-session-1')).toBeUndefined(); + expect(result.find((s) => s.agentSessionId === 'claude-session-1')).toBeUndefined(); + }); + + it('should pass through starred status correctly (true, false, undefined)', async () => { + const store = createMockOriginsStore({ + 'gemini-cli': { + '/test/project': { + 'session-starred': { sessionName: 'Starred', starred: true }, + 'session-unstarred': { sessionName: 'Unstarred', starred: false }, + 'session-no-star': { sessionName: 'No Star Field' }, + }, + }, + }); + (fs.access as ReturnType).mockRejectedValue(new Error('ENOENT')); + (fs.readdir as ReturnType).mockRejectedValue(new Error('ENOENT')); + + const storageWithStore = new GeminiSessionStorage(store); + const result = await storageWithStore.getAllNamedSessions(); + + expect(result).toHaveLength(3); + + const starred = result.find((s) => s.agentSessionId === 'session-starred'); + const unstarred = result.find((s) => s.agentSessionId === 'session-unstarred'); + const noStar = result.find((s) => s.agentSessionId === 'session-no-star'); + + expect(starred?.starred).toBe(true); + expect(unstarred?.starred).toBe(false); + expect(noStar?.starred).toBeUndefined(); + }); + + it('should include lastActivityAt when session file exists', async () => { + const mtimeMs = new Date('2026-02-15T10:00:00Z').getTime(); + const store = createMockOriginsStore({ + 'gemini-cli': { + '/test/project': { + 'test-session-id': { sessionName: 'Named Session' }, + }, + }, + }); + + // Mock findSessionFile to succeed + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + return Promise.resolve('{}'); + }); + (fs.readdir as ReturnType).mockResolvedValue([ + 'session-123-test-session-id.json', + ]); + (fs.stat as ReturnType).mockResolvedValue({ + size: 500, + mtimeMs, + mtime: new Date(mtimeMs), + isDirectory: () => true, + }); + + const storageWithStore = new GeminiSessionStorage(store); + const result = await storageWithStore.getAllNamedSessions(); + + expect(result).toHaveLength(1); + expect(result[0].lastActivityAt).toBe(mtimeMs); + }); + }); + + describe('listSessions with origin metadata enrichment', () => { + function createMockOriginsStore(data: Record = {}) { + return { + get: vi.fn().mockReturnValue(data), + set: vi.fn(), + } as never; + } + + it('should enrich sessions with sessionName and starred from origins store', async () => { + const sessionContent = buildSessionJson( + [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi!' }, + ], + 'test-session-id' + ); + + const store = createMockOriginsStore({ + 'gemini-cli': { + '/test/project': { + 'test-session-id': { sessionName: 'Custom Name', starred: true, origin: 'user' }, + }, + }, + }); + + const storageWithStore = new GeminiSessionStorage(store); + + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + return Promise.resolve(sessionContent); + }); + (fs.readdir as ReturnType).mockResolvedValue([ + 'session-123-test-session-id.json', + ]); + (fs.stat as ReturnType).mockResolvedValue({ + size: 1000, + mtimeMs: Date.now(), + isDirectory: () => true, + }); + + const sessions = await storageWithStore.listSessions('/test/project'); + + expect(sessions).toHaveLength(1); + expect(sessions[0].sessionName).toBe('Custom Name'); + expect(sessions[0].starred).toBe(true); + expect(sessions[0].origin).toBe('user'); + }); + + it('should work without origins store (no enrichment)', async () => { + const sessionContent = buildSessionJson( + [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi!' }, + ], + 'test-session-id' + ); + + const storageNoStore = new GeminiSessionStorage(); + + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + return Promise.resolve(sessionContent); + }); + (fs.readdir as ReturnType).mockResolvedValue([ + 'session-123-test-session-id.json', + ]); + (fs.stat as ReturnType).mockResolvedValue({ + size: 1000, + mtimeMs: Date.now(), + isDirectory: () => true, + }); + + const sessions = await storageNoStore.listSessions('/test/project'); + + expect(sessions).toHaveLength(1); + // sessionName should be from the parsed file (summary or first message), not from origins + expect(sessions[0].starred).toBeUndefined(); + expect(sessions[0].origin).toBeUndefined(); + }); + }); + + describe('searchSessions', () => { + function mockMultipleSessionFiles(sessionsMap: Record) { + const filenames = Object.keys(sessionsMap); + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + for (const [filename, content] of Object.entries(sessionsMap)) { + if (filePath.endsWith(filename)) { + return Promise.resolve(content); + } + } + return Promise.reject(new Error('ENOENT')); + }); + (fs.readdir as ReturnType).mockResolvedValue(filenames); + (fs.stat as ReturnType).mockResolvedValue({ + size: 1000, + mtimeMs: Date.now(), + isDirectory: () => true, + }); + } + + it('should return empty array for empty query', async () => { + const results = await storage.searchSessions('/test/project', ' ', 'all'); + expect(results).toEqual([]); + }); + + it('should return empty array when no history dir exists', async () => { + (fs.access as ReturnType).mockRejectedValue(new Error('ENOENT')); + (fs.readdir as ReturnType).mockRejectedValue(new Error('ENOENT')); + + const results = await storage.searchSessions('/test/project', 'hello', 'all'); + expect(results).toEqual([]); + }); + + it('should find sessions by user message content', async () => { + mockMultipleSessionFiles({ + 'session-100-sess-a.json': buildSessionJson( + [ + { type: 'user', content: 'Hello world' }, + { type: 'gemini', content: 'Hi there!' }, + ], + 'sess-a' + ), + 'session-200-sess-b.json': buildSessionJson( + [ + { type: 'user', content: 'Goodbye' }, + { type: 'gemini', content: 'Bye!' }, + ], + 'sess-b' + ), + }); + + const results = await storage.searchSessions('/test/project', 'Hello', 'user'); + expect(results).toHaveLength(1); + expect(results[0].sessionId).toBe('sess-a'); + expect(results[0].matchType).toBe('user'); + expect(results[0].matchCount).toBe(1); + }); + + it('should find sessions by assistant message content', async () => { + mockMultipleSessionFiles({ + 'session-100-sess-a.json': buildSessionJson( + [ + { type: 'user', content: 'Question' }, + { type: 'gemini', content: 'The answer is 42' }, + ], + 'sess-a' + ), + }); + + const results = await storage.searchSessions('/test/project', 'answer', 'assistant'); + expect(results).toHaveLength(1); + expect(results[0].matchType).toBe('assistant'); + }); + + it('should find sessions by title (summary)', async () => { + const content = JSON.stringify({ + sessionId: 'sess-a', + messages: [ + { type: 'user', content: 'Do something' }, + { type: 'gemini', content: 'Done' }, + ], + summary: 'Debugging the auth module', + startTime: '2026-01-01T00:00:00.000Z', + lastUpdated: '2026-01-01T01:00:00.000Z', + }); + + mockMultipleSessionFiles({ + 'session-100-sess-a.json': content, + }); + + const results = await storage.searchSessions('/test/project', 'auth module', 'title'); + expect(results).toHaveLength(1); + expect(results[0].matchType).toBe('title'); + }); + + it('should search all modes and prioritize title > user > assistant', async () => { + const content = JSON.stringify({ + sessionId: 'sess-a', + messages: [ + { type: 'user', content: 'keyword in user' }, + { type: 'gemini', content: 'keyword in assistant' }, + ], + summary: 'keyword in title', + startTime: '2026-01-01T00:00:00.000Z', + lastUpdated: '2026-01-01T01:00:00.000Z', + }); + + mockMultipleSessionFiles({ + 'session-100-sess-a.json': content, + }); + + const results = await storage.searchSessions('/test/project', 'keyword', 'all'); + expect(results).toHaveLength(1); + // Title match takes priority in 'all' mode + expect(results[0].matchType).toBe('title'); + }); + + it('should return no results when query does not match', async () => { + mockMultipleSessionFiles({ + 'session-100-sess-a.json': buildSessionJson( + [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi!' }, + ], + 'sess-a' + ), + }); + + const results = await storage.searchSessions('/test/project', 'nonexistent', 'all'); + expect(results).toEqual([]); + }); + + it('should skip empty session files', async () => { + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + return Promise.resolve( + buildSessionJson( + [ + { type: 'user', content: 'findme' }, + { type: 'gemini', content: 'ok' }, + ], + 'sess-a' + ) + ); + }); + (fs.readdir as ReturnType).mockResolvedValue([ + 'session-100-sess-a.json', + 'session-200-sess-empty.json', + ]); + (fs.stat as ReturnType).mockImplementation((filePath: string) => { + if (filePath.includes('sess-empty')) { + return Promise.resolve({ size: 0, mtimeMs: Date.now(), isDirectory: () => true }); + } + return Promise.resolve({ size: 1000, mtimeMs: Date.now(), isDirectory: () => true }); + }); + + const results = await storage.searchSessions('/test/project', 'findme', 'all'); + expect(results).toHaveLength(1); + expect(results[0].sessionId).toBe('sess-a'); + }); + + it('should not call listSessions or findSessionFile (no double-read)', async () => { + mockMultipleSessionFiles({ + 'session-100-sess-a.json': buildSessionJson( + [ + { type: 'user', content: 'test' }, + { type: 'gemini', content: 'ok' }, + ], + 'sess-a' + ), + }); + + await storage.searchSessions('/test/project', 'test', 'all'); + + // readdir should be called exactly once (for findSessionFiles), + // NOT twice (which would happen if listSessions was called + findSessionFile for each) + const readdirCalls = (fs.readdir as ReturnType).mock.calls.filter( + (call: unknown[]) => !(call[0] as string).endsWith('.gemini/history') + ); + // Only the session dir readdir, not base history dir scan + expect(readdirCalls.length).toBeLessThanOrEqual(1); + }); + + it('should be case-insensitive when matching', async () => { + mockMultipleSessionFiles({ + 'session-100-sess-a.json': buildSessionJson( + [ + { type: 'user', content: 'Hello WORLD' }, + { type: 'gemini', content: 'response' }, + ], + 'sess-a' + ), + }); + + const results = await storage.searchSessions('/test/project', 'hello world', 'user'); + expect(results).toHaveLength(1); + expect(results[0].sessionId).toBe('sess-a'); + }); + + it('should return matchPreview with first matching message content (truncated to 200 chars)', async () => { + const longContent = 'A'.repeat(300); + mockMultipleSessionFiles({ + 'session-100-sess-a.json': buildSessionJson( + [ + { type: 'user', content: longContent }, + { type: 'gemini', content: 'response' }, + ], + 'sess-a' + ), + }); + + const results = await storage.searchSessions('/test/project', 'AAAA', 'user'); + expect(results).toHaveLength(1); + expect(results[0].matchPreview).toBe('A'.repeat(200)); + expect(results[0].matchPreview.length).toBe(200); + }); + + it('should count multiple user matches in the same session', async () => { + mockMultipleSessionFiles({ + 'session-100-sess-a.json': buildSessionJson( + [ + { type: 'user', content: 'keyword first' }, + { type: 'gemini', content: 'reply' }, + { type: 'user', content: 'keyword second' }, + { type: 'gemini', content: 'reply again' }, + { type: 'user', content: 'keyword third' }, + ], + 'sess-a' + ), + }); + + const results = await storage.searchSessions('/test/project', 'keyword', 'user'); + expect(results).toHaveLength(1); + expect(results[0].matchCount).toBe(3); + // Preview should be from the first matching message + expect(results[0].matchPreview).toContain('keyword first'); + }); + + it('should count multiple assistant matches in the same session', async () => { + mockMultipleSessionFiles({ + 'session-100-sess-a.json': buildSessionJson( + [ + { type: 'user', content: 'question' }, + { type: 'gemini', content: 'answer with token' }, + { type: 'user', content: 'follow up' }, + { type: 'gemini', content: 'another token answer' }, + ], + 'sess-a' + ), + }); + + const results = await storage.searchSessions('/test/project', 'token', 'assistant'); + expect(results).toHaveLength(1); + expect(results[0].matchCount).toBe(2); + }); + + it('should fall through to user match in all mode when title does not match', async () => { + const content = JSON.stringify({ + sessionId: 'sess-a', + messages: [ + { type: 'user', content: 'findme in user message' }, + { type: 'gemini', content: 'no match here' }, + ], + summary: 'Unrelated title', + startTime: '2026-01-01T00:00:00.000Z', + lastUpdated: '2026-01-01T01:00:00.000Z', + }); + + mockMultipleSessionFiles({ + 'session-100-sess-a.json': content, + }); + + const results = await storage.searchSessions('/test/project', 'findme', 'all'); + expect(results).toHaveLength(1); + expect(results[0].matchType).toBe('user'); + expect(results[0].matchPreview).toContain('findme in user message'); + }); + + it('should fall through to assistant match in all mode when neither title nor user match', async () => { + const content = JSON.stringify({ + sessionId: 'sess-a', + messages: [ + { type: 'user', content: 'unrelated question' }, + { type: 'gemini', content: 'the secret answer is here' }, + ], + summary: 'Unrelated title', + startTime: '2026-01-01T00:00:00.000Z', + lastUpdated: '2026-01-01T01:00:00.000Z', + }); + + mockMultipleSessionFiles({ + 'session-100-sess-a.json': content, + }); + + const results = await storage.searchSessions('/test/project', 'secret answer', 'all'); + expect(results).toHaveLength(1); + expect(results[0].matchType).toBe('assistant'); + expect(results[0].matchPreview).toContain('the secret answer is here'); + }); + + it('should gracefully handle corrupted JSON files during search', async () => { + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + if (filePath.includes('sess-good')) { + return Promise.resolve( + buildSessionJson( + [ + { type: 'user', content: 'findme' }, + { type: 'gemini', content: 'ok' }, + ], + 'sess-good' + ) + ); + } + // Corrupted JSON for the other file + return Promise.resolve('{ not valid json !!!'); + }); + (fs.readdir as ReturnType).mockResolvedValue([ + 'session-100-sess-good.json', + 'session-200-sess-bad.json', + ]); + (fs.stat as ReturnType).mockResolvedValue({ + size: 1000, + mtimeMs: Date.now(), + isDirectory: () => true, + }); + + const results = await storage.searchSessions('/test/project', 'findme', 'all'); + // Should still return the good result, not throw + expect(results).toHaveLength(1); + expect(results[0].sessionId).toBe('sess-good'); + }); + + it('should use title preview as fallback when message preview is empty', async () => { + const content = JSON.stringify({ + sessionId: 'sess-a', + messages: [{ type: 'user', content: 'searchterm' }], + summary: 'Summary with searchterm', + startTime: '2026-01-01T00:00:00.000Z', + lastUpdated: '2026-01-01T01:00:00.000Z', + }); + + mockMultipleSessionFiles({ + 'session-100-sess-a.json': content, + }); + + // Title mode — matchPreview should come from summary + const results = await storage.searchSessions('/test/project', 'searchterm', 'title'); + expect(results).toHaveLength(1); + expect(results[0].matchPreview).toContain('Summary with searchterm'); + }); + + it('should search across multiple sessions and return all matches', async () => { + mockMultipleSessionFiles({ + 'session-100-sess-a.json': buildSessionJson( + [ + { type: 'user', content: 'common keyword here' }, + { type: 'gemini', content: 'response' }, + ], + 'sess-a' + ), + 'session-200-sess-b.json': buildSessionJson( + [ + { type: 'user', content: 'also has common keyword' }, + { type: 'gemini', content: 'reply' }, + ], + 'sess-b' + ), + 'session-300-sess-c.json': buildSessionJson( + [ + { type: 'user', content: 'no match at all' }, + { type: 'gemini', content: 'nothing' }, + ], + 'sess-c' + ), + }); + + const results = await storage.searchSessions('/test/project', 'common keyword', 'user'); + expect(results).toHaveLength(2); + const ids = results.map((r) => r.sessionId).sort(); + expect(ids).toEqual(['sess-a', 'sess-b']); + }); + + it('should skip info/error/warning messages during search', async () => { + mockMultipleSessionFiles({ + 'session-100-sess-a.json': buildSessionJson( + [ + { type: 'info', content: 'keyword in info' }, + { type: 'error', content: 'keyword in error' }, + { type: 'warning', content: 'keyword in warning' }, + { type: 'user', content: 'no match here' }, + { type: 'gemini', content: 'no match either' }, + ], + 'sess-a' + ), + }); + + const results = await storage.searchSessions('/test/project', 'keyword', 'all'); + expect(results).toEqual([]); + }); + + it('should use query as last-resort matchPreview when no other preview available', async () => { + // Edge case: title matches but messages array is empty + const content = JSON.stringify({ + sessionId: 'sess-a', + messages: [], + summary: 'findme', + startTime: '2026-01-01T00:00:00.000Z', + lastUpdated: '2026-01-01T01:00:00.000Z', + }); + + mockMultipleSessionFiles({ + 'session-100-sess-a.json': content, + }); + + const results = await storage.searchSessions('/test/project', 'findme', 'title'); + expect(results).toHaveLength(1); + // matchPreview should have the title as preview + expect(results[0].matchPreview).toBe('findme'); + }); + }); + + describe('listSessionsPaginated', () => { + function mockPaginatedFiles(count: number) { + const filenames = Array.from( + { length: count }, + (_, i) => `session-${String(i).padStart(3, '0')}-sess-${i}.json` + ); + + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readdir as ReturnType).mockResolvedValue(filenames); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + // Extract session ID from the file path + const match = filePath.match(/sess-(\d+)\.json$/); + const idx = match ? parseInt(match[1], 10) : 0; + return Promise.resolve( + buildSessionJson( + [ + { type: 'user', content: `Message ${idx}` }, + { type: 'gemini', content: `Response ${idx}` }, + ], + `sess-${idx}` + ) + ); + }); + // Each file gets a different mtime for sorting (higher index = newer) + (fs.stat as ReturnType).mockImplementation((filePath: string) => { + const match = (filePath as string).match(/sess-(\d+)\.json$/); + const idx = match ? parseInt(match[1], 10) : 0; + return Promise.resolve({ + size: 1000, + mtimeMs: 1000000 + idx * 1000, + mtime: new Date(1000000 + idx * 1000), + isDirectory: () => true, + }); + }); + } + + it('should return first page of sessions', async () => { + mockPaginatedFiles(5); + + const result = await storage.listSessionsPaginated('/test/project', { limit: 3 }); + + expect(result.sessions).toHaveLength(3); + expect(result.totalCount).toBe(5); + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBeDefined(); + }); + + it('should return all sessions when limit exceeds count', async () => { + mockPaginatedFiles(3); + + const result = await storage.listSessionsPaginated('/test/project', { limit: 10 }); + + expect(result.sessions).toHaveLength(3); + expect(result.totalCount).toBe(3); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); + }); + + it('should paginate from cursor position', async () => { + mockPaginatedFiles(5); + + // First page + const page1 = await storage.listSessionsPaginated('/test/project', { limit: 2 }); + expect(page1.sessions).toHaveLength(2); + expect(page1.hasMore).toBe(true); + + // Second page using cursor + const page2 = await storage.listSessionsPaginated('/test/project', { + limit: 2, + cursor: page1.nextCursor!, + }); + expect(page2.sessions).toHaveLength(2); + expect(page2.hasMore).toBe(true); + + // Third page + const page3 = await storage.listSessionsPaginated('/test/project', { + limit: 2, + cursor: page2.nextCursor!, + }); + expect(page3.sessions).toHaveLength(1); + expect(page3.hasMore).toBe(false); + + // No duplicates across pages + const allIds = [ + ...page1.sessions.map((s) => s.sessionId), + ...page2.sessions.map((s) => s.sessionId), + ...page3.sessions.map((s) => s.sessionId), + ]; + expect(new Set(allIds).size).toBe(5); + }); + + it('should return empty result when no history dir exists', async () => { + (fs.access as ReturnType).mockRejectedValue(new Error('ENOENT')); + (fs.readdir as ReturnType).mockRejectedValue(new Error('ENOENT')); + + const result = await storage.listSessionsPaginated('/test/project'); + + expect(result.sessions).toEqual([]); + expect(result.totalCount).toBe(0); + expect(result.hasMore).toBe(false); + }); + + it('should sort by mtime descending (newest first)', async () => { + mockPaginatedFiles(3); + + const result = await storage.listSessionsPaginated('/test/project', { limit: 10 }); + + // Higher index = newer mtime, so should come first + expect(result.sessions[0].sessionId).toBe('sess-2'); + expect(result.sessions[1].sessionId).toBe('sess-1'); + expect(result.sessions[2].sessionId).toBe('sess-0'); + }); + + it('should only parse files in page range (not all files)', async () => { + mockPaginatedFiles(10); + + await storage.listSessionsPaginated('/test/project', { limit: 3 }); + + // readFile should be called for .project_root + only 3 session files (not all 10) + const readFileCalls = (fs.readFile as ReturnType).mock.calls; + const sessionReadCalls = readFileCalls.filter( + (call: unknown[]) => + (call[0] as string).includes('sess-') && (call[0] as string).endsWith('.json') + ); + expect(sessionReadCalls.length).toBe(3); + }); + + it('should skip empty files', async () => { + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readdir as ReturnType).mockResolvedValue([ + 'session-100-sess-a.json', + 'session-200-sess-empty.json', + ]); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + return Promise.resolve( + buildSessionJson( + [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi' }, + ], + 'sess-a' + ) + ); + }); + (fs.stat as ReturnType).mockImplementation((filePath: string) => { + if ((filePath as string).includes('sess-empty')) { + return Promise.resolve({ size: 0, mtimeMs: Date.now(), isDirectory: () => true }); + } + return Promise.resolve({ size: 1000, mtimeMs: Date.now(), isDirectory: () => true }); + }); + + const result = await storage.listSessionsPaginated('/test/project'); + expect(result.sessions).toHaveLength(1); + expect(result.totalCount).toBe(1); + }); + + it('should reset to first page when cursor is not found', async () => { + mockPaginatedFiles(5); + + const result = await storage.listSessionsPaginated('/test/project', { + limit: 3, + cursor: 'nonexistent-cursor-id', + }); + + // When cursor not found, startIndex falls back to 0 + expect(result.sessions).toHaveLength(3); + expect(result.totalCount).toBe(5); + expect(result.hasMore).toBe(true); + // Should return from the beginning (newest first) + expect(result.sessions[0].sessionId).toBe('sess-4'); + }); + + it('should return empty page when cursor is the last session', async () => { + mockPaginatedFiles(3); + + // Get sessions to find the last one (oldest, since sorted newest-first) + const all = await storage.listSessionsPaginated('/test/project', { limit: 10 }); + const lastSessionId = all.sessions[all.sessions.length - 1].sessionId; + + const result = await storage.listSessionsPaginated('/test/project', { + limit: 10, + cursor: lastSessionId, + }); + + expect(result.sessions).toHaveLength(0); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); + expect(result.totalCount).toBe(3); + }); + + it('should use default limit of 100 when no options provided', async () => { + mockPaginatedFiles(5); + + const result = await storage.listSessionsPaginated('/test/project'); + + expect(result.sessions).toHaveLength(5); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); + }); + + it('should return empty result when directory has no session files', async () => { + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readdir as ReturnType).mockResolvedValue([]); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + return Promise.reject(new Error('ENOENT')); + }); + (fs.stat as ReturnType).mockResolvedValue({ + size: 0, + mtimeMs: Date.now(), + isDirectory: () => true, + }); + + const result = await storage.listSessionsPaginated('/test/project', { limit: 10 }); + + expect(result.sessions).toEqual([]); + expect(result.totalCount).toBe(0); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); + }); + + it('should handle stat failures gracefully by excluding failed files', async () => { + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readdir as ReturnType).mockResolvedValue([ + 'session-100-sess-good.json', + 'session-200-sess-statfail.json', + ]); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + return Promise.resolve( + buildSessionJson( + [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi' }, + ], + 'sess-good' + ) + ); + }); + (fs.stat as ReturnType).mockImplementation((filePath: string) => { + if ((filePath as string).includes('sess-statfail')) { + return Promise.reject(new Error('EACCES')); + } + return Promise.resolve({ + size: 1000, + mtimeMs: Date.now(), + isDirectory: () => true, + }); + }); + + const result = await storage.listSessionsPaginated('/test/project'); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0].sessionId).toBe('sess-good'); + expect(result.totalCount).toBe(1); + }); + + it('should maintain correct totalCount across paginated requests', async () => { + mockPaginatedFiles(7); + + const page1 = await storage.listSessionsPaginated('/test/project', { limit: 3 }); + const page2 = await storage.listSessionsPaginated('/test/project', { + limit: 3, + cursor: page1.nextCursor!, + }); + const page3 = await storage.listSessionsPaginated('/test/project', { + limit: 3, + cursor: page2.nextCursor!, + }); + + // totalCount should be consistent across all pages + expect(page1.totalCount).toBe(7); + expect(page2.totalCount).toBe(7); + expect(page3.totalCount).toBe(7); + + // Verify page sizes + expect(page1.sessions).toHaveLength(3); + expect(page1.hasMore).toBe(true); + expect(page2.sessions).toHaveLength(3); + expect(page2.hasMore).toBe(true); + expect(page3.sessions).toHaveLength(1); + expect(page3.hasMore).toBe(false); + }); + }); + + describe('getHistoryDir caching', () => { + it('should cache getHistoryDir results and return cached value on second call', async () => { + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + return Promise.resolve(buildSessionJson([], 'test')); + }); + (fs.readdir as ReturnType).mockResolvedValue([]); + (fs.stat as ReturnType).mockResolvedValue({ + size: 0, + mtimeMs: Date.now(), + isDirectory: () => true, + }); + + // First call — triggers filesystem access + await storage.listSessions('/test/project'); + const accessCallCount1 = (fs.access as ReturnType).mock.calls.length; + + // Second call — should use cache (no new fs.access calls for getHistoryDir) + await storage.listSessions('/test/project'); + const accessCallCount2 = (fs.access as ReturnType).mock.calls.length; + + // Second call should NOT add new access calls for directory resolution + expect(accessCallCount2).toBe(accessCallCount1); + }); + }); + + describe('file size guards (MAX_SESSION_FILE_SIZE)', () => { + const OVERSIZED = 51 * 1024 * 1024; // 51 MB — exceeds 50 MB limit + + describe('parseSessionFile (via listSessions)', () => { + it('should return minimal metadata for oversized files instead of parsing', async () => { + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readdir as ReturnType).mockResolvedValue(['session-100-sess-big.json']); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + // Should NOT be called for session file — size guard should prevent it + return Promise.resolve( + buildSessionJson([{ type: 'user', content: 'Should not read' }], 'sess-big') + ); + }); + (fs.stat as ReturnType).mockResolvedValue({ + size: OVERSIZED, + mtimeMs: 1700000000000, + isDirectory: () => true, + }); + + const sessions = await storage.listSessions('/test/project'); + + expect(sessions).toHaveLength(1); + expect(sessions[0].sessionId).toBe('sess-big'); + expect(sessions[0].sessionName).toContain('oversized'); + expect(sessions[0].firstMessage).toContain('too large'); + expect(sessions[0].messageCount).toBe(0); + expect(sessions[0].sizeBytes).toBe(OVERSIZED); + }); + + it('should still parse normal-sized files alongside oversized ones', async () => { + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readdir as ReturnType).mockResolvedValue([ + 'session-100-sess-big.json', + 'session-200-sess-ok.json', + ]); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + return Promise.resolve( + buildSessionJson( + [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi' }, + ], + 'sess-ok' + ) + ); + }); + (fs.stat as ReturnType).mockImplementation((filePath: string) => { + if ((filePath as string).includes('sess-big')) { + return Promise.resolve({ + size: OVERSIZED, + mtimeMs: 1700000000000, + isDirectory: () => true, + }); + } + return Promise.resolve({ + size: 1000, + mtimeMs: 1700000001000, + isDirectory: () => true, + }); + }); + + const sessions = await storage.listSessions('/test/project'); + + expect(sessions).toHaveLength(2); + const bigSession = sessions.find((s) => s.sessionId === 'sess-big'); + const okSession = sessions.find((s) => s.sessionId === 'sess-ok'); + expect(bigSession?.sessionName).toContain('oversized'); + expect(okSession?.messageCount).toBe(2); + }); + }); + + describe('readSessionMessages', () => { + it('should return empty result with error for oversized files', async () => { + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + return Promise.resolve( + buildSessionJson([{ type: 'user', content: 'Should not read' }], 'test-session-id') + ); + }); + (fs.readdir as ReturnType).mockResolvedValue([ + 'session-123-test-session-id.json', + ]); + (fs.stat as ReturnType).mockResolvedValue({ + size: OVERSIZED, + mtimeMs: Date.now(), + isDirectory: () => true, + }); + + const result = await storage.readSessionMessages('/test/project', 'test-session-id', { + limit: 100, + }); + + expect(result.messages).toEqual([]); + expect(result.total).toBe(0); + expect(result.hasMore).toBe(false); + expect(result.error).toContain('maximum size'); + }); + }); + + describe('searchSessions', () => { + it('should skip oversized files with warning', async () => { + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readdir as ReturnType).mockResolvedValue([ + 'session-100-sess-big.json', + 'session-200-sess-ok.json', + ]); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + return Promise.resolve( + buildSessionJson( + [ + { type: 'user', content: 'findme keyword' }, + { type: 'gemini', content: 'Found it' }, + ], + 'sess-ok' + ) + ); + }); + (fs.stat as ReturnType).mockImplementation((filePath: string) => { + if ((filePath as string).includes('sess-big')) { + return Promise.resolve({ + size: OVERSIZED, + mtimeMs: Date.now(), + isDirectory: () => true, + }); + } + return Promise.resolve({ + size: 1000, + mtimeMs: Date.now(), + isDirectory: () => true, + }); + }); + + const results = await storage.searchSessions('/test/project', 'findme', 'all'); + + // Only the normal-sized file should be searched + expect(results).toHaveLength(1); + expect(results[0].sessionId).toBe('sess-ok'); + }); + }); + + describe('deleteMessagePair', () => { + it('should reject deletion of oversized files', async () => { + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + return Promise.resolve( + buildSessionJson( + [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi' }, + ], + 'test-session-id' + ) + ); + }); + (fs.readdir as ReturnType).mockResolvedValue([ + 'session-123-test-session-id.json', + ]); + (fs.stat as ReturnType).mockResolvedValue({ + size: OVERSIZED, + mtimeMs: Date.now(), + isDirectory: () => true, + }); + + const result = await storage.deleteMessagePair('/test/project', 'test-session-id', '0'); + + expect(result.success).toBe(false); + expect(result.error).toContain('maximum size'); + }); + + it('should allow deletion of normal-sized files', async () => { + const sessionContent = buildSessionJson([ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi' }, + ]); + mockFindSessionFile(sessionContent); + + const result = await storage.deleteMessagePair('/test/project', 'test-session-id', '0'); + + expect(result.success).toBe(true); + }); + }); + }); + + describe('bounded concurrency in listSessions', () => { + it('should process multiple session files concurrently', async () => { + const filenames = Array.from({ length: 5 }, (_, i) => `session-${i}-sess-${i}.json`); + + (fs.access as ReturnType).mockResolvedValue(undefined); + (fs.readdir as ReturnType).mockResolvedValue(filenames); + (fs.readFile as ReturnType).mockImplementation((filePath: string) => { + if (filePath.endsWith('.project_root')) { + return Promise.resolve('/test/project'); + } + return Promise.resolve( + buildSessionJson( + [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hi!' }, + ], + 'test' + ) + ); + }); + (fs.stat as ReturnType).mockResolvedValue({ + size: 1000, + mtimeMs: Date.now(), + isDirectory: () => true, + }); + + const sessions = await storage.listSessions('/test/project'); + + // All 5 files should be processed + expect(sessions).toHaveLength(5); + }); + }); +}); diff --git a/src/__tests__/main/stores/instances.test.ts b/src/__tests__/main/stores/instances.test.ts index 212d0e150e..ff7bfd6587 100644 --- a/src/__tests__/main/stores/instances.test.ts +++ b/src/__tests__/main/stores/instances.test.ts @@ -60,8 +60,8 @@ describe('stores/instances', () => { it('should initialize all stores', () => { const result = initializeStores({ productionDataPath: '/mock/production/path' }); - // Should create 8 stores - expect(mockStoreConstructorCalls).toHaveLength(8); + // Should create 9 stores (including gemini-session-stats) + expect(mockStoreConstructorCalls).toHaveLength(9); // Should return syncPath and bootstrapStore expect(result.syncPath).toBe('/mock/user/data'); diff --git a/src/__tests__/main/utils/agent-args.test.ts b/src/__tests__/main/utils/agent-args.test.ts index 8381588c18..36aba09f16 100644 --- a/src/__tests__/main/utils/agent-args.test.ts +++ b/src/__tests__/main/utils/agent-args.test.ts @@ -4,7 +4,7 @@ * Covers buildAgentArgs, applyAgentConfigOverrides, and getContextWindowValue. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { buildAgentArgs, applyAgentConfigOverrides, @@ -12,6 +12,17 @@ import { } from '../../../main/utils/agent-args'; import type { AgentConfig } from '../../../main/agents'; +vi.mock('../../../main/utils/logger', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + toast: vi.fn(), + autorun: vi.fn(), + }, +})); + /** * Helper to create a minimal AgentConfig for testing. * Only the fields relevant to agent-args are populated. @@ -220,6 +231,49 @@ describe('buildAgentArgs', () => { expect(result).toEqual(['--print']); }); + it('skips resumeArgs when agentSessionId contains invalid characters', async () => { + const { logger } = await import('../../../main/utils/logger'); + vi.mocked(logger.warn).mockClear(); + + const agent = makeAgent({ + resumeArgs: (sid: string) => ['--resume', sid], + }); + const result = buildAgentArgs(agent, { + baseArgs: ['--print'], + agentSessionId: 'sess; rm -rf /', + }); + expect(result).toEqual(['--print']); + expect(logger.warn).toHaveBeenCalledWith( + 'Invalid agentSessionId format, skipping resume args', + 'AgentArgs', + { agentSessionId: 'sess; rm -rf /' } + ); + }); + + it('allows valid session IDs with dots, colons, and hyphens', () => { + const agent = makeAgent({ + resumeArgs: (sid: string) => ['--resume', sid], + }); + const result = buildAgentArgs(agent, { + baseArgs: ['--print'], + agentSessionId: 'session-2025:03.08_abc', + }); + expect(result).toEqual(['--print', '--resume', 'session-2025:03.08_abc']); + }); + + it('rejects session IDs with shell metacharacters', () => { + const agent = makeAgent({ + resumeArgs: (sid: string) => ['--resume', sid], + }); + for (const bad of ['id$(cmd)', 'id`cmd`', 'id|pipe', 'id&bg', 'id>file']) { + const result = buildAgentArgs(agent, { + baseArgs: [], + agentSessionId: bad, + }); + expect(result).toEqual([]); + } + }); + // -- combined -- it('combines multiple options together', () => { const agent = makeAgent({ @@ -243,10 +297,11 @@ describe('buildAgentArgs', () => { agentSessionId: 'abc', }); + // batchModeArgs (--skip-git) is omitted when readOnlyMode is true — + // batch mode args grant write/approval permissions that conflict with read-only expect(result).toEqual([ 'run', '--print', - '--skip-git', '--format', 'json', '-C', @@ -261,6 +316,97 @@ describe('buildAgentArgs', () => { ]); }); + // -- readOnlyMode + batchModeArgs interaction (TASK-S05) -- + it('skips batchModeArgs when readOnlyMode is true even with empty readOnlyArgs', () => { + // Gemini CLI scenario: readOnlyArgs is [] but -y should still be skipped + const agent = makeAgent({ + batchModeArgs: ['-y'], + readOnlyArgs: [], + }); + const result = buildAgentArgs(agent, { + baseArgs: ['--output-format', 'stream-json'], + prompt: 'analyze this code', + readOnlyMode: true, + }); + expect(result).not.toContain('-y'); + expect(result).toEqual(['--output-format', 'stream-json']); + }); + + it('skips batchModeArgs when readOnlyMode is true and readOnlyArgs is undefined', () => { + const agent = makeAgent({ + batchModeArgs: ['--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check'], + }); + const result = buildAgentArgs(agent, { + baseArgs: ['--json'], + prompt: 'review code', + readOnlyMode: true, + }); + expect(result).not.toContain('--dangerously-bypass-approvals-and-sandbox'); + expect(result).not.toContain('--skip-git-repo-check'); + }); + + it('includes batchModeArgs when readOnlyMode is false even with empty readOnlyArgs', () => { + const agent = makeAgent({ + batchModeArgs: ['-y'], + readOnlyArgs: [], + }); + const result = buildAgentArgs(agent, { + baseArgs: ['--output-format', 'stream-json'], + prompt: 'fix this bug', + readOnlyMode: false, + }); + expect(result).toContain('-y'); + }); + + it('logs warning when readOnlyMode requested and readOnlyCliEnforced is false', async () => { + const { logger } = await import('../../../main/utils/logger'); + vi.mocked(logger.warn).mockClear(); + + const agent = makeAgent({ + readOnlyArgs: [], + readOnlyCliEnforced: false, + }); + buildAgentArgs(agent, { + baseArgs: [], + readOnlyMode: true, + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('read-only mode requested but no CLI-level enforcement'), + 'AgentArgs', + { agentId: 'test-agent' } + ); + }); + + it('does not log warning when readOnlyCliEnforced is true', async () => { + const { logger } = await import('../../../main/utils/logger'); + vi.mocked(logger.warn).mockClear(); + + const agent = makeAgent({ + readOnlyArgs: ['--agent', 'plan'], + readOnlyCliEnforced: true, + }); + buildAgentArgs(agent, { + baseArgs: [], + readOnlyMode: true, + }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('does not log warning when readOnlyCliEnforced is undefined', async () => { + const { logger } = await import('../../../main/utils/logger'); + vi.mocked(logger.warn).mockClear(); + + const agent = makeAgent({ + readOnlyArgs: ['--sandbox', 'read-only'], + }); + buildAgentArgs(agent, { + baseArgs: [], + readOnlyMode: true, + }); + // readOnlyCliEnforced is undefined (not explicitly false), so no warning + expect(logger.warn).not.toHaveBeenCalled(); + }); + it('does not mutate the original baseArgs array', () => { const baseArgs = ['--print']; const agent = makeAgent({ jsonOutputArgs: ['--format', 'json'] }); @@ -527,6 +673,91 @@ describe('applyAgentConfigOverrides', () => { }); }); +// --------------------------------------------------------------------------- +// custom args denylist (TASK-S01) +// --------------------------------------------------------------------------- +describe('applyAgentConfigOverrides — denied custom args', () => { + it('strips --no-sandbox from custom args', () => { + const agent = makeAgent(); + const result = applyAgentConfigOverrides(agent, ['--print'], { + sessionCustomArgs: '--no-sandbox --verbose', + }); + expect(result.args).toContain('--verbose'); + expect(result.args).not.toContain('--no-sandbox'); + }); + + it('strips all denied flags from custom args', () => { + const agent = makeAgent(); + const deniedFlags = [ + '--no-sandbox', + '--include-directories', + '--dangerous-auto-approve', + '--dangerously-skip-permissions', + '--dangerously-bypass-approvals-and-sandbox', + '--approval-mode', + '-y', + ]; + + const result = applyAgentConfigOverrides(agent, [], { + sessionCustomArgs: deniedFlags.join(' '), + }); + + for (const flag of deniedFlags) { + expect(result.args).not.toContain(flag); + } + // All args were denied, so source should be 'none' + expect(result.customArgsSource).toBe('none'); + }); + + it('passes through clean custom args unchanged', () => { + const agent = makeAgent(); + const result = applyAgentConfigOverrides(agent, [], { + sessionCustomArgs: '--model gemini-2.0 --verbose --temperature 0.5', + }); + expect(result.args).toEqual(['--model', 'gemini-2.0', '--verbose', '--temperature', '0.5']); + expect(result.customArgsSource).toBe('session'); + }); + + it('filters denied flags from mixed custom args', () => { + const agent = makeAgent(); + const result = applyAgentConfigOverrides(agent, ['--base'], { + sessionCustomArgs: '--verbose --no-sandbox --output json -y --debug', + }); + expect(result.args).toEqual(['--base', '--verbose', '--output', 'json', '--debug']); + expect(result.args).not.toContain('--no-sandbox'); + expect(result.args).not.toContain('-y'); + expect(result.customArgsSource).toBe('session'); + }); + + it('logs a warning for each denied arg', async () => { + const { logger } = await import('../../../main/utils/logger'); + vi.mocked(logger.warn).mockClear(); + + const agent = makeAgent(); + applyAgentConfigOverrides(agent, [], { + sessionCustomArgs: '--no-sandbox -y --verbose', + }); + + expect(logger.warn).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith('Stripped denied custom arg', 'AgentArgs', { + arg: '--no-sandbox', + }); + expect(logger.warn).toHaveBeenCalledWith('Stripped denied custom arg', 'AgentArgs', { + arg: '-y', + }); + }); + + it('applies denylist to agent-level customArgs too', () => { + const agent = makeAgent(); + const result = applyAgentConfigOverrides(agent, [], { + agentConfigValues: { customArgs: '--dangerously-skip-permissions --safe-flag' }, + }); + expect(result.args).toContain('--safe-flag'); + expect(result.args).not.toContain('--dangerously-skip-permissions'); + expect(result.customArgsSource).toBe('agent'); + }); +}); + // --------------------------------------------------------------------------- // getContextWindowValue // --------------------------------------------------------------------------- diff --git a/src/__tests__/main/utils/path-validation.test.ts b/src/__tests__/main/utils/path-validation.test.ts new file mode 100644 index 0000000000..f44cbb6217 --- /dev/null +++ b/src/__tests__/main/utils/path-validation.test.ts @@ -0,0 +1,218 @@ +/** + * Tests for src/main/utils/path-validation.ts + */ + +import { describe, it, expect, vi } from 'vitest'; +import * as os from 'os'; +import * as fs from 'fs/promises'; +import { + normalizeApprovalPath, + normalizeApprovalPathSync, + isSystemPath, + isWithinProjectScope, +} from '../../../main/utils/path-validation'; + +vi.mock('fs/promises', () => ({ + realpath: vi.fn(), +})); + +const HOME = os.homedir(); + +describe('path-validation', () => { + describe('normalizeApprovalPathSync', () => { + it('expands tilde to home directory', () => { + const result = normalizeApprovalPathSync('~/projects/foo', '/tmp/cwd'); + expect(result).toBe(`${HOME}/projects/foo`); + }); + + it('resolves relative paths against CWD', () => { + const result = normalizeApprovalPathSync('../sibling', '/home/user/project'); + expect(result).toBe('/home/user/sibling'); + }); + + it('passes through absolute paths unchanged', () => { + const result = normalizeApprovalPathSync('/home/user/data', '/tmp/cwd'); + expect(result).toBe('/home/user/data'); + }); + + it('resolves dot-dot traversal', () => { + const result = normalizeApprovalPathSync('../..', '/home/user/project'); + expect(result).toBe('/home'); + }); + + it('resolves current directory reference', () => { + const result = normalizeApprovalPathSync('.', '/home/user/project'); + expect(result).toBe('/home/user/project'); + }); + }); + + describe('normalizeApprovalPath (async)', () => { + it('expands tilde and resolves', async () => { + vi.mocked(fs.realpath).mockRejectedValueOnce(new Error('ENOENT')); + const result = await normalizeApprovalPath('~/projects/foo', '/tmp/cwd'); + expect(result.normalized).toBe(`${HOME}/projects/foo`); + }); + + it('returns symlinkTarget when realpath differs', async () => { + vi.mocked(fs.realpath).mockResolvedValueOnce('/real/target/path' as never); + const result = await normalizeApprovalPath('/some/link', '/tmp/cwd'); + expect(result.symlinkTarget).toBe('/real/target/path'); + expect(result.normalized).toBe('/real/target/path'); + }); + + it('omits symlinkTarget when realpath matches', async () => { + vi.mocked(fs.realpath).mockResolvedValueOnce('/some/path' as never); + const result = await normalizeApprovalPath('/some/path', '/tmp/cwd'); + expect(result.symlinkTarget).toBeUndefined(); + }); + + it('falls back gracefully when realpath fails', async () => { + vi.mocked(fs.realpath).mockRejectedValueOnce(new Error('ENOENT')); + const result = await normalizeApprovalPath('/nonexistent/path', '/tmp/cwd'); + expect(result.normalized).toBe('/nonexistent/path'); + expect(result.symlinkTarget).toBeUndefined(); + }); + }); + + describe('isSystemPath', () => { + // POSIX system paths + it('rejects root /', () => { + expect(isSystemPath('/')).toBe(true); + }); + + it('rejects /etc', () => { + expect(isSystemPath('/etc')).toBe(true); + }); + + it('rejects /etc/subdir', () => { + expect(isSystemPath('/etc/nginx')).toBe(true); + }); + + it('rejects /usr', () => { + expect(isSystemPath('/usr')).toBe(true); + }); + + it('rejects /usr/local/bin', () => { + expect(isSystemPath('/usr/local/bin')).toBe(true); + }); + + it('rejects /var', () => { + expect(isSystemPath('/var')).toBe(true); + }); + + it('rejects /root', () => { + expect(isSystemPath('/root')).toBe(true); + }); + + it('rejects /bin', () => { + expect(isSystemPath('/bin')).toBe(true); + }); + + it('rejects /boot', () => { + expect(isSystemPath('/boot')).toBe(true); + }); + + it('rejects /dev', () => { + expect(isSystemPath('/dev')).toBe(true); + }); + + it('rejects /proc', () => { + expect(isSystemPath('/proc')).toBe(true); + }); + + it('rejects /sys', () => { + expect(isSystemPath('/sys')).toBe(true); + }); + + // Paths under root but in user space + it('allows /home/user', () => { + expect(isSystemPath('/home/user')).toBe(false); + }); + + it('allows /home/user/project', () => { + expect(isSystemPath('/home/user/project')).toBe(false); + }); + + it('allows /Users/user (macOS)', () => { + expect(isSystemPath('/Users/user')).toBe(false); + }); + + it('allows /tmp/data', () => { + expect(isSystemPath('/tmp/data')).toBe(false); + }); + + // Trailing slashes + it('strips trailing slashes before checking', () => { + expect(isSystemPath('/etc/')).toBe(true); + expect(isSystemPath('/home/user/')).toBe(false); + }); + + // Windows paths + it('rejects Windows drive root C:\\', () => { + expect(isSystemPath('C:\\')).toBe(true); + }); + + it('rejects bare Windows drive C:', () => { + expect(isSystemPath('C:')).toBe(true); + }); + + it('rejects C:\\Windows', () => { + expect(isSystemPath('C:\\Windows')).toBe(true); + }); + + it('rejects C:\\Windows\\System32', () => { + expect(isSystemPath('C:\\Windows\\System32')).toBe(true); + }); + + it('rejects C:\\System32', () => { + expect(isSystemPath('C:\\System32')).toBe(true); + }); + + it('rejects C:\\Program Files', () => { + expect(isSystemPath('C:\\Program Files')).toBe(true); + }); + + it('allows C:\\Users\\dev\\project', () => { + expect(isSystemPath('C:\\Users\\dev\\project')).toBe(false); + }); + + it('allows D:\\projects', () => { + expect(isSystemPath('D:\\projects')).toBe(false); + }); + }); + + describe('isWithinProjectScope', () => { + it('returns true for path under project CWD', () => { + expect(isWithinProjectScope(`${HOME}/project/src`, `${HOME}/project`)).toBe(true); + }); + + it('returns true for exact project CWD', () => { + expect(isWithinProjectScope(`${HOME}/project`, `${HOME}/project`)).toBe(true); + }); + + it('returns true for path under home directory', () => { + expect(isWithinProjectScope(`${HOME}/other-project`, '/opt/elsewhere')).toBe(true); + }); + + it('returns false for path outside project and home', () => { + expect(isWithinProjectScope('/tmp/random', `${HOME}/project`)).toBe(false); + }); + + it('returns true for sibling directory (under homedir)', () => { + expect(isWithinProjectScope(`${HOME}/other-project`, `${HOME}/project`)).toBe(true); + }); + + it('returns false for completely unrelated path outside home', () => { + expect(isWithinProjectScope('/opt/data', `${HOME}/project`)).toBe(false); + }); + + it('does not match CWD prefix without separator', () => { + // project-evil is NOT under project, but IS under homedir + expect(isWithinProjectScope(`${HOME}/project-evil`, `${HOME}/project`)).toBe(true); + }); + + it('rejects path outside both CWD and home', () => { + expect(isWithinProjectScope('/mnt/external', `${HOME}/project`)).toBe(false); + }); + }); +}); diff --git a/src/__tests__/performance/AutoRunLargeDocument.test.tsx b/src/__tests__/performance/AutoRunLargeDocument.test.tsx index d17223e471..3c731ecdd6 100644 --- a/src/__tests__/performance/AutoRunLargeDocument.test.tsx +++ b/src/__tests__/performance/AutoRunLargeDocument.test.tsx @@ -44,6 +44,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/performance/AutoRunManyDocuments.test.tsx b/src/__tests__/performance/AutoRunManyDocuments.test.tsx index 05da0da350..a11d0d5612 100644 --- a/src/__tests__/performance/AutoRunManyDocuments.test.tsx +++ b/src/__tests__/performance/AutoRunManyDocuments.test.tsx @@ -48,6 +48,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/performance/AutoRunMemoryLeaks.test.tsx b/src/__tests__/performance/AutoRunMemoryLeaks.test.tsx index fae14ea322..da82122536 100644 --- a/src/__tests__/performance/AutoRunMemoryLeaks.test.tsx +++ b/src/__tests__/performance/AutoRunMemoryLeaks.test.tsx @@ -47,6 +47,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({ @@ -881,7 +882,7 @@ describe('AutoRun Memory Leak Detection', () => { for (const sessionId of sessions) { const props = createDefaultProps({ sessionId, - folderPath: `/projects/${sessionId}/Auto Run Docs`, + folderPath: `/projects/${sessionId}/.maestro/playbooks`, content: `# ${sessionId} Content cycle ${cycle}`, }); @@ -893,7 +894,7 @@ describe('AutoRun Memory Leak Detection', () => { // Add some cache entries imageCache.set( - `/projects/${sessionId}/Auto Run Docs:images/img${cycle}.png`, + `/projects/${sessionId}/.maestro/playbooks:images/img${cycle}.png`, `data${cycle}` ); diff --git a/src/__tests__/performance/AutoRunRapidInteractions.test.tsx b/src/__tests__/performance/AutoRunRapidInteractions.test.tsx index e09c0f88dc..4dd6aad21d 100644 --- a/src/__tests__/performance/AutoRunRapidInteractions.test.tsx +++ b/src/__tests__/performance/AutoRunRapidInteractions.test.tsx @@ -45,6 +45,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({ @@ -266,7 +267,7 @@ function generateSessionData( sessions.push({ id: `session-${i}`, content: `# Session ${i} Content\n\n- [ ] Task ${i}.1\n- [x] Task ${i}.2\n- [ ] Task ${i}.3\n\nContent specific to session ${i}.`, - folderPath: `/projects/project-${i}/Auto Run Docs`, + folderPath: `/projects/project-${i}/.maestro/playbooks`, }); } return sessions; diff --git a/src/__tests__/renderer/components/AgentSessionsModal.test.tsx b/src/__tests__/renderer/components/AgentSessionsModal.test.tsx index 66a6b5736b..516eaf0652 100644 --- a/src/__tests__/renderer/components/AgentSessionsModal.test.tsx +++ b/src/__tests__/renderer/components/AgentSessionsModal.test.tsx @@ -70,6 +70,8 @@ const createMockSession = (overrides: Partial = {}): Session => fileTree: [], fileExplorerExpanded: [], messageQueue: [], + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }) as Session; diff --git a/src/__tests__/renderer/components/AppConfirmModals.test.tsx b/src/__tests__/renderer/components/AppConfirmModals.test.tsx index 6e2f2e182c..08c1080e45 100644 --- a/src/__tests__/renderer/components/AppConfirmModals.test.tsx +++ b/src/__tests__/renderer/components/AppConfirmModals.test.tsx @@ -50,6 +50,8 @@ function createMockSession(overrides: Partial): Session { state: 'idle', toolType: 'claude-code', cwd: '/tmp', + terminalTabs: [], + activeTerminalTabId: null, ...overrides, } as Session; } diff --git a/src/__tests__/renderer/components/AppModals-selfSourced.test.tsx b/src/__tests__/renderer/components/AppModals-selfSourced.test.tsx index 27686728ee..e5876603a8 100644 --- a/src/__tests__/renderer/components/AppModals-selfSourced.test.tsx +++ b/src/__tests__/renderer/components/AppModals-selfSourced.test.tsx @@ -170,6 +170,8 @@ function createMockSession(overrides: Partial = {}): Session { state: 'idle', toolType: 'claude-code', cwd: '/tmp', + terminalTabs: [], + activeTerminalTabId: null, ...overrides, } as Session; } diff --git a/src/__tests__/renderer/components/AutoRun.test.tsx b/src/__tests__/renderer/components/AutoRun.test.tsx index 6b55abe3d7..b17fb8af0f 100644 --- a/src/__tests__/renderer/components/AutoRun.test.tsx +++ b/src/__tests__/renderer/components/AutoRun.test.tsx @@ -55,6 +55,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/renderer/components/AutoRunBlurSaveTiming.test.tsx b/src/__tests__/renderer/components/AutoRunBlurSaveTiming.test.tsx index 366a130bf1..6513a0b244 100644 --- a/src/__tests__/renderer/components/AutoRunBlurSaveTiming.test.tsx +++ b/src/__tests__/renderer/components/AutoRunBlurSaveTiming.test.tsx @@ -52,6 +52,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/renderer/components/AutoRunContentSync.test.tsx b/src/__tests__/renderer/components/AutoRunContentSync.test.tsx index a86c30218a..c22a38824d 100644 --- a/src/__tests__/renderer/components/AutoRunContentSync.test.tsx +++ b/src/__tests__/renderer/components/AutoRunContentSync.test.tsx @@ -45,6 +45,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx b/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx index 063309ba8c..a16137b488 100644 --- a/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx +++ b/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx @@ -45,6 +45,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../../renderer/components/AutoRunnerHelpModal', () => ({ @@ -208,7 +209,7 @@ describe('AutoRun Session Isolation', () => { const propsA = createDefaultProps({ sessionId: 'session-a', - folderPath: '/projects/session-a/Auto Run Docs', + folderPath: '/projects/session-a/.maestro/playbooks', selectedFile: 'Phase 1', content: sessionAContent, }); @@ -225,7 +226,7 @@ describe('AutoRun Session Isolation', () => { // Now switch to Session B - the content should reset to Session B's content const propsB = createDefaultProps({ sessionId: 'session-b', - folderPath: '/projects/session-b/Auto Run Docs', + folderPath: '/projects/session-b/.maestro/playbooks', selectedFile: 'Phase 1', content: sessionBContent, }); @@ -649,7 +650,7 @@ describe('AutoRun Folder Path Isolation', () => { it('different sessions can have different folder paths', async () => { const propsA = createDefaultProps({ sessionId: 'session-a', - folderPath: '/projects/alpha/Auto Run Docs', + folderPath: '/projects/alpha/.maestro/playbooks', selectedFile: 'Phase 1', content: 'Alpha project content', }); @@ -662,7 +663,7 @@ describe('AutoRun Folder Path Isolation', () => { // Switch to session B with different folder const propsB = createDefaultProps({ sessionId: 'session-b', - folderPath: '/projects/beta/Auto Run Docs', + folderPath: '/projects/beta/.maestro/playbooks', selectedFile: 'Phase 1', content: 'Beta project content', }); diff --git a/src/__tests__/renderer/components/ContextWarningSash.test.tsx b/src/__tests__/renderer/components/ContextWarningSash.test.tsx index ef7c86a8ff..88224315f6 100644 --- a/src/__tests__/renderer/components/ContextWarningSash.test.tsx +++ b/src/__tests__/renderer/components/ContextWarningSash.test.tsx @@ -544,4 +544,45 @@ describe('ContextWarningSash', () => { expect(container.firstChild).toBeNull(); }); }); + + describe('light mode contrast', () => { + const lightTheme: Theme = { + ...theme, + id: 'light-test', + name: 'Light Test', + mode: 'light', + }; + + it('should use dark text colors in light mode for yellow warning', () => { + render( + + ); + const warningText = screen.getByText(/reaching/); + // yellow-800 (#854d0e) for light mode instead of yellow-300 + expect(warningText).toHaveStyle({ color: '#854d0e' }); + }); + + it('should use dark text colors in light mode for red warning', () => { + render( + + ); + const warningText = screen.getByText(/consider compacting/); + // red-800 (#991b1b) for light mode instead of red-300 + expect(warningText).toHaveStyle({ color: '#991b1b' }); + }); + }); }); diff --git a/src/__tests__/renderer/components/CueHelpModal.test.tsx b/src/__tests__/renderer/components/CueHelpModal.test.tsx new file mode 100644 index 0000000000..5c082b5dff --- /dev/null +++ b/src/__tests__/renderer/components/CueHelpModal.test.tsx @@ -0,0 +1,187 @@ +/** + * Tests for CueHelpContent component + * + * CueHelpContent displays comprehensive documentation about the Maestro Cue + * event-driven automation feature. It renders inline within the CueModal. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { CueHelpContent } from '../../../renderer/components/CueHelpModal'; +import type { Theme } from '../../../renderer/types'; + +// Mock formatShortcutKeys to return predictable output +vi.mock('../../../renderer/utils/shortcutFormatter', () => ({ + formatShortcutKeys: (keys: string[]) => keys.join('+'), + isMacOS: () => false, +})); + +// Sample theme for testing +const mockTheme: Theme = { + id: 'test-dark' as Theme['id'], + name: 'Test Dark', + mode: 'dark', + colors: { + bgMain: '#1a1a1a', + bgSidebar: '#252525', + bgActivity: '#2d2d2d', + border: '#444444', + textMain: '#ffffff', + textDim: '#888888', + accent: '#007acc', + accentDim: '#007acc40', + accentText: '#007acc', + accentForeground: '#ffffff', + error: '#ff4444', + success: '#44ff44', + warning: '#ffaa00', + }, +}; + +describe('CueHelpContent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + describe('Content Sections', () => { + beforeEach(() => { + render(); + }); + + it('should render What is Maestro Cue section', () => { + expect(screen.getByText('What is Maestro Cue?')).toBeInTheDocument(); + expect(screen.getByText(/event-driven automation system/)).toBeInTheDocument(); + }); + + it('should render Getting Started section', () => { + expect(screen.getByText('Getting Started')).toBeInTheDocument(); + expect(screen.getByText(/\.maestro\/cue\.yaml/)).toBeInTheDocument(); + }); + + it('should render minimal YAML example', () => { + expect(screen.getByText(/My First Cue/)).toBeInTheDocument(); + }); + + it('should render Event Types section', () => { + expect(screen.getByText('Event Types')).toBeInTheDocument(); + }); + + it('should render all event types', () => { + expect(screen.getByText('Interval')).toBeInTheDocument(); + expect(screen.getByText('File Watch')).toBeInTheDocument(); + expect(screen.getByText('Agent Completed')).toBeInTheDocument(); + }); + + it('should render event type codes', () => { + expect(screen.getByText('time.interval')).toBeInTheDocument(); + expect(screen.getByText('file.changed')).toBeInTheDocument(); + expect(screen.getByText('agent.completed')).toBeInTheDocument(); + }); + + it('should render Template Variables section', () => { + expect(screen.getByText('Template Variables')).toBeInTheDocument(); + }); + + it('should render CUE template variables', () => { + expect(screen.getByText('{{CUE_EVENT_TYPE}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_EVENT_TIMESTAMP}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_TRIGGER_NAME}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_RUN_ID}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_FILE_PATH}}')).toBeInTheDocument(); + }); + + it('should render new file and agent completion template variables', () => { + expect(screen.getByText('{{CUE_FILE_CHANGE_TYPE}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_SOURCE_STATUS}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_SOURCE_EXIT_CODE}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_SOURCE_DURATION}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_SOURCE_TRIGGERED_BY}}')).toBeInTheDocument(); + }); + + it('should mention standard Maestro template variables', () => { + expect(screen.getByText('{{AGENT_NAME}}')).toBeInTheDocument(); + expect(screen.getByText('{{DATE}}')).toBeInTheDocument(); + }); + + it('should render Multi-Agent Orchestration section', () => { + expect(screen.getByText('Multi-Agent Orchestration')).toBeInTheDocument(); + }); + + it('should render fan-out and fan-in patterns', () => { + expect(screen.getByText(/Fan-Out:/)).toBeInTheDocument(); + expect(screen.getByText(/Fan-In:/)).toBeInTheDocument(); + }); + + it('should render Timeouts & Failure Handling section', () => { + expect(screen.getByText('Timeouts & Failure Handling')).toBeInTheDocument(); + expect(screen.getByText(/Default timeout is 30 minutes/)).toBeInTheDocument(); + }); + + it('should render Visual Pipeline Editor section', () => { + expect(screen.getByText('Visual Pipeline Editor')).toBeInTheDocument(); + }); + + it('should render Coordination Patterns section', () => { + expect(screen.getByText('Coordination Patterns')).toBeInTheDocument(); + }); + + it('should render all coordination pattern names', () => { + expect(screen.getByText('Scheduled Task')).toBeInTheDocument(); + expect(screen.getByText('File Enrichment')).toBeInTheDocument(); + expect(screen.getByText('Research Swarm')).toBeInTheDocument(); + expect(screen.getByText('Sequential Chain')).toBeInTheDocument(); + expect(screen.getByText('Debate')).toBeInTheDocument(); + }); + + it('should render Event Filtering section', () => { + expect(screen.getByText('Event Filtering')).toBeInTheDocument(); + }); + + it('should mention triggeredBy filter', () => { + const elements = screen.getAllByText(/triggeredBy/); + expect(elements.length).toBeGreaterThan(0); + }); + }); + + describe('Shortcut Keys', () => { + it('should render keyboard shortcut tip', () => { + render(); + + const kbdElements = document.querySelectorAll('kbd'); + expect(kbdElements.length).toBeGreaterThan(0); + expect(screen.getByText(/to open the Cue dashboard/)).toBeInTheDocument(); + }); + + it('should render custom shortcut keys when provided', () => { + render(); + + const kbdElements = document.querySelectorAll('kbd'); + const hasCustomShortcut = Array.from(kbdElements).some((kbd) => { + const text = kbd.textContent || ''; + return text.includes('C') || text.includes('c'); + }); + expect(hasCustomShortcut).toBe(true); + }); + }); + + describe('Structure', () => { + it('should render icons for each section', () => { + render(); + + const svgElements = document.querySelectorAll('svg'); + expect(svgElements.length).toBeGreaterThan(5); + }); + + it('should render code elements for technical content', () => { + render(); + + const codeElements = document.querySelectorAll('code'); + expect(codeElements.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/__tests__/renderer/components/CueModal.test.tsx b/src/__tests__/renderer/components/CueModal.test.tsx new file mode 100644 index 0000000000..cce7e2140b --- /dev/null +++ b/src/__tests__/renderer/components/CueModal.test.tsx @@ -0,0 +1,659 @@ +/** + * Tests for CueModal component + * + * Tests the Cue Modal dashboard including: + * - Sessions table rendering (empty state and populated) + * - Active runs section with stop controls + * - Activity log rendering with success/failure indicators + * - Master enable/disable toggle + * - Close button and backdrop click + * - Help view escape-to-go-back behavior + * - Unsaved changes confirmation on close + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { CueModal } from '../../../renderer/components/CueModal'; +import type { Theme } from '../../../renderer/types'; + +// Mock LayerStackContext +const mockRegisterLayer = vi.fn(() => 'layer-cue-modal'); +const mockUnregisterLayer = vi.fn(); + +vi.mock('../../../renderer/contexts/LayerStackContext', () => ({ + useLayerStack: () => ({ + registerLayer: mockRegisterLayer, + unregisterLayer: mockUnregisterLayer, + }), +})); + +// Mock modal priorities +vi.mock('../../../renderer/constants/modalPriorities', () => ({ + MODAL_PRIORITIES: { + CUE_MODAL: 460, + CUE_YAML_EDITOR: 463, + }, +})); + +// Mock CueYamlEditor +vi.mock('../../../renderer/components/CueYamlEditor', () => ({ + CueYamlEditor: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => + isOpen ?
YAML Editor Mock
: null, +})); + +// Mock CueGraphView (kept for reference - replaced by CuePipelineEditor) +vi.mock('../../../renderer/components/CueGraphView', () => ({ + CueGraphView: () => null, +})); + +// Capture the onDirtyChange callback from CuePipelineEditor +let capturedOnDirtyChange: ((isDirty: boolean) => void) | undefined; + +vi.mock('../../../renderer/components/CuePipelineEditor', () => ({ + CuePipelineEditor: ({ onDirtyChange }: { onDirtyChange?: (isDirty: boolean) => void }) => { + capturedOnDirtyChange = onDirtyChange; + return
Pipeline Editor Mock
; + }, +})); + +// Mock sessionStore +vi.mock('../../../renderer/stores/sessionStore', () => ({ + useSessionStore: (selector: (state: unknown) => unknown) => { + const mockState = { + sessions: [], + groups: [], + setActiveSessionId: vi.fn(), + }; + return selector(mockState); + }, +})); + +// Mock window.maestro.cue +const mockGetGraphData = vi.fn().mockResolvedValue([]); +const mockDeleteYaml = vi.fn().mockResolvedValue(undefined); +if (!window.maestro) { + (window as unknown as Record).maestro = {}; +} +if (!(window.maestro as Record).cue) { + (window.maestro as Record).cue = {}; +} +(window.maestro.cue as Record).getGraphData = mockGetGraphData; +(window.maestro.cue as Record).deleteYaml = mockDeleteYaml; + +// Mock useCue hook +const mockEnable = vi.fn().mockResolvedValue(undefined); +const mockDisable = vi.fn().mockResolvedValue(undefined); +const mockStopRun = vi.fn().mockResolvedValue(undefined); +const mockStopAll = vi.fn().mockResolvedValue(undefined); +const mockRefresh = vi.fn().mockResolvedValue(undefined); + +const defaultUseCueReturn = { + sessions: [], + activeRuns: [], + activityLog: [], + queueStatus: {} as Record, + loading: false, + enable: mockEnable, + disable: mockDisable, + stopRun: mockStopRun, + stopAll: mockStopAll, + refresh: mockRefresh, +}; + +let mockUseCueReturn = { ...defaultUseCueReturn }; + +vi.mock('../../../renderer/hooks/useCue', () => ({ + useCue: () => mockUseCueReturn, +})); + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + scrollbar: '#44475a', + scrollbarHover: '#6272a4', + }, +}; + +const mockSession = { + sessionId: 'sess-1', + sessionName: 'Test Session', + toolType: 'claude-code', + projectRoot: '/test/project', + enabled: true, + subscriptionCount: 3, + activeRuns: 1, + lastTriggered: new Date().toISOString(), +}; + +const mockActiveRun = { + runId: 'run-1', + sessionId: 'sess-1', + sessionName: 'Test Session', + subscriptionName: 'on-save', + event: { + id: 'evt-1', + type: 'file.changed' as const, + timestamp: new Date().toISOString(), + triggerName: 'on-save', + payload: { file: '/src/index.ts' }, + }, + status: 'running' as const, + stdout: '', + stderr: '', + exitCode: null, + durationMs: 0, + startedAt: new Date().toISOString(), + endedAt: '', +}; + +const mockCompletedRun = { + ...mockActiveRun, + runId: 'run-2', + status: 'completed' as const, + stdout: 'Done', + exitCode: 0, + durationMs: 5000, + endedAt: new Date().toISOString(), +}; + +const mockFailedRun = { + ...mockActiveRun, + runId: 'run-3', + status: 'failed' as const, + stderr: 'Error occurred', + exitCode: 1, + durationMs: 2000, + endedAt: new Date().toISOString(), +}; + +describe('CueModal', () => { + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockUseCueReturn = { ...defaultUseCueReturn }; + capturedOnDirtyChange = undefined; + }); + + describe('rendering', () => { + it('should render the modal with header', () => { + render(); + + expect(screen.getByText('Maestro Cue')).toBeInTheDocument(); + }); + + it('should register layer on mount and unregister on unmount', () => { + const { unmount } = render(); + + expect(mockRegisterLayer).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'modal', + priority: 460, + }) + ); + + unmount(); + expect(mockUnregisterLayer).toHaveBeenCalledWith('layer-cue-modal'); + }); + + it('should show loading state on dashboard tab', () => { + mockUseCueReturn = { ...defaultUseCueReturn, loading: true }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('Loading Cue status...')).toBeInTheDocument(); + }); + }); + + describe('sessions table', () => { + it('should show empty state when no sessions have Cue configs', () => { + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText(/No sessions have a cue config file/)).toBeInTheDocument(); + }); + + it('should render sessions with status indicators', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('Test Session')).toBeInTheDocument(); + expect(screen.getByText('claude-code')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('should show Paused status for disabled sessions', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [{ ...mockSession, enabled: false }], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('Paused')).toBeInTheDocument(); + }); + }); + + describe('active runs', () => { + it('should show "No active runs" when empty', () => { + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('No active runs')).toBeInTheDocument(); + }); + + it('should render active runs with stop buttons', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + activeRuns: [mockActiveRun], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('"on-save"')).toBeInTheDocument(); + expect(screen.getByTitle('Stop run')).toBeInTheDocument(); + }); + + it('should call stopRun when stop button is clicked', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + activeRuns: [mockActiveRun], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + fireEvent.click(screen.getByTitle('Stop run')); + expect(mockStopRun).toHaveBeenCalledWith('run-1'); + }); + + it('should show Stop All button when multiple runs active', () => { + const secondRun = { ...mockActiveRun, runId: 'run-2', subscriptionName: 'on-timer' }; + mockUseCueReturn = { + ...defaultUseCueReturn, + activeRuns: [mockActiveRun, secondRun], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + const stopAllButton = screen.getByText('Stop All'); + expect(stopAllButton).toBeInTheDocument(); + + fireEvent.click(stopAllButton); + expect(mockStopAll).toHaveBeenCalledOnce(); + }); + }); + + describe('activity log', () => { + it('should show "No activity yet" when empty', () => { + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('No activity yet')).toBeInTheDocument(); + }); + + it('should render completed runs with checkmark', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + activityLog: [mockCompletedRun], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText(/completed in 5s/)).toBeInTheDocument(); + }); + + it('should render failed runs with cross mark', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + activityLog: [mockFailedRun], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText(/failed/)).toBeInTheDocument(); + }); + }); + + describe('master toggle', () => { + it('should show Disabled when no sessions are enabled', () => { + render(); + + expect(screen.getByText('Disabled')).toBeInTheDocument(); + }); + + it('should show Enabled when sessions are enabled', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + + expect(screen.getByText('Enabled')).toBeInTheDocument(); + }); + + it('should call disable when toggling off', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + + fireEvent.click(screen.getByText('Enabled')); + expect(mockDisable).toHaveBeenCalledOnce(); + }); + + it('should call enable when toggling on', () => { + render(); + + fireEvent.click(screen.getByText('Disabled')); + expect(mockEnable).toHaveBeenCalledOnce(); + }); + }); + + describe('tabs', () => { + it('should render Dashboard and Pipeline Editor tabs', () => { + render(); + + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Pipeline Editor')).toBeInTheDocument(); + }); + + it('should show Pipeline Editor content by default', () => { + render(); + + expect(screen.getByTestId('cue-pipeline-editor')).toBeInTheDocument(); + // Dashboard content should not be visible by default + expect(screen.queryByText('Sessions with Cue')).not.toBeInTheDocument(); + }); + + it('should switch to dashboard when Dashboard tab is clicked', () => { + render(); + + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('Sessions with Cue')).toBeInTheDocument(); + // Pipeline editor should not be visible + expect(screen.queryByTestId('cue-pipeline-editor')).not.toBeInTheDocument(); + }); + + it('should switch back to Pipeline Editor when Pipeline Editor tab is clicked', () => { + render(); + + // Switch to dashboard + fireEvent.click(screen.getByText('Dashboard')); + expect(screen.getByText('Sessions with Cue')).toBeInTheDocument(); + + // Switch back to pipeline editor + fireEvent.click(screen.getByText('Pipeline Editor')); + expect(screen.getByTestId('cue-pipeline-editor')).toBeInTheDocument(); + expect(screen.queryByText('Sessions with Cue')).not.toBeInTheDocument(); + }); + }); + + describe('toggle styling', () => { + it('should use theme accent color for enabled toggle', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + + const enabledButton = screen.getByText('Enabled').closest('button'); + expect(enabledButton).toHaveStyle({ + color: mockTheme.colors.accent, + }); + + // The toggle pill should use theme accent + const togglePill = enabledButton?.querySelector('.rounded-full'); + expect(togglePill).toHaveStyle({ + backgroundColor: mockTheme.colors.accent, + }); + }); + + it('should use dim colors for disabled toggle', () => { + render(); + + const disabledButton = screen.getByText('Disabled').closest('button'); + expect(disabledButton).toHaveStyle({ + color: mockTheme.colors.textDim, + }); + }); + }); + + describe('Edit YAML button', () => { + it('should render Edit YAML button for each session', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('Edit YAML')).toBeInTheDocument(); + }); + + it('should open CueYamlEditor when Edit YAML is clicked', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + fireEvent.click(screen.getByText('Edit YAML')); + + expect(screen.getByTestId('cue-yaml-editor')).toBeInTheDocument(); + }); + }); + + describe('close behavior', () => { + it('should call onClose when close button is clicked (no unsaved changes)', () => { + render(); + + // The close button has an X icon + const buttons = screen.getAllByRole('button'); + const closeButton = buttons.find((b) => b.querySelector('.lucide-x')); + if (closeButton) { + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalledOnce(); + } + }); + + it('should show confirmation when closing with unsaved pipeline changes via escape', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + render(); + + // Simulate pipeline becoming dirty + expect(capturedOnDirtyChange).toBeDefined(); + act(() => { + capturedOnDirtyChange!(true); + }); + + // Trigger escape (which goes through the same dirty check) + const layerConfig = mockRegisterLayer.mock.calls[0][0]; + layerConfig.onEscape(); + + expect(confirmSpy).toHaveBeenCalledWith( + 'You have unsaved changes in the pipeline editor. Discard and close?' + ); + // User declined, so onClose should NOT be called + expect(mockOnClose).not.toHaveBeenCalled(); + + confirmSpy.mockRestore(); + }); + + it('should close when user confirms discarding unsaved changes', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + + render(); + + // Simulate pipeline becoming dirty + act(() => { + capturedOnDirtyChange!(true); + }); + + // Trigger escape + const layerConfig = mockRegisterLayer.mock.calls[0][0]; + layerConfig.onEscape(); + + expect(confirmSpy).toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalledOnce(); + + confirmSpy.mockRestore(); + }); + + it('should not show confirmation after pipeline changes are saved (dirty cleared)', () => { + render(); + + // Simulate pipeline becoming dirty then saved + act(() => { + capturedOnDirtyChange!(true); + }); + act(() => { + capturedOnDirtyChange!(false); + }); + + // Trigger escape + const layerConfig = mockRegisterLayer.mock.calls[0][0]; + layerConfig.onEscape(); + + // Should close without confirmation + expect(mockOnClose).toHaveBeenCalledOnce(); + }); + }); + + describe('help view escape behavior', () => { + it('should navigate to help view when help button is clicked', () => { + render(); + + // Click help button + const helpButton = screen.getByTitle('Help'); + fireEvent.click(helpButton); + + expect(screen.getByText('Maestro Cue Guide')).toBeInTheDocument(); + }); + + it('should go back from help view when escape is pressed (not close modal)', () => { + render(); + + // Click help button to enter help view + const helpButton = screen.getByTitle('Help'); + fireEvent.click(helpButton); + expect(screen.getByText('Maestro Cue Guide')).toBeInTheDocument(); + + // Trigger the onEscape callback from the registered layer + const layerConfig = mockRegisterLayer.mock.calls[0][0]; + act(() => { + layerConfig.onEscape(); + }); + + // Should go back to main view, not close the modal + expect(mockOnClose).not.toHaveBeenCalled(); + // Help view should be gone, main header should be back + expect(screen.getByText('Maestro Cue')).toBeInTheDocument(); + }); + + it('should go back from help view via back arrow button', () => { + render(); + + // Click help button + fireEvent.click(screen.getByTitle('Help')); + expect(screen.getByText('Maestro Cue Guide')).toBeInTheDocument(); + + // Click the back arrow + fireEvent.click(screen.getByTitle('Back to dashboard')); + + // Should be back to main view + expect(screen.getByText('Maestro Cue')).toBeInTheDocument(); + }); + + it('should close modal on escape when not in help view', () => { + render(); + + // Trigger the onEscape callback + const layerConfig = mockRegisterLayer.mock.calls[0][0]; + layerConfig.onEscape(); + + expect(mockOnClose).toHaveBeenCalledOnce(); + }); + + it('should show confirmation on escape when pipeline has unsaved changes', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + render(); + + // Simulate dirty pipeline + act(() => { + capturedOnDirtyChange!(true); + }); + + // Trigger escape + const layerConfig = mockRegisterLayer.mock.calls[0][0]; + layerConfig.onEscape(); + + expect(confirmSpy).toHaveBeenCalled(); + expect(mockOnClose).not.toHaveBeenCalled(); + + confirmSpy.mockRestore(); + }); + + it('should not show confirmation on escape from help view even with unsaved changes', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + render(); + + // Make pipeline dirty + act(() => { + capturedOnDirtyChange!(true); + }); + + // Enter help view + fireEvent.click(screen.getByTitle('Help')); + expect(screen.getByText('Maestro Cue Guide')).toBeInTheDocument(); + + // Press escape — should go back from help, not trigger confirmation + const layerConfig = mockRegisterLayer.mock.calls[0][0]; + act(() => { + layerConfig.onEscape(); + }); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(mockOnClose).not.toHaveBeenCalled(); + expect(screen.getByText('Maestro Cue')).toBeInTheDocument(); + + confirmSpy.mockRestore(); + }); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/PipelineSelector.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/PipelineSelector.test.tsx new file mode 100644 index 0000000000..d6004d895e --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/PipelineSelector.test.tsx @@ -0,0 +1,176 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { PipelineSelector } from '../../../../renderer/components/CuePipelineEditor/PipelineSelector'; +import type { CuePipeline } from '../../../../shared/cue-pipeline-types'; + +const mockPipelines: CuePipeline[] = [ + { + id: 'p1', + name: 'Deploy Pipeline', + color: '#06b6d4', + nodes: [], + edges: [], + }, + { + id: 'p2', + name: 'Review Pipeline', + color: '#8b5cf6', + nodes: [], + edges: [], + }, +]; + +const defaultProps = { + pipelines: mockPipelines, + selectedPipelineId: null as string | null, + onSelect: vi.fn(), + onCreatePipeline: vi.fn(), + onDeletePipeline: vi.fn(), + onRenamePipeline: vi.fn(), + onChangePipelineColor: vi.fn(), +}; + +describe('PipelineSelector', () => { + it('should show "All Pipelines" when no pipeline is selected', () => { + render(); + expect(screen.getByText('All Pipelines')).toBeInTheDocument(); + }); + + it('should show selected pipeline name', () => { + render(); + expect(screen.getByText('Deploy Pipeline')).toBeInTheDocument(); + }); + + it('should open dropdown on click and list all pipelines', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + + // Dropdown shows All Pipelines option + each pipeline + expect(screen.getByText('Deploy Pipeline')).toBeInTheDocument(); + expect(screen.getByText('Review Pipeline')).toBeInTheDocument(); + expect(screen.getByText('New Pipeline')).toBeInTheDocument(); + }); + + it('should call onSelect when a pipeline is clicked', () => { + const onSelect = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + fireEvent.click(screen.getByText('Deploy Pipeline')); + + expect(onSelect).toHaveBeenCalledWith('p1'); + }); + + it('should call onCreatePipeline when New Pipeline is clicked', () => { + const onCreatePipeline = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + fireEvent.click(screen.getByText('New Pipeline')); + + expect(onCreatePipeline).toHaveBeenCalled(); + }); + + it('should enter rename mode on double-click', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + + const pipelineItem = screen.getByText('Deploy Pipeline').closest('div[class]')!; + fireEvent.doubleClick(pipelineItem); + + const input = screen.getByDisplayValue('Deploy Pipeline'); + expect(input).toBeInTheDocument(); + }); + + it('should call onRenamePipeline on Enter in rename mode', () => { + const onRenamePipeline = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + + const pipelineItem = screen.getByText('Deploy Pipeline').closest('div[class]')!; + fireEvent.doubleClick(pipelineItem); + + const input = screen.getByDisplayValue('Deploy Pipeline'); + fireEvent.change(input, { target: { value: 'Renamed Pipeline' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onRenamePipeline).toHaveBeenCalledWith('p1', 'Renamed Pipeline'); + }); + + it('should cancel rename on Escape', () => { + const onRenamePipeline = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + + const pipelineItem = screen.getByText('Deploy Pipeline').closest('div[class]')!; + fireEvent.doubleClick(pipelineItem); + + const input = screen.getByDisplayValue('Deploy Pipeline'); + fireEvent.keyDown(input, { key: 'Escape' }); + + expect(onRenamePipeline).not.toHaveBeenCalled(); + // Should be back to showing text, not input + expect(screen.getByText('Deploy Pipeline')).toBeInTheDocument(); + }); + + it('should enter rename mode when pencil icon is clicked', () => { + const onRenamePipeline = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + + const pencilButtons = screen.getAllByTitle('Rename pipeline'); + expect(pencilButtons.length).toBeGreaterThan(0); + + fireEvent.click(pencilButtons[0]); + + const input = screen.getByDisplayValue('Deploy Pipeline'); + expect(input).toBeInTheDocument(); + }); + + it('should show color picker when color dot is clicked', () => { + const onChangePipelineColor = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + + // Click the first color dot (has title "Change color") + const colorDots = screen.getAllByTitle('Change color'); + expect(colorDots.length).toBeGreaterThan(0); + fireEvent.click(colorDots[0]); + + // Color palette should appear with 12 swatches + const swatches = screen.getAllByTitle(/^#/); + expect(swatches.length).toBe(12); + + // Click a swatch + fireEvent.click(swatches[2]); // yellow #eab308 + expect(onChangePipelineColor).toHaveBeenCalledWith('p1', '#eab308'); + }); + + it('should apply custom textColor and borderColor', () => { + const { container } = render( + + ); + + const button = container.querySelector('button')!; + // JSDOM normalizes hex to rgb + expect(button.style.color).toBe('rgb(255, 0, 0)'); + expect(button.style.border).toContain('rgb(0, 255, 0)'); + }); + + it('should use default colors when textColor and borderColor are not provided', () => { + const { container } = render(); + + const button = container.querySelector('button')!; + // Browser normalizes rgba spacing + expect(button.style.color).toContain('rgba'); + expect(button.style.color).toContain('0.9'); + expect(button.style.border).toContain('rgba'); + expect(button.style.border).toContain('0.12'); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/drawers/AgentDrawer.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/drawers/AgentDrawer.test.tsx new file mode 100644 index 0000000000..573a50520a --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/drawers/AgentDrawer.test.tsx @@ -0,0 +1,194 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AgentDrawer } from '../../../../../renderer/components/CuePipelineEditor/drawers/AgentDrawer'; +import type { Theme } from '../../../../../renderer/types'; + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentDim: '#bd93f940', + accentText: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + }, +}; + +const mockGroups = [ + { id: 'grp-1', name: 'Dev', emoji: '🛠️' }, + { id: 'grp-2', name: 'Ops', emoji: '🚀' }, +]; + +const mockSessions = [ + { id: 'sess-1', name: 'Maestro', toolType: 'claude-code', groupId: 'grp-1' }, + { id: 'sess-2', name: 'Codex Helper', toolType: 'codex', groupId: 'grp-2' }, + { id: 'sess-3', name: 'Review Bot', toolType: 'claude-code', groupId: 'grp-1' }, +]; + +describe('AgentDrawer', () => { + it('should render all sessions when open', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + expect(screen.getByText('Maestro')).toBeInTheDocument(); + expect(screen.getByText('Codex Helper')).toBeInTheDocument(); + expect(screen.getByText('Review Bot')).toBeInTheDocument(); + }); + + it('should filter sessions by name', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const input = screen.getByPlaceholderText('Search agents...'); + fireEvent.change(input, { target: { value: 'maestro' } }); + + expect(screen.getByText('Maestro')).toBeInTheDocument(); + expect(screen.queryByText('Codex Helper')).not.toBeInTheDocument(); + expect(screen.queryByText('Review Bot')).not.toBeInTheDocument(); + }); + + it('should filter sessions by toolType', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const input = screen.getByPlaceholderText('Search agents...'); + fireEvent.change(input, { target: { value: 'codex' } }); + + expect(screen.getByText('Codex Helper')).toBeInTheDocument(); + expect(screen.queryByText('Maestro')).not.toBeInTheDocument(); + }); + + it('should show empty state when no agents match', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const input = screen.getByPlaceholderText('Search agents...'); + fireEvent.change(input, { target: { value: 'zzzznothing' } }); + + expect(screen.getByText('No agents match')).toBeInTheDocument(); + }); + + it('should show empty state when no sessions provided', () => { + render( {}} sessions={[]} theme={mockTheme} />); + + expect(screen.getByText('No agents available')).toBeInTheDocument(); + }); + + it('should show on-canvas indicator for agents already on canvas', () => { + const onCanvas = new Set(['sess-1']); + render( + {}} + sessions={mockSessions} + onCanvasSessionIds={onCanvas} + theme={mockTheme} + /> + ); + + const indicators = screen.getAllByTitle('On canvas'); + expect(indicators).toHaveLength(1); + }); + + it('should group agents by user-defined groups', () => { + render( + {}} + sessions={mockSessions} + groups={mockGroups} + theme={mockTheme} + /> + ); + + expect(screen.getByText('🛠️ Dev')).toBeInTheDocument(); + expect(screen.getByText('🚀 Ops')).toBeInTheDocument(); + }); + + it('should alphabetize groups and agents within groups', () => { + const groups = [ + { id: 'grp-z', name: 'Zeta', emoji: '⚡' }, + { id: 'grp-a', name: 'Alpha', emoji: '🅰️' }, + ]; + const sessions = [ + { id: 's1', name: 'Charlie', toolType: 'claude-code', groupId: 'grp-a' }, + { id: 's2', name: 'Alice', toolType: 'claude-code', groupId: 'grp-a' }, + { id: 's3', name: 'Bravo', toolType: 'codex', groupId: 'grp-z' }, + { id: 's4', name: 'Delta', toolType: 'codex', groupId: 'grp-z' }, + { id: 's5', name: 'Echo', toolType: 'codex' }, // ungrouped + ]; + + const { container } = render( + {}} + sessions={sessions} + groups={groups} + theme={mockTheme} + /> + ); + + // Verify group order: Alpha before Zeta, Ungrouped last + const groupHeaders = container.querySelectorAll('[style*="text-transform: uppercase"]'); + const headerTexts = Array.from(groupHeaders).map((el) => el.textContent); + expect(headerTexts).toEqual(['🅰️ Alpha', '⚡ Zeta', 'Ungrouped']); + + // Verify agent order within each group by checking DOM order + // Each draggable row:
> > > + + // The name is in the first div child with fontWeight:500 + const agentNames = Array.from(container.querySelectorAll('[draggable="true"]')).map( + (el) => el.querySelector('[style*="font-weight: 500"]')?.textContent + ); + expect(agentNames).toEqual(['Alice', 'Charlie', 'Bravo', 'Delta', 'Echo']); + }); + + it('should use theme colors for styling', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const header = screen.getByText('Agents'); + expect(header).toHaveStyle({ color: mockTheme.colors.textMain }); + }); + + it('should be hidden when not open', () => { + const { container } = render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const drawer = container.firstChild as HTMLElement; + expect(drawer.style.transform).toBe('translateX(100%)'); + }); + + it('should be visible when open', () => { + const { container } = render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const drawer = container.firstChild as HTMLElement; + expect(drawer.style.transform).toBe('translateX(0)'); + }); + + it('should make agent items draggable', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const maestro = screen.getByText('Maestro').closest('[draggable]'); + expect(maestro).toHaveAttribute('draggable', 'true'); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx new file mode 100644 index 0000000000..d727f5fa89 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TriggerDrawer } from '../../../../../renderer/components/CuePipelineEditor/drawers/TriggerDrawer'; +import type { Theme } from '../../../../../renderer/types'; + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentDim: '#bd93f940', + accentText: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + }, +}; + +describe('TriggerDrawer', () => { + it('should render all trigger types when open', () => { + render( {}} theme={mockTheme} />); + + expect(screen.getByText('Scheduled')).toBeInTheDocument(); + expect(screen.getByText('File Change')).toBeInTheDocument(); + expect(screen.getByText('Agent Done')).toBeInTheDocument(); + expect(screen.getByText('Pull Request')).toBeInTheDocument(); + expect(screen.getByText('Issue')).toBeInTheDocument(); + expect(screen.getByText('Pending Task')).toBeInTheDocument(); + }); + + it('should render descriptions for each trigger', () => { + render( {}} theme={mockTheme} />); + + expect(screen.getByText('Run on a timer')).toBeInTheDocument(); + expect(screen.getByText('Watch for file modifications')).toBeInTheDocument(); + expect(screen.getByText('After an agent finishes')).toBeInTheDocument(); + }); + + it('should filter triggers by label', () => { + render( {}} theme={mockTheme} />); + + const input = screen.getByPlaceholderText('Filter triggers...'); + fireEvent.change(input, { target: { value: 'file' } }); + + expect(screen.getByText('File Change')).toBeInTheDocument(); + expect(screen.queryByText('Scheduled')).not.toBeInTheDocument(); + expect(screen.queryByText('Pull Request')).not.toBeInTheDocument(); + }); + + it('should filter triggers by event type', () => { + render( {}} theme={mockTheme} />); + + const input = screen.getByPlaceholderText('Filter triggers...'); + fireEvent.change(input, { target: { value: 'github' } }); + + expect(screen.getByText('Pull Request')).toBeInTheDocument(); + expect(screen.getByText('Issue')).toBeInTheDocument(); + expect(screen.queryByText('Scheduled')).not.toBeInTheDocument(); + }); + + it('should filter triggers by description', () => { + render( {}} theme={mockTheme} />); + + const input = screen.getByPlaceholderText('Filter triggers...'); + fireEvent.change(input, { target: { value: 'timer' } }); + + expect(screen.getByText('Scheduled')).toBeInTheDocument(); + expect(screen.queryByText('File Change')).not.toBeInTheDocument(); + }); + + it('should show empty state when no triggers match', () => { + render( {}} theme={mockTheme} />); + + const input = screen.getByPlaceholderText('Filter triggers...'); + fireEvent.change(input, { target: { value: 'zzzznothing' } }); + + expect(screen.getByText('No triggers match')).toBeInTheDocument(); + }); + + it('should use theme colors for styling', () => { + render( {}} theme={mockTheme} />); + + const header = screen.getByText('Triggers'); + expect(header).toHaveStyle({ color: mockTheme.colors.textMain }); + }); + + it('should be hidden when not open', () => { + const { container } = render( + {}} theme={mockTheme} /> + ); + + const drawer = container.firstChild as HTMLElement; + expect(drawer.style.transform).toBe('translateX(-100%)'); + }); + + it('should be visible when open', () => { + const { container } = render( + {}} theme={mockTheme} /> + ); + + const drawer = container.firstChild as HTMLElement; + expect(drawer.style.transform).toBe('translateX(0)'); + }); + + it('should make trigger items draggable', () => { + render( {}} theme={mockTheme} />); + + const scheduled = screen.getByText('Scheduled').closest('[draggable]'); + expect(scheduled).toHaveAttribute('draggable', 'true'); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/nodes/AgentNode.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/nodes/AgentNode.test.tsx new file mode 100644 index 0000000000..88e8fd059f --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/nodes/AgentNode.test.tsx @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { AgentNode } from '../../../../../renderer/components/CuePipelineEditor/nodes/AgentNode'; +import { ReactFlowProvider } from 'reactflow'; +import type { NodeProps } from 'reactflow'; +import type { AgentNodeDataProps } from '../../../../../renderer/components/CuePipelineEditor/nodes/AgentNode'; + +const defaultData: AgentNodeDataProps = { + compositeId: 'pipeline-1:agent-1', + sessionId: 'sess-1', + sessionName: 'Test Agent', + toolType: 'claude-code', + hasPrompt: false, + hasOutgoingEdge: false, + pipelineColor: '#06b6d4', + pipelineCount: 1, + pipelineColors: ['#06b6d4'], +}; + +function renderAgentNode(overrides: Partial = {}) { + const data = { ...defaultData, ...overrides }; + const props = { + id: 'test-node', + data, + type: 'agent', + selected: false, + isConnectable: true, + xPos: 0, + yPos: 0, + zIndex: 0, + dragging: false, + } as NodeProps; + + return render( + + + + ); +} + +describe('AgentNode', () => { + it('should render session name and tool type', () => { + const { getByText } = renderAgentNode(); + + expect(getByText('Test Agent')).toBeInTheDocument(); + expect(getByText('claude-code')).toBeInTheDocument(); + }); + + it('should not clip badge overflow (overflow: visible on root)', () => { + const { container } = renderAgentNode({ pipelineCount: 3 }); + + // Find the agent node root div (variable width with min-width, position: relative) + const rootDiv = container.querySelector('div[style*="min-width: 180px"]') as HTMLElement; + expect(rootDiv).not.toBeNull(); + expect(rootDiv.style.overflow).toBe('visible'); + }); + + it('should render a drag handle with the drag-handle class', () => { + const { container } = renderAgentNode(); + const dragHandle = container.querySelector('.drag-handle'); + expect(dragHandle).not.toBeNull(); + }); + + it('should render a gear icon for configuration', () => { + const { container } = renderAgentNode(); + // Gear icon area has title="Configure" + const gearButton = container.querySelector('[title="Configure"]'); + expect(gearButton).not.toBeNull(); + }); + + it('should show pipeline count badge when pipelineCount > 1', () => { + const { getByText } = renderAgentNode({ pipelineCount: 3 }); + + expect(getByText('3')).toBeInTheDocument(); + }); + + it('should not show pipeline count badge when pipelineCount is 1', () => { + const { queryByText } = renderAgentNode({ pipelineCount: 1 }); + + // No badge number should be rendered + const badge = queryByText('1'); + expect(badge).toBeNull(); + }); + + it('should show multi-pipeline color dots when multiple colors', () => { + const { container } = renderAgentNode({ + pipelineColors: ['#06b6d4', '#8b5cf6', '#f59e0b'], + }); + + // Find color dots (8x8 circles) + const dots = container.querySelectorAll( + 'div[style*="border-radius: 50%"][style*="width: 8px"]' + ); + expect(dots.length).toBe(3); + }); + + it('should not show multi-pipeline dots with single color', () => { + const { container } = renderAgentNode({ + pipelineColors: ['#06b6d4'], + }); + + // No color strip should render + const dots = container.querySelectorAll( + 'div[style*="border-radius: 50%"][style*="width: 8px"]' + ); + expect(dots.length).toBe(0); + }); + + it('should show prompt icon when hasPrompt is true', () => { + const { container } = renderAgentNode({ hasPrompt: true }); + + // MessageSquare icon renders as an SVG + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineLayout.test.ts b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineLayout.test.ts new file mode 100644 index 0000000000..a09c8790d0 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineLayout.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for pipeline layout merge/restore utilities. + * + * Verifies that saved layout state is correctly merged with live pipeline + * data, including the critical case where selectedPipelineId is null + * ("All Pipelines" mode). + */ + +import { describe, it, expect } from 'vitest'; +import { mergePipelinesWithSavedLayout } from '../../../../../renderer/components/CuePipelineEditor/utils/pipelineLayout'; +import type { CuePipeline, PipelineLayoutState } from '../../../../../shared/cue-pipeline-types'; + +function makePipeline(overrides: Partial = {}): CuePipeline { + return { + id: 'p1', + name: 'test-pipeline', + color: '#06b6d4', + nodes: [ + { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.interval', + label: 'Timer', + config: { interval_minutes: 5 }, + }, + }, + { + id: 'agent-1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + inputPrompt: 'Do work', + }, + }, + ], + edges: [{ id: 'e1', source: 'trigger-1', target: 'agent-1', mode: 'pass' }], + ...overrides, + }; +} + +describe('mergePipelinesWithSavedLayout', () => { + it('preserves null selectedPipelineId (All Pipelines mode)', () => { + const livePipelines = [makePipeline()]; + const savedLayout: PipelineLayoutState = { + pipelines: [makePipeline()], + selectedPipelineId: null, + }; + + const result = mergePipelinesWithSavedLayout(livePipelines, savedLayout); + expect(result.selectedPipelineId).toBeNull(); + }); + + it('preserves a specific selectedPipelineId from saved layout', () => { + const livePipelines = [makePipeline(), makePipeline({ id: 'p2', name: 'second' })]; + const savedLayout: PipelineLayoutState = { + pipelines: livePipelines, + selectedPipelineId: 'p2', + }; + + const result = mergePipelinesWithSavedLayout(livePipelines, savedLayout); + expect(result.selectedPipelineId).toBe('p2'); + }); + + it('defaults to first pipeline id when selectedPipelineId is missing from layout', () => { + const livePipelines = [makePipeline()]; + // Simulate a legacy saved layout that doesn't have selectedPipelineId at all + const savedLayout = { + pipelines: [makePipeline()], + } as PipelineLayoutState; + + // Delete the property so `in` check fails + delete (savedLayout as unknown as Record).selectedPipelineId; + + const result = mergePipelinesWithSavedLayout(livePipelines, savedLayout); + expect(result.selectedPipelineId).toBe('p1'); + }); + + it('merges saved node positions with live pipeline data', () => { + const livePipelines = [makePipeline()]; + const savedLayout: PipelineLayoutState = { + pipelines: [ + makePipeline({ + nodes: [ + { + id: 'trigger-1', + type: 'trigger', + position: { x: 100, y: 200 }, + data: { + eventType: 'time.interval', + label: 'Timer', + config: { interval_minutes: 5 }, + }, + }, + { + id: 'agent-1', + type: 'agent', + position: { x: 500, y: 300 }, + data: { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + inputPrompt: 'Do work', + }, + }, + ], + }), + ], + selectedPipelineId: 'p1', + }; + + const result = mergePipelinesWithSavedLayout(livePipelines, savedLayout); + + // Positions from saved layout should override live defaults + const triggerNode = result.pipelines[0].nodes.find((n) => n.id === 'trigger-1'); + const agentNode = result.pipelines[0].nodes.find((n) => n.id === 'agent-1'); + expect(triggerNode?.position).toEqual({ x: 100, y: 200 }); + expect(agentNode?.position).toEqual({ x: 500, y: 300 }); + }); + + it('keeps live node positions when saved layout has no matching nodes', () => { + const livePipelines = [makePipeline()]; + const savedLayout: PipelineLayoutState = { + pipelines: [makePipeline({ nodes: [] })], + selectedPipelineId: 'p1', + }; + + const result = mergePipelinesWithSavedLayout(livePipelines, savedLayout); + + // Original positions preserved + const triggerNode = result.pipelines[0].nodes.find((n) => n.id === 'trigger-1'); + expect(triggerNode?.position).toEqual({ x: 0, y: 0 }); + }); + + it('returns all live pipelines even when saved layout has fewer', () => { + const livePipelines = [ + makePipeline({ id: 'p1', name: 'first' }), + makePipeline({ id: 'p2', name: 'second' }), + ]; + const savedLayout: PipelineLayoutState = { + pipelines: [makePipeline({ id: 'p1', name: 'first' })], + selectedPipelineId: null, + }; + + const result = mergePipelinesWithSavedLayout(livePipelines, savedLayout); + expect(result.pipelines).toHaveLength(2); + expect(result.selectedPipelineId).toBeNull(); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineToYaml.test.ts b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineToYaml.test.ts new file mode 100644 index 0000000000..7b25225547 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineToYaml.test.ts @@ -0,0 +1,600 @@ +/** + * Tests for pipelineToYaml conversion utilities. + * + * Verifies that visual pipeline graphs correctly convert to + * CueSubscription objects and YAML strings. + */ + +import { describe, it, expect } from 'vitest'; +import { + pipelineToYamlSubscriptions, + pipelinesToYaml, +} from '../../../../../renderer/components/CuePipelineEditor/utils/pipelineToYaml'; +import type { CuePipeline } from '../../../../../shared/cue-pipeline-types'; + +function makePipeline(overrides: Partial = {}): CuePipeline { + return { + id: 'p1', + name: 'test-pipeline', + color: '#06b6d4', + nodes: [], + edges: [], + ...overrides, + }; +} + +describe('pipelineToYamlSubscriptions', () => { + it('returns empty array for pipeline with no nodes', () => { + const pipeline = makePipeline(); + expect(pipelineToYamlSubscriptions(pipeline)).toEqual([]); + }); + + it('returns empty array for trigger with no outgoing edges', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.interval', + label: 'Scheduled', + config: { interval_minutes: 5 }, + }, + }, + ], + }); + expect(pipelineToYamlSubscriptions(pipeline)).toEqual([]); + }); + + it('converts simple trigger -> agent chain', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.interval', + label: 'Scheduled', + config: { interval_minutes: 10 }, + }, + }, + { + id: 'agent-1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + inputPrompt: 'Do the work', + }, + }, + ], + edges: [{ id: 'e1', source: 'trigger-1', target: 'agent-1', mode: 'pass' }], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs).toHaveLength(1); + expect(subs[0].name).toBe('test-pipeline'); + expect(subs[0].event).toBe('time.interval'); + expect(subs[0].interval_minutes).toBe(10); + expect(subs[0].prompt).toBe('Do the work'); + }); + + it('converts trigger -> agent1 -> agent2 chain', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'file.changed', + label: 'File Change', + config: { watch: 'src/**/*.ts' }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'builder', + toolType: 'claude-code', + inputPrompt: 'Build it', + }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 600, y: 0 }, + data: { + sessionId: 's2', + sessionName: 'tester', + toolType: 'claude-code', + inputPrompt: 'Test it', + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' }, + { id: 'e2', source: 'a1', target: 'a2', mode: 'pass' }, + ], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs).toHaveLength(2); + + expect(subs[0].name).toBe('test-pipeline'); + expect(subs[0].event).toBe('file.changed'); + expect(subs[0].watch).toBe('src/**/*.ts'); + expect(subs[0].prompt).toBe('Build it'); + + expect(subs[1].name).toBe('test-pipeline-chain-1'); + expect(subs[1].event).toBe('agent.completed'); + expect(subs[1].source_session).toBe('builder'); + expect(subs[1].prompt).toBe('Test it'); + }); + + it('handles fan-out (trigger -> [agent1, agent2])', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.interval', + label: 'Scheduled', + config: { interval_minutes: 30 }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: -100 }, + data: { + sessionId: 's1', + sessionName: 'worker-a', + toolType: 'claude-code', + inputPrompt: 'Task A', + }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 300, y: 100 }, + data: { + sessionId: 's2', + sessionName: 'worker-b', + toolType: 'claude-code', + inputPrompt: 'Task B', + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' }, + { id: 'e2', source: 't1', target: 'a2', mode: 'pass' }, + ], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs).toHaveLength(1); + expect(subs[0].fan_out).toEqual(['worker-a', 'worker-b']); + expect(subs[0].interval_minutes).toBe(30); + }); + + it('handles fan-in ([agent1, agent2] -> agent3)', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.interval', + label: 'Scheduled', + config: { interval_minutes: 5 }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: -100 }, + data: { + sessionId: 's1', + sessionName: 'worker-a', + toolType: 'claude-code', + inputPrompt: 'A', + }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 300, y: 100 }, + data: { + sessionId: 's2', + sessionName: 'worker-b', + toolType: 'claude-code', + inputPrompt: 'B', + }, + }, + { + id: 'a3', + type: 'agent', + position: { x: 600, y: 0 }, + data: { + sessionId: 's3', + sessionName: 'aggregator', + toolType: 'claude-code', + inputPrompt: 'Combine', + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' }, + { id: 'e2', source: 't1', target: 'a2', mode: 'pass' }, + { id: 'e3', source: 'a1', target: 'a3', mode: 'pass' }, + { id: 'e4', source: 'a2', target: 'a3', mode: 'pass' }, + ], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + + // Find the fan-in subscription (the one targeting aggregator) + const fanInSub = subs.find((s) => s.source_session && Array.isArray(s.source_session)); + expect(fanInSub).toBeDefined(); + expect(fanInSub!.event).toBe('agent.completed'); + expect(fanInSub!.source_session).toEqual(['worker-a', 'worker-b']); + expect(fanInSub!.prompt).toBe('Combine'); + }); + + it('maps github.pull_request trigger config', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'github.pull_request', + label: 'PR', + config: { repo: 'owner/repo', poll_minutes: 5 }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'reviewer', + toolType: 'claude-code', + inputPrompt: 'Review PR', + }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs[0].repo).toBe('owner/repo'); + expect(subs[0].poll_minutes).toBe(5); + expect(subs[0].event).toBe('github.pull_request'); + }); + + it('maps task.pending trigger config', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'task.pending', + label: 'Task', + config: { watch: 'docs/**/*.md' }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'tasker', + toolType: 'claude-code', + inputPrompt: 'Complete tasks', + }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs[0].watch).toBe('docs/**/*.md'); + expect(subs[0].event).toBe('task.pending'); + }); +}); + +describe('pipelinesToYaml', () => { + it('produces valid YAML with prompt_file references', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.interval', + label: 'Scheduled', + config: { interval_minutes: 15 }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + inputPrompt: 'Do stuff', + }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const { yaml: yamlStr, promptFiles } = pipelinesToYaml([pipeline]); + expect(yamlStr).toContain('# Pipeline: test-pipeline (color: #06b6d4)'); + expect(yamlStr).toContain('subscriptions:'); + expect(yamlStr).toContain('name: test-pipeline'); + expect(yamlStr).toContain('event: time.interval'); + expect(yamlStr).toContain('interval_minutes: 15'); + expect(yamlStr).toContain('prompt_file: .maestro/prompts/worker-test-pipeline.md'); + expect(yamlStr).not.toContain('prompt: Do stuff'); + + // Prompt content saved to external file + expect(promptFiles.get('.maestro/prompts/worker-test-pipeline.md')).toBe('Do stuff'); + }); + + it('includes settings block when provided', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'time.interval', label: 'Timer', config: { interval_minutes: 5 } }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { sessionId: 's1', sessionName: 'w', toolType: 'claude-code', inputPrompt: 'go' }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const { yaml: yamlStr } = pipelinesToYaml([pipeline], { + timeout_minutes: 60, + max_concurrent: 3, + }); + expect(yamlStr).toContain('settings:'); + expect(yamlStr).toContain('timeout_minutes: 60'); + expect(yamlStr).toContain('max_concurrent: 3'); + }); + + it('adds debate mode edge comment', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'time.interval', label: 'Timer', config: { interval_minutes: 5 } }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'debater', + toolType: 'claude-code', + inputPrompt: 'argue', + }, + }, + ], + edges: [ + { + id: 'e1', + source: 't1', + target: 'a1', + mode: 'debate' as const, + debateConfig: { maxRounds: 5, timeoutPerRound: 120 }, + }, + ], + }); + + const { yaml: yamlStr } = pipelinesToYaml([pipeline]); + expect(yamlStr).toContain('mode: debate, max_rounds: 5, timeout_per_round: 120'); + }); + + it('handles multiple pipelines', () => { + const p1 = makePipeline({ + id: 'p1', + name: 'pipeline-a', + color: '#06b6d4', + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'time.interval', label: 'Timer', config: { interval_minutes: 5 } }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'w1', + toolType: 'claude-code', + inputPrompt: 'go 1', + }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const p2 = makePipeline({ + id: 'p2', + name: 'pipeline-b', + color: '#8b5cf6', + nodes: [ + { + id: 't2', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'file.changed', label: 'Files', config: { watch: '**/*.md' } }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's2', + sessionName: 'w2', + toolType: 'claude-code', + inputPrompt: 'go 2', + }, + }, + ], + edges: [{ id: 'e2', source: 't2', target: 'a2', mode: 'pass' }], + }); + + const { yaml: yamlStr } = pipelinesToYaml([p1, p2]); + expect(yamlStr).toContain('# Pipeline: pipeline-a'); + expect(yamlStr).toContain('# Pipeline: pipeline-b'); + expect(yamlStr).toContain('name: pipeline-a'); + expect(yamlStr).toContain('name: pipeline-b'); + }); + + it('returns empty subscriptions for empty pipelines array', () => { + const { yaml: yamlStr } = pipelinesToYaml([]); + expect(yamlStr).toContain('subscriptions: []'); + }); + + it('includes agent_id from agent node sessionId', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'time.interval', label: 'Timer', config: { interval_minutes: 5 } }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 'uuid-abc-123', + sessionName: 'worker', + toolType: 'claude-code', + inputPrompt: 'go', + }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const { yaml: yamlStr } = pipelinesToYaml([pipeline]); + expect(yamlStr).toContain('agent_id: uuid-abc-123'); + }); + + it('includes agent_id for each agent in a chain', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'file.changed', label: 'Files', config: { watch: '**/*' } }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 'id-builder', + sessionName: 'builder', + toolType: 'claude-code', + inputPrompt: 'build', + }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 600, y: 0 }, + data: { + sessionId: 'id-tester', + sessionName: 'tester', + toolType: 'claude-code', + inputPrompt: 'test', + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' }, + { id: 'e2', source: 'a1', target: 'a2', mode: 'pass' }, + ], + }); + + const { yaml: yamlStr } = pipelinesToYaml([pipeline]); + expect(yamlStr).toContain('agent_id: id-builder'); + expect(yamlStr).toContain('agent_id: id-tester'); + }); + + it('saves output_prompt to separate file with -output suffix', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'time.interval', label: 'Timer', config: { interval_minutes: 5 } }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + inputPrompt: 'Do work', + outputPrompt: 'Summarize output', + }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const { yaml: yamlStr, promptFiles } = pipelinesToYaml([pipeline]); + expect(yamlStr).toContain('prompt_file: .maestro/prompts/worker-test-pipeline.md'); + expect(yamlStr).toContain( + 'output_prompt_file: .maestro/prompts/worker-test-pipeline-output.md' + ); + expect(promptFiles.get('.maestro/prompts/worker-test-pipeline.md')).toBe('Do work'); + expect(promptFiles.get('.maestro/prompts/worker-test-pipeline-output.md')).toBe( + 'Summarize output' + ); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/utils/yamlToPipeline.test.ts b/src/__tests__/renderer/components/CuePipelineEditor/utils/yamlToPipeline.test.ts new file mode 100644 index 0000000000..d2d9548ae5 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/utils/yamlToPipeline.test.ts @@ -0,0 +1,569 @@ +/** + * Tests for yamlToPipeline conversion utilities. + * + * Verifies that CueSubscription objects and CueGraphSession data + * correctly convert back into visual CuePipeline structures. + */ + +import { describe, it, expect } from 'vitest'; +import { + subscriptionsToPipelines, + graphSessionsToPipelines, +} from '../../../../../renderer/components/CuePipelineEditor/utils/yamlToPipeline'; +import type { CueSubscription, CueGraphSession } from '../../../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../../../shared/types'; + +const makeSessions = (...names: string[]): SessionInfo[] => + names.map((name, i) => ({ + id: `session-${i}`, + name, + toolType: 'claude-code' as const, + cwd: '/tmp', + projectRoot: '/tmp', + })); + +describe('subscriptionsToPipelines', () => { + it('returns empty array for no subscriptions', () => { + const result = subscriptionsToPipelines([], []); + expect(result).toEqual([]); + }); + + it('converts a simple trigger -> agent subscription', () => { + const subs: CueSubscription[] = [ + { + name: 'my-pipeline', + event: 'time.interval', + enabled: true, + prompt: 'Do the work', + interval_minutes: 10, + }, + ]; + const sessions = makeSessions('worker'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + expect(pipelines[0].name).toBe('my-pipeline'); + + // Should have a trigger node and an agent node + const triggers = pipelines[0].nodes.filter((n) => n.type === 'trigger'); + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + expect(triggers).toHaveLength(1); + expect(agents).toHaveLength(1); + + // Trigger should have correct event type and config + expect(triggers[0].data).toMatchObject({ + eventType: 'time.interval', + config: { interval_minutes: 10 }, + }); + + // Agent should have the input prompt + expect(agents[0].data).toMatchObject({ + sessionName: 'worker', + inputPrompt: 'Do the work', + }); + + // Should have one edge connecting them + expect(pipelines[0].edges).toHaveLength(1); + expect(pipelines[0].edges[0].source).toBe(triggers[0].id); + expect(pipelines[0].edges[0].target).toBe(agents[0].id); + }); + + it('converts trigger -> agent1 -> agent2 chain', () => { + const subs: CueSubscription[] = [ + { + name: 'chain-test', + event: 'file.changed', + enabled: true, + prompt: 'Build it', + watch: 'src/**/*.ts', + }, + { + name: 'chain-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Test it', + source_session: 'builder', + }, + ]; + const sessions = makeSessions('builder', 'tester'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + + const triggers = pipelines[0].nodes.filter((n) => n.type === 'trigger'); + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + expect(triggers).toHaveLength(1); + expect(agents).toHaveLength(2); + + // Trigger config + expect(triggers[0].data).toMatchObject({ + eventType: 'file.changed', + config: { watch: 'src/**/*.ts' }, + }); + + // Should have edges: trigger -> builder, builder -> tester + expect(pipelines[0].edges).toHaveLength(2); + }); + + it('handles fan-out (trigger -> [agent1, agent2])', () => { + const subs: CueSubscription[] = [ + { + name: 'fanout-test', + event: 'time.interval', + enabled: true, + prompt: 'Task A', + interval_minutes: 30, + fan_out: ['worker-a', 'worker-b'], + }, + ]; + const sessions = makeSessions('worker-a', 'worker-b'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + + const triggers = pipelines[0].nodes.filter((n) => n.type === 'trigger'); + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + expect(triggers).toHaveLength(1); + expect(agents).toHaveLength(2); + + // Both agents should be connected to the trigger + expect(pipelines[0].edges).toHaveLength(2); + for (const edge of pipelines[0].edges) { + expect(edge.source).toBe(triggers[0].id); + } + + const agentNames = agents.map((a) => (a.data as { sessionName: string }).sessionName); + expect(agentNames).toContain('worker-a'); + expect(agentNames).toContain('worker-b'); + }); + + it('handles fan-in ([agent1, agent2] -> agent3)', () => { + const subs: CueSubscription[] = [ + { + name: 'fanin-test', + event: 'time.interval', + enabled: true, + prompt: 'Start', + interval_minutes: 5, + fan_out: ['worker-a', 'worker-b'], + }, + { + name: 'fanin-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Combine results', + source_session: ['worker-a', 'worker-b'], + }, + ]; + const sessions = makeSessions('worker-a', 'worker-b', 'aggregator'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + // worker-a, worker-b, and the aggregator target + expect(agents.length).toBeGreaterThanOrEqual(3); + + // The aggregator should have 2 incoming edges (from worker-a and worker-b) + const aggregatorNode = agents.find( + (a) => (a.data as { sessionName: string }).sessionName === 'aggregator' + ); + expect(aggregatorNode).toBeDefined(); + + const incomingEdges = pipelines[0].edges.filter((e) => e.target === aggregatorNode!.id); + expect(incomingEdges).toHaveLength(2); + }); + + it('maps github.pull_request trigger config', () => { + const subs: CueSubscription[] = [ + { + name: 'pr-review', + event: 'github.pull_request', + enabled: true, + prompt: 'Review this PR', + repo: 'owner/repo', + poll_minutes: 5, + }, + ]; + const sessions = makeSessions('reviewer'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + const trigger = pipelines[0].nodes.find((n) => n.type === 'trigger'); + expect(trigger).toBeDefined(); + expect(trigger!.data).toMatchObject({ + eventType: 'github.pull_request', + config: { repo: 'owner/repo', poll_minutes: 5 }, + }); + }); + + it('maps task.pending trigger config', () => { + const subs: CueSubscription[] = [ + { + name: 'task-handler', + event: 'task.pending', + enabled: true, + prompt: 'Complete tasks', + watch: 'docs/**/*.md', + }, + ]; + const sessions = makeSessions('tasker'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + const trigger = pipelines[0].nodes.find((n) => n.type === 'trigger'); + expect(trigger!.data).toMatchObject({ + eventType: 'task.pending', + config: { watch: 'docs/**/*.md' }, + }); + }); + + it('groups subscriptions into separate pipelines by name prefix', () => { + const subs: CueSubscription[] = [ + { + name: 'pipeline-a', + event: 'time.interval', + enabled: true, + prompt: 'Task A', + interval_minutes: 5, + }, + { + name: 'pipeline-b', + event: 'file.changed', + enabled: true, + prompt: 'Task B', + watch: '**/*.ts', + }, + ]; + const sessions = makeSessions('worker-a', 'worker-b'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(2); + expect(pipelines[0].name).toBe('pipeline-a'); + expect(pipelines[1].name).toBe('pipeline-b'); + }); + + it('assigns unique colors to each pipeline', () => { + const subs: CueSubscription[] = [ + { + name: 'p1', + event: 'time.interval', + enabled: true, + prompt: 'A', + interval_minutes: 5, + }, + { + name: 'p2', + event: 'time.interval', + enabled: true, + prompt: 'B', + interval_minutes: 10, + }, + ]; + const sessions = makeSessions('worker'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines[0].color).not.toBe(pipelines[1].color); + }); + + it('auto-layouts nodes left-to-right', () => { + const subs: CueSubscription[] = [ + { + name: 'layout-test', + event: 'time.interval', + enabled: true, + prompt: 'Build', + interval_minutes: 5, + }, + { + name: 'layout-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Test', + source_session: 'builder', + }, + ]; + const sessions = makeSessions('builder', 'tester'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + const triggers = pipelines[0].nodes.filter((n) => n.type === 'trigger'); + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + + // Trigger should be leftmost + expect(triggers[0].position.x).toBe(100); + // First agent should be further right + expect(agents[0].position.x).toBeGreaterThan(triggers[0].position.x); + // Second agent should be even further right (if present) + if (agents.length > 1) { + expect(agents[1].position.x).toBeGreaterThan(agents[0].position.x); + } + }); + + it('deduplicates agent nodes by session name', () => { + const subs: CueSubscription[] = [ + { + name: 'dedup-test', + event: 'time.interval', + enabled: true, + prompt: 'Start', + interval_minutes: 5, + fan_out: ['worker-a', 'worker-b'], + }, + { + name: 'dedup-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Combine', + source_session: ['worker-a', 'worker-b'], + }, + ]; + const sessions = makeSessions('worker-a', 'worker-b', 'combiner'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + const sessionNames = agents.map((a) => (a.data as { sessionName: string }).sessionName); + + // worker-a and worker-b should appear only once each + const workerACount = sessionNames.filter((n) => n === 'worker-a').length; + const workerBCount = sessionNames.filter((n) => n === 'worker-b').length; + expect(workerACount).toBe(1); + expect(workerBCount).toBe(1); + }); + + it('resolves target session from agent_id', () => { + const subs: CueSubscription[] = [ + { + name: 'agent-id-test', + event: 'time.interval', + enabled: true, + prompt: 'Do work', + interval_minutes: 10, + agent_id: 'session-1', + }, + ]; + // session-1 maps to 'specific-worker', session-0 maps to 'other-agent' + const sessions = makeSessions('other-agent', 'specific-worker'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + expect(agents).toHaveLength(1); + expect((agents[0].data as { sessionName: string }).sessionName).toBe('specific-worker'); + expect((agents[0].data as { sessionId: string }).sessionId).toBe('session-1'); + }); + + it('resolves agent_id in chain subscriptions', () => { + const subs: CueSubscription[] = [ + { + name: 'chain-id', + event: 'file.changed', + enabled: true, + prompt: 'Build', + watch: 'src/**/*', + agent_id: 'session-0', + }, + { + name: 'chain-id-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Test', + source_session: 'builder', + agent_id: 'session-1', + }, + ]; + const sessions = makeSessions('builder', 'tester'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + const agentNames = agents.map((a) => (a.data as { sessionName: string }).sessionName); + expect(agentNames).toContain('builder'); + expect(agentNames).toContain('tester'); + }); + + it('sets default edge mode to pass', () => { + const subs: CueSubscription[] = [ + { + name: 'mode-test', + event: 'time.interval', + enabled: true, + prompt: 'Go', + interval_minutes: 5, + }, + ]; + const sessions = makeSessions('worker'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + for (const edge of pipelines[0].edges) { + expect(edge.mode).toBe('pass'); + } + }); +}); + +describe('graphSessionsToPipelines', () => { + it('extracts subscriptions from graph sessions and converts', () => { + const graphSessions: CueGraphSession[] = [ + { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + subscriptions: [ + { + name: 'graph-test', + event: 'time.interval', + enabled: true, + prompt: 'Do work', + interval_minutes: 15, + }, + ], + }, + ]; + const sessions = makeSessions('worker'); + + const pipelines = graphSessionsToPipelines(graphSessions, sessions); + expect(pipelines).toHaveLength(1); + expect(pipelines[0].name).toBe('graph-test'); + + const triggers = pipelines[0].nodes.filter((n) => n.type === 'trigger'); + expect(triggers).toHaveLength(1); + expect(triggers[0].data).toMatchObject({ + eventType: 'time.interval', + config: { interval_minutes: 15 }, + }); + }); + + it('combines subscriptions from multiple graph sessions', () => { + const graphSessions: CueGraphSession[] = [ + { + sessionId: 's1', + sessionName: 'builder', + toolType: 'claude-code', + subscriptions: [ + { + name: 'multi-test', + event: 'file.changed', + enabled: true, + prompt: 'Build', + watch: 'src/**/*', + }, + ], + }, + { + sessionId: 's2', + sessionName: 'tester', + toolType: 'claude-code', + subscriptions: [ + { + name: 'multi-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Test', + source_session: 'builder', + }, + ], + }, + ]; + const sessions = makeSessions('builder', 'tester'); + + const pipelines = graphSessionsToPipelines(graphSessions, sessions); + expect(pipelines).toHaveLength(1); + expect(pipelines[0].name).toBe('multi-test'); + expect(pipelines[0].edges.length).toBeGreaterThanOrEqual(2); + }); + + it('returns empty array for no graph sessions', () => { + const result = graphSessionsToPipelines([], []); + expect(result).toEqual([]); + }); + + it('uses owning graph session name for agent nodes (dashboard matching)', () => { + // Simulates the dashboard scenario: a session "PedTome RSSidian" has a + // cue.yaml with an issue trigger. The agent node should use that session's + // name so getPipelineColorForAgent can match it by sessionId. + const graphSessions: CueGraphSession[] = [ + { + sessionId: 'real-uuid-123', + sessionName: 'PedTome RSSidian', + toolType: 'claude-code', + subscriptions: [ + { + name: 'issue-triage', + event: 'github.issue', + enabled: true, + prompt: 'Triage this issue', + repo: 'RunMaestro/Maestro', + }, + ], + }, + ]; + const sessions: SessionInfo[] = [ + { + id: 'real-uuid-123', + name: 'PedTome RSSidian', + toolType: 'claude-code', + cwd: '/tmp', + projectRoot: '/tmp', + }, + { + id: 'other-uuid-456', + name: 'Maestro', + toolType: 'claude-code', + cwd: '/tmp', + projectRoot: '/tmp', + }, + ]; + + const pipelines = graphSessionsToPipelines(graphSessions, sessions); + expect(pipelines).toHaveLength(1); + + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + expect(agents).toHaveLength(1); + expect((agents[0].data as { sessionName: string }).sessionName).toBe('PedTome RSSidian'); + expect((agents[0].data as { sessionId: string }).sessionId).toBe('real-uuid-123'); + }); + + it('correctly maps agents when multiple sessions share subscriptions', () => { + // Two sessions share the same project root / cue.yaml with a chain pipeline. + // Both report all subscriptions. The builder should be target of the initial + // trigger, and the tester should be target of the chain-1 sub. + const sharedSubs = [ + { + name: 'shared-pipeline', + event: 'file.changed' as const, + enabled: true, + prompt: 'Build', + watch: 'src/**/*', + }, + { + name: 'shared-pipeline-chain-1', + event: 'agent.completed' as const, + enabled: true, + prompt: 'Test', + source_session: 'builder', + }, + ]; + const graphSessions: CueGraphSession[] = [ + { + sessionId: 'builder-id', + sessionName: 'builder', + toolType: 'claude-code', + subscriptions: sharedSubs, + }, + { + sessionId: 'tester-id', + sessionName: 'tester', + toolType: 'claude-code', + subscriptions: sharedSubs, + }, + ]; + const sessions = makeSessions('builder', 'tester'); + + const pipelines = graphSessionsToPipelines(graphSessions, sessions); + expect(pipelines).toHaveLength(1); + + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + const agentNames = agents.map((a) => (a.data as { sessionName: string }).sessionName); + expect(agentNames).toContain('builder'); + expect(agentNames).toContain('tester'); + }); +}); diff --git a/src/__tests__/renderer/components/CueYamlEditor.test.tsx b/src/__tests__/renderer/components/CueYamlEditor.test.tsx new file mode 100644 index 0000000000..ecb9f62f68 --- /dev/null +++ b/src/__tests__/renderer/components/CueYamlEditor.test.tsx @@ -0,0 +1,818 @@ +/** + * Tests for CueYamlEditor component + * + * Tests the Cue YAML editor including: + * - Loading existing YAML content on mount + * - YAML template shown when no file exists + * - Real-time validation with error display + * - AI assist chat with agent spawn and conversation resume + * - Save/Exit functionality with dirty state + * - Line numbers gutter + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { CueYamlEditor } from '../../../renderer/components/CueYamlEditor'; +import type { Theme } from '../../../renderer/types'; + +// Mock the Modal component +vi.mock('../../../renderer/components/ui/Modal', () => ({ + Modal: ({ + children, + footer, + title, + testId, + onClose, + }: { + children: React.ReactNode; + footer?: React.ReactNode; + title: string; + testId?: string; + onClose: () => void; + }) => ( +
+ +
{children}
+ {footer &&
{footer}
} +
+ ), + ModalFooter: ({ + onCancel, + onConfirm, + confirmLabel, + cancelLabel = 'Cancel', + confirmDisabled, + }: { + onCancel: () => void; + onConfirm: () => void; + confirmLabel: string; + cancelLabel?: string; + confirmDisabled: boolean; + theme: Theme; + }) => ( + <> + + + + ), +})); + +// Mock modal priorities +vi.mock('../../../renderer/constants/modalPriorities', () => ({ + MODAL_PRIORITIES: { + CUE_YAML_EDITOR: 463, + CUE_PATTERN_PREVIEW: 464, + }, +})); + +// Mock sessionStore +const mockSession = { + id: 'sess-1', + toolType: 'claude-code', + cwd: '/test/project', + customPath: undefined, + customArgs: undefined, + customEnvVars: undefined, + customModel: undefined, + customContextWindow: undefined, + sessionSshRemoteConfig: undefined, +}; + +vi.mock('../../../renderer/stores/sessionStore', () => ({ + useSessionStore: vi.fn((selector: (s: any) => any) => selector({ sessions: [mockSession] })), + selectSessionById: (id: string) => (state: any) => state.sessions.find((s: any) => s.id === id), +})); + +// Mock buildSpawnConfigForAgent +const mockBuildSpawnConfig = vi.fn(); +vi.mock('../../../renderer/utils/sessionHelpers', () => ({ + buildSpawnConfigForAgent: (...args: any[]) => mockBuildSpawnConfig(...args), +})); + +// Mock IPC methods +const mockReadYaml = vi.fn(); +const mockWriteYaml = vi.fn(); +const mockValidateYaml = vi.fn(); +const mockRefreshSession = vi.fn(); +const mockSpawn = vi.fn(); +const mockOnData = vi.fn(); +const mockOnExit = vi.fn(); +const mockOnSessionId = vi.fn(); +const mockOnAgentError = vi.fn(); + +const existingWindowMaestro = (window as any).maestro; + +beforeEach(() => { + vi.clearAllMocks(); + + (window as any).maestro = { + ...existingWindowMaestro, + cue: { + ...existingWindowMaestro?.cue, + readYaml: mockReadYaml, + writeYaml: mockWriteYaml, + validateYaml: mockValidateYaml, + refreshSession: mockRefreshSession, + }, + process: { + ...existingWindowMaestro?.process, + spawn: mockSpawn, + onData: mockOnData, + onExit: mockOnExit, + onSessionId: mockOnSessionId, + onAgentError: mockOnAgentError, + }, + }; + + // Default: file doesn't exist, YAML is valid + mockReadYaml.mockResolvedValue(null); + mockWriteYaml.mockResolvedValue(undefined); + mockValidateYaml.mockResolvedValue({ valid: true, errors: [] }); + mockRefreshSession.mockResolvedValue(undefined); + mockSpawn.mockResolvedValue({ pid: 123, success: true }); + mockBuildSpawnConfig.mockResolvedValue({ + sessionId: 'sess-1-cue-assist-123', + toolType: 'claude-code', + cwd: '/test/project', + command: 'claude', + args: [], + prompt: 'test prompt', + }); + + // Default: listeners return cleanup functions + mockOnData.mockReturnValue(vi.fn()); + mockOnExit.mockReturnValue(vi.fn()); + mockOnSessionId.mockReturnValue(vi.fn()); + mockOnAgentError.mockReturnValue(vi.fn()); +}); + +afterEach(() => { + vi.restoreAllMocks(); + (window as any).maestro = existingWindowMaestro; +}); + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + scrollbar: '#44475a', + scrollbarHover: '#6272a4', + }, +}; + +const defaultProps = { + isOpen: true, + onClose: vi.fn(), + projectRoot: '/test/project', + sessionId: 'sess-1', + theme: mockTheme, +}; + +describe('CueYamlEditor', () => { + describe('rendering', () => { + it('should not render when isOpen is false', () => { + render(); + expect(screen.queryByTestId('cue-yaml-editor')).not.toBeInTheDocument(); + }); + + it('should render when isOpen is true', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('cue-yaml-editor')).toBeInTheDocument(); + }); + }); + + it('should show loading state initially', () => { + mockReadYaml.mockReturnValue(new Promise(() => {})); + render(); + + expect(screen.getByText('Loading YAML...')).toBeInTheDocument(); + }); + + it('should render AI assist chat section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('AI Assist')).toBeInTheDocument(); + }); + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + expect(screen.getByTestId('ai-chat-send')).toBeInTheDocument(); + expect(screen.getByTestId('ai-chat-history')).toBeInTheDocument(); + }); + + it('should render YAML editor section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('YAML Configuration')).toBeInTheDocument(); + }); + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + it('should render line numbers gutter', async () => { + mockReadYaml.mockResolvedValue('line1\nline2\nline3'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('line-numbers')).toBeInTheDocument(); + }); + expect(screen.getByTestId('line-numbers').textContent).toContain('1'); + expect(screen.getByTestId('line-numbers').textContent).toContain('2'); + expect(screen.getByTestId('line-numbers').textContent).toContain('3'); + }); + }); + + describe('YAML loading', () => { + it('should load existing YAML from projectRoot on mount', async () => { + const existingYaml = 'subscriptions:\n - name: "test"\n event: time.interval'; + mockReadYaml.mockResolvedValue(existingYaml); + + render(); + + await waitFor(() => { + expect(mockReadYaml).toHaveBeenCalledWith('/test/project'); + }); + expect(screen.getByTestId('yaml-editor')).toHaveValue(existingYaml); + }); + + it('should show template when no YAML file exists', async () => { + mockReadYaml.mockResolvedValue(null); + + render(); + + await waitFor(() => { + const editor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; + expect(editor.value).toContain('# .maestro/cue.yaml'); + }); + }); + + it('should show template when readYaml throws', async () => { + mockReadYaml.mockRejectedValue(new Error('File read error')); + + render(); + + await waitFor(() => { + const editor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; + expect(editor.value).toContain('# .maestro/cue.yaml'); + }); + }); + }); + + describe('validation', () => { + it('should show valid indicator when YAML is valid', async () => { + mockReadYaml.mockResolvedValue('subscriptions: []'); + mockValidateYaml.mockResolvedValue({ valid: true, errors: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Valid YAML')).toBeInTheDocument(); + }); + }); + + it('should show validation errors when YAML is invalid', async () => { + mockReadYaml.mockResolvedValue('subscriptions: []'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + mockValidateYaml.mockResolvedValue({ + valid: false, + errors: ['Missing required field: name'], + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'invalid: yaml: content' }, + }); + + await waitFor( + () => { + expect(screen.getByTestId('validation-errors')).toBeInTheDocument(); + }, + { timeout: 2000 } + ); + + expect(screen.getByText('Missing required field: name')).toBeInTheDocument(); + expect(screen.getByText('1 error')).toBeInTheDocument(); + }); + + it('should show plural error count for multiple errors', async () => { + mockReadYaml.mockResolvedValue('subscriptions: []'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + mockValidateYaml.mockResolvedValue({ + valid: false, + errors: ['Error one', 'Error two'], + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'bad' }, + }); + + await waitFor( + () => { + expect(screen.getByText('2 errors')).toBeInTheDocument(); + }, + { timeout: 2000 } + ); + }); + + it('should debounce validation calls', async () => { + vi.useFakeTimers(); + mockReadYaml.mockResolvedValue('initial'); + + render(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + const editor = screen.getByTestId('yaml-editor'); + fireEvent.change(editor, { target: { value: 'change1' } }); + fireEvent.change(editor, { target: { value: 'change2' } }); + fireEvent.change(editor, { target: { value: 'change3' } }); + + const callsBeforeDebounce = mockValidateYaml.mock.calls.length; + + await act(async () => { + vi.advanceTimersByTime(600); + }); + + expect(mockValidateYaml.mock.calls.length).toBe(callsBeforeDebounce + 1); + expect(mockValidateYaml).toHaveBeenLastCalledWith('change3'); + + vi.useRealTimers(); + }); + }); + + describe('AI assist chat', () => { + it('should have disabled send button when input is empty', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-send')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('ai-chat-send')).toBeDisabled(); + }); + + it('should enable send button when input has text', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Watch for file changes' }, + }); + + expect(screen.getByTestId('ai-chat-send')).not.toBeDisabled(); + }); + + it('should add user message to chat history on send', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up file watching' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(screen.getByTestId('chat-message-user')).toBeInTheDocument(); + }); + expect(screen.getByText('Set up file watching')).toBeInTheDocument(); + }); + + it('should show busy indicator while agent is working', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up file watching' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(screen.getByTestId('chat-busy-indicator')).toBeInTheDocument(); + }); + expect(screen.getByText('Agent is working...')).toBeInTheDocument(); + }); + + it('should clear input after sending', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up file watching' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect((screen.getByTestId('ai-chat-input') as HTMLTextAreaElement).value).toBe(''); + }); + }); + + it('should include system prompt on first message', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Run code review' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(mockBuildSpawnConfig).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining('configuring maestro-cue.yaml'), + }) + ); + }); + + // Should include the file path + const prompt = mockBuildSpawnConfig.mock.calls[0][0].prompt; + expect(prompt).toContain('/test/project/.maestro/cue.yaml'); + expect(prompt).toContain('Run code review'); + }); + + it('should spawn agent process', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Run code review' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(mockSpawn).toHaveBeenCalled(); + }); + }); + + it('should freeze YAML editor while agent is working', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up automation' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + const editor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; + expect(editor.readOnly).toBe(true); + }); + }); + + it('should register onData, onExit, onSessionId, and onAgentError listeners', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up automation' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(mockOnData).toHaveBeenCalledWith(expect.any(Function)); + expect(mockOnExit).toHaveBeenCalledWith(expect.any(Function)); + expect(mockOnSessionId).toHaveBeenCalledWith(expect.any(Function)); + expect(mockOnAgentError).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + it('should show error message when agent config is unavailable', async () => { + mockBuildSpawnConfig.mockResolvedValue(null); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up automation' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(screen.getByText(/Agent not available/)).toBeInTheDocument(); + }); + }); + + it('should show placeholder text when chat is empty', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/Describe what you want to automate/)).toBeInTheDocument(); + }); + }); + + it('should disable input while agent is working', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Do something' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeDisabled(); + }); + }); + }); + + describe('save and cancel', () => { + it('should disable Save when content has not changed', async () => { + mockReadYaml.mockResolvedValue('original content'); + + render(); + + await waitFor(() => { + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + expect(screen.getByText('Save')).toBeDisabled(); + }); + + it('should enable Save when content is modified and valid', async () => { + mockReadYaml.mockResolvedValue('original content'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'modified content' }, + }); + + expect(screen.getByText('Save')).not.toBeDisabled(); + }); + + it('should disable Save when validation fails', async () => { + vi.useFakeTimers(); + mockReadYaml.mockResolvedValue('original'); + + render(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + mockValidateYaml.mockResolvedValue({ valid: false, errors: ['Bad YAML'] }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'invalid' }, + }); + + await act(async () => { + vi.advanceTimersByTime(600); + }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(screen.getByText('Save')).toBeDisabled(); + + vi.useRealTimers(); + }); + + it('should call writeYaml and refreshSession on Save', async () => { + mockReadYaml.mockResolvedValue('original'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'new content' }, + }); + + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(mockWriteYaml).toHaveBeenCalledWith('/test/project', 'new content'); + }); + expect(mockRefreshSession).toHaveBeenCalledWith('sess-1', '/test/project'); + expect(defaultProps.onClose).toHaveBeenCalledOnce(); + }); + + it('should call onClose when Exit is clicked and content is not dirty', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Exit')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Exit')); + + expect(defaultProps.onClose).toHaveBeenCalledOnce(); + }); + + it('should prompt for confirmation when Exit is clicked with dirty content', async () => { + const mockConfirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + mockReadYaml.mockResolvedValue('original'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'modified' }, + }); + + fireEvent.click(screen.getByText('Exit')); + + expect(mockConfirm).toHaveBeenCalledWith('You have unsaved changes. Discard them?'); + expect(defaultProps.onClose).not.toHaveBeenCalled(); + + mockConfirm.mockRestore(); + }); + + it('should close when user confirms discard on Exit', async () => { + const mockConfirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + mockReadYaml.mockResolvedValue('original'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'modified' }, + }); + + fireEvent.click(screen.getByText('Exit')); + + expect(defaultProps.onClose).toHaveBeenCalledOnce(); + + mockConfirm.mockRestore(); + }); + }); + + describe('pattern presets', () => { + it('should render pattern preset buttons', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('pattern-presets')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('pattern-scheduled-task')).toBeInTheDocument(); + expect(screen.getByTestId('pattern-file-enrichment')).toBeInTheDocument(); + expect(screen.getByTestId('pattern-reactive')).toBeInTheDocument(); + expect(screen.getByTestId('pattern-research-swarm')).toBeInTheDocument(); + expect(screen.getByTestId('pattern-sequential-chain')).toBeInTheDocument(); + expect(screen.getByTestId('pattern-debate')).toBeInTheDocument(); + }); + + it('should render "Start from a pattern" heading', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Start from a pattern')).toBeInTheDocument(); + }); + }); + + it('should open a preview overlay when a pattern is clicked', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('pattern-scheduled-task')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('pattern-scheduled-task')); + + // Preview overlay should show the explanation and copy button + expect(screen.getByText(/Runs a prompt on a fixed interval/)).toBeInTheDocument(); + expect(screen.getByText('Copy to Clipboard')).toBeInTheDocument(); + }); + + it('should not modify the editor when a pattern is clicked', async () => { + mockReadYaml.mockResolvedValue('original content'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('pattern-scheduled-task')); + + // Editor should still have original content + const editor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; + expect(editor.value).toBe('original content'); + }); + + it('should copy YAML to clipboard when Copy button is clicked', async () => { + const mockWriteText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { + clipboard: { writeText: mockWriteText }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('pattern-scheduled-task')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('pattern-scheduled-task')); + fireEvent.click(screen.getByText('Copy to Clipboard')); + + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith(expect.stringContaining('time.interval')); + }); + + expect(screen.getByText('Copied')).toBeInTheDocument(); + }); + + it('should close preview modal when close is triggered', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('pattern-scheduled-task')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('pattern-scheduled-task')); + expect(screen.getByText('Copy to Clipboard')).toBeInTheDocument(); + expect(screen.getByTestId('cue-pattern-preview')).toBeInTheDocument(); + + // Close via the mock Modal's close button + fireEvent.click(screen.getByTestId('cue-pattern-preview-close')); + + await waitFor(() => { + expect(screen.queryByTestId('cue-pattern-preview')).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx b/src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx index c4c1cf7655..5752e31336 100644 --- a/src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx +++ b/src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { UnifiedHistoryTab } from '../../../../renderer/components/DirectorNotes/UnifiedHistoryTab'; import type { Theme } from '../../../../renderer/types'; +import { useSettingsStore } from '../../../../renderer/stores/settingsStore'; // Mock useSettings hook (mutable so individual tests can override) const mockDirNotesSettings = vi.hoisted(() => ({ @@ -108,7 +109,7 @@ vi.mock('../../../../renderer/components/History', () => ({ )}
), - HistoryFilterToggle: ({ activeFilters, onToggleFilter }: any) => ( + HistoryFilterToggle: ({ activeFilters, onToggleFilter, visibleTypes }: any) => (
+ {visibleTypes?.includes('CUE') && ( + + )}
), HistoryStatsBar: ({ stats }: any) => ( @@ -226,6 +236,7 @@ beforeEach(() => { (window as any).maestro = { directorNotes: { getUnifiedHistory: mockGetUnifiedHistory, + onHistoryEntryAdded: vi.fn().mockReturnValue(() => {}), }, history: { update: mockHistoryUpdate, @@ -233,6 +244,11 @@ beforeEach(() => { }; mockHistoryUpdate.mockResolvedValue(true); mockGetUnifiedHistory.mockResolvedValue(createPaginatedResponse(createMockEntries())); + + // Default: maestroCue disabled + useSettingsStore.setState({ + encoreFeatures: { directorNotes: false, usageStats: false, symphony: false, maestroCue: false }, + }); }); afterEach(() => { @@ -395,6 +411,43 @@ describe('UnifiedHistoryTab', () => { // USER entries should remain expect(screen.getByText('User performed action A')).toBeInTheDocument(); }); + + it('hides CUE filter when maestroCue is disabled', async () => { + useSettingsStore.setState({ + encoreFeatures: { + directorNotes: false, + usageStats: false, + symphony: false, + maestroCue: false, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('filter-auto')).toBeInTheDocument(); + expect(screen.getByTestId('filter-user')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('filter-cue')).not.toBeInTheDocument(); + }); + + it('shows CUE filter when maestroCue is enabled', async () => { + useSettingsStore.setState({ + encoreFeatures: { + directorNotes: false, + usageStats: false, + symphony: false, + maestroCue: true, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('filter-cue')).toBeInTheDocument(); + }); + }); }); describe('Activity Graph', () => { diff --git a/src/__tests__/renderer/components/FileExplorerPanel.test.tsx b/src/__tests__/renderer/components/FileExplorerPanel.test.tsx index 5a3783721e..452030d6e7 100644 --- a/src/__tests__/renderer/components/FileExplorerPanel.test.tsx +++ b/src/__tests__/renderer/components/FileExplorerPanel.test.tsx @@ -235,6 +235,8 @@ const createMockSession = (overrides: Partial = {}): Session => ({ messageQueue: [], changedFiles: [], fileTreeAutoRefreshInterval: 0, + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }); diff --git a/src/__tests__/renderer/components/FilePreview.test.tsx b/src/__tests__/renderer/components/FilePreview.test.tsx index 9325c856eb..e4db177418 100644 --- a/src/__tests__/renderer/components/FilePreview.test.tsx +++ b/src/__tests__/renderer/components/FilePreview.test.tsx @@ -49,6 +49,7 @@ vi.mock('react-syntax-highlighter', () => ({ })); vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); // Mock unist-util-visit @@ -164,6 +165,7 @@ vi.mock('../../../shared/gitUtils', () => ({ })); const mockTheme = { + mode: 'dark', colors: { bgMain: '#1a1a2e', bgActivity: '#16213e', diff --git a/src/__tests__/renderer/components/GroupChatInput.test.tsx b/src/__tests__/renderer/components/GroupChatInput.test.tsx index eff4a1db95..3c5d559d92 100644 --- a/src/__tests__/renderer/components/GroupChatInput.test.tsx +++ b/src/__tests__/renderer/components/GroupChatInput.test.tsx @@ -74,6 +74,8 @@ function createMockSession(id: string, name: string, toolType: string = 'claude- aiTabs: [], activeTabId: '', closedTabHistory: [], + terminalTabs: [], + activeTerminalTabId: null, }; } diff --git a/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx b/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx index b1c4be32ee..9a4a7476f3 100644 --- a/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx +++ b/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx @@ -97,6 +97,85 @@ describe('HistoryEntryItem', () => { expect(screen.getByText('USER')).toBeInTheDocument(); }); + it('shows CUE type pill for CUE entries', () => { + render( + + ); + expect(screen.getByText('CUE')).toBeInTheDocument(); + }); + + it('shows CUE pill with teal color', () => { + render( + + ); + const cuePill = screen.getByText('CUE').closest('span')!; + expect(cuePill).toHaveStyle({ color: '#06b6d4' }); + }); + + it('shows success indicator for successful CUE entries', () => { + render( + + ); + expect(screen.getByTitle('Task completed successfully')).toBeInTheDocument(); + }); + + it('shows failure indicator for failed CUE entries', () => { + render( + + ); + expect(screen.getByTitle('Task failed')).toBeInTheDocument(); + }); + + it('shows CUE event type metadata when present', () => { + render( + + ); + expect(screen.getByText('Triggered by: file_change')).toBeInTheDocument(); + }); + + it('does not show CUE metadata for non-CUE entries', () => { + render( + + ); + expect(screen.queryByText(/Triggered by:/)).not.toBeInTheDocument(); + }); + it('shows success indicator for successful AUTO entries', () => { render( { expect(screen.getByText('ABC12345')).toBeInTheDocument(); }); + it('session name pill is shrinkable to avoid date collision', () => { + const entry = createMockEntry({ + agentSessionId: 'abc12345-def6-7890', + sessionName: 'A Very Long Session Name That Should Truncate', + }); + render( + + ); + const sessionButton = screen.getByTitle('A Very Long Session Name That Should Truncate'); + expect(sessionButton).toHaveClass('flex-shrink'); + expect(sessionButton).not.toHaveClass('flex-shrink-0'); + }); + it('shows session name when both sessionName and agentSessionId are present', () => { const entry = createMockEntry({ agentSessionId: 'abc12345-def6-7890', diff --git a/src/__tests__/renderer/components/History/HistoryFilterToggle.test.tsx b/src/__tests__/renderer/components/History/HistoryFilterToggle.test.tsx index a9236c260c..614c5dd84e 100644 --- a/src/__tests__/renderer/components/History/HistoryFilterToggle.test.tsx +++ b/src/__tests__/renderer/components/History/HistoryFilterToggle.test.tsx @@ -135,7 +135,7 @@ describe('HistoryFilterToggle', () => { expect(userButton).toHaveStyle({ color: mockTheme.colors.textDim }); }); - it('renders both buttons even when no filters are active', () => { + it('renders all three buttons even when no filters are active', () => { render( ([])} @@ -145,5 +145,82 @@ describe('HistoryFilterToggle', () => { ); expect(screen.getByText('AUTO')).toBeInTheDocument(); expect(screen.getByText('USER')).toBeInTheDocument(); + expect(screen.getByText('CUE')).toBeInTheDocument(); + }); + + it('hides CUE button when visibleTypes excludes it', () => { + render( + (['AUTO', 'USER'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + visibleTypes={['AUTO', 'USER']} + /> + ); + expect(screen.getByText('AUTO')).toBeInTheDocument(); + expect(screen.getByText('USER')).toBeInTheDocument(); + expect(screen.queryByText('CUE')).not.toBeInTheDocument(); + }); + + it('shows CUE button when visibleTypes includes it', () => { + render( + (['AUTO', 'USER', 'CUE'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + visibleTypes={['AUTO', 'USER', 'CUE']} + /> + ); + expect(screen.getByText('AUTO')).toBeInTheDocument(); + expect(screen.getByText('USER')).toBeInTheDocument(); + expect(screen.getByText('CUE')).toBeInTheDocument(); + }); + + it('renders CUE filter button', () => { + render( + (['AUTO', 'USER', 'CUE'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + /> + ); + expect(screen.getByText('CUE')).toBeInTheDocument(); + }); + + it('calls onToggleFilter with CUE when CUE button is clicked', () => { + const onToggleFilter = vi.fn(); + render( + (['AUTO', 'USER', 'CUE'])} + onToggleFilter={onToggleFilter} + theme={mockTheme} + /> + ); + fireEvent.click(screen.getByText('CUE')); + expect(onToggleFilter).toHaveBeenCalledWith('CUE'); + }); + + it('styles active CUE button with teal colors', () => { + render( + (['CUE'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + /> + ); + const cueButton = screen.getByText('CUE').closest('button')!; + expect(cueButton).toHaveStyle({ color: '#06b6d4' }); + }); + + it('shows CUE button as inactive when not in active filters', () => { + render( + (['AUTO', 'USER'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + /> + ); + const cueButton = screen.getByText('CUE').closest('button')!; + expect(cueButton.className).toContain('opacity-40'); }); }); diff --git a/src/__tests__/renderer/components/HistoryDetailModal.test.tsx b/src/__tests__/renderer/components/HistoryDetailModal.test.tsx index 0904364954..ebc06b2051 100644 --- a/src/__tests__/renderer/components/HistoryDetailModal.test.tsx +++ b/src/__tests__/renderer/components/HistoryDetailModal.test.tsx @@ -207,6 +207,74 @@ describe('HistoryDetailModal', () => { ); expect(validatedIndicator).toBeInTheDocument(); }); + + it('should render CUE type with correct pill and teal color', () => { + render( + + ); + + const cuePill = screen.getByText('CUE'); + expect(cuePill).toBeInTheDocument(); + expect(cuePill.closest('span')).toHaveStyle({ color: '#06b6d4' }); + }); + + it('should show success indicator for CUE entries with success=true', () => { + render( + + ); + + const successIndicator = screen.getByTitle('Task completed successfully'); + expect(successIndicator).toBeInTheDocument(); + }); + + it('should show failure indicator for CUE entries with success=false', () => { + render( + + ); + + const failureIndicator = screen.getByTitle('Task failed'); + expect(failureIndicator).toBeInTheDocument(); + }); + + it('should display CUE trigger metadata when available', () => { + render( + + ); + + expect(screen.getByTitle('Trigger: lint-on-save')).toBeInTheDocument(); + }); + + it('should not display CUE trigger metadata for non-CUE entries', () => { + render( + + ); + + expect(screen.queryByTitle(/Trigger:/)).not.toBeInTheDocument(); + }); }); describe('Content Display', () => { @@ -810,6 +878,21 @@ describe('HistoryDetailModal', () => { expect(screen.getByText(/auto history entry/)).toBeInTheDocument(); }); + it('should show correct type in delete confirmation for CUE entry', () => { + render( + + ); + + fireEvent.click(screen.getByTitle('Delete this history entry')); + + expect(screen.getByText(/cue history entry/)).toBeInTheDocument(); + }); + it('should cancel delete when Cancel button is clicked', () => { render( ({ FileJson: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( ), + Zap: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + + ), })); // Create a mock theme @@ -86,6 +90,8 @@ describe('HistoryHelpModal', () => { beforeEach(() => { vi.clearAllMocks(); mockRegisterLayer.mockReturnValue('test-layer-id'); + // Default: maestroCue disabled + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: false } }); }); afterEach(() => { @@ -294,6 +300,42 @@ describe('HistoryHelpModal', () => { screen.getByText(/Entries automatically generated by the Auto Runner/) ).toBeInTheDocument(); }); + + it('does not render CUE entry type when maestroCue is disabled', () => { + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: false } }); + + const { container } = render(); + + const cueBadges = container.querySelectorAll('.rounded-full.text-\\[10px\\]'); + const cueBadge = Array.from(cueBadges).find((el) => el.textContent?.includes('CUE')); + expect(cueBadge).toBeFalsy(); + }); + + it('renders CUE entry type when maestroCue is enabled', () => { + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: true } }); + + const { container } = render(); + + const cueBadges = container.querySelectorAll('.rounded-full.text-\\[10px\\]'); + const cueBadge = Array.from(cueBadges).find((el) => el.textContent?.includes('CUE')); + expect(cueBadge).toBeTruthy(); + }); + + it('describes CUE entry triggers when maestroCue is enabled', () => { + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: true } }); + + render(); + + expect(screen.getByText(/Entries created by Maestro Cue automations/)).toBeInTheDocument(); + }); + + it('renders Zap icon in CUE badge when maestroCue is enabled', () => { + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: true } }); + + render(); + + expect(screen.getByTestId('zap-icon')).toBeInTheDocument(); + }); }); describe('Status Indicators Section', () => { diff --git a/src/__tests__/renderer/components/HistoryPanel.test.tsx b/src/__tests__/renderer/components/HistoryPanel.test.tsx index e7d72bb0cd..1447b46782 100644 --- a/src/__tests__/renderer/components/HistoryPanel.test.tsx +++ b/src/__tests__/renderer/components/HistoryPanel.test.tsx @@ -23,6 +23,7 @@ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { HistoryPanel, HistoryPanelHandle } from '../../../renderer/components/HistoryPanel'; import type { Theme, Session, HistoryEntry, HistoryEntryType } from '../../../renderer/types'; import { useUIStore } from '../../../renderer/stores/uiStore'; +import { useSettingsStore } from '../../../renderer/stores/settingsStore'; // Mock child components vi.mock('../../../renderer/components/HistoryDetailModal', () => ({ @@ -142,6 +143,8 @@ const createMockSession = (overrides: Partial = {}): Session => ({ fileTree: [], fileExplorerExpanded: [], messageQueue: [], + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }); @@ -167,6 +170,16 @@ describe('HistoryPanel', () => { // Reset uiStore state used by HistoryPanel useUIStore.setState({ historySearchFilterOpen: false }); + // Default: maestroCue disabled + useSettingsStore.setState({ + encoreFeatures: { + directorNotes: false, + usageStats: false, + symphony: false, + maestroCue: false, + }, + }); + // Mock scrollIntoView for jsdom Element.prototype.scrollIntoView = vi.fn(); @@ -507,6 +520,80 @@ describe('HistoryPanel', () => { }); }); + it('should toggle CUE filter', async () => { + // Enable maestroCue so CUE filter button is visible + useSettingsStore.setState({ + encoreFeatures: { + directorNotes: false, + usageStats: false, + symphony: false, + maestroCue: true, + }, + }); + + const autoEntry = createMockEntry({ type: 'AUTO', summary: 'Auto task' }); + const cueEntry = createMockEntry({ + id: 'cue-1', + type: 'CUE', + summary: 'Cue triggered task', + cueTriggerName: 'lint-on-save', + cueEventType: 'file_change', + }); + mockHistoryGetAll.mockResolvedValue([autoEntry, cueEntry]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Auto task')).toBeInTheDocument(); + expect(screen.getByText('Cue triggered task')).toBeInTheDocument(); + }); + + // Toggle off CUE + const cueFilter = screen.getByRole('button', { name: /CUE/i }); + fireEvent.click(cueFilter); + + await waitFor(() => { + expect(screen.getByText('Auto task')).toBeInTheDocument(); + expect(screen.queryByText('Cue triggered task')).not.toBeInTheDocument(); + }); + + // Toggle CUE back on + fireEvent.click(cueFilter); + + await waitFor(() => { + expect(screen.getByText('Cue triggered task')).toBeInTheDocument(); + }); + }); + + it('should hide CUE filter button when maestroCue is disabled', async () => { + useSettingsStore.setState({ + encoreFeatures: { + directorNotes: false, + usageStats: false, + symphony: false, + maestroCue: false, + }, + }); + + const cueEntry = createMockEntry({ + type: 'CUE', + summary: 'Cue triggered task', + }); + mockHistoryGetAll.mockResolvedValue([cueEntry]); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /AUTO/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /USER/i })).toBeInTheDocument(); + }); + + // CUE button should not be rendered + expect(screen.queryByRole('button', { name: /CUE/i })).not.toBeInTheDocument(); + // CUE entries should be filtered out (not in activeFilters) + expect(screen.queryByText('Cue triggered task')).not.toBeInTheDocument(); + }); + it('should filter by search text in summary', async () => { const entry1 = createMockEntry({ summary: 'Alpha task' }); const entry2 = createMockEntry({ summary: 'Beta task' }); @@ -1660,16 +1747,26 @@ describe('HistoryPanel', () => { describe('filter button styling', () => { it('should apply active styling to selected filters', async () => { mockHistoryGetAll.mockResolvedValue([]); + useSettingsStore.setState({ + encoreFeatures: { + directorNotes: false, + usageStats: false, + symphony: false, + maestroCue: true, + }, + }); render(); await waitFor(() => { const autoFilter = screen.getByRole('button', { name: /AUTO/i }); const userFilter = screen.getByRole('button', { name: /USER/i }); + const cueFilter = screen.getByRole('button', { name: /CUE/i }); - // Both should be active by default + // All should be active by default expect(autoFilter).toHaveClass('opacity-100'); expect(userFilter).toHaveClass('opacity-100'); + expect(cueFilter).toHaveClass('opacity-100'); }); }); diff --git a/src/__tests__/renderer/components/InlineWizard/WizardInputPanel.test.tsx b/src/__tests__/renderer/components/InlineWizard/WizardInputPanel.test.tsx index 35925c11ba..5a4c39c8da 100644 --- a/src/__tests__/renderer/components/InlineWizard/WizardInputPanel.test.tsx +++ b/src/__tests__/renderer/components/InlineWizard/WizardInputPanel.test.tsx @@ -98,6 +98,8 @@ const createMockSession = (overrides?: Partial): Session => showThinking: 'off', }, }, + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }) as Session; diff --git a/src/__tests__/renderer/components/InputArea.test.tsx b/src/__tests__/renderer/components/InputArea.test.tsx index a77a65881a..9e302be645 100644 --- a/src/__tests__/renderer/components/InputArea.test.tsx +++ b/src/__tests__/renderer/components/InputArea.test.tsx @@ -167,6 +167,8 @@ const createMockSession = (overrides: Partial & { wizardState?: any } = closedTabHistory: [], shellCwd: '/Users/test/project', busySource: null, + terminalTabs: [], + activeTerminalTabId: null, ...sessionOverrides, }; }; diff --git a/src/__tests__/renderer/components/LogViewer.test.tsx b/src/__tests__/renderer/components/LogViewer.test.tsx index 85f2ef46a9..d150179cad 100644 --- a/src/__tests__/renderer/components/LogViewer.test.tsx +++ b/src/__tests__/renderer/components/LogViewer.test.tsx @@ -2,7 +2,7 @@ * LogViewer.tsx Test Suite * * Tests for the LogViewer component which displays Maestro system logs with: - * - Log level filtering (debug, info, warn, error, toast) + * - Log level filtering (debug, info, warn, error, toast, autorun, cue) * - Search functionality * - Expand/collapse log details * - Export and clear logs @@ -43,7 +43,7 @@ const mockTheme: Theme = { const createMockLog = ( overrides: Partial<{ timestamp: number; - level: 'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun'; + level: 'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun' | 'cue'; message: string; context?: string; data?: unknown; @@ -228,6 +228,8 @@ describe('LogViewer', () => { expect(screen.getByRole('button', { name: 'WARN' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'ERROR' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'TOAST' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'AUTORUN' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'CUE' })).toBeInTheDocument(); }); }); @@ -316,6 +318,45 @@ describe('LogViewer', () => { }); }); + it('should always enable cue level regardless of logLevel', async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'CUE' })).not.toBeDisabled(); + }); + }); + + it('should filter cue logs by level when CUE toggle clicked', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ level: 'cue', message: 'Cue event fired' }), + createMockLog({ level: 'info', message: 'Info message' }), + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Cue event fired')).toBeInTheDocument(); + expect(screen.getByText('Info message')).toBeInTheDocument(); + }); + + // Click CUE to disable it + const cueButton = screen.getByRole('button', { name: 'CUE' }); + fireEvent.click(cueButton); + + await waitFor(() => { + expect(screen.queryByText('Cue event fired')).not.toBeInTheDocument(); + // Info should still be visible + expect(screen.getByText('Info message')).toBeInTheDocument(); + }); + + // Click CUE to re-enable it + fireEvent.click(cueButton); + + await waitFor(() => { + expect(screen.getByText('Cue event fired')).toBeInTheDocument(); + }); + }); + it('should persist level selections via callback', async () => { const onSelectedLevelsChange = vi.fn(); @@ -1064,6 +1105,83 @@ describe('LogViewer', () => { }); }); + it('should display agent pill for cue entries with context', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'cue', + message: '[CUE] "On PR Opened" triggered (pull_request.opened)', + context: 'My Cue Agent', + }), + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('My Cue Agent')).toBeInTheDocument(); + }); + }); + + it('should render cue agent pill with teal color', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'cue', + message: '[CUE] "Deploy Check" triggered (push)', + context: 'Cue Session', + }), + ]); + + render(); + + await waitFor(() => { + const agentPill = screen.getByText('Cue Session'); + expect(agentPill).toBeInTheDocument(); + expect(agentPill.closest('span')).toHaveStyle({ + backgroundColor: 'rgba(6, 182, 212, 0.2)', + color: '#06b6d4', + }); + }); + }); + + it('should not show context badge for cue entries (uses agent pill instead)', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'cue', + message: 'Cue triggered', + context: 'CueContext', + }), + ]); + + render(); + + await waitFor(() => { + // The context should appear as an agent pill, not as a context badge + const contextElement = screen.getByText('CueContext'); + expect(contextElement).toBeInTheDocument(); + // Verify it's styled as an agent pill (teal), not a context badge (accent color) + expect(contextElement.closest('span')).toHaveStyle({ color: '#06b6d4' }); + }); + }); + + it('should render cue level pill with teal color', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'cue', + message: 'Cue level test', + }), + ]); + + render(); + + await waitFor(() => { + const levelPill = screen.getByText('cue'); + expect(levelPill).toBeInTheDocument(); + expect(levelPill).toHaveStyle({ + color: '#06b6d4', + backgroundColor: 'rgba(6, 182, 212, 0.15)', + }); + }); + }); + it('should not show context badge for toast entries', async () => { getMockGetLogs().mockResolvedValue([ createMockLog({ diff --git a/src/__tests__/renderer/components/MainPanel.test.tsx b/src/__tests__/renderer/components/MainPanel.test.tsx index 06834dfae2..a09935d008 100644 --- a/src/__tests__/renderer/components/MainPanel.test.tsx +++ b/src/__tests__/renderer/components/MainPanel.test.tsx @@ -12,12 +12,47 @@ import type { import { gitService } from '../../../renderer/services/git'; import { useUIStore } from '../../../renderer/stores/uiStore'; import { useSettingsStore } from '../../../renderer/stores/settingsStore'; +import { useSessionStore } from '../../../renderer/stores/sessionStore'; import { clearCapabilitiesCache, setCapabilitiesCache, } from '../../../renderer/hooks/agent/useAgentCapabilities'; // Mock child components to simplify testing - must be before MainPanel import + +// TerminalView: forwardRef stub that records render calls per session so we can +// assert persistence (kept mounted) vs destruction (unmounted) across sessions. +const terminalViewSessions: string[] = []; +vi.mock('../../../renderer/components/TerminalView', () => { + const React = require('react'); + const TerminalView = React.forwardRef( + (props: { session: { id: string }; isVisible: boolean }, ref: React.Ref) => { + React.useImperativeHandle(ref, () => ({ + clearActiveTerminal: vi.fn(), + focusActiveTerminal: vi.fn(), + })); + // Track which session IDs have been mounted + React.useEffect(() => { + terminalViewSessions.push(props.session.id); + return () => { + const idx = terminalViewSessions.lastIndexOf(props.session.id); + if (idx !== -1) terminalViewSessions.splice(idx, 1); + }; + }, [props.session.id]); + return React.createElement('div', { + 'data-testid': `terminal-view-${props.session.id}`, + 'data-visible': String(props.isVisible), + }); + } + ); + TerminalView.displayName = 'TerminalView'; + return { + TerminalView, + createTabStateChangeHandler: vi.fn(() => vi.fn()), + createTabPidChangeHandler: vi.fn(() => vi.fn()), + }; +}); + vi.mock('../../../renderer/components/LogViewer', () => ({ LogViewer: (props: { onClose: () => void }) => { return React.createElement( @@ -329,6 +364,13 @@ describe('MainPanel', () => { }, ], activeTabId: 'tab-1', + filePreviewTabs: [], + activeFileTabId: null, + terminalTabs: [], + activeTerminalTabId: null, + unifiedTabOrder: [{ type: 'ai' as const, id: 'tab-1' }], + unifiedClosedTabHistory: [], + closedTabHistory: [], ...overrides, }); @@ -738,12 +780,13 @@ describe('MainPanel', () => { expect(screen.getByTestId('tab-tab-2')).toBeInTheDocument(); }); - it('should not render TabBar in terminal mode', () => { + it('should render TabBar in terminal mode (unified tab system shows tabs in all modes)', () => { const session = createSession({ inputMode: 'terminal' }); render(); - expect(screen.queryByTestId('tab-bar')).not.toBeInTheDocument(); + // TabBar renders in both AI and terminal modes when aiTabs exist + expect(screen.queryByTestId('tab-bar')).toBeInTheDocument(); }); it('should call onTabSelect when tab is clicked', () => { @@ -3297,4 +3340,123 @@ describe('MainPanel', () => { expect(screen.getByTestId('wizard-conversation-view')).toBeInTheDocument(); }); }); + + // --------------------------------------------------------------------------- + // Terminal session persistence + // --------------------------------------------------------------------------- + describe('terminal session persistence', () => { + const makeTerminalTab = (id = 'ttab-1') => ({ + id, + name: null, + shellType: 'zsh' as const, + pid: 9000, + cwd: '/tmp', + createdAt: Date.now(), + state: 'idle' as const, + exitCode: undefined, + }); + + beforeEach(() => { + terminalViewSessions.length = 0; + }); + + it('renders TerminalView when active session has terminal tabs in terminal mode', () => { + const tab = makeTerminalTab(); + const session = createSession({ + id: 'session-term', + inputMode: 'terminal', + terminalTabs: [tab], + activeTerminalTabId: tab.id, + unifiedTabOrder: [{ type: 'terminal' as const, id: tab.id }], + }); + // Seed session store so the eviction effect keeps the session alive + useSessionStore.setState({ sessions: [session] }); + + render(); + + const view = screen.getByTestId('terminal-view-session-term'); + expect(view).toBeInTheDocument(); + expect(view.getAttribute('data-visible')).toBe('true'); + }); + + it('hides TerminalView (display:none) when switching to AI mode, but keeps it mounted', async () => { + const tab = makeTerminalTab(); + const sessionTerminal = createSession({ + id: 'session-persist', + inputMode: 'terminal', + terminalTabs: [tab], + activeTerminalTabId: tab.id, + unifiedTabOrder: [{ type: 'terminal' as const, id: tab.id }], + }); + const sessionAI = createSession({ + id: 'session-persist', + inputMode: 'ai', + terminalTabs: [tab], + activeTerminalTabId: tab.id, + unifiedTabOrder: [{ type: 'terminal' as const, id: tab.id }], + }); + useSessionStore.setState({ sessions: [sessionTerminal] }); + + const { rerender } = render( + + ); + + // Confirm it is visible + expect(screen.getByTestId('terminal-view-session-persist').getAttribute('data-visible')).toBe('true'); + + // Simulate switching to AI mode (inputMode changes, terminalTabs unchanged) + await act(async () => { + rerender(); + }); + + // TerminalView must still be in the DOM (not unmounted) + const view = screen.getByTestId('terminal-view-session-persist'); + expect(view).toBeInTheDocument(); + // But hidden + expect(view.getAttribute('data-visible')).toBe('false'); + }); + + it('shows TerminalView again when switching back from AI mode to terminal mode', async () => { + const tab = makeTerminalTab(); + const sessionTerminal = createSession({ + id: 'session-roundtrip', + inputMode: 'terminal', + terminalTabs: [tab], + activeTerminalTabId: tab.id, + unifiedTabOrder: [{ type: 'terminal' as const, id: tab.id }], + }); + const sessionAI = createSession({ + id: 'session-roundtrip', + inputMode: 'ai', + terminalTabs: [tab], + activeTerminalTabId: tab.id, + unifiedTabOrder: [{ type: 'terminal' as const, id: tab.id }], + }); + useSessionStore.setState({ sessions: [sessionTerminal] }); + + const { rerender } = render( + + ); + + // Switch to AI mode + await act(async () => { + rerender(); + }); + + // Switch back to terminal mode + await act(async () => { + rerender(); + }); + + const view = screen.getByTestId('terminal-view-session-roundtrip'); + expect(view.getAttribute('data-visible')).toBe('true'); + }); + + it('does not render TerminalView when session has no terminal tabs', () => { + const session = createSession({ inputMode: 'ai', terminalTabs: [] }); + useSessionStore.setState({ sessions: [session] }); + render(); + expect(screen.queryByTestId('terminal-view-session-1')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/__tests__/renderer/components/MarkdownRenderer.test.tsx b/src/__tests__/renderer/components/MarkdownRenderer.test.tsx index 5dd664e397..15a60f5dbf 100644 --- a/src/__tests__/renderer/components/MarkdownRenderer.test.tsx +++ b/src/__tests__/renderer/components/MarkdownRenderer.test.tsx @@ -11,6 +11,7 @@ vi.mock('react-syntax-highlighter', () => ({ })); vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); // Mock lucide-react icons @@ -22,6 +23,7 @@ vi.mock('lucide-react', () => ({ const mockTheme = { id: 'test-theme', + mode: 'dark', colors: { bgMain: '#1a1a2e', bgActivity: '#16213e', diff --git a/src/__tests__/renderer/components/MergeSessionModal.test.tsx b/src/__tests__/renderer/components/MergeSessionModal.test.tsx index 0765d5c29e..137b3af717 100644 --- a/src/__tests__/renderer/components/MergeSessionModal.test.tsx +++ b/src/__tests__/renderer/components/MergeSessionModal.test.tsx @@ -96,6 +96,8 @@ const createMockSession = (overrides: Partial = {}): Session => ({ ], activeTabId: 'tab-1', closedTabHistory: [], + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }); diff --git a/src/__tests__/renderer/components/NewInstanceModal.test.tsx b/src/__tests__/renderer/components/NewInstanceModal.test.tsx index 5c6a631fbe..9c5eec0fce 100644 --- a/src/__tests__/renderer/components/NewInstanceModal.test.tsx +++ b/src/__tests__/renderer/components/NewInstanceModal.test.tsx @@ -1774,9 +1774,9 @@ describe('NewInstanceModal', () => { it('should have tabindex=-1 for unsupported agents (coming soon)', async () => { // Note: tabIndex is based on isSupported (in SUPPORTED_AGENTS), not availability - // gemini-cli is not in SUPPORTED_AGENTS so it should have tabIndex=-1 + // terminal is not in SUPPORTED_AGENTS so it should have tabIndex=-1 vi.mocked(window.maestro.agents.detect).mockResolvedValue([ - createAgentConfig({ id: 'gemini-cli', name: 'Gemini CLI', available: false }), + createAgentConfig({ id: 'terminal', name: 'Terminal', available: false }), ]); render( @@ -1790,7 +1790,7 @@ describe('NewInstanceModal', () => { ); await waitFor(() => { - const option = screen.getByRole('option', { name: /Gemini CLI/i }); + const option = screen.getByRole('option', { name: /Terminal/i }); expect(option).toHaveAttribute('tabIndex', '-1'); }); }); diff --git a/src/__tests__/renderer/components/QuickActionsModal.test.tsx b/src/__tests__/renderer/components/QuickActionsModal.test.tsx index 3401f0252e..5ea30f1005 100644 --- a/src/__tests__/renderer/components/QuickActionsModal.test.tsx +++ b/src/__tests__/renderer/components/QuickActionsModal.test.tsx @@ -133,6 +133,8 @@ const createMockSession = (overrides: Partial = {}): Session => ({ aiTabs: [{ id: 'tab-1', name: 'Tab 1', logs: [] }], activeTabId: 'tab-1', closedTabHistory: [], + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }); @@ -1615,4 +1617,121 @@ describe('QuickActionsModal', () => { expect(screen.queryByText('Context: Send to Agent')).not.toBeInTheDocument(); }); }); + + describe('Create Worktree action', () => { + it('shows Create Worktree action for git repo sessions with callback', () => { + const onQuickCreateWorktree = vi.fn(); + const props = createDefaultProps({ + sessions: [createMockSession({ isGitRepo: true })], + onQuickCreateWorktree, + }); + render(); + + expect(screen.getByText('Create Worktree')).toBeInTheDocument(); + }); + + it('calls onQuickCreateWorktree with active session and closes modal', () => { + const onQuickCreateWorktree = vi.fn(); + const session = createMockSession({ isGitRepo: true }); + const props = createDefaultProps({ + sessions: [session], + onQuickCreateWorktree, + }); + render(); + + fireEvent.click(screen.getByText('Create Worktree')); + + expect(onQuickCreateWorktree).toHaveBeenCalledWith(session); + expect(props.setQuickActionOpen).toHaveBeenCalledWith(false); + }); + + it('resolves to parent session when active session is a worktree child', () => { + const onQuickCreateWorktree = vi.fn(); + const parentSession = createMockSession({ + id: 'parent-1', + name: 'Parent', + isGitRepo: true, + }); + const childSession = createMockSession({ + id: 'child-1', + name: 'Child', + isGitRepo: true, + parentSessionId: 'parent-1', + worktreeBranch: 'feature-1', + }); + const props = createDefaultProps({ + sessions: [parentSession, childSession], + activeSessionId: 'child-1', + onQuickCreateWorktree, + }); + render(); + + fireEvent.click(screen.getByText('Create Worktree')); + + // Should resolve to parent, not the child + expect(onQuickCreateWorktree).toHaveBeenCalledWith(parentSession); + }); + + it('does not show Create Worktree when session is not a git repo', () => { + const onQuickCreateWorktree = vi.fn(); + const props = createDefaultProps({ + sessions: [createMockSession({ isGitRepo: false })], + onQuickCreateWorktree, + }); + render(); + + expect(screen.queryByText('Create Worktree')).not.toBeInTheDocument(); + }); + + it('does not show Create Worktree when callback is not provided', () => { + const props = createDefaultProps({ + sessions: [createMockSession({ isGitRepo: true })], + }); + render(); + + expect(screen.queryByText('Create Worktree')).not.toBeInTheDocument(); + }); + }); + + describe('Configure Maestro Cue action', () => { + it('shows Configure Maestro Cue command with agent name when onConfigureCue is provided', () => { + const onConfigureCue = vi.fn(); + const props = createDefaultProps({ onConfigureCue }); + render(); + + expect(screen.getByText('Configure Maestro Cue: Test Session')).toBeInTheDocument(); + expect(screen.getByText('Open YAML editor for event-driven automation')).toBeInTheDocument(); + }); + + it('handles Configure Maestro Cue action - calls onConfigureCue with active session and closes modal', () => { + const onConfigureCue = vi.fn(); + const props = createDefaultProps({ onConfigureCue }); + render(); + + fireEvent.click(screen.getByText('Configure Maestro Cue: Test Session')); + + expect(onConfigureCue).toHaveBeenCalledWith( + expect.objectContaining({ id: 'session-1', name: 'Test Session' }) + ); + expect(props.setQuickActionOpen).toHaveBeenCalledWith(false); + }); + + it('does not show Configure Maestro Cue when onConfigureCue is not provided', () => { + const props = createDefaultProps(); + render(); + + expect(screen.queryByText(/Configure Maestro Cue/)).not.toBeInTheDocument(); + }); + + it('Configure Maestro Cue appears when searching for "cue"', () => { + const onConfigureCue = vi.fn(); + const props = createDefaultProps({ onConfigureCue }); + render(); + + const input = screen.getByPlaceholderText('Type a command or jump to agent...'); + fireEvent.change(input, { target: { value: 'cue' } }); + + expect(screen.getByText('Configure Maestro Cue: Test Session')).toBeInTheDocument(); + }); + }); }); diff --git a/src/__tests__/renderer/components/RenameSessionModal.test.tsx b/src/__tests__/renderer/components/RenameSessionModal.test.tsx index b0963b8601..7c4de0a3bc 100644 --- a/src/__tests__/renderer/components/RenameSessionModal.test.tsx +++ b/src/__tests__/renderer/components/RenameSessionModal.test.tsx @@ -54,6 +54,8 @@ const createMockSessions = (): Session[] => [ fileTree: [], fileExplorerExpanded: [], agentSessionId: 'claude-123', + terminalTabs: [], + activeTerminalTabId: null, }, { id: 'session-2', @@ -71,6 +73,8 @@ const createMockSessions = (): Session[] => [ isGitRepo: false, fileTree: [], fileExplorerExpanded: [], + terminalTabs: [], + activeTerminalTabId: null, }, ]; diff --git a/src/__tests__/renderer/components/RightPanel.test.tsx b/src/__tests__/renderer/components/RightPanel.test.tsx index 97b8ece1d2..f8c174f30a 100644 --- a/src/__tests__/renderer/components/RightPanel.test.tsx +++ b/src/__tests__/renderer/components/RightPanel.test.tsx @@ -323,7 +323,12 @@ describe('RightPanel', () => { const props = createDefaultProps(); render(); - expect(screen.getByTestId('file-explorer-panel')).toBeInTheDocument(); + // FileExplorerPanel stays mounted (for auto-refresh timer) but is visible only on files tab + const fileExplorer = screen.getByTestId('file-explorer-panel'); + expect(fileExplorer).toBeInTheDocument(); + expect(fileExplorer.closest('[data-tour="files-panel"]')).not.toHaveStyle({ + display: 'none', + }); expect(screen.queryByTestId('history-panel')).not.toBeInTheDocument(); expect(screen.queryByTestId('auto-run')).not.toBeInTheDocument(); }); @@ -333,7 +338,9 @@ describe('RightPanel', () => { const props = createDefaultProps(); render(); - expect(screen.queryByTestId('file-explorer-panel')).not.toBeInTheDocument(); + // FileExplorerPanel stays mounted but hidden (for auto-refresh timer persistence) + const fileExplorer = screen.getByTestId('file-explorer-panel'); + expect(fileExplorer.closest('[data-tour="files-panel"]')).toHaveStyle({ display: 'none' }); expect(screen.getByTestId('history-panel')).toBeInTheDocument(); expect(screen.queryByTestId('auto-run')).not.toBeInTheDocument(); }); @@ -343,7 +350,9 @@ describe('RightPanel', () => { const props = createDefaultProps(); render(); - expect(screen.queryByTestId('file-explorer-panel')).not.toBeInTheDocument(); + // FileExplorerPanel stays mounted but hidden (for auto-refresh timer persistence) + const fileExplorer = screen.getByTestId('file-explorer-panel'); + expect(fileExplorer.closest('[data-tour="files-panel"]')).toHaveStyle({ display: 'none' }); expect(screen.queryByTestId('history-panel')).not.toBeInTheDocument(); expect(screen.getByTestId('auto-run')).toBeInTheDocument(); }); @@ -986,6 +995,32 @@ describe('RightPanel', () => { expect(setActiveRightTab).toHaveBeenCalledWith('history'); }); + it('should show "View history" link when on files tab during batch run', () => { + useUIStore.setState({ activeRightTab: 'files' }); + const setActiveRightTab = vi.fn(); + const currentSessionBatchState: BatchRunState = { + isRunning: true, + isStopping: false, + documents: ['doc1'], + currentDocumentIndex: 0, + totalTasks: 10, + completedTasks: 5, + currentDocTasksTotal: 10, + currentDocTasksCompleted: 5, + totalTasksAcrossAllDocs: 10, + completedTasksAcrossAllDocs: 5, + loopEnabled: false, + loopIteration: 0, + }; + const props = createDefaultProps({ currentSessionBatchState, setActiveRightTab }); + render(); + + const link = screen.getByText('View history'); + expect(link).toBeInTheDocument(); + fireEvent.click(link); + expect(setActiveRightTab).toHaveBeenCalledWith('history'); + }); + it('should not show "View history" link when on history tab during batch run', () => { useUIStore.setState({ activeRightTab: 'history' }); const currentSessionBatchState: BatchRunState = { diff --git a/src/__tests__/renderer/components/SendToAgentModal.test.tsx b/src/__tests__/renderer/components/SendToAgentModal.test.tsx index 1c6d7f3d6c..d1782a0373 100644 --- a/src/__tests__/renderer/components/SendToAgentModal.test.tsx +++ b/src/__tests__/renderer/components/SendToAgentModal.test.tsx @@ -183,6 +183,8 @@ const createMockSession = (overrides: Partial = {}): Session => ({ ], activeTabId: 'tab-1', closedTabHistory: [], + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }); diff --git a/src/__tests__/renderer/components/SessionItemCue.test.tsx b/src/__tests__/renderer/components/SessionItemCue.test.tsx new file mode 100644 index 0000000000..6b2f5a6a23 --- /dev/null +++ b/src/__tests__/renderer/components/SessionItemCue.test.tsx @@ -0,0 +1,153 @@ +/** + * @fileoverview Tests for SessionItem Cue status indicator + * + * Validates that the Zap icon appears next to session names when + * the session has active Cue subscriptions, with correct tooltip text. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SessionItem } from '../../../renderer/components/SessionItem'; +import type { Session, Theme } from '../../../renderer/types'; + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + Activity: () => , + GitBranch: () => , + Bot: () => , + Bookmark: ({ fill }: { fill?: string }) => , + AlertCircle: () => , + Server: () => , + Zap: ({ + title, + style, + fill, + }: { + title?: string; + style?: Record; + fill?: string; + }) => , +})); + +const defaultTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + info: '#8be9fd', + }, +}; + +const createMockSession = (overrides: Partial = {}): Session => ({ + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + state: 'idle', + inputMode: 'ai', + cwd: '/home/user/project', + projectRoot: '/home/user/project', + aiPid: 12345, + terminalPid: 12346, + aiLogs: [], + shellLogs: [], + isGitRepo: true, + fileTree: [], + fileExplorerExpanded: [], + messageQueue: [], + contextUsage: 30, + activeTimeMs: 60000, + ...overrides, +}); + +const defaultProps = { + variant: 'flat' as const, + theme: defaultTheme, + isActive: false, + isKeyboardSelected: false, + isDragging: false, + isEditing: false, + leftSidebarOpen: true, + onSelect: vi.fn(), + onDragStart: vi.fn(), + onContextMenu: vi.fn(), + onFinishRename: vi.fn(), + onStartRename: vi.fn(), + onToggleBookmark: vi.fn(), +}; + +describe('SessionItem Cue Indicator', () => { + it('shows Zap icon when cueSubscriptionCount > 0', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + expect(zapIcon).toBeInTheDocument(); + // Title is on the wrapper span, not the icon itself + expect(zapIcon.closest('span[title]')).toHaveAttribute( + 'title', + 'Maestro Cue active (3 subscriptions)' + ); + }); + + it('does not show Zap icon when cueSubscriptionCount is undefined', () => { + render(); + + expect(screen.queryByTestId('icon-zap')).not.toBeInTheDocument(); + }); + + it('does not show Zap icon when cueSubscriptionCount is 0', () => { + render( + + ); + + expect(screen.queryByTestId('icon-zap')).not.toBeInTheDocument(); + }); + + it('shows singular "subscription" for count of 1', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + expect(zapIcon.closest('span[title]')).toHaveAttribute( + 'title', + 'Maestro Cue active (1 subscription)' + ); + }); + + it('uses teal color for the Zap icon', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + // jsdom converts hex to rgb + expect(zapIcon.style.color).toBe('rgb(45, 212, 191)'); + }); + + it('does not show Zap icon when session is in editing mode', () => { + render( + + ); + + // In editing mode, the name row is replaced by an input field + expect(screen.queryByTestId('icon-zap')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/SessionList.test.tsx b/src/__tests__/renderer/components/SessionList.test.tsx index 91f18ee29d..48d08bcf70 100644 --- a/src/__tests__/renderer/components/SessionList.test.tsx +++ b/src/__tests__/renderer/components/SessionList.test.tsx @@ -69,6 +69,9 @@ vi.mock('lucide-react', () => ({ Music: () => , Command: () => , MessageSquare: () => , + Zap: ({ title, style }: { title?: string; style?: Record }) => ( + + ), })); // Mock gitService @@ -153,6 +156,7 @@ const defaultShortcuts: Record = { processMonitor: { keys: ['meta', 'shift', 'p'], description: 'Process monitor' }, usageDashboard: { keys: ['alt', 'meta', 'u'], description: 'Usage dashboard' }, toggleSidebar: { keys: ['meta', 'b'], description: 'Toggle sidebar' }, + filterUnreadAgents: { keys: ['meta', 'shift', 'u'], description: 'Filter unread agents' }, }; // Create mock session @@ -174,6 +178,8 @@ const createMockSession = (overrides: Partial = {}): Session => ({ messageQueue: [], contextUsage: 30, activeTimeMs: 60000, + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }); @@ -310,6 +316,16 @@ describe('SessionList', () => { expect(screen.getByText('MAESTRO')).toBeInTheDocument(); }); + it('branding header has z-20 to stack menu above sidebar content', () => { + useUIStore.setState({ leftSidebarOpen: true }); + const props = createDefaultProps({}); + render(); + + const header = screen.getByText('MAESTRO').closest('.border-b'); + expect(header).toHaveClass('z-20'); + expect(header).toHaveClass('relative'); + }); + it('renders collapsed sidebar mode', () => { useUIStore.setState({ leftSidebarOpen: false }); const props = createDefaultProps({}); @@ -1159,7 +1175,7 @@ describe('SessionList', () => { expect(menuContainer).toHaveClass('overflow-y-auto'); expect(menuContainer).toHaveClass('scrollbar-thin'); // Verify max-height is set via inline style for scroll support - expect(menuContainer?.style.maxHeight).toBe('calc(100vh - 90px)'); + expect(menuContainer?.style.maxHeight).toBe('calc(100vh - 120px)'); }); it("shows Director's Notes menu item in hamburger menu", () => { @@ -2084,10 +2100,10 @@ describe('SessionList', () => { }); // ============================================================================ - // Tunnel/Remote Access Tests + // Tunnel/Remote Control Tests // ============================================================================ - describe('Tunnel and Remote Access', () => { + describe('Tunnel and Remote Control', () => { it('checks cloudflared installation when live overlay opens', async () => { const mockIsInstalled = vi.fn().mockResolvedValue(true); (window.maestro as Record).tunnel = { @@ -2154,12 +2170,12 @@ describe('SessionList', () => { // Wait for cloudflared check to complete await waitFor(() => { - const toggleButton = screen.getByTitle('Enable remote access'); + const toggleButton = screen.getByTitle('Enable remote control'); expect(toggleButton).toBeInTheDocument(); }); // Click the toggle to start tunnel - const toggleButton = screen.getByTitle('Enable remote access'); + const toggleButton = screen.getByTitle('Enable remote control'); fireEvent.click(toggleButton); await waitFor(() => { @@ -2188,19 +2204,19 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('LIVE')); await waitFor(() => { - const toggleButton = screen.getByTitle('Enable remote access'); + const toggleButton = screen.getByTitle('Enable remote control'); expect(toggleButton).toBeInTheDocument(); }); // Start tunnel first - fireEvent.click(screen.getByTitle('Enable remote access')); + fireEvent.click(screen.getByTitle('Enable remote control')); await waitFor(() => { - expect(screen.getByTitle('Disable remote access')).toBeInTheDocument(); + expect(screen.getByTitle('Disable remote control')).toBeInTheDocument(); }); // Now stop tunnel - fireEvent.click(screen.getByTitle('Disable remote access')); + fireEvent.click(screen.getByTitle('Disable remote control')); await waitFor(() => { expect(mockStop).toHaveBeenCalled(); @@ -2225,7 +2241,7 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('LIVE')); await waitFor(() => { - const toggleButton = screen.getByTitle('Enable remote access'); + const toggleButton = screen.getByTitle('Enable remote control'); fireEvent.click(toggleButton); }); @@ -2252,7 +2268,7 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('LIVE')); await waitFor(() => { - const toggleButton = screen.getByTitle('Enable remote access'); + const toggleButton = screen.getByTitle('Enable remote control'); fireEvent.click(toggleButton); }); @@ -2281,7 +2297,7 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('LIVE')); await waitFor(() => { - fireEvent.click(screen.getByTitle('Enable remote access')); + fireEvent.click(screen.getByTitle('Enable remote control')); }); await waitFor(() => { @@ -2310,7 +2326,7 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('LIVE')); await waitFor(() => { - fireEvent.click(screen.getByTitle('Enable remote access')); + fireEvent.click(screen.getByTitle('Enable remote control')); }); await waitFor(() => { @@ -2347,7 +2363,7 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('LIVE')); await waitFor(() => { - fireEvent.click(screen.getByTitle('Enable remote access')); + fireEvent.click(screen.getByTitle('Enable remote control')); }); await waitFor(() => { @@ -2381,7 +2397,7 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('LIVE')); await waitFor(() => { - fireEvent.click(screen.getByTitle('Enable remote access')); + fireEvent.click(screen.getByTitle('Enable remote control')); }); await waitFor(() => { @@ -3059,7 +3075,7 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('LIVE')); await waitFor(() => { - fireEvent.click(screen.getByTitle('Enable remote access')); + fireEvent.click(screen.getByTitle('Enable remote control')); }); await waitFor(() => { @@ -3126,4 +3142,97 @@ describe('SessionList', () => { expect(screen.queryByText('Rename')).not.toBeInTheDocument(); }); }); + + // ============================================================================ + // Cue Status Indicator Tests + // ============================================================================ + + describe('Cue Status Indicator', () => { + it('shows Zap icon for sessions with active Cue subscriptions when Encore Feature enabled', async () => { + const session = createMockSession({ id: 's1', name: 'Cue Session' }); + useSessionStore.setState({ sessions: [session] }); + useUIStore.setState({ leftSidebarOpen: true }); + useSettingsStore.setState({ + shortcuts: defaultShortcuts, + encoreFeatures: { directorNotes: false, maestroCue: true }, + }); + + // Mock Cue status to return session with subscriptions + (window.maestro as Record).cue = { + getStatus: vi.fn().mockResolvedValue([ + { + sessionId: 's1', + sessionName: 'Cue Session', + subscriptionCount: 3, + enabled: true, + activeRuns: 0, + }, + ]), + getActiveRuns: vi.fn().mockResolvedValue([]), + getActivityLog: vi.fn().mockResolvedValue([]), + onActivityUpdate: vi.fn().mockReturnValue(() => {}), + }; + + const props = createDefaultProps({ sortedSessions: [session] }); + render(); + + // Wait for async status fetch to complete + await waitFor(() => { + expect(screen.getByTestId('icon-zap')).toBeInTheDocument(); + }); + + const zapIcon = screen.getByTestId('icon-zap'); + expect(zapIcon.closest('span[title]')).toHaveAttribute( + 'title', + 'Maestro Cue active (3 subscriptions)' + ); + }); + + it('does not show Zap icon when Encore Feature is disabled', async () => { + const session = createMockSession({ id: 's1', name: 'No Cue Session' }); + useSessionStore.setState({ sessions: [session] }); + useUIStore.setState({ leftSidebarOpen: true }); + useSettingsStore.setState({ + shortcuts: defaultShortcuts, + encoreFeatures: { directorNotes: false, maestroCue: false }, + }); + + const props = createDefaultProps({ sortedSessions: [session] }); + render(); + + // Give async effects time to settle + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(screen.queryByTestId('icon-zap')).not.toBeInTheDocument(); + }); + + it('does not show Zap icon for sessions without Cue subscriptions', async () => { + const session = createMockSession({ id: 's1', name: 'No Sub Session' }); + useSessionStore.setState({ sessions: [session] }); + useUIStore.setState({ leftSidebarOpen: true }); + useSettingsStore.setState({ + shortcuts: defaultShortcuts, + encoreFeatures: { directorNotes: false, maestroCue: true }, + }); + + // Mock Cue status with no sessions having subscriptions + (window.maestro as Record).cue = { + getStatus: vi.fn().mockResolvedValue([]), + getActiveRuns: vi.fn().mockResolvedValue([]), + getActivityLog: vi.fn().mockResolvedValue([]), + onActivityUpdate: vi.fn().mockReturnValue(() => {}), + }; + + const props = createDefaultProps({ sortedSessions: [session] }); + render(); + + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(screen.queryByTestId('icon-zap')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/__tests__/renderer/components/SessionList/LiveOverlayPanel.test.tsx b/src/__tests__/renderer/components/SessionList/LiveOverlayPanel.test.tsx index f0a181a081..a13b0b1115 100644 --- a/src/__tests__/renderer/components/SessionList/LiveOverlayPanel.test.tsx +++ b/src/__tests__/renderer/components/SessionList/LiveOverlayPanel.test.tsx @@ -125,17 +125,17 @@ describe('LiveOverlayPanel', () => { // ----------------------------------------------------------------------- // Remote Access // ----------------------------------------------------------------------- - describe('remote access', () => { - it('renders Remote Access section', () => { + describe('remote control', () => { + it('renders Remote Control section', () => { render(); - expect(screen.getByText('Remote Access')).toBeTruthy(); + expect(screen.getByText('Remote Control')).toBeTruthy(); }); it('calls handleTunnelToggle when toggle button is clicked', () => { const handleTunnelToggle = vi.fn(); render(); - const toggleBtn = screen.getByTitle('Enable remote access'); + const toggleBtn = screen.getByTitle('Enable remote control'); fireEvent.click(toggleBtn); expect(handleTunnelToggle).toHaveBeenCalledOnce(); }); @@ -155,7 +155,7 @@ describe('LiveOverlayPanel', () => { it('disables toggle when tunnel is starting', () => { render(); - const toggleBtn = screen.getByTitle('Enable remote access'); + const toggleBtn = screen.getByTitle('Enable remote control'); expect(toggleBtn).toBeDisabled(); }); @@ -180,7 +180,7 @@ describe('LiveOverlayPanel', () => { it('shows disconnect title when tunnel is connected', () => { render(); - expect(screen.getByTitle('Disable remote access')).toBeTruthy(); + expect(screen.getByTitle('Disable remote control')).toBeTruthy(); }); }); diff --git a/src/__tests__/renderer/components/SessionList/SidebarActions.test.tsx b/src/__tests__/renderer/components/SessionList/SidebarActions.test.tsx index 4c7d740953..709d3665b9 100644 --- a/src/__tests__/renderer/components/SessionList/SidebarActions.test.tsx +++ b/src/__tests__/renderer/components/SessionList/SidebarActions.test.tsx @@ -22,6 +22,7 @@ const mockTheme: Theme = { const defaultShortcuts = { toggleSidebar: { keys: ['Cmd', 'B'], label: 'Toggle Sidebar' }, + filterUnreadAgents: { keys: ['Meta', 'Shift', 'u'], label: 'Filter Unread Agents' }, } as any; function createProps(overrides: Partial[0]> = {}) { @@ -30,9 +31,12 @@ function createProps(overrides: Partial[0]> = leftSidebarOpen: true, hasNoSessions: false, shortcuts: defaultShortcuts, + showUnreadAgentsOnly: false, + hasUnreadAgents: false, addNewSession: vi.fn(), openWizard: vi.fn(), setLeftSidebarOpen: vi.fn(), + toggleShowUnreadAgentsOnly: vi.fn(), ...overrides, }; } @@ -110,4 +114,22 @@ describe('SidebarActions', () => { fireEvent.click(expandBtn); expect(setLeftSidebarOpen).toHaveBeenCalledWith(true); }); + + it('renders unread agents filter button', () => { + render(); + expect(screen.getByTitle(/Filter unread agents/)).toBeTruthy(); + }); + + it('calls toggleShowUnreadAgentsOnly when unread filter button is clicked', () => { + const toggleShowUnreadAgentsOnly = vi.fn(); + render(); + + fireEvent.click(screen.getByTitle(/Filter unread agents/)); + expect(toggleShowUnreadAgentsOnly).toHaveBeenCalledOnce(); + }); + + it('shows active state when showUnreadAgentsOnly is true', () => { + render(); + expect(screen.getByTitle(/Showing unread agents only/)).toBeTruthy(); + }); }); diff --git a/src/__tests__/renderer/components/Settings/tabs/DisplayTab.test.tsx b/src/__tests__/renderer/components/Settings/tabs/DisplayTab.test.tsx index 968d1c5c26..3e4ee4c241 100644 --- a/src/__tests__/renderer/components/Settings/tabs/DisplayTab.test.tsx +++ b/src/__tests__/renderer/components/Settings/tabs/DisplayTab.test.tsx @@ -562,81 +562,6 @@ describe('DisplayTab', () => { }); // ========================================================================= - // Terminal Width - // ========================================================================= - - describe('Terminal Width', () => { - it('should render Terminal Width label', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(50); - }); - - expect(screen.getByText('Terminal Width (Columns)')).toBeInTheDocument(); - }); - - it('should call setTerminalWidth with 80', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(50); - }); - - fireEvent.click(screen.getByRole('button', { name: '80' })); - expect(mockSetTerminalWidth).toHaveBeenCalledWith(80); - }); - - it('should call setTerminalWidth with 100', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(50); - }); - - // There may be multiple "100" on screen (e.g., from max nodes slider) - // so get the one in the terminal width section - const buttons = screen.getAllByRole('button', { name: '100' }); - fireEvent.click(buttons[0]); - expect(mockSetTerminalWidth).toHaveBeenCalledWith(100); - }); - - it('should call setTerminalWidth with 120', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(50); - }); - - fireEvent.click(screen.getByRole('button', { name: '120' })); - expect(mockSetTerminalWidth).toHaveBeenCalledWith(120); - }); - - it('should call setTerminalWidth with 160', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(50); - }); - - fireEvent.click(screen.getByRole('button', { name: '160' })); - expect(mockSetTerminalWidth).toHaveBeenCalledWith(160); - }); - - it('should highlight selected terminal width (100)', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(50); - }); - - // Find the 100 button that has ring-2 class (the active one) - const buttons = screen.getAllByRole('button', { name: '100' }); - const activeButton = buttons.find((btn) => btn.classList.contains('ring-2')); - expect(activeButton).toBeTruthy(); - }); - }); - // ========================================================================= // Max Log Buffer // ========================================================================= @@ -1615,8 +1540,6 @@ describe('DisplayTab', () => { expect(screen.getByText('Interface Font')).toBeInTheDocument(); // Font Size expect(screen.getByText('Font Size')).toBeInTheDocument(); - // Terminal Width - expect(screen.getByText('Terminal Width (Columns)')).toBeInTheDocument(); // Max Log Buffer expect(screen.getByText('Maximum Log Buffer')).toBeInTheDocument(); // Max Output Lines diff --git a/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx b/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx index 681edd5a9c..432625cc4e 100644 --- a/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx +++ b/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx @@ -68,7 +68,7 @@ vi.mock('../../../../../renderer/components/shared/AgentConfigPanel', () => ({ /> + )} + + + {/* Color picker palette */} + {colorPickerId === pipeline.id && onChangePipelineColor && ( +
e.stopPropagation()} + style={{ + display: 'grid', + gridTemplateColumns: 'repeat(6, 1fr)', + gap: 4, + padding: '8px 10px', + backgroundColor: '#16162a', + borderTop: '1px solid rgba(255,255,255,0.08)', + zIndex: 10, + }} + > + {PIPELINE_COLORS.map((c) => ( +
+ )} + + ))} + + {/* Divider */} +
+ + {/* New Pipeline button */} + +
+ )} + + ); +} + +/** Small multi-color icon representing "All Pipelines" */ +function MultiColorIcon() { + const colors = PIPELINE_COLORS.slice(0, 4); + return ( + + ); +} diff --git a/src/renderer/components/CuePipelineEditor/drawers/AgentDrawer.tsx b/src/renderer/components/CuePipelineEditor/drawers/AgentDrawer.tsx new file mode 100644 index 0000000000..59e9c9e225 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/drawers/AgentDrawer.tsx @@ -0,0 +1,262 @@ +import { memo, useState, useMemo } from 'react'; +import { Bot, Search, X } from 'lucide-react'; +import type { Theme } from '../../../types'; + +export interface AgentSessionInfo { + id: string; + groupId?: string; + name: string; + toolType: string; +} + +export interface AgentDrawerProps { + isOpen: boolean; + onClose: () => void; + sessions: AgentSessionInfo[]; + groups?: { id: string; name: string; emoji: string }[]; + onCanvasSessionIds?: Set; + theme: Theme; +} + +function handleDragStart(e: React.DragEvent, session: AgentSessionInfo) { + e.dataTransfer.setData( + 'application/cue-pipeline', + JSON.stringify({ + type: 'agent', + sessionId: session.id, + sessionName: session.name, + toolType: session.toolType, + }) + ); + e.dataTransfer.effectAllowed = 'move'; +} + +export const AgentDrawer = memo(function AgentDrawer({ + isOpen, + onClose, + sessions, + groups, + onCanvasSessionIds, + theme, +}: AgentDrawerProps) { + const [search, setSearch] = useState(''); + + const filtered = useMemo(() => { + if (!search.trim()) return sessions; + const q = search.toLowerCase(); + return sessions.filter( + (s) => s.name.toLowerCase().includes(q) || s.toolType.toLowerCase().includes(q) + ); + }, [sessions, search]); + + // Build group lookup + const groupMap = useMemo(() => { + const map = new Map(); + for (const g of groups ?? []) { + map.set(g.id, { name: g.name, emoji: g.emoji }); + } + return map; + }, [groups]); + + // Group by user-defined groups, alphabetize groups (ungrouped last), alphabetize agents within each group + const grouped = useMemo(() => { + const result = new Map< + string, + { label: string; sortName: string; sessions: AgentSessionInfo[] } + >(); + for (const s of filtered) { + const key = s.groupId ?? '__ungrouped__'; + if (!result.has(key)) { + const g = s.groupId ? groupMap.get(s.groupId) : undefined; + const label = g ? `${g.emoji} ${g.name}` : 'Ungrouped'; + const sortName = g ? g.name : 'Ungrouped'; + result.set(key, { label, sortName, sessions: [] }); + } + result.get(key)!.sessions.push(s); + } + // Sort agents within each group alphabetically by name + for (const entry of result.values()) { + entry.sessions.sort((a, b) => a.name.localeCompare(b.name)); + } + // Sort groups alphabetically by name (ignoring emoji), with ungrouped last + const sorted = new Map( + Array.from(result.entries()).sort(([keyA, a], [keyB, b]) => { + if (keyA === '__ungrouped__') return 1; + if (keyB === '__ungrouped__') return -1; + return a.sortName.localeCompare(b.sortName); + }) + ); + return sorted; + }, [filtered, groupMap]); + + return ( +
+ {/* Header */} +
+ Agents + +
+ + {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Search agents..." + style={{ + flex: 1, + background: 'none', + border: 'none', + outline: 'none', + color: theme.colors.textMain, + fontSize: 12, + }} + /> +
+
+ + {/* Agent list */} +
+ {Array.from(grouped.entries()).map(([key, { label, sessions: agents }]) => ( +
+ {grouped.size > 1 && ( +
+ {label} +
+ )} + {agents.map((session) => { + const isOnCanvas = onCanvasSessionIds?.has(session.id) ?? false; + return ( +
handleDragStart(e, session)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '8px 10px', + marginBottom: 4, + borderRadius: 6, + backgroundColor: theme.colors.bgActivity, + cursor: 'grab', + transition: 'filter 0.15s', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLElement).style.filter = 'brightness(1.2)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.filter = 'brightness(1)'; + }} + > + +
+
+ {session.name} +
+
+ {session.toolType} +
+
+ {isOnCanvas && ( +
+ )} +
+ ); + })} +
+ ))} + {filtered.length === 0 && ( +
+ {search ? 'No agents match' : 'No agents available'} +
+ )} +
+
+ ); +}); diff --git a/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx b/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx new file mode 100644 index 0000000000..582b09d827 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx @@ -0,0 +1,232 @@ +import { memo, useState, useMemo } from 'react'; +import { + Clock, + FileText, + Zap, + GitPullRequest, + GitBranch, + CheckSquare, + Search, + X, +} from 'lucide-react'; +import type { CueEventType } from '../../../../shared/cue-pipeline-types'; +import type { Theme } from '../../../types'; + +export interface TriggerDrawerProps { + isOpen: boolean; + onClose: () => void; + theme: Theme; +} + +interface TriggerItem { + eventType: CueEventType; + label: string; + description: string; + icon: typeof Clock; + color: string; +} + +const TRIGGER_ITEMS: TriggerItem[] = [ + { + eventType: 'time.interval', + label: 'Scheduled', + description: 'Run on a timer', + icon: Clock, + color: '#f59e0b', + }, + { + eventType: 'file.changed', + label: 'File Change', + description: 'Watch for file modifications', + icon: FileText, + color: '#3b82f6', + }, + { + eventType: 'agent.completed', + label: 'Agent Done', + description: 'After an agent finishes', + icon: Zap, + color: '#22c55e', + }, + { + eventType: 'github.pull_request', + label: 'Pull Request', + description: 'GitHub PR events', + icon: GitPullRequest, + color: '#a855f7', + }, + { + eventType: 'github.issue', + label: 'Issue', + description: 'GitHub issue events', + icon: GitBranch, + color: '#f97316', + }, + { + eventType: 'task.pending', + label: 'Pending Task', + description: 'Markdown task checkboxes', + icon: CheckSquare, + color: '#06b6d4', + }, +]; + +function handleDragStart(e: React.DragEvent, item: TriggerItem) { + e.dataTransfer.setData( + 'application/cue-pipeline', + JSON.stringify({ type: 'trigger', eventType: item.eventType, label: item.label }) + ); + e.dataTransfer.effectAllowed = 'move'; +} + +export const TriggerDrawer = memo(function TriggerDrawer({ + isOpen, + onClose, + theme, +}: TriggerDrawerProps) { + const [search, setSearch] = useState(''); + + const filtered = useMemo(() => { + if (!search.trim()) return TRIGGER_ITEMS; + const q = search.toLowerCase(); + return TRIGGER_ITEMS.filter( + (item) => + item.label.toLowerCase().includes(q) || + item.eventType.toLowerCase().includes(q) || + item.description.toLowerCase().includes(q) + ); + }, [search]); + + return ( +
+ {/* Header */} +
+ + Triggers + + +
+ + {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Filter triggers..." + style={{ + flex: 1, + background: 'none', + border: 'none', + outline: 'none', + color: theme.colors.textMain, + fontSize: 12, + }} + /> +
+
+ + {/* Trigger list */} +
+ {filtered.map((item) => { + const Icon = item.icon; + return ( +
handleDragStart(e, item)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '8px 10px', + marginBottom: 4, + borderRadius: 6, + borderLeft: `3px solid ${item.color}`, + backgroundColor: theme.colors.bgActivity, + cursor: 'grab', + transition: 'filter 0.15s', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLElement).style.filter = 'brightness(1.2)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.filter = 'brightness(1)'; + }} + > + +
+
+ {item.label} +
+
{item.description}
+
+
+ ); + })} + {filtered.length === 0 && ( +
+ No triggers match +
+ )} +
+
+ ); +}); diff --git a/src/renderer/components/CuePipelineEditor/edges/PipelineEdge.tsx b/src/renderer/components/CuePipelineEditor/edges/PipelineEdge.tsx new file mode 100644 index 0000000000..15147fb2b1 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/edges/PipelineEdge.tsx @@ -0,0 +1,100 @@ +import { memo } from 'react'; +import { getBezierPath, BaseEdge, EdgeLabelRenderer, type EdgeProps } from 'reactflow'; +import { MessageCircle, FileText } from 'lucide-react'; +import type { EdgeMode } from '../../../../shared/cue-pipeline-types'; + +// Inject the pipeline dash animation once into the document head +let pipelineDashInjected = false; +function ensurePipelineDashStyle() { + if (pipelineDashInjected) return; + pipelineDashInjected = true; + const style = document.createElement('style'); + style.textContent = `@keyframes pipeline-dash { to { stroke-dashoffset: -9; } }`; + document.head.appendChild(style); +} + +export interface PipelineEdgeData { + pipelineColor: string; + mode: EdgeMode; + isActivePipeline: boolean; + isRunning?: boolean; +} + +export const PipelineEdge = memo(function PipelineEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + selected, + markerEnd, +}: EdgeProps) { + ensurePipelineDashStyle(); + const color = data?.pipelineColor ?? '#06b6d4'; + const mode = data?.mode ?? 'pass'; + const isActive = data?.isActivePipeline !== false; + const isRunning = data?.isRunning ?? false; + const opacity = isActive ? 1 : 0.25; + + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); + + return ( + <> + + + {/* Mode label for non-pass modes */} + {mode !== 'pass' && ( + +
+ {mode === 'debate' && } + {mode === 'autorun' && } + {mode} +
+
+ )} + + ); +}); + +export const edgeTypes = { + pipeline: PipelineEdge, +}; diff --git a/src/renderer/components/CuePipelineEditor/index.ts b/src/renderer/components/CuePipelineEditor/index.ts new file mode 100644 index 0000000000..d63cca584f --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/index.ts @@ -0,0 +1,2 @@ +export { CuePipelineEditor } from './CuePipelineEditor'; +export type { CuePipelineEditorProps } from './CuePipelineEditor'; diff --git a/src/renderer/components/CuePipelineEditor/nodes/AgentNode.tsx b/src/renderer/components/CuePipelineEditor/nodes/AgentNode.tsx new file mode 100644 index 0000000000..778515dea6 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/nodes/AgentNode.tsx @@ -0,0 +1,208 @@ +import { memo } from 'react'; +import { Handle, Position, type NodeProps } from 'reactflow'; +import { MessageSquare, GripVertical, Settings } from 'lucide-react'; + +export interface AgentNodeDataProps { + compositeId: string; + sessionId: string; + sessionName: string; + toolType: string; + hasPrompt: boolean; + hasOutgoingEdge: boolean; + pipelineColor: string; + pipelineCount: number; + pipelineColors: string[]; + onConfigure?: (compositeId: string) => void; +} + +export const AgentNode = memo(function AgentNode({ + data, + selected, +}: NodeProps) { + const accentColor = data.pipelineColor; + + return ( +
+ {/* Drag handle */} +
{ + e.currentTarget.style.color = '#fff'; + e.currentTarget.style.filter = 'brightness(1.3)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = '#555'; + e.currentTarget.style.filter = 'brightness(1)'; + }} + title="Drag to move" + > + +
+ + {/* Content */} +
+
+ + {data.sessionName} + + {data.hasPrompt && ( + + )} +
+ + {data.toolType} + + + {/* Multi-pipeline color strip */} + {data.pipelineColors.length > 1 && ( +
+ {data.pipelineColors.map((c, i) => ( +
+ ))} +
+ )} +
+ + {/* Gear icon */} +
{ + e.stopPropagation(); + data.onConfigure?.(data.compositeId); + }} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + color: selected ? accentColor : '#555', + flexShrink: 0, + padding: '0 6px', + marginRight: 14, + borderRadius: 4, + transition: 'color 0.15s', + }} + onMouseEnter={(e) => (e.currentTarget.style.color = accentColor)} + onMouseLeave={(e) => (e.currentTarget.style.color = selected ? accentColor : '#555')} + title="Configure" + > + +
+ + {/* Pipeline count badge */} + {data.pipelineCount > 1 && ( +
+ {data.pipelineCount} +
+ )} + + + +
+ ); +}); diff --git a/src/renderer/components/CuePipelineEditor/nodes/TriggerNode.tsx b/src/renderer/components/CuePipelineEditor/nodes/TriggerNode.tsx new file mode 100644 index 0000000000..d30603c490 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/nodes/TriggerNode.tsx @@ -0,0 +1,184 @@ +import { memo } from 'react'; +import { Handle, Position, type NodeProps } from 'reactflow'; +import { + Clock, + FileText, + GitPullRequest, + GitBranch, + CheckSquare, + Zap, + GripVertical, + Settings, +} from 'lucide-react'; +import type { CueEventType } from '../../../../shared/cue-pipeline-types'; + +export interface TriggerNodeDataProps { + compositeId: string; + eventType: CueEventType; + label: string; + configSummary: string; + onConfigure?: (compositeId: string) => void; +} + +const EVENT_COLORS: Record = { + 'time.interval': '#f59e0b', + 'file.changed': '#3b82f6', + 'agent.completed': '#22c55e', + 'github.pull_request': '#a855f7', + 'github.issue': '#f97316', + 'task.pending': '#06b6d4', +}; + +const EVENT_ICONS: Record = { + 'time.interval': Clock, + 'file.changed': FileText, + 'agent.completed': Zap, + 'github.pull_request': GitPullRequest, + 'github.issue': GitBranch, + 'task.pending': CheckSquare, +}; + +export const TriggerNode = memo(function TriggerNode({ + data, + selected, +}: NodeProps) { + const color = EVENT_COLORS[data.eventType] ?? '#06b6d4'; + const Icon = EVENT_ICONS[data.eventType] ?? Zap; + + return ( +
+ {/* Drag handle */} +
{ + e.currentTarget.style.color = '#fff'; + e.currentTarget.style.filter = 'brightness(1.3)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = '#555'; + e.currentTarget.style.filter = 'brightness(1)'; + }} + title="Drag to move" + > + +
+ + {/* Content */} +
+
+ + + {data.label} + +
+ {data.configSummary && ( + + {data.configSummary} + + )} +
+ + {/* Gear icon - placed before connector to avoid overlap */} +
{ + e.stopPropagation(); + data.onConfigure?.(data.compositeId); + }} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + color: selected ? color : `${color}60`, + flexShrink: 0, + padding: '4px 4px', + marginRight: 14, + borderRadius: 4, + transition: 'color 0.15s', + }} + onMouseEnter={(e) => (e.currentTarget.style.color = color)} + onMouseLeave={(e) => (e.currentTarget.style.color = selected ? color : `${color}60`)} + title="Configure" + > + +
+ + +
+ ); +}); diff --git a/src/renderer/components/CuePipelineEditor/panels/CueSettingsPanel.tsx b/src/renderer/components/CuePipelineEditor/panels/CueSettingsPanel.tsx new file mode 100644 index 0000000000..f54497063e --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/panels/CueSettingsPanel.tsx @@ -0,0 +1,167 @@ +/** + * CueSettingsPanel — Popover panel for global Cue settings. + * + * Configures: timeout, failure behavior, concurrency, queue size. + */ + +import type { CueSettings } from '../../../../main/cue/cue-types'; + +const inputStyle: React.CSSProperties = { + backgroundColor: '#2a2a3e', + border: '1px solid #444', + borderRadius: 4, + color: '#e4e4e7', + padding: '4px 8px', + fontSize: 12, + width: '100%', + outline: 'none', +}; + +const selectStyle: React.CSSProperties = { + ...inputStyle, + cursor: 'pointer', +}; + +const labelStyle: React.CSSProperties = { + color: '#9ca3af', + fontSize: 11, + fontWeight: 500, + marginBottom: 2, +}; + +interface CueSettingsPanelProps { + settings: CueSettings; + onChange: (settings: CueSettings) => void; + onClose: () => void; +} + +export function CueSettingsPanel({ settings, onChange, onClose }: CueSettingsPanelProps) { + return ( +
+
+ Cue Settings + +
+ +
+ {/* Timeout */} +
+
Timeout (minutes)
+ + onChange({ + ...settings, + timeout_minutes: Math.max(1, parseInt(e.target.value) || 30), + }) + } + style={inputStyle} + /> +
+ + {/* Timeout on fail */} +
+
On Source Failure
+ +
+ + {/* Max concurrent */} +
+
Max Concurrent Runs
+ + onChange({ + ...settings, + max_concurrent: Math.min(10, Math.max(1, parseInt(e.target.value) || 1)), + }) + } + style={inputStyle} + /> +
+ + {/* Queue size */} +
+
Event Queue Size
+ + onChange({ + ...settings, + queue_size: Math.min(50, Math.max(0, parseInt(e.target.value) || 10)), + }) + } + style={inputStyle} + /> +
+
+ +
+ Settings are saved to .maestro/cue.yaml when you save the pipeline. +
+
+ ); +} diff --git a/src/renderer/components/CuePipelineEditor/panels/EdgeConfigPanel.tsx b/src/renderer/components/CuePipelineEditor/panels/EdgeConfigPanel.tsx new file mode 100644 index 0000000000..25d7c823f6 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/panels/EdgeConfigPanel.tsx @@ -0,0 +1,256 @@ +/** + * EdgeConfigPanel — Bottom panel for configuring selected pipeline edges. + * + * Provides mode selection (pass/debate/autorun) and mode-specific settings. + * All changes update immediately. + */ + +import { ArrowRight, MessageCircle, FileText, Trash2 } from 'lucide-react'; +import type { PipelineEdge, EdgeMode, PipelineNode } from '../../../../shared/cue-pipeline-types'; + +interface EdgeConfigPanelProps { + selectedEdge: PipelineEdge | null; + sourceNode: PipelineNode | null; + targetNode: PipelineNode | null; + pipelineColor: string; + onUpdateEdge: (edgeId: string, updates: Partial) => void; + onDeleteEdge: (edgeId: string) => void; +} + +function getNodeLabel(node: PipelineNode | null): string { + if (!node) return '?'; + if (node.type === 'trigger') { + return (node.data as { label: string }).label; + } + return (node.data as { sessionName: string }).sessionName; +} + +const MODES: Array<{ + mode: EdgeMode; + label: string; + icon: typeof ArrowRight; + description: string; +}> = [ + { + mode: 'pass', + label: 'Pass', + icon: ArrowRight, + description: 'Data passes through to next agent', + }, + { + mode: 'debate', + label: 'Debate', + icon: MessageCircle, + description: 'Multiple agents debate before passing result', + }, + { + mode: 'autorun', + label: 'Auto Run', + icon: FileText, + description: 'Agent creates auto-run documents for next agent', + }, +]; + +export function EdgeConfigPanel({ + selectedEdge, + sourceNode, + targetNode, + pipelineColor, + onUpdateEdge, + onDeleteEdge, +}: EdgeConfigPanelProps) { + if (!selectedEdge) return null; + + const currentMode = selectedEdge.mode; + + return ( +
+ + + {/* Header */} +
+
+ + Connection Settings + + + {getNodeLabel(sourceNode)} + + {getNodeLabel(targetNode)} + +
+ +
+ + {/* Content */} +
+ {/* Mode selector */} +
+ {MODES.map(({ mode, label, icon: Icon }) => { + const isActive = currentMode === mode; + return ( + + ); + })} +
+ + {/* Mode description */} +
+ {MODES.find((m) => m.mode === currentMode)?.description} +
+ + {/* Debate settings */} + {currentMode === 'debate' && ( +
+ + +
+ )} + + {/* Auto Run explanation */} + {currentMode === 'autorun' && ( +
+ The source agent will produce auto-run documents that the target agent will execute + sequentially. +
+ )} +
+
+ ); +} diff --git a/src/renderer/components/CuePipelineEditor/panels/NodeConfigPanel.tsx b/src/renderer/components/CuePipelineEditor/panels/NodeConfigPanel.tsx new file mode 100644 index 0000000000..cee7892df4 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/panels/NodeConfigPanel.tsx @@ -0,0 +1,528 @@ +/** + * NodeConfigPanel — Bottom panel for configuring selected trigger or agent nodes. + * + * Shows event-specific fields for triggers, prompt textarea for agents. + * All changes update immediately (debounced for text inputs). + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + Trash2, + Clock, + FileText, + Zap, + GitPullRequest, + GitBranch, + CheckSquare, + ExternalLink, +} from 'lucide-react'; +import type { + PipelineNode, + TriggerNodeData, + AgentNodeData, + CueEventType, + CuePipeline, +} from '../../../../shared/cue-pipeline-types'; +import { useDebouncedCallback } from '../../../hooks/utils'; + +interface NodeConfigPanelProps { + selectedNode: PipelineNode | null; + pipelines: CuePipeline[]; + hasOutgoingEdge?: boolean; + onUpdateNode: (nodeId: string, data: Partial) => void; + onDeleteNode: (nodeId: string) => void; + onSwitchToAgent?: (sessionId: string) => void; +} + +const EVENT_ICONS: Record = { + 'time.interval': Clock, + 'file.changed': FileText, + 'agent.completed': Zap, + 'github.pull_request': GitPullRequest, + 'github.issue': GitBranch, + 'task.pending': CheckSquare, +}; + +const EVENT_LABELS: Record = { + 'time.interval': 'Scheduled Timer', + 'file.changed': 'File Change', + 'agent.completed': 'Agent Completed', + 'github.pull_request': 'Pull Request', + 'github.issue': 'GitHub Issue', + 'task.pending': 'Pending Task', +}; + +const inputStyle: React.CSSProperties = { + backgroundColor: '#2a2a3e', + border: '1px solid #444', + borderRadius: 4, + color: '#e4e4e7', + padding: '4px 8px', + fontSize: 12, + outline: 'none', + width: '100%', +}; + +const selectStyle: React.CSSProperties = { + ...inputStyle, + cursor: 'pointer', +}; + +const labelStyle: React.CSSProperties = { + color: '#9ca3af', + fontSize: 11, + fontWeight: 500, + marginBottom: 4, + display: 'block', +}; + +function TriggerConfig({ + node, + onUpdateNode, +}: { + node: PipelineNode; + onUpdateNode: NodeConfigPanelProps['onUpdateNode']; +}) { + const data = node.data as TriggerNodeData; + const [localConfig, setLocalConfig] = useState(data.config); + + useEffect(() => { + setLocalConfig(data.config); + }, [data.config]); + + const { debouncedCallback: debouncedUpdate } = useDebouncedCallback((...args: unknown[]) => { + const config = args[0] as TriggerNodeData['config']; + onUpdateNode(node.id, { config } as Partial); + }, 300); + + const updateConfig = useCallback( + (key: string, value: string | number) => { + const updated = { ...localConfig, [key]: value }; + setLocalConfig(updated); + debouncedUpdate(updated); + }, + [localConfig, debouncedUpdate] + ); + + const updateFilter = useCallback( + (key: string, value: string) => { + const updated = { + ...localConfig, + filter: { ...(localConfig.filter ?? {}), [key]: value }, + }; + setLocalConfig(updated); + debouncedUpdate(updated); + }, + [localConfig, debouncedUpdate] + ); + + switch (data.eventType) { + case 'time.interval': + return ( +
+ +
+ ); + case 'file.changed': + return ( +
+ + +
+ ); + case 'agent.completed': + return ( +
+ Source agent is determined by incoming edges. Connect a trigger or agent node to configure + the source. +
+ ); + case 'github.pull_request': + case 'github.issue': + return ( +
+ + +
+ ); + case 'task.pending': + return ( +
+ +
+ ); + default: + return null; + } +} + +function AgentConfig({ + node, + pipelines, + hasOutgoingEdge, + onUpdateNode, + onSwitchToAgent, +}: { + node: PipelineNode; + pipelines: CuePipeline[]; + hasOutgoingEdge?: boolean; + onUpdateNode: NodeConfigPanelProps['onUpdateNode']; + onSwitchToAgent?: (sessionId: string) => void; +}) { + const data = node.data as AgentNodeData; + const [localInputPrompt, setLocalInputPrompt] = useState(data.inputPrompt ?? ''); + const [localOutputPrompt, setLocalOutputPrompt] = useState(data.outputPrompt ?? ''); + + useEffect(() => { + setLocalInputPrompt(data.inputPrompt ?? ''); + }, [data.inputPrompt]); + + useEffect(() => { + setLocalOutputPrompt(data.outputPrompt ?? ''); + }, [data.outputPrompt]); + + const { debouncedCallback: debouncedUpdateInput } = useDebouncedCallback((...args: unknown[]) => { + const inputPrompt = args[0] as string; + onUpdateNode(node.id, { inputPrompt } as Partial); + }, 300); + + const { debouncedCallback: debouncedUpdateOutput } = useDebouncedCallback( + (...args: unknown[]) => { + const outputPrompt = args[0] as string; + onUpdateNode(node.id, { outputPrompt } as Partial); + }, + 300 + ); + + const handleInputPromptChange = useCallback( + (e: React.ChangeEvent) => { + setLocalInputPrompt(e.target.value); + debouncedUpdateInput(e.target.value); + }, + [debouncedUpdateInput] + ); + + const handleOutputPromptChange = useCallback( + (e: React.ChangeEvent) => { + setLocalOutputPrompt(e.target.value); + debouncedUpdateOutput(e.target.value); + }, + [debouncedUpdateOutput] + ); + + // Find which pipelines contain this agent + const agentPipelines = pipelines.filter((p) => + p.nodes.some( + (n) => n.type === 'agent' && (n.data as AgentNodeData).sessionId === data.sessionId + ) + ); + + const outputDisabled = !hasOutgoingEdge; + + return ( +
+
+ {/* Input Prompt */} +
+