diff --git a/CLAUDE-AGENTS.md b/CLAUDE-AGENTS.md index 8f1205fd07..ec02787b3b 100644 --- a/CLAUDE-AGENTS.md +++ b/CLAUDE-AGENTS.md @@ -4,13 +4,14 @@ Agent support documentation for the Maestro codebase. For the main guide, see [[ ## Supported Agents -| ID | Name | Status | Notes | -| --------------- | ------------- | ---------- | ---------------------------------------------------------------- | -| `claude-code` | Claude Code | **Active** | Primary agent, `--print --verbose --output-format stream-json` | -| `codex` | Codex | **Active** | Full support, `--json`, YOLO mode default | -| `opencode` | OpenCode | **Active** | Multi-provider support (75+ LLMs), stub provider session storage | -| `factory-droid` | Factory Droid | **Active** | Factory's AI coding assistant, `-o stream-json` | -| `terminal` | Terminal | Internal | Hidden from UI, used for shell sessions | +| ID | Name | Status | Notes | +| --------------- | -------------- | ---------- | ---------------------------------------------------------------------------------------------------------- | +| `claude-code` | Claude Code | **Active** | Primary agent, `--print --verbose --output-format stream-json` | +| `codex` | Codex | **Active** | Full support, `--json`, YOLO mode default | +| `opencode` | OpenCode | **Active** | Multi-provider support (75+ LLMs), stub provider session storage | +| `factory-droid` | Factory Droid | **Active** | Factory's AI coding assistant, `-o stream-json` | +| `copilot` | GitHub Copilot | **Beta** | `-p/--prompt`, `--output-format json`, `--resume`, `@image` mentions, permission filters, reasoning stream | +| `terminal` | Terminal | Internal | Hidden from UI, used for shell sessions | ## Agent Capabilities @@ -90,6 +91,19 @@ Centralized in `src/shared/agentMetadata.ts` (importable from any process): - **YOLO Mode:** Auto-enabled in batch mode (no flag needed) - **Multi-Provider:** Supports 75+ LLMs including Ollama, LM Studio, llama.cpp +### GitHub Copilot CLI + +- **Binary:** `copilot` +- **JSON Output:** `--output-format json` +- **Batch Mode:** `-p, --prompt ` +- **Resume:** `--continue`, `--resume[=session-id]` +- **Read-only:** CLI-enforced via `--allow-tool=read,url`, `--deny-tool=write,shell,memory,github`, `--no-ask-user` +- **Thinking Display:** Streams `assistant.reasoning_delta` / `assistant.reasoning` into Maestro's thinking panel +- **Images:** Prompt-embedded `@/tmp/...` mentions (maps Maestro uploads to Copilot file/image mentions) +- **Session Storage:** `~/.copilot/session-state//` +- **Known Limitations:** + - **SSH interactive mode:** PTY-based interactive Copilot sessions do not go through `wrapSpawnWithSsh()`, so interactive Copilot over SSH remote is not supported. Batch mode (`-p`) over SSH works correctly via the standard child-process spawner. + ## Adding New Agents To add support for a new agent: diff --git a/CLAUDE.md b/CLAUDE.md index 2524467eef..c9ac0a02cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,13 +102,14 @@ Maestro is an Electron desktop app for managing multiple AI coding assistants si ### Supported Agents -| ID | Name | Status | -| --------------- | ------------- | ---------- | -| `claude-code` | Claude Code | **Active** | -| `codex` | OpenAI Codex | **Active** | -| `opencode` | OpenCode | **Active** | -| `factory-droid` | Factory Droid | **Active** | -| `terminal` | Terminal | Internal | +| ID | Name | Status | +| --------------- | -------------- | ---------- | +| `claude-code` | Claude Code | **Active** | +| `codex` | OpenAI Codex | **Active** | +| `opencode` | OpenCode | **Active** | +| `factory-droid` | Factory Droid | **Active** | +| `copilot` | GitHub Copilot | **Beta** | +| `terminal` | Terminal | Internal | See [[CLAUDE-AGENTS.md]] for capabilities and integration details. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ece6ee195..f0c4b933a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -645,13 +645,14 @@ Based on capabilities, these UI features are automatically enabled/disabled: ### Supported Agents Reference -| Agent | Resume | Read-Only | JSON | Images | Sessions | Cost | Status | -| ------------- | --------------------- | --------------------------- | ---- | ------ | ----------------------------- | ---------------- | ----------- | -| Claude Code | ✅ `--resume` | ✅ `--permission-mode plan` | ✅ | ✅ | ✅ `~/.claude/` | ✅ | ✅ Complete | -| Codex | ✅ `exec resume` | ✅ `--sandbox read-only` | ✅ | ✅ | ✅ `~/.codex/` | ❌ (tokens only) | ✅ Complete | -| OpenCode | ✅ `--session` | ✅ `--agent plan` | ✅ | ✅ | ✅ `~/.local/share/opencode/` | ✅ | ✅ Complete | -| Factory Droid | ✅ `-s, --session-id` | ✅ (default mode) | ✅ | ✅ | ✅ `~/.factory/` | ❌ (tokens only) | ✅ Complete | -| Gemini CLI | TBD | TBD | TBD | TBD | TBD | ✅ | 📋 Planned | +| Agent | Resume | Read-Only | JSON | Images | Sessions | Cost | Status | +| ------------------ | ---------------------------- | --------------------------- | ---- | ------ | ------------------------------ | ----------------------- | ----------- | +| Claude Code | ✅ `--resume` | ✅ `--permission-mode plan` | ✅ | ✅ | ✅ `~/.claude/` | ✅ | ✅ Complete | +| Codex | ✅ `exec resume` | ✅ `--sandbox read-only` | ✅ | ✅ | ✅ `~/.codex/` | ❌ (tokens only) | ✅ Complete | +| OpenCode | ✅ `--session` | ✅ `--agent plan` | ✅ | ✅ | ✅ `~/.local/share/opencode/` | ✅ | ✅ Complete | +| Factory Droid | ✅ `-s, --session-id` | ✅ (default mode) | ✅ | ✅ | ✅ `~/.factory/` | ❌ (tokens only) | ✅ Complete | +| GitHub Copilot CLI | ✅ `--resume` / `--continue` | ✅ permission rules | ✅ | ✅ | ✅ `~/.copilot/session-state/` | ❌ (not exposed by CLI) | 🧪 Beta | +| Gemini CLI | TBD | TBD | TBD | TBD | TBD | ✅ | 📋 Planned | For detailed implementation guide, see [AGENT_SUPPORT.md](AGENT_SUPPORT.md). @@ -975,11 +976,11 @@ Place icons in `build/` directory: ### 2. Update Version -Update in `package.json`. Use **odd** minor versions for `main` (stable) and **even** minor versions for `rc` (pre-release). See [Branching & Release Strategy](#branching--release-strategy). +Update in `package.json`: ```json { - "version": "0.15.0" + "version": "X.Y.Z" } ``` @@ -999,16 +1000,11 @@ Output in `release/` directory. Create a release tag to trigger automated builds: ```bash -# Stable release (from main) -git tag v0.15.0 -git push origin v0.15.0 - -# Release candidate (from rc) — use -RC suffix -git tag v0.16.0-RC -git push origin v0.16.0-RC +git tag vX.Y.Z +git push origin vX.Y.Z ``` -GitHub Actions will build for all platforms and create a release. Tags containing `-RC`, `-beta`, or `-alpha` are automatically marked as pre-releases on GitHub. +GitHub Actions will build for all platforms and create a release. ## Documentation diff --git a/README.md b/README.md index 0463ec2a57..6d23667b3a 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ npm run dev - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) - Anthropic's AI coding assistant - [OpenAI Codex](https://github.com/openai/codex) - OpenAI's coding agent - [OpenCode](https://github.com/sst/opencode) - Open-source AI coding assistant + - [GitHub Copilot CLI](https://docs.github.com/copilot/how-tos/copilot-cli) - GitHub's terminal coding agent - Git (optional, for git-aware features) ### Essential Keyboard Shortcuts diff --git a/docs/installation.md b/docs/installation.md index 2a7d4b0b0a..dcc585d8c8 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -20,6 +20,7 @@ Download the latest release for your platform from the [Releases](https://github - [Codex](https://github.com/openai/codex) — OpenAI's coding agent (fully integrated) - [OpenCode](https://github.com/sst/opencode) — Open-source AI coding assistant (fully integrated) - [Factory Droid](https://docs.factory.ai/cli) — Factory's AI coding assistant (fully integrated) + - [GitHub Copilot CLI](https://docs.github.com/copilot/how-tos/copilot-cli) — GitHub's terminal coding agent (beta integration) - [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Qwen3 Coder](https://github.com/QwenLM/Qwen-Agent) — Planned support - Git (optional, for git-aware features) diff --git a/docs/releases.md b/docs/releases.md index b23f80dfb9..1f7950aec7 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -17,9 +17,9 @@ Maestro can update itself automatically! This feature was introduced in **v0.8.7 **Latest: v0.15.3** | Released January 1, 1 -# Major 0.15.x Additions +### Major 0.15.x Additions -🎶 **Maestro Symphony** — Contribute to open source with AI assistance! Browse curated issues from projects with the `runmaestro.ai` label, clone repos with one click, and automatically process the relevant Auto Run playbooks. Track your contributions, streaks, and stats. You're contributing CPU and tokens towards your favorite open source projects and features. +🎶 **Maestro Symphony** — Contribute to open source with AI assistance! Browse curated issues from projects with the `runmaestro.ai` label, clone repos with one click, and automatically process the relevant Auto Run playbooks. Track your contributions, streaks, and stats. You're contributing CPU and tokens towards your favorite open-source projects and features. 🎬 **Director's Notes** — Aggregates history across all agents into a unified timeline with search, filters, and an activity graph. Includes an AI Overview tab that generates a structured synopsis of recent work. Off by default, gated behind a new "Encore Features" panel under settings. This is a precursor to an eventual plugin system, allowing for extensions and customizations without bloating the core app. @@ -29,7 +29,7 @@ Maestro can update itself automatically! This feature was introduced in **v0.8.7 🤖 **Factory.ai Droid Support** — Added support for the [Factory.ai](https://factory.ai/product/cli) droid agent. Full session management and output parsing integration. -## Change in v0.15.2 +### Changes in v0.15.2 Patch release with bug fixes, UX improvements, and cherry-picks from the 0.16.0 RC. @@ -88,7 +88,7 @@ Changes in this point release include: - Desktop app performance improvements (more to come on this, we want Maestro blazing fast) 🐌 - Added local manifest feature for custom playbooks 📖 -- Agents are now inherently aware of your activity history as seen in the history panel 📜 (this is built-in cross context memory!) +- Agents are now inherently aware of your activity history as seen in the history panel 📜 (this is built-in cross-context memory!) - Added markdown rendering support for AI responses in mobile view 📱 - Bugfix in tracking costs from JSONL files that were aged out 🏦 - Added BlueSky social media handle for leaderboard 🦋 @@ -105,7 +105,7 @@ The major contributions to 0.14.x remain: 🧙‍♂️ Added an in-tab wizard for generating Auto Run Playbooks via `/wizard` or a new button in the Auto Run panel. -# Smaller Changes in 0.14.x +### Smaller Changes in 0.14.x - Improved User Dashboard, available from hamburger menu, command palette or hotkey 🎛️ - Leaderboard tracking now works across multiple systems and syncs level from cloud 🏆 @@ -174,11 +174,11 @@ The big changes in the v0.12.x line are the following three: ## GitHub Spec-Kit Integration -🎯 Added [GitHub Spec-Kit](https://github.com/github/spec-kit) commands into Maestro with a built in updater to grab the latest prompts from the repository. We do override `/speckit-implement` (the final step) to create Auto Run docs and guide the user through their execution, which thanks to Wortrees from v0.11.x allows us to run in parallel! +🎯 Added [GitHub Spec-Kit](https://github.com/github/spec-kit) commands into Maestro with a built-in updater to grab the latest prompts from the repository. We do override `/speckit-implement` (the final step) to create Auto Run docs and guide the user through their execution, which thanks to Worktrees from v0.11.x allows us to run in parallel! ## Context Management Tools -📖 Added context management options from tab right-click menu. You can now compress, merge, and transfer contexts between agents. You will received (configurable) warnings at 60% and 80% context consumption with a hint to compact. +📖 Added context management options from tab right-click menu. You can now compress, merge, and transfer contexts between agents. You will receive (configurable) warnings at 60% and 80% context consumption with a hint to compact. ## Changes Specific to v0.12.3: @@ -199,9 +199,9 @@ The big changes in the v0.12.x line are the following three: **Latest: v0.11.0** | Released December 22, 2025 -🌳 Github Worktree support was added. Any agent bound to a Git repository has the option to enable worktrees, each of which show up as a sub-agent with their own write-lock and Auto Run capability. Now you can truly develop in parallel on the same project and issue PRs when you're ready, all from within Maestro. Huge improvement, major thanks to @petersilberman. +🌳 GitHub Worktree support was added. Any agent bound to a Git repository has the option to enable worktrees, each of which show up as a sub-agent with their own write-lock and Auto Run capability. Now you can truly develop in parallel on the same project and issue PRs when you're ready, all from within Maestro. Huge improvement, major thanks to @petersilberman. -# Other Changes +### Other Changes - @ file mentions now include documents from your Auto Run folder (which may not live in your agent working directory) 🗄️ - The wizard is now capable of detecting and continuing on past started projects 🧙 @@ -238,7 +238,7 @@ The big changes in the v0.12.x line are the following three: ### Changes -- Add Sentry crashing reporting monitoring with opt-out 🐛 +- Added Sentry crash reporting with opt-out 🐛 - Stability fixes on v0.9.0 along with all the changes it brought along, including... - Major refactor to enable supporting of multiple providers 👨‍👩‍👧‍👦 - Added OpenAI Codex support 👨‍💻 @@ -264,7 +264,7 @@ The big changes in the v0.12.x line are the following three: - Implemented fuzzy file search in quick actions for instant navigation 🔍 - Added "clear" command support to clean terminal shell logs 🧹 - Simplified search highlighting by integrating into markdown pipeline ✨ -- Enhanced update checker to filter prerelease tags like -rc, -beta 🚀 +- Enhanced update checker to filter pre-release tags like -rc, -beta 🚀 - Fixed RPM package compatibility for OpenSUSE Tumbleweed 🐧 (H/T @JOduMonT) - Added libuuid1 support alongside standard libuuid dependency 📦 - Introduced Cmd+Shift+U shortcut for tab unread toggle ⌨️ @@ -293,7 +293,7 @@ The big changes in the v0.12.x line are the following three: Minor bugfixes on top of v0.7.3: -# Onboarding, Wizard, and Tours +### Onboarding, Wizard, and Tours - Implemented comprehensive onboarding wizard with integrated tour system 🚀 - Added project-understanding confidence display to wizard UI 🎨 @@ -301,7 +301,7 @@ Minor bugfixes on top of v0.7.3: - Added analytics tracking for wizard and tour completion 📈 - Added First Run Celebration modal with confetti animation 🎉 -# UI / UX Enhancements +### UI / UX Enhancements - Added expand-to-fullscreen button for Auto Run interface 🖥️ - Created dedicated modal component and improved modal priority constants for expanded Auto Run view 📐 @@ -311,18 +311,18 @@ Minor bugfixes on top of v0.7.3: - Implemented drag-and-drop reordering for execution queue items 🎯 - Enhanced toast context with agent name for OS notifications 📢 -# Auto Run Workflow Improvements +### Auto Run Workflow Improvements - Created phase document generation for Auto Run workflow 📄 - Added real-time log streaming to the LogViewer component 📊 -# Application Behavior / Core Fixes +### Application Behavior / Core Fixes - Added validation to prevent nested worktrees inside the main repository 🚫 - Fixed process manager to properly emit exit events on errors 🔧 - Fixed process exit handling to ensure proper cleanup 🧹 -# Update System +### Update System - Implemented automatic update checking on application startup 🚀 - Added settings toggle for enabling/disabling startup update checks ⚙️ diff --git a/package-lock.json b/package-lock.json index 3f79a06d67..10e5a3bbef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -274,7 +274,6 @@ "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", @@ -678,7 +677,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -722,7 +720,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2296,7 +2293,6 @@ "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" } @@ -2318,7 +2314,6 @@ "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" }, @@ -2331,7 +2326,6 @@ "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" }, @@ -2347,7 +2341,6 @@ "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", @@ -2735,7 +2728,6 @@ "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" @@ -2752,7 +2744,6 @@ "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", @@ -2770,7 +2761,6 @@ "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" } @@ -3829,7 +3819,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4381,7 +4372,6 @@ "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" @@ -4393,7 +4383,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4519,7 +4508,6 @@ "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", @@ -4989,7 +4977,6 @@ "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" }, @@ -5071,7 +5058,6 @@ "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", @@ -6087,7 +6073,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6570,7 +6555,6 @@ "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", @@ -7296,7 +7280,6 @@ "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" } @@ -7706,7 +7689,6 @@ "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" } @@ -8204,7 +8186,6 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -8300,7 +8281,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.3.0", @@ -8444,6 +8426,7 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -8457,6 +8440,7 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -8476,6 +8460,7 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -8498,6 +8483,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8514,6 +8500,7 @@ "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", @@ -8530,6 +8517,7 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -8544,6 +8532,7 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -8559,6 +8548,7 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -8571,7 +8561,8 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/electron-builder-squirrel-windows/node_modules/string_decoder": { "version": "1.1.1", @@ -8579,6 +8570,7 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -8589,6 +8581,7 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -8599,6 +8592,7 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -8614,6 +8608,7 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -9295,7 +9290,6 @@ "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", @@ -11215,7 +11209,6 @@ "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" @@ -12036,7 +12029,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12506,14 +12498,16 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "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" + "license": "MIT", + "peer": true }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", @@ -12526,7 +12520,8 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -12540,7 +12535,8 @@ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -12554,7 +12550,8 @@ "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" + "license": "MIT", + "peer": true }, "node_modules/log-symbols": { "version": "4.1.0", @@ -12645,6 +12642,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -14935,7 +14933,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15156,7 +15153,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15397,6 +15393,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -15412,6 +15409,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -15756,7 +15754,6 @@ "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" }, @@ -15786,7 +15783,6 @@ "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" @@ -15834,7 +15830,6 @@ "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" @@ -16033,8 +16028,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -18088,7 +18082,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18462,7 +18455,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -18968,7 +18960,6 @@ "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", @@ -19559,7 +19550,6 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -20157,7 +20147,6 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/__tests__/main/agents/capabilities.test.ts b/src/__tests__/main/agents/capabilities.test.ts index 33f0f46b51..99b91fa355 100644 --- a/src/__tests__/main/agents/capabilities.test.ts +++ b/src/__tests__/main/agents/capabilities.test.ts @@ -135,6 +135,27 @@ describe('agent-capabilities', () => { expect(capabilities.supportsResultMessages).toBe(true); }); + it('should have verified capabilities for copilot', () => { + const capabilities = AGENT_CAPABILITIES['copilot']; + expect(capabilities).toBeDefined(); + expect(capabilities.supportsResume).toBe(true); + expect(capabilities.supportsReadOnlyMode).toBe(true); + expect(capabilities.supportsJsonOutput).toBe(true); + expect(capabilities.supportsSessionId).toBe(true); + expect(capabilities.supportsImageInput).toBe(true); + expect(capabilities.supportsImageInputOnResume).toBe(true); + expect(capabilities.supportsSlashCommands).toBe(true); + expect(capabilities.supportsSessionStorage).toBe(true); + expect(capabilities.supportsBatchMode).toBe(true); + expect(capabilities.supportsStreaming).toBe(true); + expect(capabilities.supportsResultMessages).toBe(true); + expect(capabilities.supportsThinkingDisplay).toBe(true); + expect(capabilities.supportsContextMerge).toBe(true); + expect(capabilities.supportsContextExport).toBe(true); + expect(capabilities.supportsWizard).toBe(true); + expect(capabilities.supportsGroupChatModeration).toBe(true); + }); + it('should define capabilities for all known agents', () => { const knownAgents = [ 'claude-code', @@ -145,6 +166,7 @@ describe('agent-capabilities', () => { 'opencode', 'factory-droid', 'aider', + 'copilot', ]; for (const agentId of knownAgents) { @@ -232,6 +254,7 @@ describe('agent-capabilities', () => { expect(hasCapability('claude-code', 'supportsWizard')).toBe(true); expect(hasCapability('codex', 'supportsWizard')).toBe(true); expect(hasCapability('opencode', 'supportsWizard')).toBe(true); + expect(hasCapability('copilot', 'supportsWizard')).toBe(true); expect(hasCapability('factory-droid', 'supportsWizard')).toBe(false); expect(hasCapability('terminal', 'supportsWizard')).toBe(false); @@ -239,6 +262,7 @@ describe('agent-capabilities', () => { expect(hasCapability('claude-code', 'supportsGroupChatModeration')).toBe(true); expect(hasCapability('codex', 'supportsGroupChatModeration')).toBe(true); expect(hasCapability('opencode', 'supportsGroupChatModeration')).toBe(true); + expect(hasCapability('copilot', 'supportsGroupChatModeration')).toBe(true); expect(hasCapability('factory-droid', 'supportsGroupChatModeration')).toBe(true); expect(hasCapability('terminal', 'supportsGroupChatModeration')).toBe(false); diff --git a/src/__tests__/main/agents/definitions.test.ts b/src/__tests__/main/agents/definitions.test.ts index c8772aa3eb..efe2eac9dd 100644 --- a/src/__tests__/main/agents/definitions.test.ts +++ b/src/__tests__/main/agents/definitions.test.ts @@ -71,6 +71,19 @@ describe('agent-definitions', () => { expect(opencode?.noPromptSeparator).toBeUndefined(); }); + it('should have copilot configured to use a PTY for interactive sessions', () => { + const copilot = AGENT_DEFINITIONS.find((def) => def.id === 'copilot'); + expect(copilot).toBeDefined(); + expect(copilot?.requiresPty).toBe(true); + expect(copilot?.jsonOutputArgs).toEqual(['--output-format', 'json']); + expect(copilot?.readOnlyArgs).toEqual([ + '--allow-tool=read,url', + '--deny-tool=write,shell,memory,github', + '--no-ask-user', + ]); + expect(copilot?.readOnlyCliEnforced).toBe(true); + }); + it('should have opencode with default env vars for YOLO mode and disabled question tool', () => { const opencode = AGENT_DEFINITIONS.find((def) => def.id === 'opencode'); expect(opencode?.defaultEnvVars).toBeDefined(); @@ -196,6 +209,14 @@ describe('agent-definitions', () => { expect(args).toEqual(['-C', '/path/to/project']); }); + it('should use = syntax for Copilot resume args', () => { + const copilot = getAgentDefinition('copilot'); + expect(copilot?.resumeArgs).toBeDefined(); + + const args = copilot?.resumeArgs?.('session-789'); + expect(args).toEqual(['--resume=session-789']); + }); + it('should have imageArgs function for codex', () => { const codex = getAgentDefinition('codex'); expect(codex?.imageArgs).toBeDefined(); @@ -211,6 +232,18 @@ describe('agent-definitions', () => { const args = opencode?.imageArgs?.('/path/to/image.png'); expect(args).toEqual(['-f', '/path/to/image.png']); }); + + it('should embed Copilot images into prompts using @mentions', () => { + const copilot = getAgentDefinition('copilot'); + expect(copilot?.imagePromptBuilder).toBeDefined(); + + const promptPrefix = copilot?.imagePromptBuilder?.([ + '/tmp/screenshot-1.png', + '/tmp/screenshot-2.jpg', + ]); + expect(promptPrefix).toContain('@/tmp/screenshot-1.png'); + expect(promptPrefix).toContain('@/tmp/screenshot-2.jpg'); + }); }); describe('Agent config options', () => { @@ -240,6 +273,32 @@ describe('agent-definitions', () => { expect(modelOption?.argBuilder?.('')).toEqual([]); expect(modelOption?.argBuilder?.(' ')).toEqual([]); }); + + it('should have Copilot-specific config options for supported CLI flags', () => { + const copilot = getAgentDefinition('copilot'); + expect(copilot?.configOptions).toBeDefined(); + + const reasoningEffort = copilot?.configOptions?.find((opt) => opt.key === 'reasoningEffort'); + expect(reasoningEffort?.type).toBe('select'); + expect(reasoningEffort?.argBuilder?.('high')).toEqual(['--reasoning-effort', 'high']); + + const autopilot = copilot?.configOptions?.find((opt) => opt.key === 'autopilot'); + expect(autopilot?.type).toBe('checkbox'); + expect(autopilot?.argBuilder?.(true)).toEqual(['--autopilot']); + expect(autopilot?.argBuilder?.(false)).toEqual([]); + + const allowAllPaths = copilot?.configOptions?.find((opt) => opt.key === 'allowAllPaths'); + expect(allowAllPaths?.argBuilder?.(true)).toEqual(['--allow-all-paths']); + + const allowAllUrls = copilot?.configOptions?.find((opt) => opt.key === 'allowAllUrls'); + expect(allowAllUrls?.argBuilder?.(true)).toEqual(['--allow-all-urls']); + + const experimental = copilot?.configOptions?.find((opt) => opt.key === 'experimental'); + expect(experimental?.argBuilder?.(true)).toEqual(['--experimental']); + + const screenReader = copilot?.configOptions?.find((opt) => opt.key === 'screenReader'); + expect(screenReader?.argBuilder?.(true)).toEqual(['--screen-reader']); + }); }); describe('Type definitions', () => { diff --git a/src/__tests__/main/agents/detector.test.ts b/src/__tests__/main/agents/detector.test.ts index cfedf5f4f6..f6dc00fe14 100644 --- a/src/__tests__/main/agents/detector.test.ts +++ b/src/__tests__/main/agents/detector.test.ts @@ -278,8 +278,8 @@ describe('agent-detector', () => { const agents = await detector.detectAgents(); - // Should have all 8 agents (terminal, claude-code, codex, gemini-cli, qwen3-coder, opencode, factory-droid, aider) - expect(agents.length).toBe(8); + // Should have all 9 agents (terminal, claude-code, codex, gemini-cli, qwen3-coder, opencode, factory-droid, aider, copilot) + expect(agents.length).toBe(9); const agentIds = agents.map((a) => a.id); expect(agentIds).toContain('terminal'); @@ -290,6 +290,7 @@ describe('agent-detector', () => { expect(agentIds).toContain('opencode'); expect(agentIds).toContain('factory-droid'); expect(agentIds).toContain('aider'); + expect(agentIds).toContain('copilot'); }); it('should mark agents as available when binary is found', async () => { @@ -924,8 +925,8 @@ describe('agent-detector', () => { const result = await detectPromise; expect(result).toBeDefined(); - // Should have all 8 agents (terminal, claude-code, codex, gemini-cli, qwen3-coder, opencode, factory-droid, aider) - expect(result.length).toBe(8); + // Should have all 9 agents (terminal, claude-code, codex, gemini-cli, qwen3-coder, opencode, factory-droid, aider, copilot) + expect(result.length).toBe(9); }); it('should handle very long PATH', async () => { diff --git a/src/__tests__/main/agents/session-storage.test.ts b/src/__tests__/main/agents/session-storage.test.ts index dcadaff1c9..d1469699dc 100644 --- a/src/__tests__/main/agents/session-storage.test.ts +++ b/src/__tests__/main/agents/session-storage.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import path from 'path'; +import fs from 'fs/promises'; import type Store from 'electron-store'; import type { ClaudeSessionOriginsData } from '../../../main/storage/claude-session-storage'; import { @@ -371,6 +372,185 @@ describe('CodexSessionStorage', () => { }); }); +describe('CopilotSessionStorage', () => { + let originalCopilotConfigDir: string | undefined; + const copilotSessionStateDir = path.join( + '/tmp/maestro-session-storage-home', + '.copilot', + 'session-state' + ); + + async function writeCopilotSessionFixture( + sessionId: string, + workspaceContent: string, + eventsContent?: string + ): Promise { + const sessionDir = path.join(copilotSessionStateDir, sessionId); + await fs.mkdir(sessionDir, { recursive: true }); + await fs.writeFile(path.join(sessionDir, 'workspace.yaml'), workspaceContent, 'utf8'); + if (eventsContent !== undefined) { + await fs.writeFile(path.join(sessionDir, 'events.jsonl'), eventsContent, 'utf8'); + } + } + + beforeEach(async () => { + originalCopilotConfigDir = process.env.COPILOT_CONFIG_DIR; + delete process.env.COPILOT_CONFIG_DIR; + await fs.rm(path.join('/tmp/maestro-session-storage-home', '.copilot'), { + recursive: true, + force: true, + }); + }); + + afterEach(async () => { + await fs.rm(path.join('/tmp/maestro-session-storage-home', '.copilot'), { + recursive: true, + force: true, + }); + if (originalCopilotConfigDir === undefined) { + delete process.env.COPILOT_CONFIG_DIR; + } else { + process.env.COPILOT_CONFIG_DIR = originalCopilotConfigDir; + } + }); + + it('should be importable', async () => { + const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); + expect(CopilotSessionStorage).toBeDefined(); + }); + + it('should have copilot as agentId', async () => { + const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); + const storage = new CopilotSessionStorage(); + expect(storage.agentId).toBe('copilot'); + }); + + it('should return empty results for non-existent projects', async () => { + const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); + const storage = new CopilotSessionStorage(); + + const sessions = await storage.listSessions('/test/nonexistent/project'); + expect(sessions).toEqual([]); + + const messages = await storage.readSessionMessages('/test/nonexistent/project', 'session-123'); + expect(messages.messages).toEqual([]); + expect(messages.total).toBe(0); + }); + + it('should return local events path for getSessionPath', async () => { + const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); + const storage = new CopilotSessionStorage(); + + const sessionPath = storage.getSessionPath('/test/project', 'session-123'); + expect(sessionPath).toContain('.copilot'); + expect(sessionPath).toContain('session-state'); + expect(sessionPath).toContain('session-123'); + expect(sessionPath).toContain('events.jsonl'); + }); + + it('should return remote events path for getSessionPath with sshConfig', async () => { + const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); + const storage = new CopilotSessionStorage(); + + const sessionPath = storage.getSessionPath('/test/project', 'session-123', { + id: 'test-ssh', + name: 'Test SSH Server', + host: 'test-server.example.com', + port: 22, + username: 'testuser', + useSshConfig: false, + enabled: true, + }); + expect(sessionPath).toBe('~/.copilot/session-state/session-123/events.jsonl'); + }); + + it('should report delete as unsupported', async () => { + const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); + const storage = new CopilotSessionStorage(); + + const result = await storage.deleteMessagePair('/test/project', 'session-123', 'uuid-456'); + expect(result.success).toBe(false); + expect(result.error).toContain('not supported'); + }); + + it('should parse camelCase workspace metadata keys when loading sessions', async () => { + await writeCopilotSessionFixture( + 'session-camel', + [ + 'id: session-camel', + 'cwd: /test/project', + 'gitRoot: /test/project', + 'createdAt: 2026-03-13T00:00:00.000Z', + 'updatedAt: 2026-03-13T00:05:00.000Z', + 'summary: Camel case metadata', + ].join('\n'), + [ + JSON.stringify({ + type: 'user.message', + id: 'user-1', + timestamp: '2026-03-13T00:00:00.000Z', + data: { content: 'Hello from Copilot' }, + }), + ].join('\n') + ); + + const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); + const storage = new CopilotSessionStorage(); + const sessions = await storage.listSessions('/test/project'); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toEqual( + expect.objectContaining({ + sessionId: 'session-camel', + projectPath: '/test/project', + timestamp: '2026-03-13T00:00:00.000Z', + modifiedAt: '2026-03-13T00:05:00.000Z', + firstMessage: 'Hello from Copilot', + messageCount: 1, + }) + ); + }); + + it('should skip missing, empty, and malformed Copilot event logs', async () => { + await writeCopilotSessionFixture( + 'session-valid', + ['id: session-valid', 'cwd: /test/project', 'git_root: /test/project'].join('\n'), + [ + JSON.stringify({ + type: 'assistant.message', + id: 'assistant-1', + timestamp: '2026-03-13T00:00:00.000Z', + data: { content: 'Ready', phase: 'final_answer' }, + }), + ].join('\n') + ); + + await writeCopilotSessionFixture( + 'session-empty', + ['id: session-empty', 'cwd: /test/project', 'git_root: /test/project'].join('\n'), + ' \n' + ); + + await writeCopilotSessionFixture( + 'session-malformed', + ['id: session-malformed', 'cwd: /test/project', 'git_root: /test/project'].join('\n'), + 'not-json\nstill-not-json\n' + ); + + await writeCopilotSessionFixture( + 'session-missing-events', + ['id: session-missing-events', 'cwd: /test/project', 'git_root: /test/project'].join('\n') + ); + + const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); + const storage = new CopilotSessionStorage(); + const sessions = await storage.listSessions('/test/project'); + + expect(sessions).toHaveLength(1); + expect(sessions[0]?.sessionId).toBe('session-valid'); + }); +}); + describe('Storage Module Initialization', () => { it('should export initializeSessionStorages function', async () => { const { initializeSessionStorages } = await import('../../../main/storage/index'); @@ -453,7 +633,6 @@ describe('CodexSessionStorage SSH Remote Support', () => { host: 'test-server.example.com', port: 22, username: 'testuser', - privateKeyPath: '', useSshConfig: false, enabled: true, }; @@ -796,7 +975,6 @@ describe('OpenCodeSessionStorage SSH Remote Support', () => { host: 'test-server.example.com', port: 22, username: 'testuser', - privateKeyPath: '', useSshConfig: false, enabled: true, }; @@ -1111,7 +1289,6 @@ describe('FactoryDroidSessionStorage SSH Remote Support', () => { host: 'test-server.example.com', port: 22, username: 'testuser', - privateKeyPath: '', useSshConfig: false, enabled: true, }; @@ -1558,7 +1735,6 @@ describe('SSH Config Integration Flow Verification', () => { host: 'dev-server.internal.example.com', port: 22, username: 'developer', - privateKeyPath: '', useSshConfig: true, enabled: true, }; @@ -1570,7 +1746,6 @@ describe('SSH Config Integration Flow Verification', () => { host: '192.168.1.100', port: 2222, username: 'admin', - privateKeyPath: '', useSshConfig: false, enabled: true, }; @@ -1832,7 +2007,6 @@ describe('SSH Config Integration Flow Verification', () => { host: 'full.example.com', port: 22, username: 'fulluser', - privateKeyPath: '', useSshConfig: true, enabled: true, }; @@ -1860,7 +2034,6 @@ describe('SSH Config Integration Flow Verification', () => { host: 'min.example.com', port: 22, username: 'user', - privateKeyPath: '', useSshConfig: false, enabled: true, }; diff --git a/src/__tests__/main/parsers/copilot-output-parser.test.ts b/src/__tests__/main/parsers/copilot-output-parser.test.ts new file mode 100644 index 0000000000..95990eb4a6 --- /dev/null +++ b/src/__tests__/main/parsers/copilot-output-parser.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect } from 'vitest'; +import { CopilotOutputParser } from '../../../main/parsers/copilot-output-parser'; + +describe('CopilotOutputParser', () => { + it('parses final assistant messages as result events', () => { + const parser = new CopilotOutputParser(); + + const event = parser.parseJsonObject({ + type: 'assistant.message', + data: { + content: 'DONE', + phase: 'final_answer', + }, + }); + + expect(event).toEqual( + expect.objectContaining({ + type: 'result', + text: 'DONE', + }) + ); + expect(event && parser.isResultMessage(event)).toBe(true); + }); + + it('treats tool-only final assistant messages as result events', () => { + const parser = new CopilotOutputParser(); + + const event = parser.parseJsonObject({ + type: 'assistant.message', + data: { + content: '', + phase: 'final_answer', + toolRequests: [ + { + toolCallId: 'call_123', + name: 'view', + arguments: { path: '/tmp/project' }, + }, + ], + }, + }); + + expect(event).toEqual( + expect.objectContaining({ + type: 'result', + toolUseBlocks: [ + { + name: 'view', + id: 'call_123', + input: { path: '/tmp/project' }, + }, + ], + }) + ); + expect(event && parser.isResultMessage(event)).toBe(true); + }); + + it('tracks tool request metadata from commentary messages for later tool completion events', () => { + const parser = new CopilotOutputParser(); + + const commentaryEvent = parser.parseJsonObject({ + type: 'assistant.message', + data: { + content: '', + phase: 'commentary', + toolRequests: [ + { + toolCallId: 'call_123', + name: 'view', + arguments: { path: '/tmp/project' }, + }, + ], + }, + }); + + expect(commentaryEvent).toEqual( + expect.objectContaining({ + type: 'text', + text: '', + }) + ); + + const completionEvent = parser.parseJsonObject({ + type: 'tool.execution_complete', + data: { + toolCallId: 'call_123', + success: true, + result: { + content: 'README.md', + }, + }, + }); + + expect(completionEvent).toEqual( + expect.objectContaining({ + type: 'tool_use', + toolName: 'view', + toolState: { + status: 'completed', + output: 'README.md', + }, + }) + ); + }); + + it('parses assistant message deltas as partial text events', () => { + const parser = new CopilotOutputParser(); + + const event = parser.parseJsonObject({ + type: 'assistant.message_delta', + data: { + deltaContent: 'OK', + }, + }); + + expect(event).toEqual( + expect.objectContaining({ + type: 'text', + text: 'OK', + isPartial: true, + }) + ); + }); + + it('skips assistant reasoning summary when deltas already streamed the content', () => { + const parser = new CopilotOutputParser(); + + // Simulate a turn with reasoning deltas first + parser.parseJsonObject({ type: 'assistant.turn_start' }); + parser.parseJsonObject({ + type: 'assistant.reasoning_delta', + data: { deltaContent: 'Thinking through the repository structure...' }, + }); + + // The summary should be skipped since deltas already delivered the content + const event = parser.parseJsonObject({ + type: 'assistant.reasoning', + data: { + content: 'Thinking through the repository structure...', + }, + }); + + expect(event).toBeNull(); + }); + + it('uses assistant reasoning content when no deltas preceded it', () => { + const parser = new CopilotOutputParser(); + + parser.parseJsonObject({ type: 'assistant.turn_start' }); + + const event = parser.parseJsonObject({ + type: 'assistant.reasoning', + data: { + content: 'Thinking through the repository structure...', + }, + }); + + expect(event).toEqual( + expect.objectContaining({ + type: 'text', + text: 'Thinking through the repository structure...', + isPartial: true, + }) + ); + }); + + it('parses assistant reasoning delta events as partial text events', () => { + const parser = new CopilotOutputParser(); + + const event = parser.parseJsonObject({ + type: 'assistant.reasoning_delta', + data: { + deltaContent: 'Thinking live...', + }, + }); + + expect(event).toEqual( + expect.objectContaining({ + type: 'text', + text: 'Thinking live...', + isPartial: true, + }) + ); + }); + + it('tracks tool execution start and completion by toolCallId', () => { + const parser = new CopilotOutputParser(); + + const startEvent = parser.parseJsonObject({ + type: 'tool.execution_start', + data: { + toolCallId: 'call_123', + toolName: 'view', + arguments: { path: '/tmp/project' }, + }, + }); + + expect(startEvent).toEqual( + expect.objectContaining({ + type: 'tool_use', + toolName: 'view', + toolCallId: 'call_123', + toolState: { + status: 'running', + input: { path: '/tmp/project' }, + }, + }) + ); + + const completeEvent = parser.parseJsonObject({ + type: 'tool.execution_complete', + data: { + toolCallId: 'call_123', + success: true, + result: { + content: 'README.md', + }, + }, + }); + + expect(completeEvent).toEqual( + expect.objectContaining({ + type: 'tool_use', + toolName: 'view', + toolCallId: 'call_123', + toolState: { + status: 'completed', + output: 'README.md', + }, + }) + ); + }); + + it('treats failed tool execution as tool state, not a top-level agent error', () => { + const parser = new CopilotOutputParser(); + + const event = parser.parseJsonObject({ + type: 'tool.execution_complete', + data: { + toolCallId: 'call_123', + toolName: 'read_bash', + success: false, + error: + 'Invalid shell ID: $SHELL_2. Please supply a valid shell ID to read output from. ', + }, + }); + + const error = parser.detectErrorFromParsed({ + type: 'tool.execution_complete', + data: { + toolCallId: 'call_123', + toolName: 'read_bash', + success: false, + error: + 'Invalid shell ID: $SHELL_2. Please supply a valid shell ID to read output from. ', + }, + }); + + expect(event).toEqual( + expect.objectContaining({ + type: 'tool_use', + toolCallId: 'call_123', + toolState: { + status: 'failed', + output: + 'Invalid shell ID: $SHELL_2. Please supply a valid shell ID to read output from. ', + }, + }) + ); + expect(error).toBeNull(); + }); + + it('extracts session ids from result events', () => { + const parser = new CopilotOutputParser(); + const event = parser.parseJsonObject({ + type: 'result', + sessionId: '8654632e-5527-4b25-8994-66b1be2c6cc8', + exitCode: 0, + }); + + expect(event?.type).toBe('result'); + expect(event && parser.extractSessionId(event)).toBe('8654632e-5527-4b25-8994-66b1be2c6cc8'); + }); + + it('detects structured error events', () => { + const parser = new CopilotOutputParser(); + const error = parser.detectErrorFromParsed({ + type: 'error', + error: { message: 'Authentication expired. Please run /login.' }, + }); + + expect(error).toEqual( + expect.objectContaining({ + agentId: 'copilot', + message: expect.any(String), + }) + ); + }); + + it('does not treat reasoning message content as an agent error', () => { + const parser = new CopilotOutputParser(); + const error = parser.detectErrorFromParsed({ + type: 'assistant.reasoning', + data: { + message: 'Thinking through the repository structure...', + }, + }); + + expect(error).toBeNull(); + }); + + it('maps no-tty interactive launch failures to a clearer crash message', () => { + const parser = new CopilotOutputParser(); + const error = parser.detectErrorFromExit( + 1, + 'No prompt provided. Run in an interactive terminal or provide a prompt with -p or via standard in.', + '' + ); + + expect(error).toEqual( + expect.objectContaining({ + type: 'agent_crashed', + message: expect.stringContaining('require PTY mode'), + agentId: 'copilot', + }) + ); + }); +}); diff --git a/src/__tests__/main/parsers/index.test.ts b/src/__tests__/main/parsers/index.test.ts index 792f404fea..bf9713c0bf 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, + CopilotOutputParser, } 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 Copilot parser', () => { + expect(hasOutputParser('copilot')).toBe(false); + + initializeOutputParsers(); + + expect(hasOutputParser('copilot')).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, Copilot }); 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', () => { @@ -119,6 +128,12 @@ describe('parsers/index', () => { const parser = getOutputParser('unknown'); expect(parser).toBeNull(); }); + + it('should return CopilotOutputParser for copilot', () => { + const parser = getOutputParser('copilot'); + expect(parser).not.toBeNull(); + expect(parser).toBeInstanceOf(CopilotOutputParser); + }); }); describe('parser exports', () => { @@ -136,6 +151,11 @@ describe('parsers/index', () => { const parser = new CodexOutputParser(); expect(parser.agentId).toBe('codex'); }); + + it('should export CopilotOutputParser class', () => { + const parser = new CopilotOutputParser(); + expect(parser.agentId).toBe('copilot'); + }); }); describe('integration', () => { diff --git a/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts b/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts index 9b14d1561d..94259fa728 100644 --- a/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts +++ b/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts @@ -42,13 +42,17 @@ vi.mock('../../../../main/parsers/usage-aggregator', () => ({ })); vi.mock('../../../../main/parsers/error-patterns', () => ({ + getErrorPatterns: vi.fn(() => ({})), + matchErrorPattern: vi.fn(() => null), matchSshErrorPattern: vi.fn(() => null), })); // ── Imports (after mocks) ────────────────────────────────────────────────── import { StdoutHandler } from '../../../../main/process-manager/handlers/StdoutHandler'; +import { CopilotOutputParser } from '../../../../main/parsers/copilot-output-parser'; import type { ManagedProcess } from '../../../../main/process-manager/types'; +import { logger } from '../../../../main/utils/logger'; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -207,6 +211,360 @@ describe('StdoutHandler', () => { handler.handleData(sessionId, '\n\n\n'); expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); }); + + it('should process concatenated Copilot JSON objects without newlines', () => { + const parser = new CopilotOutputParser(); + const { handler, bufferManager, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + const sessionIdSpy = vi.fn(); + emitter.on('session-id', sessionIdSpy); + + const payload = [ + JSON.stringify({ + type: 'assistant.message', + data: { + content: 'Working on it...', + phase: 'commentary', + }, + }), + JSON.stringify({ + type: 'assistant.message', + data: { + content: 'Final answer', + phase: 'final_answer', + }, + }), + JSON.stringify({ + type: 'result', + sessionId: 'copilot-session-123', + exitCode: 0, + }), + ].join(' '); + + handler.handleData(sessionId, payload); + + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'Final answer'); + expect(sessionIdSpy).toHaveBeenCalledWith(sessionId, 'copilot-session-123'); + expect(proc.jsonBuffer).toBe(''); + }); + + it('should buffer partial Copilot JSON objects across chunks', () => { + const parser = new CopilotOutputParser(); + const { handler, bufferManager, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + const sessionIdSpy = vi.fn(); + emitter.on('session-id', sessionIdSpy); + + const chunkOne = JSON.stringify({ + type: 'assistant.message', + data: { + content: 'Working on it...', + phase: 'commentary', + }, + }); + const chunkTwo = [ + JSON.stringify({ + type: 'assistant.message', + data: { + content: 'Final answer', + phase: 'final_answer', + }, + }), + JSON.stringify({ + type: 'result', + sessionId: 'copilot-session-456', + exitCode: 0, + }), + ].join(''); + + handler.handleData(sessionId, chunkOne.slice(0, 25)); + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + expect(proc.jsonBuffer).toBe(chunkOne.slice(0, 25)); + + handler.handleData(sessionId, chunkOne.slice(25) + chunkTwo); + + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'Final answer'); + expect(sessionIdSpy).toHaveBeenCalledWith(sessionId, 'copilot-session-456'); + expect(proc.jsonBuffer).toBe(''); + }); + + it('should drop oversized incomplete Copilot JSON buffers', () => { + const parser = new CopilotOutputParser(); + const { handler, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + + // Seed stale tool IDs to verify they get cleared on corruption + proc.emittedToolCallIds = new Set(['stale_call_1']); + + const oversizedPayload = + '{"type":"assistant.message","data":{"content":"' + 'x'.repeat(1024 * 1024 + 64); + + handler.handleData(sessionId, oversizedPayload); + + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + expect(proc.jsonBuffer).toBe(''); + expect(proc.jsonBufferCorrupted).toBe(true); + expect(proc.emittedToolCallIds?.size).toBe(0); + expect(logger.warn).toHaveBeenCalledWith( + '[ProcessManager] Dropping oversized Copilot JSON buffer remainder', + 'ProcessManager', + expect.objectContaining({ + sessionId, + bufferLength: oversizedPayload.length, + maxBufferLength: 1024 * 1024, + }) + ); + }); + + it('should resync after corrupted buffer on the next valid JSON object', () => { + const parser = new CopilotOutputParser(); + const { handler, bufferManager, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + const sessionIdSpy = vi.fn(); + emitter.on('session-id', sessionIdSpy); + + // Force corrupted state with stale tool IDs + proc.jsonBufferCorrupted = true; + proc.emittedToolCallIds = new Set(['stale_call_2']); + + // Send trailing garbage from old object, then a clean new object + const payload = + 'leftover junk from old object"}' + + JSON.stringify({ + type: 'assistant.message', + data: { content: 'Recovered', phase: 'final_answer' }, + }); + + handler.handleData(sessionId, payload); + + expect(proc.jsonBufferCorrupted).toBe(false); + expect(proc.emittedToolCallIds?.size).toBe(0); + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'Recovered'); + expect(proc.jsonBuffer).toBe(''); + }); + + it('should discard Copilot preamble noise once JSON output begins', () => { + const parser = new CopilotOutputParser(); + const { handler, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + + handler.handleData( + sessionId, + 'Authenticating...\n' + + JSON.stringify({ + type: 'assistant.message', + data: { + content: 'Final answer', + phase: 'final_answer', + }, + }) + ); + + expect(bufferManager.emitDataBuffered).toHaveBeenNthCalledWith( + 1, + sessionId, + 'Authenticating...' + ); + expect(bufferManager.emitDataBuffered).toHaveBeenNthCalledWith(2, sessionId, 'Final answer'); + expect(proc.jsonBuffer).toBe(''); + }); + + it('should emit non-JSON Copilot output immediately when no JSON payload follows', () => { + const parser = new CopilotOutputParser(); + const { handler, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + + handler.handleData(sessionId, 'Authenticating...'); + + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'Authenticating...'); + expect(proc.jsonBuffer).toBe(''); + }); + + it('should still emit Copilot session IDs from result events with non-zero exit codes', () => { + const parser = new CopilotOutputParser(); + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + const sessionIdSpy = vi.fn(); + const errorSpy = vi.fn(); + emitter.on('session-id', sessionIdSpy); + emitter.on('agent-error', errorSpy); + + handler.handleData( + sessionId, + JSON.stringify({ + type: 'result', + sessionId: 'copilot-session-error', + exitCode: 1, + }) + ); + + // Session ID should still be extracted from bare exit-code result events + expect(sessionIdSpy).toHaveBeenCalledWith(sessionId, 'copilot-session-error'); + // Bare exit codes without error text should NOT trigger an inline error — + // the richer detectErrorFromExit() runs at process exit with stderr context + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('should dedupe Copilot tool starts emitted from tool.execution_start and final toolUseBlocks', () => { + const parser = new CopilotOutputParser(); + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + const toolSpy = vi.fn(); + emitter.on('tool-execution', toolSpy); + + handler.handleData( + sessionId, + JSON.stringify({ + type: 'tool.execution_start', + data: { + toolCallId: 'call_123', + toolName: 'view', + arguments: { path: '/tmp/project' }, + }, + }) + ); + + handler.handleData( + sessionId, + JSON.stringify({ + type: 'assistant.message', + data: { + content: 'Done', + phase: 'final_answer', + toolRequests: [ + { + toolCallId: 'call_123', + name: 'view', + arguments: { path: '/tmp/project' }, + }, + ], + }, + }) + ); + + expect(toolSpy).toHaveBeenCalledTimes(1); + expect(toolSpy).toHaveBeenCalledWith( + sessionId, + expect.objectContaining({ + toolName: 'view', + state: { + status: 'running', + input: { path: '/tmp/project' }, + }, + }) + ); + }); + + it('should not emit Copilot reasoning summaries as thinking chunks or final text', () => { + const parser = new CopilotOutputParser(); + const { handler, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + const thinkingSpy = vi.fn(); + emitter.on('thinking-chunk', thinkingSpy); + + handler.handleData( + sessionId, + JSON.stringify({ + type: 'assistant.reasoning', + data: { + content: 'Analyzing the codebase before making edits...', + }, + }) + ); + + expect(thinkingSpy).not.toHaveBeenCalled(); + expect(proc.streamedText).toBe(''); + }); + + it('should not emit Copilot reasoning deltas as thinking chunks or final text', () => { + const parser = new CopilotOutputParser(); + const { handler, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + const thinkingSpy = vi.fn(); + emitter.on('thinking-chunk', thinkingSpy); + + handler.handleData( + sessionId, + JSON.stringify({ + type: 'assistant.reasoning_delta', + data: { + deltaContent: 'Live reasoning chunk...', + }, + }) + ); + + expect(thinkingSpy).not.toHaveBeenCalled(); + expect(proc.streamedText).toBe(''); + }); + + it('should keep failed Copilot tool executions as tool events instead of agent errors', () => { + const parser = new CopilotOutputParser(); + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + const toolSpy = vi.fn(); + const errorSpy = vi.fn(); + emitter.on('tool-execution', toolSpy); + emitter.on('agent-error', errorSpy); + + handler.handleData( + sessionId, + JSON.stringify({ + type: 'tool.execution_complete', + data: { + toolCallId: 'call_456', + toolName: 'read_bash', + success: false, + error: + 'Invalid shell ID: $SHELL_2. Please supply a valid shell ID to read output from. ', + }, + }) + ); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(toolSpy).toHaveBeenCalledWith( + sessionId, + expect.objectContaining({ + state: { + status: 'failed', + output: + 'Invalid shell ID: $SHELL_2. Please supply a valid shell ID to read output from. ', + }, + }) + ); + }); }); // ── Legacy message handling (no outputParser) ────────────────────────── diff --git a/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts b/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts index 83d84e3ecf..4cbf7fb231 100644 --- a/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts +++ b/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts @@ -51,7 +51,7 @@ vi.mock('../../../../main/utils/logger', () => ({ })); vi.mock('../../../../main/parsers', () => ({ - getOutputParser: vi.fn(() => ({ + createOutputParser: vi.fn(() => ({ agentId: 'claude-code', parseJsonLine: vi.fn(), extractUsage: vi.fn(), @@ -94,6 +94,7 @@ vi.mock('../../../../main/process-manager/utils/shellEscape', () => ({ import { ChildProcessSpawner } from '../../../../main/process-manager/spawners/ChildProcessSpawner'; import type { ManagedProcess, ProcessConfig } from '../../../../main/process-manager/types'; import { getAgentCapabilities } from '../../../../main/agents'; +import { buildChildProcessEnv } from '../../../../main/process-manager/utils/envBuilder'; import { buildStreamJsonMessage } from '../../../../main/process-manager/utils/streamJsonBuilder'; import { saveImageToTempFile, @@ -178,6 +179,38 @@ describe('ChildProcessSpawner', () => { expect(proc?.isStreamJsonMode).toBe(true); }); + it('should enable stream-json mode when args contain "--output-format" and "json"', () => { + const { processes, spawner } = createTestContext(); + + spawner.spawn( + createBaseConfig({ + toolType: 'copilot', + command: 'copilot', + args: ['--output-format', 'json'], + prompt: 'test prompt', + }) + ); + + const proc = processes.get('test-session'); + expect(proc?.isStreamJsonMode).toBe(true); + expect(proc?.isBatchMode).toBe(true); + }); + + it('treats --resume= as a resumed session when building env', () => { + const { spawner } = createTestContext(); + + spawner.spawn( + createBaseConfig({ + toolType: 'copilot', + command: 'copilot', + args: ['--output-format', 'json', '--resume=session-123'], + prompt: 'continue', + }) + ); + + expect(buildChildProcessEnv).toHaveBeenCalledWith(undefined, true, undefined); + }); + it('should enable stream-json mode when sendPromptViaStdin is true', () => { const { processes, spawner } = createTestContext(); @@ -574,6 +607,34 @@ describe('ChildProcessSpawner', () => { // Should NOT have --input-format since this agent doesn't support it expect(spawnArgs).not.toContain('--input-format'); }); + + it('should embed Copilot image paths into the prompt when imagePromptBuilder is provided', () => { + vi.mocked(getAgentCapabilities).mockReturnValueOnce({ + supportsStreamJsonInput: false, + } as any); + vi.mocked(saveImageToTempFile).mockReturnValueOnce('/tmp/maestro-image-0.png'); + + const { spawner } = createTestContext(); + + spawner.spawn( + createBaseConfig({ + toolType: 'copilot', + command: 'copilot', + args: ['--output-format', 'json'], + images: ['data:image/png;base64,abc123'], + prompt: 'describe this image', + imagePromptBuilder: (paths: string[]) => + `Use these attached images as context:\n${paths.map((imagePath) => `@${imagePath}`).join('\n')}\n\n`, + promptArgs: (prompt: string) => ['-p', prompt], + }) + ); + + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + expect(spawnArgs).toContain('-p'); + const promptArg = spawnArgs[spawnArgs.indexOf('-p') + 1]; + expect(promptArg).toContain('@/tmp/maestro-image-0.png'); + expect(promptArg).toContain('describe this image'); + }); }); describe('resume mode with prompt-embed image handling', () => { diff --git a/src/__tests__/main/utils/agent-args.test.ts b/src/__tests__/main/utils/agent-args.test.ts index a1593e57fe..6d259d3ab0 100644 --- a/src/__tests__/main/utils/agent-args.test.ts +++ b/src/__tests__/main/utils/agent-args.test.ts @@ -86,21 +86,48 @@ describe('buildAgentArgs', () => { }); // -- jsonOutputArgs -- - it('adds jsonOutputArgs when not already present', () => { + it('adds jsonOutputArgs when prompt provided and not already present', () => { + const agent = makeAgent({ jsonOutputArgs: ['--format', 'json'] }); + const result = buildAgentArgs(agent, { baseArgs: ['--print'], prompt: 'hello' }); + expect(result).toEqual(['--print', '--format', 'json']); + }); + + it('does not add jsonOutputArgs for interactive sessions without a prompt', () => { const agent = makeAgent({ jsonOutputArgs: ['--format', 'json'] }); const result = buildAgentArgs(agent, { baseArgs: ['--print'] }); + expect(result).toEqual(['--print']); + }); + + it('does not duplicate jsonOutputArgs when exact sequence already present', () => { + const agent = makeAgent({ jsonOutputArgs: ['--format', 'json'] }); + const result = buildAgentArgs(agent, { + baseArgs: ['--print', '--format', 'json'], + prompt: 'hello', + }); + // '--format json' exact sequence is already in baseArgs, so jsonOutputArgs should not be added expect(result).toEqual(['--print', '--format', 'json']); }); - it('does not duplicate jsonOutputArgs when already present', () => { + it('does not duplicate jsonOutputArgs when same flag key present with different value', () => { const agent = makeAgent({ jsonOutputArgs: ['--format', 'json'] }); const result = buildAgentArgs(agent, { baseArgs: ['--print', '--format', 'stream'], + prompt: 'hello', }); - // '--format' is already in baseArgs, so jsonOutputArgs should not be added + // '--format' flag key is already present, so jsonOutputArgs should not be added expect(result).toEqual(['--print', '--format', 'stream']); }); + it('does not false-match jsonOutputArgs on bare value token', () => { + const agent = makeAgent({ jsonOutputArgs: ['--output-format', 'json'] }); + const result = buildAgentArgs(agent, { + baseArgs: ['--print', 'json'], + prompt: 'hello', + }); + // 'json' is a positional arg, not the '--output-format' flag, so jsonOutputArgs should be added + expect(result).toEqual(['--print', 'json', '--output-format', 'json']); + }); + // -- workingDirArgs -- it('adds workingDirArgs when cwd provided', () => { const agent = makeAgent({ diff --git a/src/__tests__/main/utils/remote-fs.test.ts b/src/__tests__/main/utils/remote-fs.test.ts index 05976e0ba3..c050f4d311 100644 --- a/src/__tests__/main/utils/remote-fs.test.ts +++ b/src/__tests__/main/utils/remote-fs.test.ts @@ -167,6 +167,20 @@ describe('remote-fs', () => { // Path should be properly escaped in the command expect(remoteCommand).toContain("'/path/with spaces/and'\\''quotes'"); }); + + it('expands remote home-relative paths before executing over SSH', async () => { + const deps = createMockDeps({ + stdout: 'file.txt\n', + stderr: '', + exitCode: 0, + }); + + await readDirRemote('~/.copilot/session-state', baseConfig, deps); + + const call = (deps.execSsh as any).mock.calls[0][1]; + const remoteCommand = call[call.length - 1]; + expect(remoteCommand).toContain('"$HOME/.copilot/session-state"'); + }); }); describe('readFileRemote', () => { diff --git a/src/__tests__/main/utils/ssh-command-builder.test.ts b/src/__tests__/main/utils/ssh-command-builder.test.ts index 13077edb26..ad46e1bc80 100644 --- a/src/__tests__/main/utils/ssh-command-builder.test.ts +++ b/src/__tests__/main/utils/ssh-command-builder.test.ts @@ -1036,6 +1036,28 @@ describe('ssh-command-builder', () => { expect(result.stdinScript).toContain('; rm -f'); }); + it('embeds Copilot image @mentions when imagePromptBuilder is provided', async () => { + const testImage = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=='; + const result = await buildSshCommandWithStdin(baseConfig, { + command: 'copilot', + args: ['--output-format', 'json'], + stdinInput: 'describe this image', + images: [testImage], + imagePromptBuilder: (paths: string[]) => + `Use these attached images as context:\n${paths.map((imagePath) => `@${imagePath}`).join('\n')}\n\n`, + }); + + expect(result.stdinScript).toContain('base64 -d >'); + const cmdLine = result.stdinScript?.split('\n').find((line) => line.startsWith('copilot ')); + expect(cmdLine).toBeDefined(); + expect(cmdLine).not.toContain("'-i'"); + expect(cmdLine).toContain('; rm -f'); + + const afterCmd = result.stdinScript?.split(cmdLine + '\n')[1]; + expect(afterCmd).toContain('@/tmp/maestro-image-'); + expect(afterCmd).toContain('describe this image'); + }); + it('does not embed image paths when imageResumeMode is not set (default behavior)', async () => { const testImage = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=='; const result = await buildSshCommandWithStdin(baseConfig, { diff --git a/src/__tests__/renderer/components/TerminalOutput.test.tsx b/src/__tests__/renderer/components/TerminalOutput.test.tsx index f784f6ae83..2d0ce0ad36 100644 --- a/src/__tests__/renderer/components/TerminalOutput.test.tsx +++ b/src/__tests__/renderer/components/TerminalOutput.test.tsx @@ -1666,6 +1666,68 @@ describe('TerminalOutput', () => { expect(screen.getByText('someWeirdField=true')).toBeInTheDocument(); }); + describe('hidden progress rendering', () => { + it('renders hidden tool progress with the polished activity treatment', () => { + const logs: LogEntry[] = [ + createLogEntry({ + id: 'hidden-progress:tab-1', + text: 'Reading src/renderer/App.tsx', + source: 'system', + metadata: { + toolState: { + status: 'running', + input: { path: 'src/renderer/App.tsx' }, + }, + hiddenProgress: { + kind: 'tool', + toolName: 'view', + }, + }, + }), + ]; + + const session = createDefaultSession({ + tabs: [{ id: 'tab-1', agentSessionId: 'claude-123', logs, isUnread: false }], + activeTabId: 'tab-1', + }); + + render(); + + expect(screen.getByText('view')).toBeInTheDocument(); + expect(screen.getByText('Reading src/renderer/App.tsx')).toBeInTheDocument(); + expect(screen.queryByTestId('react-markdown')).not.toBeInTheDocument(); + }); + + it('uses the standard failed icon treatment for hidden progress', () => { + const logs: LogEntry[] = [ + createLogEntry({ + id: 'hidden-progress:tab-1', + text: 'Command failed', + source: 'system', + metadata: { + toolState: { + status: 'failed', + }, + hiddenProgress: { + kind: 'tool', + toolName: 'bash', + }, + }, + }), + ]; + + const session = createDefaultSession({ + tabs: [{ id: 'tab-1', agentSessionId: 'claude-123', logs, isUnread: false }], + activeTabId: 'tab-1', + }); + + render(); + + expect(screen.getByText('!')).toBeInTheDocument(); + expect(screen.queryByText('×')).not.toBeInTheDocument(); + }); + }); + it('renders any tool with string input fields generically', () => { const logs: LogEntry[] = [ createLogEntry({ diff --git a/src/__tests__/renderer/hooks/useAgentListeners.test.ts b/src/__tests__/renderer/hooks/useAgentListeners.test.ts index b0f8358948..ae27457585 100644 --- a/src/__tests__/renderer/hooks/useAgentListeners.test.ts +++ b/src/__tests__/renderer/hooks/useAgentListeners.test.ts @@ -245,6 +245,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); // ============================================================================ @@ -416,6 +417,58 @@ describe('useAgentListeners', () => { expect.any(Number) ); }); + + it('removes a recovered agent error log when successful data resumes', () => { + const deps = createMockDeps(); + const recoveredError: AgentError = { + type: 'permission_denied', + message: 'Permission denied. Check file and directory permissions.', + recoverable: false, + agentId: 'copilot', + timestamp: 1700000000000, + }; + const session = createMockSession({ + id: 'sess-1', + state: 'error', + toolType: 'copilot', + agentError: recoveredError, + agentErrorTabId: 'tab-1', + agentErrorPaused: true, + aiTabs: [ + createMockTab({ + id: 'tab-1', + agentError: recoveredError, + logs: [ + { + id: 'log-error', + timestamp: recoveredError.timestamp, + source: 'error', + text: recoveredError.message, + agentError: recoveredError, + }, + ], + }), + ], + activeTabId: 'tab-1', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 'sess-1', + }); + + renderHook(() => useAgentListeners(deps)); + + onDataHandler?.('sess-1-ai-tab-1', 'Final answer'); + + const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1'); + expect(updated?.agentError).toBeUndefined(); + expect(updated?.agentErrorTabId).toBeUndefined(); + expect(updated?.agentErrorPaused).toBe(false); + expect(updated?.state).toBe('busy'); + expect(updated?.aiTabs[0]?.agentError).toBeUndefined(); + expect(updated?.aiTabs[0]?.logs).toEqual([]); + expect(window.maestro.agentError.clearError).toHaveBeenCalledWith('sess-1'); + }); }); // ======================================================================== @@ -839,6 +892,217 @@ describe('useAgentListeners', () => { }); }); + // ======================================================================== + // onThinkingChunk handler + // ======================================================================== + + describe('onThinkingChunk', () => { + it('shows lightweight progress when thinking is hidden', async () => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + + const deps = createMockDeps(); + const session = createMockSession({ + id: 'sess-1', + aiTabs: [createMockTab({ id: 'tab-1', showThinking: 'off' })], + activeTabId: 'tab-1', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 'sess-1', + }); + + renderHook(() => useAgentListeners(deps)); + + onThinkingChunkHandler?.('sess-1-ai-tab-1', 'reasoning...'); + await new Promise((r) => setTimeout(r, 0)); + + const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1'); + expect(updated?.aiTabs[0]?.logs).toHaveLength(1); + expect(updated?.aiTabs[0]?.logs[0]).toMatchObject({ + source: 'system', + text: 'Thinking through the next step...', + }); + }); + + it('keeps active tool progress visible while thinking stays hidden', async () => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + + const deps = createMockDeps(); + const session = createMockSession({ + id: 'sess-1', + aiTabs: [createMockTab({ id: 'tab-1', showThinking: 'off' })], + activeTabId: 'tab-1', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 'sess-1', + }); + + renderHook(() => useAgentListeners(deps)); + + onToolExecutionHandler?.('sess-1-ai-tab-1', { + toolName: 'view', + state: { status: 'running', input: { path: 'src/App.tsx' } }, + timestamp: 1700000000000, + }); + onThinkingChunkHandler?.('sess-1-ai-tab-1', 'reasoning...'); + await new Promise((r) => setTimeout(r, 0)); + + const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1'); + expect(updated?.aiTabs[0]?.logs[0]).toMatchObject({ + text: 'Reading src/App.tsx', + }); + }); + + it('removes hidden progress once visible output arrives', () => { + const deps = createMockDeps(); + const session = createMockSession({ + id: 'sess-1', + state: 'busy', + aiTabs: [ + createMockTab({ + id: 'tab-1', + showThinking: 'off', + logs: [ + { + id: 'hidden-progress:tab-1', + timestamp: 1700000000000, + source: 'system', + text: 'Thinking through the next step...', + }, + ], + }), + ], + activeTabId: 'tab-1', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 'sess-1', + }); + + renderHook(() => useAgentListeners(deps)); + + onDataHandler?.('sess-1-ai-tab-1', 'Visible response'); + + const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1'); + expect(updated?.aiTabs[0]?.logs).toEqual([]); + }); + }); + + // ======================================================================== + // onToolExecution handler + // ======================================================================== + + describe('onToolExecution', () => { + it('shows lightweight tool status when thinking is hidden', () => { + const deps = createMockDeps(); + const session = createMockSession({ + id: 'sess-1', + aiTabs: [createMockTab({ id: 'tab-1', showThinking: 'off' })], + activeTabId: 'tab-1', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 'sess-1', + }); + + renderHook(() => useAgentListeners(deps)); + + onToolExecutionHandler?.('sess-1-ai-tab-1', { + toolName: 'view', + state: { status: 'running', input: { path: 'src/renderer/App.tsx' } }, + timestamp: 1700000000000, + }); + + const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1'); + expect(updated?.aiTabs[0]?.logs).toHaveLength(1); + expect(updated?.aiTabs[0]?.logs[0]).toMatchObject({ + source: 'system', + text: 'Reading src/renderer/App.tsx', + }); + }); + + it('preserves prior tool detail when completion events omit input metadata', () => { + const deps = createMockDeps(); + const session = createMockSession({ + id: 'sess-1', + aiTabs: [createMockTab({ id: 'tab-1', showThinking: 'off' })], + activeTabId: 'tab-1', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 'sess-1', + }); + + renderHook(() => useAgentListeners(deps)); + + onToolExecutionHandler?.('sess-1-ai-tab-1', { + toolName: 'view', + state: { status: 'running', input: { path: 'src/renderer/App.tsx' } }, + timestamp: 1700000000000, + }); + onToolExecutionHandler?.('sess-1-ai-tab-1', { + toolName: 'view', + state: { status: 'completed', output: 'done' }, + timestamp: 1700000000100, + }); + + const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1'); + expect(updated?.aiTabs[0]?.logs).toHaveLength(1); + expect(updated?.aiTabs[0]?.logs[0]).toMatchObject({ + source: 'system', + text: 'Read src/renderer/App.tsx', + metadata: { + toolState: expect.objectContaining({ + status: 'completed', + input: { path: 'src/renderer/App.tsx' }, + }), + }, + }); + }); + + it('uses explicit completion copy when a completed tool event has no detail', () => { + const deps = createMockDeps(); + const session = createMockSession({ + id: 'sess-1', + aiTabs: [createMockTab({ id: 'tab-1', showThinking: 'off' })], + activeTabId: 'tab-1', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 'sess-1', + }); + + renderHook(() => useAgentListeners(deps)); + + onToolExecutionHandler?.('sess-1-ai-tab-1', { + toolName: 'view', + state: { status: 'completed', output: 'done' }, + timestamp: 1700000000000, + }); + + const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1'); + expect(updated?.aiTabs[0]?.logs).toHaveLength(1); + expect(updated?.aiTabs[0]?.logs[0]).toMatchObject({ + source: 'system', + text: 'Finished reading', + metadata: { + toolState: expect.objectContaining({ + status: 'completed', + }), + }, + }); + }); + }); + // ======================================================================== // onSshRemote handler // ======================================================================== @@ -902,6 +1166,41 @@ describe('useAgentListeners', () => { expect(updated?.state).toBe('idle'); }); + it('clears hidden progress logs on AI exit', async () => { + const deps = createMockDeps(); + const tab = createMockTab({ + id: 'tab-1', + showThinking: 'off', + logs: [ + { + id: 'hidden-progress:tab-1', + timestamp: 1700000000000, + source: 'system', + text: 'Reading src/renderer/App.tsx', + }, + ], + }); + const session = createMockSession({ + id: 'sess-1', + state: 'busy', + busySource: 'ai', + aiTabs: [tab], + activeTabId: 'tab-1', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 'sess-1', + }); + + renderHook(() => useAgentListeners(deps)); + + await onExitHandler?.('sess-1-ai-tab-1'); + await new Promise((r) => setTimeout(r, 50)); + + const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1'); + expect(updated?.aiTabs[0]?.logs).toEqual([]); + }); + it('processes execution queue on exit', async () => { const processQueuedItem = vi.fn().mockResolvedValue(undefined); const deps = createMockDeps({ diff --git a/src/__tests__/renderer/hooks/useWizardHandlers.test.ts b/src/__tests__/renderer/hooks/useWizardHandlers.test.ts index d5815de3f9..10a1c9c870 100644 --- a/src/__tests__/renderer/hooks/useWizardHandlers.test.ts +++ b/src/__tests__/renderer/hooks/useWizardHandlers.test.ts @@ -406,6 +406,67 @@ describe('useWizardHandlers', () => { expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); }); + + it('discovers agent slash commands for copilot sessions', async () => { + const session = createMockSession({ + toolType: 'copilot' as any, + agentCommands: undefined, + }); + useSessionStore.setState({ sessions: [session], activeSessionId: 'session-1' }); + + (window as any).maestro.agents.discoverSlashCommands.mockResolvedValue([ + { name: 'help' }, + { name: 'model' }, + ]); + + const deps = createMockDeps(); + renderHook(() => useWizardHandlers(deps)); + + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect((window as any).maestro.claude.getCommands).not.toHaveBeenCalled(); + expect((window as any).maestro.agents.discoverSlashCommands).toHaveBeenCalledWith( + 'copilot', + '/projects/test', + undefined + ); + + const updatedSession = useSessionStore.getState().sessions[0]; + expect(updatedSession.agentCommands).toEqual( + expect.arrayContaining([ + expect.objectContaining({ command: '/help' }), + expect.objectContaining({ command: '/model' }), + ]) + ); + }); + + it('discovers agent slash commands for opencode sessions', async () => { + const session = createMockSession({ + toolType: 'opencode' as any, + agentCommands: undefined, + }); + useSessionStore.setState({ sessions: [session], activeSessionId: 'session-1' }); + + (window as any).maestro.agents.discoverSlashCommands.mockResolvedValue([ + { name: 'deploy', prompt: 'Deploy the app' }, + ]); + + const deps = createMockDeps(); + renderHook(() => useWizardHandlers(deps)); + + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect((window as any).maestro.claude.getCommands).not.toHaveBeenCalled(); + expect((window as any).maestro.agents.discoverSlashCommands).toHaveBeenCalledWith( + 'opencode', + '/projects/test', + undefined + ); + }); }); // ======================================================================== diff --git a/src/__tests__/renderer/services/inlineWizardConversation.test.ts b/src/__tests__/renderer/services/inlineWizardConversation.test.ts index 6985a05a94..45b248db38 100644 --- a/src/__tests__/renderer/services/inlineWizardConversation.test.ts +++ b/src/__tests__/renderer/services/inlineWizardConversation.test.ts @@ -280,6 +280,66 @@ describe('inlineWizardConversation', () => { await messagePromise; }); + + it('should apply Copilot read-only wizard args and parse final_answer responses', async () => { + const mockAgent = { + id: 'copilot', + available: true, + command: 'copilot', + args: [], + readOnlyArgs: [ + '--allow-tool=read,url', + '--deny-tool=write,shell,memory,github', + '--no-ask-user', + ], + }; + mockMaestro.agents.get.mockResolvedValue(mockAgent); + mockMaestro.process.spawn.mockResolvedValue(undefined); + + const session = await startInlineWizardConversation({ + agentType: 'copilot', + directoryPath: '/test/project', + projectName: 'Test Project', + mode: 'ask', + }); + + const messagePromise = sendWizardMessage(session, 'Hello', []); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const spawnCall = mockMaestro.process.spawn.mock.calls[0][0]; + expect(spawnCall.args).toEqual( + expect.arrayContaining([ + '--allow-tool=read,url', + '--deny-tool=write,shell,memory,github', + '--no-ask-user', + ]) + ); + + const dataCallback = mockMaestro.process.onData.mock.calls[0][0]; + dataCallback( + session.sessionId, + '{"type":"assistant.message","data":{"phase":"final_answer","content":"{\\"confidence\\":91,\\"ready\\":true,\\"message\\":\\"Ready to proceed\\"}"}}\n' + ); + dataCallback( + session.sessionId, + '{"type":"result","sessionId":"copilot-session-123","exitCode":0,"usage":{"sessionDurationMs":1200}}\n' + ); + + const exitCallback = mockMaestro.process.onExit.mock.calls[0][0]; + exitCallback(session.sessionId, 0); + + await expect(messagePromise).resolves.toEqual( + expect.objectContaining({ + success: true, + agentSessionId: 'copilot-session-123', + response: expect.objectContaining({ + confidence: 91, + ready: true, + message: 'Ready to proceed', + }), + }) + ); + }); }); describe('activity-based timeout', () => { diff --git a/src/__tests__/shared/agentIds.test.ts b/src/__tests__/shared/agentIds.test.ts index 19bb21d461..d578494dab 100644 --- a/src/__tests__/shared/agentIds.test.ts +++ b/src/__tests__/shared/agentIds.test.ts @@ -27,6 +27,10 @@ describe('agentIds', () => { expect(AGENT_IDS).toContain('aider'); }); + it('should contain beta agents', () => { + expect(AGENT_IDS).toContain('copilot'); + }); + it('should have no duplicates', () => { const unique = new Set(AGENT_IDS); expect(unique.size).toBe(AGENT_IDS.length); diff --git a/src/__tests__/shared/agentMetadata.test.ts b/src/__tests__/shared/agentMetadata.test.ts index 0692cc336d..af0e4d4be4 100644 --- a/src/__tests__/shared/agentMetadata.test.ts +++ b/src/__tests__/shared/agentMetadata.test.ts @@ -30,6 +30,7 @@ describe('agentMetadata', () => { expect(AGENT_DISPLAY_NAMES['gemini-cli']).toBe('Gemini CLI'); expect(AGENT_DISPLAY_NAMES['qwen3-coder']).toBe('Qwen3 Coder'); expect(AGENT_DISPLAY_NAMES['aider']).toBe('Aider'); + expect(AGENT_DISPLAY_NAMES['copilot']).toBe('GitHub Copilot'); expect(AGENT_DISPLAY_NAMES['terminal']).toBe('Terminal'); }); @@ -77,6 +78,7 @@ describe('agentMetadata', () => { expect(BETA_AGENTS.has('codex')).toBe(true); expect(BETA_AGENTS.has('opencode')).toBe(true); expect(BETA_AGENTS.has('factory-droid')).toBe(true); + expect(BETA_AGENTS.has('copilot')).toBe(true); }); it('should not contain non-beta agents', () => { @@ -99,6 +101,7 @@ describe('agentMetadata', () => { expect(isBetaAgent('codex')).toBe(true); expect(isBetaAgent('opencode')).toBe(true); expect(isBetaAgent('factory-droid')).toBe(true); + expect(isBetaAgent('copilot')).toBe(true); }); it('should return false for non-beta agents', () => { diff --git a/src/main/agents/capabilities.ts b/src/main/agents/capabilities.ts index ca204f83d2..bad235a297 100644 --- a/src/main/agents/capabilities.ts +++ b/src/main/agents/capabilities.ts @@ -385,6 +385,39 @@ export const AGENT_CAPABILITIES: Record = { usesJsonLineOutput: false, // PLACEHOLDER usesCombinedContextWindow: false, // PLACEHOLDER }, + + /** + * GitHub Copilot CLI - AI coding assistant from GitHub + * https://github.com/github/copilot-cli + * + * Capabilities based on verified CLI help output (copilot --help). + * Conservative approach: only mark capabilities as true if explicitly verified. + */ + copilot: { + supportsResume: true, // --continue, --resume[=sessionId] + supportsReadOnlyMode: true, // Maestro enforces read-only via Copilot's CLI tool permission rules + supportsJsonOutput: true, // --output-format json (JSONL) + supportsSessionId: true, // result event includes sessionId + supportsImageInput: true, // Copilot supports @file/@image mentions; Maestro maps uploads to temp-file mentions + supportsImageInputOnResume: true, // Prompt-based @image mentions work for resumed sessions as well + supportsSlashCommands: true, // Interactive mode supports slash commands + supportsSessionStorage: true, // ~/.copilot/session-state// + supportsCostTracking: false, // Not verified + supportsUsageStats: true, // session.shutdown event includes modelMetrics with per-model token counts + supportsBatchMode: true, // -p, --prompt for batch mode + requiresPromptToStart: false, // Default interactive mode works without prompt, -i flag allows initial prompt + supportsStreaming: true, // Streams assistant/tool execution events as JSONL + supportsResultMessages: true, // assistant.message with phase=final_answer + supportsModelSelection: true, // --model + supportsStreamJsonInput: false, // Not verified + supportsThinkingDisplay: true, // assistant.reasoning events are rendered through Maestro's thinking-chunk pipeline + supportsContextMerge: true, // Can receive merged context via prompts + supportsContextExport: true, // Session storage supports context export + supportsWizard: true, // Wizard structured output works with Copilot JSON final_answer events + supportsGroupChatModeration: true, // Group chat moderation uses the standard batch-mode orchestration path + usesJsonLineOutput: true, // --output-format json produces JSONL + usesCombinedContextWindow: false, // Default Copilot model is Claude Sonnet; model-specific behavior varies + }, }; /** diff --git a/src/main/agents/definitions.ts b/src/main/agents/definitions.ts index 8ee7aee686..1192bef706 100644 --- a/src/main/agents/definitions.ts +++ b/src/main/agents/definitions.ts @@ -94,6 +94,7 @@ export interface AgentConfig { yoloModeArgs?: string[]; // Args for YOLO/full-access mode (e.g., ['--dangerously-bypass-approvals-and-sandbox']) workingDirArgs?: (dir: string) => string[]; // Function to build working directory args (e.g., ['-C', dir]) imageArgs?: (imagePath: string) => string[]; // Function to build image attachment args (e.g., ['-i', imagePath] for Codex) + imagePromptBuilder?: (imagePaths: string[]) => string; // Function to embed image references into the prompt (e.g., Copilot @mentions) promptArgs?: (prompt: string) => string[]; // Function to build prompt args (e.g., ['-p', prompt] for OpenCode) noPromptSeparator?: boolean; // If true, don't add '--' before the prompt in batch mode (OpenCode doesn't support it) defaultEnvVars?: Record; // Default environment variables for this agent (merged with user customEnvVars) @@ -401,6 +402,112 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [ command: 'aider', args: [], // Base args (placeholder - to be configured when implemented) }, + { + id: 'copilot', + name: 'GitHub Copilot', + binaryName: 'copilot', + command: 'copilot', + args: [], // Base args for interactive mode (default copilot) + requiresPty: true, // Interactive Copilot exits immediately when launched over plain pipes without a TTY + // GitHub Copilot CLI argument builders + // Interactive mode: copilot (default) + // Interactive with initial prompt: copilot -i "prompt" + // Batch mode: copilot -p "prompt" (or --prompt "prompt") + // Silent/non-interactive: -s, --silent + batchModePrefix: [], // No exec subcommand needed + batchModeArgs: ['--allow-all-tools', '--silent'], // Non-interactive mode requires tool auto-approval + jsonOutputArgs: ['--output-format', 'json'], // JSONL output + resumeArgs: (sessionId: string) => [`--resume=${sessionId}`], // Resume with session ID (--continue or --resume=sessionId) + readOnlyArgs: [ + '--allow-tool=read,url', + '--deny-tool=write,shell,memory,github', + '--no-ask-user', + ], // Enforce read-only by denying write/shell/memory/github actions at the Copilot CLI layer + readOnlyCliEnforced: true, // CLI-enforced via explicit tool permission rules + modelArgs: (modelId: string) => ['--model', modelId], // Model selection + yoloModeArgs: ['--allow-all-tools'], // Auto-approve all tools (--allow-all-tools or --allow-all) + imagePromptBuilder: (imagePaths: string[]) => + imagePaths.length > 0 + ? `Use these attached images as context:\n${imagePaths.map((imagePath) => `@${imagePath}`).join('\n')}\n\n` + : '', + promptArgs: (prompt: string) => ['-p', prompt], // Batch mode prompt arg + // Agent-specific configuration options + configOptions: [ + { + key: 'model', + type: 'text', + label: 'Model', + description: + 'Model to use (e.g., claude-sonnet-4.6, gpt-5.3-codex). Leave empty for default.', + default: '', // Empty = use Copilot's default model + argBuilder: (value: string) => { + if (value && value.trim()) { + return ['--model', value.trim()]; + } + return []; + }, + }, + { + key: 'contextWindow', + type: 'number', + label: 'Context Window Size', + description: + 'Maximum context window size in tokens. Required for context usage display. Varies by model.', + default: 200000, // Default for Claude/GPT-5 models + }, + { + key: 'reasoningEffort', + type: 'select', + label: 'Reasoning Effort', + description: 'Control how much deliberate reasoning Copilot uses before responding.', + options: ['', 'low', 'medium', 'high', 'xhigh'], + default: '', + argBuilder: (value: string) => + value && value.trim() ? ['--reasoning-effort', value.trim()] : [], + }, + { + key: 'autopilot', + type: 'checkbox', + label: 'Autopilot', + description: 'Allow Copilot to continue with follow-up turns automatically in prompt mode.', + default: false, + argBuilder: (value: boolean) => (value ? ['--autopilot'] : []), + }, + { + key: 'allowAllPaths', + type: 'checkbox', + label: 'Allow All Paths', + description: + 'Disable file path verification and allow access to any path without prompting.', + default: false, + argBuilder: (value: boolean) => (value ? ['--allow-all-paths'] : []), + }, + { + key: 'allowAllUrls', + type: 'checkbox', + label: 'Allow All URLs', + description: 'Allow network access to any URL without prompting.', + default: false, + argBuilder: (value: boolean) => (value ? ['--allow-all-urls'] : []), + }, + { + key: 'experimental', + type: 'checkbox', + label: 'Experimental Features', + description: 'Enable Copilot CLI experimental features for this agent.', + default: false, + argBuilder: (value: boolean) => (value ? ['--experimental'] : []), + }, + { + key: 'screenReader', + type: 'checkbox', + label: 'Screen Reader Mode', + description: 'Enable Copilot CLI screen reader optimizations.', + default: false, + argBuilder: (value: boolean) => (value ? ['--screen-reader'] : []), + }, + ], + }, ]; /** diff --git a/src/main/agents/path-prober.ts b/src/main/agents/path-prober.ts index 689dcf9bc2..ed288228ce 100644 --- a/src/main/agents/path-prober.ts +++ b/src/main/agents/path-prober.ts @@ -308,6 +308,25 @@ function getWindowsKnownPaths(binaryName: string): string[] { // npm (has known issues on Windows, but check anyway) ...npmGlobal('opencode'), ], + copilot: [ + // WinGet installation (primary method on Windows) + path.join(programFiles, 'GitHub Copilot CLI', 'copilot.exe'), + // npm global installation + ...npmGlobal('copilot'), + // Scoop installation + path.join(home, 'scoop', 'shims', 'copilot.exe'), + path.join(home, 'scoop', 'apps', 'copilot', 'current', 'copilot.exe'), + // Chocolatey installation + path.join( + process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', + 'bin', + 'copilot.exe' + ), + // Standalone installation + ...localBin('copilot'), + // Winget + ...wingetLinks('copilot'), + ], gemini: [ // npm global installation ...npmGlobal('gemini'), @@ -410,6 +429,19 @@ function getUnixKnownPaths(binaryName: string): string[] { // Node version managers (nvm, fnm, volta, etc.) ...nodeVersionManagers('opencode'), ], + copilot: [ + // Homebrew installation (primary method on macOS) + ...homebrew('copilot'), + // GitHub CLI installation + '/usr/local/bin/copilot', + path.join(home, '.local', 'bin', 'copilot'), + // npm global + ...npmGlobal('copilot'), + // User bin + path.join(home, 'bin', 'copilot'), + // Node version managers + ...nodeVersionManagers('copilot'), + ], gemini: [ // npm global paths ...npmGlobal('gemini'), diff --git a/src/main/ipc/handlers/agents.ts b/src/main/ipc/handlers/agents.ts index 0e8c5d56f8..afd50b31da 100644 --- a/src/main/ipc/handlers/agents.ts +++ b/src/main/ipc/handlers/agents.ts @@ -29,6 +29,35 @@ const handlerOpts = ( operation, }); +// Copilot CLI built-in slash commands (always available in interactive mode) +const COPILOT_BUILTIN_COMMANDS = [ + 'help', + 'clear', + 'compact', + 'context', + 'model', + 'usage', + 'session', + 'share', + 'mcp', + 'fleet', + 'tasks', + 'delegate', + 'review', +]; + +/** + * Discover GitHub Copilot CLI slash commands. + * + * Unlike Claude Code (which emits commands via its init JSON event), Copilot + * commands are interactive-only and cannot be discovered by spawning the CLI + * in batch mode. We return a static list of well-documented built-in commands. + */ +function discoverCopilotSlashCommands(): { name: string; description: string }[] { + logger.info(`Discovered ${COPILOT_BUILTIN_COMMANDS.length} Copilot slash commands`, LOG_CONTEXT); + return COPILOT_BUILTIN_COMMANDS.map((cmd) => ({ name: cmd, description: '' })); +} + /** * Discover OpenCode slash commands by reading from disk. * @@ -223,7 +252,7 @@ function getSshRemoteById( * Helper to strip non-serializable functions from agent configs. * Agent configs can have function properties that cannot be sent over IPC: * - argBuilder in configOptions - * - resumeArgs, modelArgs, workingDirArgs, imageArgs, promptArgs on the agent config + * - resumeArgs, modelArgs, workingDirArgs, imageArgs, imagePromptBuilder, promptArgs on the agent config */ function stripAgentFunctions(agent: any) { if (!agent) return null; @@ -234,6 +263,7 @@ function stripAgentFunctions(agent: any) { modelArgs: _modelArgs, workingDirArgs: _workingDirArgs, imageArgs: _imageArgs, + imagePromptBuilder: _imagePromptBuilder, promptArgs: _promptArgs, ...serializableAgent } = agent; @@ -1005,6 +1035,11 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void { return discoverOpenCodeSlashCommands(cwd); } + if (agentId === 'copilot') { + return discoverCopilotSlashCommands(); + } + + // Only Claude Code supports slash command discovery via init message if (agentId !== 'claude-code') { logger.debug(`Agent ${agentId} does not support slash command discovery`, LOG_CONTEXT); return null; diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts index a1d56f889f..033f2ca702 100644 --- a/src/main/ipc/handlers/process.ts +++ b/src/main/ipc/handlers/process.ts @@ -462,13 +462,21 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void // File-based image agents (Codex, OpenCode): pass images for remote temp file creation // Also needed for resume-with-prompt-embed (still creates temp files, just no -i args) images: - hasImages && agent?.imageArgs && !agent?.capabilities?.supportsStreamJsonInput + hasImages && + (agent?.imageArgs || agent?.imagePromptBuilder) && + !agent?.capabilities?.supportsStreamJsonInput ? config.images : undefined, imageArgs: hasImages && agent?.imageArgs && !agent?.capabilities?.supportsStreamJsonInput ? agent.imageArgs : undefined, + imagePromptBuilder: + hasImages && + agent?.imagePromptBuilder && + !agent?.capabilities?.supportsStreamJsonInput + ? agent.imagePromptBuilder + : undefined, // Signal resume mode for prompt embedding instead of -i CLI args imageResumeMode: isResumeWithImages ? 'prompt-embed' : undefined, }); @@ -532,6 +540,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void // When using SSH, env vars are passed in the stdin script, not locally customEnvVars: customEnvVarsToPass, imageArgs: agent?.imageArgs, // Function to build image CLI args (for Codex, OpenCode) + imagePromptBuilder: agent?.imagePromptBuilder, // Function to embed image refs into prompts (for Copilot) promptArgs: agent?.promptArgs, // Function to build prompt args (e.g., ['-p', prompt] for OpenCode) noPromptSeparator: agent?.noPromptSeparator, // Some agents don't support '--' before prompt // Stats tracking: use cwd as projectPath if not explicitly provided diff --git a/src/main/parsers/agent-output-parser.ts b/src/main/parsers/agent-output-parser.ts index 0e5afa5584..d939ff65a1 100644 --- a/src/main/parsers/agent-output-parser.ts +++ b/src/main/parsers/agent-output-parser.ts @@ -74,6 +74,11 @@ export interface ParsedEvent { */ toolName?: string; + /** + * Tool call identifier (for agents that provide stable tool call IDs) + */ + toolCallId?: string; + /** * Tool execution state (for 'tool_use' type) * Format varies by agent, preserved for UI rendering @@ -110,6 +115,14 @@ export interface ParsedEvent { */ isPartial?: boolean; + /** + * Is this reasoning/thinking content? + * If true, this is internal agent reasoning that should not be included + * in the final response text (streamedText). Used by agents like Copilot + * where reasoning and answer deltas share the same event type. + */ + isReasoning?: boolean; + /** * Tool use blocks extracted from the message (for agents with mixed content) * When a message contains both text and tool_use, text goes in 'text' field diff --git a/src/main/parsers/copilot-output-parser.ts b/src/main/parsers/copilot-output-parser.ts new file mode 100644 index 0000000000..609150a494 --- /dev/null +++ b/src/main/parsers/copilot-output-parser.ts @@ -0,0 +1,524 @@ +/** + * GitHub Copilot CLI Output Parser + * + * Parses structured output from `copilot --output-format json`. + * + * Verified locally against Copilot CLI 1.0.5 output. The CLI emits JSON + * events, and the live stdout stream may concatenate multiple objects in a + * single chunk without newline separators. The events include: + * - session.tools_updated + * - user.message + * - assistant.turn_start / assistant.turn_end + * - assistant.message + * - assistant.reasoning_delta + * - assistant.reasoning + * - tool.execution_start / tool.execution_complete + * - result + */ + +import type { ToolType, AgentError } from '../../shared/types'; +import type { AgentOutputParser, ParsedEvent } from './agent-output-parser'; +import { getErrorPatterns, matchErrorPattern } from './error-patterns'; + +interface CopilotToolRequest { + toolCallId?: string; + name?: string; + arguments?: unknown; +} + +interface CopilotToolExecutionResult { + content?: string; + detailedContent?: string; +} + +interface CopilotRawMessage { + type?: string; + id?: string; + timestamp?: string; + sessionId?: string; + exitCode?: number; + data?: { + sessionId?: string; + content?: string; + deltaContent?: string; + phase?: string; + toolRequests?: CopilotToolRequest[]; + toolCallId?: string; + toolName?: string; + arguments?: unknown; + success?: boolean; + result?: CopilotToolExecutionResult; + error?: string; + message?: string; + /** Per-model token metrics emitted in session.shutdown events */ + modelMetrics?: Record< + string, + { + usage?: { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + }; + } + >; + }; + error?: string | { message?: string }; +} + +/** Extract non-empty text from strings or simple string arrays. */ +function extractTextValue(value: unknown): string { + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value)) { + return value.filter((part): part is string => typeof part === 'string').join(''); + } + return ''; +} + +/** Extract a human-readable error message from a string or { message } object. */ +function extractErrorText(value: unknown): string | null { + if (!value) return null; + if (typeof value === 'string' && value.trim()) return value.trim(); + if (typeof value === 'object' && value !== null) { + const message = (value as { message?: string }).message; + if (typeof message === 'string' && message.trim()) { + return message.trim(); + } + } + return null; +} + +/** Extract tool output text from a Copilot tool execution result. */ +function extractToolOutput(result: CopilotToolExecutionResult | undefined): string { + if (!result) return ''; + return result.content || result.detailedContent || ''; +} + +/** + * Parses GitHub Copilot CLI JSON output into normalized ParsedEvents. + * + * Handles concatenated JSON objects (no newline separators), tracks tool + * names across execution_start/complete events, and detects agent errors + * from structured error events and non-zero exit codes. + */ +export class CopilotOutputParser implements AgentOutputParser { + readonly agentId: ToolType = 'copilot'; + + private toolNames = new Map(); + /** Tracks whether message deltas were received in the current turn. */ + private turnHadMessageDeltas = false; + /** Tracks whether reasoning deltas were received in the current turn. */ + private turnHadReasoningDeltas = false; + + /** Parse a single JSON line from Copilot's JSONL output stream. */ + parseJsonLine(line: string): ParsedEvent | null { + if (!line.trim()) { + return null; + } + + try { + return this.parseJsonObject(JSON.parse(line)); + } catch { + return { + type: 'text', + text: line, + raw: line, + }; + } + } + + /** Parse an already-deserialized JSON object into a normalized ParsedEvent. */ + parseJsonObject(parsed: unknown): ParsedEvent | null { + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const msg = parsed as CopilotRawMessage; + + switch (msg.type) { + case 'assistant.message': + return this.parseAssistantMessage(msg); + case 'assistant.message_delta': + return this.parseAssistantMessageDelta(msg); + case 'assistant.reasoning_delta': + case 'assistant.reasoning': + return this.parseAssistantReasoning(msg); + case 'assistant.turn_start': + this.turnHadMessageDeltas = false; + this.turnHadReasoningDeltas = false; + return { + type: 'system', + raw: msg, + }; + case 'assistant.turn_end': + case 'session.tools_updated': + case 'user.message': + return { + type: 'system', + raw: msg, + }; + case 'session.start': + return { + type: 'init', + sessionId: msg.data?.sessionId, + raw: msg, + }; + case 'session.shutdown': + return this.parseSessionShutdown(msg); + case 'tool.execution_start': + return this.parseToolExecutionStart(msg); + case 'tool.execution_complete': + return this.parseToolExecutionComplete(msg); + case 'result': + return { + type: 'result', + sessionId: msg.sessionId, + raw: msg, + }; + case 'error': + return { + type: 'error', + text: + extractErrorText(msg.error || msg.data?.error || msg.data?.message) || 'Unknown error', + raw: msg, + }; + default: + return { + type: 'system', + raw: msg, + }; + } + } + + /** Parse assistant.message events, detecting final_answer phase as result events. + * Non-final messages repeat content already streamed via assistant.message_delta + * events — when deltas were received, the summary is skipped to avoid + * double-accumulation. When no deltas preceded it, the content is used. */ + private parseAssistantMessage(msg: CopilotRawMessage): ParsedEvent { + const content = msg.data?.content || ''; + const phase = msg.data?.phase; + const toolRequests = msg.data?.toolRequests || []; + + const toolUseBlocks = toolRequests + .filter( + (tool): tool is Required> & CopilotToolRequest => + !!tool.name + ) + .map((tool) => { + if (tool.toolCallId && tool.name) { + this.toolNames.set(tool.toolCallId, tool.name); + } + return { + name: tool.name, + id: tool.toolCallId, + input: tool.arguments, + }; + }); + + if (phase === 'final_answer') { + return { + type: 'result', + text: content, + toolUseBlocks: toolUseBlocks.length > 0 ? toolUseBlocks : undefined, + raw: msg, + }; + } + + // Non-final message with tool requests — forward tool blocks only + if (toolUseBlocks.length > 0) { + return { + type: 'text', + text: '', + toolUseBlocks, + raw: msg, + }; + } + + // If deltas already streamed this content, skip the summary to avoid duplication + if (this.turnHadMessageDeltas) { + return { + type: 'system', + raw: msg, + }; + } + + // No deltas preceded this message — use its content directly + if (content) { + return { + type: 'text', + text: content, + isPartial: true, + raw: msg, + }; + } + + return { + type: 'system', + raw: msg, + }; + } + + /** Parse assistant.message_delta events as partial streaming text. */ + private parseAssistantMessageDelta(msg: CopilotRawMessage): ParsedEvent | null { + const deltaContent = msg.data?.deltaContent || ''; + if (!deltaContent) { + return null; + } + + this.turnHadMessageDeltas = true; + return { + type: 'text', + text: deltaContent, + isPartial: true, + raw: msg, + }; + } + + /** Parse assistant.reasoning and assistant.reasoning_delta events. + * Deltas are forwarded as partial text with isReasoning=true so + * StdoutHandler can display them in thinking UI without accumulating + * them into the final response. The summary (assistant.reasoning) + * repeats content already streamed via deltas — when deltas were received, + * the summary is skipped to avoid double-accumulation. */ + private parseAssistantReasoning(msg: CopilotRawMessage): ParsedEvent | null { + const deltaContent = extractTextValue(msg.data?.deltaContent); + + if (deltaContent) { + this.turnHadReasoningDeltas = true; + return { + type: 'text', + text: deltaContent, + isPartial: true, + isReasoning: true, + raw: msg, + }; + } + + // Summary event (assistant.reasoning with content only, no deltaContent). + // Skip if deltas already delivered this content. + if (this.turnHadReasoningDeltas) { + return null; + } + + // No deltas preceded — use the content directly + const content = extractTextValue(msg.data?.content); + if (content) { + return { + type: 'text', + text: content, + isPartial: true, + isReasoning: true, + raw: msg, + }; + } + + return null; + } + + /** Parse tool.execution_start and register the tool name for later correlation. */ + private parseToolExecutionStart(msg: CopilotRawMessage): ParsedEvent { + const callId = msg.data?.toolCallId; + const toolName = msg.data?.toolName; + if (callId && toolName) { + this.toolNames.set(callId, toolName); + } + + return { + type: 'tool_use', + toolName, + toolCallId: callId, + toolState: { + status: 'running', + input: msg.data?.arguments, + }, + raw: msg, + }; + } + + /** Parse tool.execution_complete, resolving tool name from the tracked map. */ + private parseToolExecutionComplete(msg: CopilotRawMessage): ParsedEvent { + const callId = msg.data?.toolCallId; + const toolName = (callId && this.toolNames.get(callId)) || msg.data?.toolName || undefined; + const success = msg.data?.success !== false; + const toolOutput = extractToolOutput(msg.data?.result); + const errorOutput = extractErrorText(msg.data?.error); + + if (callId) { + this.toolNames.delete(callId); + } + + return { + type: 'tool_use', + toolName, + toolCallId: callId, + toolState: { + status: success ? 'completed' : 'failed', + output: toolOutput || (!success ? errorOutput || '' : ''), + }, + raw: msg, + }; + } + + /** Parse session.shutdown events, extracting aggregate token usage from modelMetrics. */ + private parseSessionShutdown(msg: CopilotRawMessage): ParsedEvent { + const modelMetrics = msg.data?.modelMetrics; + if (!modelMetrics) { + return { type: 'system', raw: msg }; + } + + let inputTokens = 0; + let outputTokens = 0; + let cacheReadTokens = 0; + let cacheCreationTokens = 0; + + for (const metric of Object.values(modelMetrics)) { + inputTokens += metric.usage?.inputTokens || 0; + outputTokens += metric.usage?.outputTokens || 0; + cacheReadTokens += metric.usage?.cacheReadTokens || 0; + cacheCreationTokens += metric.usage?.cacheWriteTokens || 0; + } + + if (inputTokens === 0 && outputTokens === 0) { + return { type: 'system', raw: msg }; + } + + return { + type: 'usage', + usage: { + inputTokens, + outputTokens, + cacheReadTokens, + cacheCreationTokens, + }, + raw: msg, + }; + } + + /** Check whether a parsed event represents a completed agent response. */ + isResultMessage(event: ParsedEvent): boolean { + if (event.type !== 'result') return false; + + // Treat any final_answer event as a result, including empty ones (tool-only responses) + const raw = event.raw as CopilotRawMessage | undefined; + if (raw?.data?.phase === 'final_answer') return true; + + // The session-end "result" event from Copilot has no text but signals completion. + // Recognizing it sets resultEmitted, preventing the ExitHandler from re-emitting + // content that was already streamed via thinking-chunk events. + if (raw?.type === 'result') return true; + + return !!event.text || !!event.toolUseBlocks?.length; + } + + /** Extract the Copilot session ID from a parsed event, if present. */ + extractSessionId(event: ParsedEvent): string | null { + if (event.sessionId) return event.sessionId; + + const raw = event.raw as CopilotRawMessage | undefined; + return raw?.sessionId || raw?.data?.sessionId || null; + } + + /** Extract usage/token statistics from a parsed event. */ + extractUsage(event: ParsedEvent): ParsedEvent['usage'] | null { + return event.usage || null; + } + + /** Extract slash commands from events. Returns null — Copilot slash commands are interactive-only. */ + extractSlashCommands(_event: ParsedEvent): string[] | null { + return null; + } + + /** Detect agent errors from a raw JSON line string. */ + detectErrorFromLine(line: string): AgentError | null { + if (!line.trim()) { + return null; + } + + try { + const error = this.detectErrorFromParsed(JSON.parse(line)); + if (error) { + error.raw = { ...(error.raw as Record), errorLine: line }; + } + return error; + } catch { + return null; + } + } + + /** Detect agent errors from an already-parsed JSON object. Skips bare exit codes to allow detectErrorFromExit to classify with full context. */ + detectErrorFromParsed(parsed: unknown): AgentError | null { + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const msg = parsed as CopilotRawMessage; + if (msg.type === 'tool.execution_complete') { + return null; + } + + const errorText = extractErrorText(msg.error) || extractErrorText(msg.data?.error); + + // Do NOT synthesize an error for bare non-zero exit codes. + // Returning null here lets detectErrorFromExit() run with full + // stderr+stdout context for richer error classification. + if (!errorText) { + return null; + } + + const patterns = getErrorPatterns(this.agentId); + const match = matchErrorPattern(patterns, errorText); + + if (match) { + return { + type: match.type, + message: match.message, + recoverable: match.recoverable, + agentId: this.agentId, + timestamp: Date.now(), + parsedJson: parsed, + }; + } + + return { + type: 'unknown', + message: errorText, + recoverable: true, + agentId: this.agentId, + timestamp: Date.now(), + parsedJson: parsed, + }; + } + + /** Detect agent errors from process exit code and stderr/stdout content. */ + detectErrorFromExit(exitCode: number, stderr: string, stdout: string): AgentError | null { + if (exitCode === 0) { + return null; + } + + const combined = `${stderr}\n${stdout}`; + const patterns = getErrorPatterns(this.agentId); + const match = matchErrorPattern(patterns, combined); + + if (match) { + return { + type: match.type, + message: match.message, + recoverable: match.recoverable, + agentId: this.agentId, + timestamp: Date.now(), + raw: { exitCode, stderr, stdout }, + }; + } + + return { + type: 'agent_crashed', + message: `Agent exited with code ${exitCode}`, + recoverable: true, + agentId: this.agentId, + timestamp: Date.now(), + raw: { exitCode, stderr, stdout }, + }; + } +} diff --git a/src/main/parsers/error-patterns.ts b/src/main/parsers/error-patterns.ts index b027697dae..2f3a0d2679 100644 --- a/src/main/parsers/error-patterns.ts +++ b/src/main/parsers/error-patterns.ts @@ -855,6 +855,134 @@ export const SSH_ERROR_PATTERNS: AgentErrorPatterns = { ], }; +// ============================================================================ +// GitHub Copilot CLI Error Patterns +// ============================================================================ + +const COPILOT_ERROR_PATTERNS: AgentErrorPatterns = { + auth_expired: [ + { + pattern: /authentication failed/i, + message: 'Authentication failed. Please run "gh auth login" to re-authenticate.', + recoverable: true, + }, + { + pattern: /not authenticated/i, + message: 'Not authenticated. Please run "gh auth login" to authenticate.', + recoverable: true, + }, + { + pattern: /unauthorized/i, + message: 'Unauthorized. Please check your GitHub authentication.', + recoverable: true, + }, + { + pattern: /invalid.*token/i, + message: 'Invalid GitHub token. Please re-authenticate with "gh auth login".', + recoverable: true, + }, + ], + + rate_limited: [ + { + pattern: /rate limit exceeded/i, + message: 'GitHub API rate limit exceeded. Please wait and try again.', + recoverable: true, + }, + { + pattern: /quota.*exceeded/i, + message: 'API quota exceeded. Resume when quota resets.', + recoverable: true, + }, + ], + + network_error: [ + { + pattern: /connection failed/i, + message: 'Connection failed. Check your internet connection.', + recoverable: true, + }, + { + pattern: /network error/i, + message: 'Network error. Please check your connection.', + recoverable: true, + }, + { + pattern: /timeout/i, + message: 'Request timed out. Please try again.', + recoverable: true, + }, + ], + + permission_denied: [ + { + pattern: /permission denied/i, + message: 'Permission denied. Check file and directory permissions.', + recoverable: false, + }, + ], + + session_not_found: [ + { + pattern: /session.*not found/i, + message: 'Session not found. Starting fresh conversation.', + recoverable: true, + }, + ], + + token_exhaustion: [ + { + pattern: /prompt.*too\s+long/i, + message: 'Prompt is too long. Try a shorter message or start a new session.', + recoverable: true, + }, + { + pattern: /context.*exceeded/i, + message: 'Context limit exceeded. Start a new session.', + recoverable: true, + }, + { + pattern: /context.*too long/i, + message: 'The conversation has exceeded the context limit. Start a new session.', + recoverable: true, + }, + { + pattern: /maximum.*tokens/i, + message: 'Maximum token limit reached. Start a new session to continue.', + recoverable: true, + }, + { + pattern: /context window/i, + message: 'Context window exceeded. Please start a new session.', + recoverable: true, + }, + { + pattern: /token limit/i, + message: 'Token limit reached. Consider starting a fresh conversation.', + recoverable: true, + }, + { + pattern: /input.*too large/i, + message: 'Input is too large for the context window.', + recoverable: true, + }, + ], + + agent_crashed: [ + { + pattern: /no prompt provided.*interactive terminal/i, + message: + 'GitHub Copilot was launched without a terminal. Interactive Copilot sessions require PTY mode.', + recoverable: true, + }, + { + pattern: /unexpected error/i, + message: 'An unexpected error occurred in the agent.', + recoverable: true, + }, + ], +}; + // ============================================================================ // Pattern Registry // ============================================================================ @@ -864,6 +992,7 @@ const patternRegistry = new Map([ ['opencode', OPENCODE_ERROR_PATTERNS], ['codex', CODEX_ERROR_PATTERNS], ['factory-droid', FACTORY_DROID_ERROR_PATTERNS], + ['copilot', COPILOT_ERROR_PATTERNS], ]); /** diff --git a/src/main/parsers/index.ts b/src/main/parsers/index.ts index d2f55e477f..5d6458972d 100644 --- a/src/main/parsers/index.ts +++ b/src/main/parsers/index.ts @@ -37,6 +37,9 @@ export { clearParserRegistry, } from './agent-output-parser'; +// Re-export factory function +export { createOutputParser } from './parser-factory'; + // Re-export error pattern utilities (access patterns via getErrorPatterns(agentId)) export type { ErrorPattern, AgentErrorPatterns } from './error-patterns'; export { @@ -54,6 +57,7 @@ import { ClaudeOutputParser } from './claude-output-parser'; import { OpenCodeOutputParser } from './opencode-output-parser'; import { CodexOutputParser } from './codex-output-parser'; import { FactoryDroidOutputParser } from './factory-droid-output-parser'; +import { CopilotOutputParser } from './copilot-output-parser'; import { registerOutputParser, clearParserRegistry, @@ -66,6 +70,7 @@ export { ClaudeOutputParser } from './claude-output-parser'; export { OpenCodeOutputParser } from './opencode-output-parser'; export { CodexOutputParser } from './codex-output-parser'; export { FactoryDroidOutputParser } from './factory-droid-output-parser'; +export { CopilotOutputParser } from './copilot-output-parser'; const LOG_CONTEXT = '[OutputParsers]'; @@ -82,6 +87,7 @@ export function initializeOutputParsers(): void { registerOutputParser(new OpenCodeOutputParser()); registerOutputParser(new CodexOutputParser()); registerOutputParser(new FactoryDroidOutputParser()); + registerOutputParser(new CopilotOutputParser()); // Log registered parsers for debugging const registeredParsers = getAllOutputParsers().map((p) => p.agentId); diff --git a/src/main/parsers/parser-factory.ts b/src/main/parsers/parser-factory.ts new file mode 100644 index 0000000000..0139c96770 --- /dev/null +++ b/src/main/parsers/parser-factory.ts @@ -0,0 +1,37 @@ +/** + * Output Parser Factory + * + * Creates fresh parser instances per-process to avoid shared mutable state. + * Parsers like CopilotOutputParser and CodexOutputParser track tool names + * on the instance; sharing a singleton across concurrent sessions causes + * cross-session state leakage. + * + * Use createOutputParser() when assigning a parser to a ManagedProcess. + * Use getOutputParser() only for capability checks or read-only queries. + */ + +import type { ToolType } from '../../shared/types'; +import type { AgentOutputParser } from './agent-output-parser'; +import { ClaudeOutputParser } from './claude-output-parser'; +import { OpenCodeOutputParser } from './opencode-output-parser'; +import { CodexOutputParser } from './codex-output-parser'; +import { FactoryDroidOutputParser } from './factory-droid-output-parser'; +import { CopilotOutputParser } from './copilot-output-parser'; + +const PARSER_CONSTRUCTORS: Record AgentOutputParser> = { + 'claude-code': () => new ClaudeOutputParser(), + opencode: () => new OpenCodeOutputParser(), + codex: () => new CodexOutputParser(), + 'factory-droid': () => new FactoryDroidOutputParser(), + copilot: () => new CopilotOutputParser(), +}; + +/** + * Create a fresh output parser instance for a given agent type. + * Each call returns a new instance so per-process mutable state + * (e.g., tool name tracking) is session-isolated. + */ +export function createOutputParser(agentId: ToolType | string): AgentOutputParser | null { + const factory = PARSER_CONSTRUCTORS[agentId]; + return factory ? factory() : null; +} diff --git a/src/main/preload/agents.ts b/src/main/preload/agents.ts index 5d9eb1a2a1..dd4cfc82e3 100644 --- a/src/main/preload/agents.ts +++ b/src/main/preload/agents.ts @@ -31,6 +31,14 @@ export interface AgentCapabilities { supportsResultMessages: boolean; supportsModelSelection: boolean; supportsStreamJsonInput: boolean; + supportsThinkingDisplay: boolean; + supportsContextMerge: boolean; + supportsContextExport: boolean; + supportsWizard: boolean; + supportsGroupChatModeration: boolean; + usesJsonLineOutput: boolean; + usesCombinedContextWindow: boolean; + imageResumeMode?: 'prompt-embed'; } /** @@ -173,14 +181,14 @@ export function createAgentsApi() { ipcRenderer.invoke('agents:getModels', agentId, forceRefresh, sshRemoteId), /** - * Discover available slash commands for an agent. - * Returns objects with name and optional prompt for all agents. + * Discover available slash commands for an agent by spawning it briefly + * Returns array of command names (e.g., ['compact', 'help', 'my-custom-command']) */ discoverSlashCommands: ( agentId: string, cwd: string, customPath?: string - ): Promise<{ name: string; prompt?: string }[] | null> => + ): Promise => ipcRenderer.invoke('agents:discoverSlashCommands', agentId, cwd, customPath), }; } diff --git a/src/main/process-manager/handlers/StdoutHandler.ts b/src/main/process-manager/handlers/StdoutHandler.ts index 3b90331245..2d2dd560ca 100644 --- a/src/main/process-manager/handlers/StdoutHandler.ts +++ b/src/main/process-manager/handlers/StdoutHandler.ts @@ -16,6 +16,8 @@ interface StdoutHandlerDependencies { bufferManager: DataBufferManager; } +const MAX_COPILOT_JSON_BUFFER_LENGTH = 1024 * 1024; + /** * Normalize usage stats to handle cumulative vs per-turn usage reporting. * @@ -97,6 +99,142 @@ function normalizeUsageToDelta( }; } +/** Split a buffer of concatenated JSON objects (no newline separators) into individual complete objects and a partial remainder. */ +function extractConcatenatedJsonObjects(buffer: string): { messages: string[]; remainder: string } { + const messages: string[] = []; + let start = -1; + let depth = 0; + let inString = false; + let isEscaped = false; + + for (let i = 0; i < buffer.length; i++) { + const char = buffer[i]; + + if (start === -1) { + if (/\s/.test(char)) { + continue; + } + + if (char !== '{') { + return { + messages, + remainder: buffer.slice(i), + }; + } + + start = i; + depth = 1; + inString = false; + isEscaped = false; + continue; + } + + if (inString) { + if (isEscaped) { + isEscaped = false; + continue; + } + + if (char === '\\') { + isEscaped = true; + continue; + } + + if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === '{') { + depth++; + continue; + } + + if (char === '}') { + depth--; + if (depth === 0) { + messages.push(buffer.slice(start, i + 1)); + start = -1; + } + } + } + + return { + messages, + remainder: start === -1 ? '' : buffer.slice(start), + }; +} + +/** Extract the Copilot session ID from a parsed JSON event's top-level or nested data field. */ +function extractCopilotSessionId(parsed: unknown): string | null { + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const raw = parsed as { + sessionId?: unknown; + data?: { + sessionId?: unknown; + }; + }; + + if (typeof raw.sessionId === 'string' && raw.sessionId.trim()) { + return raw.sessionId; + } + + if (typeof raw.data?.sessionId === 'string' && raw.data.sessionId.trim()) { + return raw.data.sessionId; + } + + return null; +} + +/** Extract the status string from a tool execution state object. */ +function getToolStatus(toolState: unknown): string | null { + if (!toolState || typeof toolState !== 'object') { + return null; + } + + const status = (toolState as { status?: unknown }).status; + return typeof status === 'string' ? status : null; +} + +/** Get or lazily initialize the per-process set of emitted tool call IDs for deduplication. */ +function getEmittedToolCallIds(managedProcess: ManagedProcess): Set { + if (!managedProcess.emittedToolCallIds) { + managedProcess.emittedToolCallIds = new Set(); + } + return managedProcess.emittedToolCallIds; +} + +/** Drop the Copilot JSON remainder buffer if it exceeds the safety limit. Sets the corrupted flag and clears stale tool state. */ +function resetOversizedCopilotJsonBuffer(sessionId: string, managedProcess: ManagedProcess): void { + const bufferLength = managedProcess.jsonBuffer?.length || 0; + if (bufferLength <= MAX_COPILOT_JSON_BUFFER_LENGTH) { + return; + } + + logger.warn( + '[ProcessManager] Dropping oversized Copilot JSON buffer remainder', + 'ProcessManager', + { + sessionId, + bufferLength, + maxBufferLength: MAX_COPILOT_JSON_BUFFER_LENGTH, + } + ); + managedProcess.jsonBuffer = ''; + // Mark corrupted so subsequent chunks discard until a clean resync point + managedProcess.jsonBufferCorrupted = true; + managedProcess.emittedToolCallIds?.clear(); +} + /** * Handles stdout data processing for child processes. * Extracts session IDs, usage stats, and result data from agent output. @@ -119,9 +257,8 @@ export class StdoutHandler { const managedProcess = this.processes.get(sessionId); if (!managedProcess) return; - // Strip ANSI/control sequences before parsing. Applied unconditionally — - // control codes are never useful on the child-process stdout path regardless - // of transport (local or SSH). + // SSH-launched agent CLIs can leak terminal mode switches like ESC[?1h ESC= + // before their real output. Strip non-printing control bytes before parsing. const cleanedOutput = stripAllAnsiCodes(output); if (!cleanedOutput) return; @@ -140,6 +277,7 @@ export class StdoutHandler { } } + /** Process stdout data in stream-JSON mode. Handles Copilot concatenated JSON and standard newline-delimited JSON. */ private handleStreamJsonData( sessionId: string, managedProcess: ManagedProcess, @@ -147,6 +285,58 @@ export class StdoutHandler { ): void { managedProcess.jsonBuffer = (managedProcess.jsonBuffer || '') + output; + if (managedProcess.toolType === 'copilot') { + // If a previous buffer overflow corrupted state, discard data until + // we find a top-level '{' that starts a fresh JSON object. + if (managedProcess.jsonBufferCorrupted) { + const resyncIndex = managedProcess.jsonBuffer.indexOf('{'); + if (resyncIndex === -1) { + managedProcess.jsonBuffer = ''; + return; + } + managedProcess.jsonBuffer = managedProcess.jsonBuffer.slice(resyncIndex); + managedProcess.jsonBufferCorrupted = false; + managedProcess.emittedToolCallIds?.clear(); + } + + const firstNonWhitespaceIndex = managedProcess.jsonBuffer.search(/\S/); + if ( + firstNonWhitespaceIndex >= 0 && + managedProcess.jsonBuffer[firstNonWhitespaceIndex] !== '{' + ) { + const firstJsonStart = managedProcess.jsonBuffer.indexOf('{', firstNonWhitespaceIndex); + if (firstJsonStart === -1) { + const plainText = managedProcess.jsonBuffer.trim(); + if (plainText) { + this.bufferManager.emitDataBuffered(sessionId, plainText); + } + managedProcess.jsonBuffer = ''; + return; + } + + if (firstJsonStart > firstNonWhitespaceIndex) { + const prefix = managedProcess.jsonBuffer.slice(0, firstJsonStart).trim(); + if (prefix) { + this.bufferManager.emitDataBuffered(sessionId, prefix); + } + managedProcess.jsonBuffer = managedProcess.jsonBuffer.slice(firstJsonStart); + } + } + + const { messages, remainder } = extractConcatenatedJsonObjects(managedProcess.jsonBuffer); + managedProcess.jsonBuffer = remainder; + resetOversizedCopilotJsonBuffer(sessionId, managedProcess); + + for (const message of messages) { + managedProcess.stdoutBuffer = appendToBuffer( + managedProcess.stdoutBuffer || '', + message + '\n' + ); + this.processLine(sessionId, managedProcess, message); + } + return; + } + const lines = managedProcess.jsonBuffer.split('\n'); managedProcess.jsonBuffer = lines.pop() || ''; @@ -159,6 +349,7 @@ export class StdoutHandler { } } + /** Parse a single JSON line: detect errors, extract session IDs, and dispatch to the event handler. */ private processLine(sessionId: string, managedProcess: ManagedProcess, line: string): void { const { outputParser, toolType } = managedProcess; @@ -172,6 +363,10 @@ export class StdoutHandler { // Not valid JSON — handled in the else branch below } + if (parsed !== null && toolType === 'copilot') { + this.emitSessionIdIfNeeded(sessionId, managedProcess, extractCopilotSessionId(parsed)); + } + // ── Error detection from parser ── if (outputParser && !managedProcess.errorEmitted) { // Use pre-parsed object when available; fall back to line-based detection @@ -235,6 +430,7 @@ export class StdoutHandler { } } + /** Handle a parsed JSON event: extract usage, session IDs, tool executions, and result data. */ private handleParsedEvent( sessionId: string, managedProcess: ManagedProcess, @@ -299,15 +495,7 @@ export class StdoutHandler { // Extract session ID const eventSessionId = outputParser.extractSessionId(event); - if (eventSessionId && !managedProcess.sessionIdEmitted) { - managedProcess.sessionIdEmitted = true; - logger.debug('[ProcessManager] Emitting session-id event', 'ProcessManager', { - sessionId, - eventSessionId, - toolType: managedProcess.toolType, - }); - this.emitter.emit('session-id', sessionId, eventSessionId); - } + this.emitSessionIdIfNeeded(sessionId, managedProcess, eventSessionId); // Extract slash commands const slashCommands = outputParser.extractSlashCommands(event); @@ -333,12 +521,32 @@ export class StdoutHandler { sessionId, textLength: event.text.length, }); - this.emitter.emit('thinking-chunk', sessionId, event.text); - managedProcess.streamedText = (managedProcess.streamedText || '') + event.text; + // For Copilot, skip thinking-chunk emission — the parser's delta events + // accumulate in streamedText which is emitted once as the result at exit. + // Emitting thinking-chunks AND result would duplicate the content. + if (managedProcess.toolType !== 'copilot') { + this.emitter.emit('thinking-chunk', sessionId, event.text); + } + // Reasoning content is internal thinking — don't include it in the + // final response text. Only message content should be in streamedText. + if (!event.isReasoning) { + managedProcess.streamedText = (managedProcess.streamedText || '') + event.text; + } } // Handle tool execution events (OpenCode, Codex) if (event.type === 'tool_use' && event.toolName) { + const toolStatus = getToolStatus(event.toolState); + if (event.toolCallId && toolStatus === 'running') { + const emittedToolCallIds = getEmittedToolCallIds(managedProcess); + if (emittedToolCallIds.has(event.toolCallId)) { + return; + } + emittedToolCallIds.add(event.toolCallId); + } else if (event.toolCallId && (toolStatus === 'completed' || toolStatus === 'failed')) { + getEmittedToolCallIds(managedProcess).delete(event.toolCallId); + } + this.emitter.emit('tool-execution', sessionId, { toolName: event.toolName, state: event.toolState, @@ -349,6 +557,14 @@ export class StdoutHandler { // Handle tool_use blocks embedded in text events (Claude Code mixed content) if (event.toolUseBlocks?.length) { for (const tool of event.toolUseBlocks) { + if (tool.id) { + const emittedToolCallIds = getEmittedToolCallIds(managedProcess); + if (emittedToolCallIds.has(tool.id)) { + continue; + } + emittedToolCallIds.add(tool.id); + } + this.emitter.emit('tool-execution', sessionId, { toolName: tool.name, state: { status: 'running', input: tool.input }, @@ -398,6 +614,9 @@ export class StdoutHandler { !managedProcess.resultEmitted ) { managedProcess.resultEmitted = true; + // For most agents, prefer the result event's text. Fall back to + // accumulated streamedText (covers Copilot where the result event + // is empty and Factory Droid which never sends an explicit result). const resultText = event.text || managedProcess.streamedText || ''; // Log synopsis result processing (for debugging empty synopsis issue) @@ -433,6 +652,7 @@ export class StdoutHandler { } } + /** Handle legacy (non-parser) JSON messages for Claude Code's native format. */ private handleLegacyMessage( sessionId: string, managedProcess: ManagedProcess, @@ -488,6 +708,7 @@ export class StdoutHandler { } } + /** Build a normalized UsageStats object from parser-extracted token counts. */ private buildUsageStats( managedProcess: ManagedProcess, usage: { @@ -512,4 +733,23 @@ export class StdoutHandler { reasoningTokens: usage.reasoningTokens, }; } + + /** Emit session-id event at most once per managed process lifecycle. */ + private emitSessionIdIfNeeded( + sessionId: string, + managedProcess: ManagedProcess, + eventSessionId: string | null | undefined + ): void { + if (!eventSessionId || managedProcess.sessionIdEmitted) { + return; + } + + managedProcess.sessionIdEmitted = true; + logger.debug('[ProcessManager] Emitting session-id event', 'ProcessManager', { + sessionId, + eventSessionId, + toolType: managedProcess.toolType, + }); + this.emitter.emit('session-id', sessionId, eventSessionId); + } } diff --git a/src/main/process-manager/spawners/ChildProcessSpawner.ts b/src/main/process-manager/spawners/ChildProcessSpawner.ts index b66f9c7531..3d25b1cac2 100644 --- a/src/main/process-manager/spawners/ChildProcessSpawner.ts +++ b/src/main/process-manager/spawners/ChildProcessSpawner.ts @@ -5,7 +5,7 @@ import { EventEmitter } from 'events'; import * as path from 'path'; import * as fs from 'fs'; import { logger } from '../../utils/logger'; -import { getOutputParser } from '../../parsers'; +import { createOutputParser } from '../../parsers'; import { getAgentCapabilities } from '../../agents'; import type { ProcessConfig, ManagedProcess, SpawnResult } from '../types'; import type { DataBufferManager } from '../handlers/DataBufferManager'; @@ -61,6 +61,7 @@ export class ChildProcessSpawner { prompt, images, imageArgs, + imagePromptBuilder, promptArgs, contextWindow, customEnvVars, @@ -107,8 +108,9 @@ export class ChildProcessSpawner { : []; finalArgs = [...args, ...needsInputFormat]; // Prompt will be sent via stdin as stream-json with embedded images (not in CLI args) - } else if (hasImages && prompt && imageArgs) { - // For agents that use file-based image args (like Codex, OpenCode) + } else if (hasImages && prompt && (imageArgs || imagePromptBuilder)) { + // For agents that use file-based image args (like Codex, OpenCode) or + // prompt-embedded image mentions (like Copilot's @path syntax) finalArgs = [...args]; tempImageFiles = []; for (let i = 0; i < images.length; i++) { @@ -120,10 +122,14 @@ export class ChildProcessSpawner { const isResumeWithPromptEmbed = capabilities.imageResumeMode === 'prompt-embed' && args.some((a) => a === 'resume'); - - if (isResumeWithPromptEmbed) { - // Resume mode: embed file paths in prompt text, don't use -i flag - const imagePrefix = buildImagePromptPrefix(tempImageFiles); + const shouldEmbedImagesInPrompt = !!imagePromptBuilder || isResumeWithPromptEmbed; + + if (shouldEmbedImagesInPrompt) { + // Some agents consume images by mentioning temp file paths inside the prompt + // instead of accepting a dedicated CLI image flag. + const imagePrefix = imagePromptBuilder + ? imagePromptBuilder(tempImageFiles) + : buildImagePromptPrefix(tempImageFiles); effectivePrompt = imagePrefix + prompt; if (!promptViaStdin) { if (promptArgs) { @@ -135,19 +141,19 @@ export class ChildProcessSpawner { } promptAddedToArgs = true; } - logger.debug( - '[ProcessManager] Resume mode: embedded image paths in prompt', - 'ProcessManager', - { - sessionId, - imageCount: images.length, - tempFiles: tempImageFiles, - promptViaStdin, - } - ); + logger.debug('[ProcessManager] Embedded image paths in prompt', 'ProcessManager', { + sessionId, + imageCount: images.length, + tempFiles: tempImageFiles, + embedMode: imagePromptBuilder ? 'prompt-builder' : 'resume-prompt-embed', + promptViaStdin, + }); } else { // Initial spawn: use -i flag as before for (const tempPath of tempImageFiles) { + if (!imageArgs) { + continue; + } finalArgs = [...finalArgs, ...imageArgs(tempPath)]; } if (!promptViaStdin) { @@ -208,7 +214,9 @@ export class ChildProcessSpawner { try { // Build environment - const isResuming = finalArgs.includes('--resume') || finalArgs.includes('--session'); + const isResuming = + args.some((arg) => arg === '--resume' || arg.startsWith('--resume=')) || + args.includes('--session'); const env = buildChildProcessEnv(customEnvVars, isResuming, shellEnvVars); // Log environment variable application for troubleshooting @@ -336,17 +344,27 @@ export class ChildProcessSpawner { // because the SSH command wraps the actual agent command. Without this, the output // parser won't process JSON output from remote agents, causing raw JSON to display. // NOTE: sendPromptViaStdinRaw sends RAW text (not JSON), so it should NOT set isStreamJsonMode - const argsContain = (pattern: string) => finalArgs.some((arg) => arg.includes(pattern)); + // Use the pre-prompt args for detection to avoid false positives from prompt content + // (e.g., a prompt like "Explain --json" should not flip isStreamJsonMode) + const cliArgs = promptAddedToArgs ? args : finalArgs; + const argsContain = (pattern: string) => cliArgs.some((arg) => arg.includes(pattern)); + const argsHaveFlagValue = (flag: string, value: string) => + cliArgs.some( + (arg, index) => + arg === `${flag}=${value}` || (arg === flag && cliArgs[index + 1] === value) + ); const isStreamJsonMode = argsContain('stream-json') || argsContain('--json') || - (argsContain('--format') && argsContain('json')) || + argsHaveFlagValue('--format', 'json') || + argsHaveFlagValue('--output-format', 'json') || (hasImages && !!prompt) || !!config.sendPromptViaStdin || !!config.sshStdinScript; - // Get the output parser for this agent type - const outputParser = getOutputParser(toolType) || undefined; + // Create a fresh output parser instance for this process (not the shared singleton) + // to isolate mutable state like tool name tracking across concurrent sessions + const outputParser = createOutputParser(toolType) || undefined; logger.debug('[ProcessManager] Output parser lookup', 'ProcessManager', { sessionId, diff --git a/src/main/process-manager/spawners/PtySpawner.ts b/src/main/process-manager/spawners/PtySpawner.ts index d302351b4d..7c681bc243 100644 --- a/src/main/process-manager/spawners/PtySpawner.ts +++ b/src/main/process-manager/spawners/PtySpawner.ts @@ -2,9 +2,11 @@ import { EventEmitter } from 'events'; import * as pty from 'node-pty'; import { stripControlSequences } from '../../utils/terminalFilter'; import { logger } from '../../utils/logger'; +import { needsWindowsShell } from '../../utils/execFile'; import type { ProcessConfig, ManagedProcess, SpawnResult } from '../types'; import type { DataBufferManager } from '../handlers/DataBufferManager'; import { buildPtyTerminalEnv, buildChildProcessEnv } from '../utils/envBuilder'; +import { escapeArgsForShell } from '../utils/shellEscape'; import { isWindows } from '../../../shared/platformDetection'; /** @@ -72,8 +74,18 @@ export class PtySpawner { } } else { // Spawn the AI agent directly with PTY support - ptyCommand = command; - ptyArgs = args; + if (isWindows() && needsWindowsShell(command)) { + ptyCommand = process.env.ComSpec || 'cmd.exe'; + ptyArgs = [ + '/d', + '/s', + '/c', + escapeArgsForShell([command, ...args], ptyCommand).join(' '), + ]; + } else { + ptyCommand = command; + ptyArgs = args; + } } // Build environment for PTY process diff --git a/src/main/process-manager/types.ts b/src/main/process-manager/types.ts index 8f0f5f87bd..1127159c68 100644 --- a/src/main/process-manager/types.ts +++ b/src/main/process-manager/types.ts @@ -19,6 +19,7 @@ export interface ProcessConfig { shellEnvVars?: Record; images?: string[]; imageArgs?: (imagePath: string) => string[]; + imagePromptBuilder?: (imagePaths: string[]) => string; promptArgs?: (prompt: string) => string[]; contextWindow?: number; customEnvVars?: Record; @@ -56,6 +57,9 @@ export interface ManagedProcess { isBatchMode?: boolean; isStreamJsonMode?: boolean; jsonBuffer?: string; + /** When true, the JSON buffer was force-cleared after exceeding size limits. + * Subsequent chunks are discarded until a clean top-level `{` resync point. */ + jsonBufferCorrupted?: boolean; lastCommand?: string; sessionIdEmitted?: boolean; resultEmitted?: boolean; @@ -71,6 +75,7 @@ export interface ManagedProcess { args?: string[]; lastUsageTotals?: UsageTotals; usageIsCumulative?: boolean; + emittedToolCallIds?: Set; querySource?: 'user' | 'auto'; tabId?: string; projectPath?: string; diff --git a/src/main/storage/copilot-session-storage.ts b/src/main/storage/copilot-session-storage.ts new file mode 100644 index 0000000000..590508b19f --- /dev/null +++ b/src/main/storage/copilot-session-storage.ts @@ -0,0 +1,651 @@ +import path from 'path'; +import os from 'os'; +import fs from 'fs/promises'; +import { logger } from '../utils/logger'; +import { captureException } from '../utils/sentry'; +import { readFileRemote, readDirRemote, directorySizeRemote } from '../utils/remote-fs'; +import type { + AgentSessionInfo, + SessionMessagesResult, + SessionReadOptions, + SessionMessage, +} from '../agents'; +import type { ToolType, SshRemoteConfig } from '../../shared/types'; +import { BaseSessionStorage } from './base-session-storage'; +import type { SearchableMessage } from './base-session-storage'; + +const LOG_CONTEXT = '[CopilotSessionStorage]'; + +/** Resolve the local Copilot session state directory, respecting COPILOT_CONFIG_DIR. */ +function getLocalCopilotSessionStateDir(): string { + const configDir = process.env.COPILOT_CONFIG_DIR || path.join(os.homedir(), '.copilot'); + return path.join(configDir, 'session-state'); +} + +interface CopilotWorkspaceMetadata { + id: string; + cwd?: string; + git_root?: string; + repository?: string; + branch?: string; + summary?: string; + created_at?: string; + updated_at?: string; +} + +interface CopilotToolRequest { + toolCallId?: string; + name?: string; + arguments?: unknown; +} + +interface CopilotSessionStats { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + durationSeconds: number; +} + +interface ParsedCopilotSessionData { + messages: SessionMessage[]; + firstAssistantMessage: string; + firstUserMessage: string; + stats: CopilotSessionStats; + parsedEventCount: number; + malformedEventCount: number; + hasMeaningfulContent: boolean; +} + +interface CopilotEvent { + type?: string; + id?: string; + timestamp?: string; + usage?: { + sessionDurationMs?: number; + }; + data?: { + content?: string; + toolRequests?: CopilotToolRequest[]; + sessionDurationMs?: number; + modelMetrics?: Record< + string, + { + usage?: { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + }; + } + >; + }; +} + +/** Strip surrounding quotes and unescape common YAML sequences in scalar values. */ +function normalizeYamlScalar(value: string): string { + let trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + const inner = trimmed.slice(1, -1); + // Unescape common sequences within double-quoted scalars + return trimmed.startsWith('"') ? inner.replace(/\\"/g, '"').replace(/\\\\/g, '\\') : inner; + } + + const inlineCommentIndex = trimmed.search(/\s+#/); + if (inlineCommentIndex >= 0) { + trimmed = trimmed.slice(0, inlineCommentIndex).trim(); + } + + return trimmed; +} + +const WORKSPACE_METADATA_KEYS = new Set([ + 'id', + 'cwd', + 'git_root', + 'repository', + 'branch', + 'summary', + 'created_at', + 'updated_at', +]); + +/** Normalize a workspace metadata key from camelCase/kebab-case to the canonical snake_case form. */ +function normalizeWorkspaceMetadataKey(key: string): keyof CopilotWorkspaceMetadata | null { + const normalized = key + .trim() + .replace(/-/g, '_') + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toLowerCase(); + + return WORKSPACE_METADATA_KEYS.has(normalized as keyof CopilotWorkspaceMetadata) + ? (normalized as keyof CopilotWorkspaceMetadata) + : null; +} + +/** Parse workspace.yaml content into typed metadata, tolerating format variations. */ +function parseWorkspaceMetadata(content: string, sessionId: string): CopilotWorkspaceMetadata { + const metadata: CopilotWorkspaceMetadata = { id: sessionId }; + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#') || line === '---' || line === '...') continue; + + const match = rawLine.match(/^\s*([A-Za-z0-9_-]+)\s*:\s*(.*)$/); + if (!match) continue; + + const key = normalizeWorkspaceMetadataKey(match[1]); + if (!key) continue; + + const value = normalizeYamlScalar(match[2]); + if (!value) continue; + + metadata[key] = value; + } + + return metadata; +} + +/** Normalize a filesystem path for cross-platform comparison. Case-folds Windows-style paths (drive letter prefix). */ +function normalizePath(value?: string): string | null { + if (!value) return null; + let normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); + // Preserve POSIX root "/" — stripping its trailing slash would produce "" + if (!normalized && value === '/') normalized = '/'; + // Case-fold Windows-style paths (e.g., C:/Users) for case-insensitive comparison + if (/^[A-Za-z]:/.test(normalized)) { + normalized = normalized.toLowerCase(); + } + return normalized; +} + +/** Check whether session metadata matches the given project path. */ +function matchesProject(metadata: CopilotWorkspaceMetadata, projectPath: string): boolean { + const normalizedProject = normalizePath(projectPath); + const gitRoot = normalizePath(metadata.git_root); + const cwd = normalizePath(metadata.cwd); + + if (!normalizedProject) return true; + return ( + gitRoot === normalizedProject || + cwd === normalizedProject || + cwd?.startsWith(`${normalizedProject}/`) === true + ); +} + +/** Convert Copilot tool requests into a normalized tool-use structure. */ +function buildToolUse(toolRequests?: CopilotToolRequest[]): unknown { + if (!toolRequests?.length) return undefined; + const toolUse = toolRequests + .filter((tool) => tool.name) + .map((tool) => ({ + name: tool.name, + id: tool.toolCallId, + input: tool.arguments, + })); + return toolUse.length > 0 ? toolUse : undefined; +} + +/** Parse events.jsonl content into messages, statistics, and content indicators. */ +function parseEvents(content: string): ParsedCopilotSessionData { + const messages: SessionMessage[] = []; + let firstAssistantMessage = ''; + let firstUserMessage = ''; + let parsedEventCount = 0; + let malformedEventCount = 0; + const stats: CopilotSessionStats = { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + durationSeconds: 0, + }; + + for (const line of content.split(/\r?\n/)) { + if (!line.trim()) continue; + + try { + const entry = JSON.parse(line) as CopilotEvent; + parsedEventCount += 1; + + if (entry.type === 'user.message') { + const contentText = entry.data?.content || ''; + if (contentText.trim()) { + firstUserMessage ||= contentText; + messages.push({ + type: 'user', + role: 'user', + content: contentText, + timestamp: entry.timestamp || '', + uuid: entry.id || `copilot-user-${messages.length}`, + }); + } + continue; + } + + if (entry.type === 'assistant.message') { + const contentText = entry.data?.content || ''; + const toolUse = buildToolUse(entry.data?.toolRequests); + if (contentText.trim() || toolUse) { + firstAssistantMessage ||= contentText; + messages.push({ + type: 'assistant', + role: 'assistant', + content: contentText, + timestamp: entry.timestamp || '', + uuid: entry.id || `copilot-assistant-${messages.length}`, + toolUse, + }); + } + continue; + } + + if (entry.type === 'session.shutdown') { + const modelMetrics = entry.data?.modelMetrics || {}; + for (const metric of Object.values(modelMetrics)) { + stats.inputTokens += metric.usage?.inputTokens || 0; + stats.outputTokens += metric.usage?.outputTokens || 0; + stats.cacheReadTokens += metric.usage?.cacheReadTokens || 0; + stats.cacheCreationTokens += metric.usage?.cacheWriteTokens || 0; + } + if (entry.data?.sessionDurationMs) { + stats.durationSeconds = Math.max(0, Math.floor(entry.data.sessionDurationMs / 1000)); + } + continue; + } + + if (entry.type === 'result' && entry.usage?.sessionDurationMs) { + stats.durationSeconds = Math.max(0, Math.floor(entry.usage.sessionDurationMs / 1000)); + } + } catch { + malformedEventCount += 1; + // Ignore malformed lines so a single bad event does not hide the whole session. + } + } + + const hasMeaningfulContent = + messages.length > 0 || + stats.inputTokens > 0 || + stats.outputTokens > 0 || + stats.cacheReadTokens > 0 || + stats.cacheCreationTokens > 0 || + stats.durationSeconds > 0; + + return { + messages, + firstAssistantMessage, + firstUserMessage, + stats, + parsedEventCount, + malformedEventCount, + hasMeaningfulContent, + }; +} + +/** Recursively calculate the total size of a local directory in bytes. */ +async function getLocalDirectorySize(sessionDir: string): Promise { + try { + const entries = await fs.readdir(sessionDir, { withFileTypes: true }); + let total = 0; + for (const entry of entries) { + const entryPath = path.join(sessionDir, entry.name); + if (entry.isDirectory()) { + total += await getLocalDirectorySize(entryPath); + } else { + const stat = await fs.stat(entryPath); + total += stat.size; + } + } + return total; + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT' || code === 'EACCES' || code === 'EPERM') { + return 0; + } + captureException(error, { operation: 'copilotStorage:getLocalDirectorySize', sessionDir }); + return 0; + } +} + +/** + * Session storage implementation for GitHub Copilot CLI. + * + * Reads session metadata from `~/.copilot/session-state//workspace.yaml` + * and conversation history from `events.jsonl`. Supports both local and SSH remote access. + */ +export class CopilotSessionStorage extends BaseSessionStorage { + readonly agentId: ToolType = 'copilot'; + + /** Remote session state directory path using POSIX tilde expansion. */ + private getRemoteSessionStateDir(): string { + return '~/.copilot/session-state'; + } + + /** Resolve the session state base directory (local or remote). */ + private getSessionStateDir(sshConfig?: SshRemoteConfig): string { + return sshConfig ? this.getRemoteSessionStateDir() : getLocalCopilotSessionStateDir(); + } + + /** Resolve the directory path for a specific session. */ + private getSessionDir(sessionId: string, sshConfig?: SshRemoteConfig): string { + return sshConfig + ? path.posix.join(this.getRemoteSessionStateDir(), sessionId) + : path.join(getLocalCopilotSessionStateDir(), sessionId); + } + + /** Resolve the workspace.yaml path for a specific session. */ + private getWorkspacePath(sessionId: string, sshConfig?: SshRemoteConfig): string { + return sshConfig + ? path.posix.join(this.getSessionDir(sessionId, sshConfig), 'workspace.yaml') + : path.join(this.getSessionDir(sessionId), 'workspace.yaml'); + } + + /** Resolve the events.jsonl path for a specific session. */ + private getEventsPath(sessionId: string, sshConfig?: SshRemoteConfig): string { + return sshConfig + ? path.posix.join(this.getSessionDir(sessionId, sshConfig), 'events.jsonl') + : path.join(this.getSessionDir(sessionId), 'events.jsonl'); + } + + /** List all Copilot sessions matching the given project path. */ + async listSessions( + projectPath: string, + sshConfig?: SshRemoteConfig + ): Promise { + const sessionIds = await this.listSessionIds(sshConfig); + const sessions = await Promise.all( + sessionIds.map((sessionId) => this.loadSessionInfo(projectPath, sessionId, sshConfig)) + ); + + return sessions + .filter((session): session is AgentSessionInfo => session !== null) + .sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()); + } + + /** Read messages from a Copilot session's events.jsonl file. */ + async readSessionMessages( + _projectPath: string, + sessionId: string, + options?: SessionReadOptions, + sshConfig?: SshRemoteConfig + ): Promise { + // Guard: verify session belongs to the requested project before returning content + if (!(await this.sessionMatchesProject(sessionId, _projectPath, sshConfig))) { + return { messages: [], total: 0, hasMore: false }; + } + + const eventsContent = await this.readEventsFile(sessionId, sshConfig); + if (!eventsContent) { + return { messages: [], total: 0, hasMore: false }; + } + + const { messages } = parseEvents(eventsContent); + return BaseSessionStorage.applyMessagePagination(messages, options); + } + + /** Get searchable user/assistant messages for session search. */ + protected async getSearchableMessages( + sessionId: string, + _projectPath: string, + sshConfig?: SshRemoteConfig + ): Promise { + // Guard: verify session belongs to the requested project before returning content + if (!(await this.sessionMatchesProject(sessionId, _projectPath, sshConfig))) { + return []; + } + + const eventsContent = await this.readEventsFile(sessionId, sshConfig); + if (!eventsContent) { + return []; + } + + return parseEvents(eventsContent) + .messages.filter((message) => message.role === 'user' || message.role === 'assistant') + .map((message) => ({ + role: message.role as 'user' | 'assistant', + textContent: message.content, + })) + .filter((message) => message.textContent.trim().length > 0); + } + + /** Get the filesystem path to a session's events.jsonl file. */ + getSessionPath( + _projectPath: string, + sessionId: string, + sshConfig?: SshRemoteConfig + ): string | null { + return this.getEventsPath(sessionId, sshConfig); + } + + /** Delete a message pair. Not supported for Copilot sessions. */ + async deleteMessagePair( + _projectPath: string, + _sessionId: string, + _userMessageUuid: string, + _fallbackContent?: string, + _sshConfig?: SshRemoteConfig + ): Promise<{ success: boolean; error?: string; linesRemoved?: number }> { + return { + success: false, + error: 'Deleting Copilot session history is not supported.', + }; + } + + /** Check whether a session belongs to the given project. Returns true if ownership cannot be determined (fail-open for missing metadata). */ + private async sessionMatchesProject( + sessionId: string, + projectPath: string, + sshConfig?: SshRemoteConfig + ): Promise { + try { + const workspacePath = this.getWorkspacePath(sessionId, sshConfig); + const workspaceContent = sshConfig + ? await this.readRemoteFile(workspacePath, sshConfig) + : await fs.readFile(workspacePath, 'utf8'); + if (!workspaceContent) return false; // No metadata → can't verify ownership, fail-closed + const metadata = parseWorkspaceMetadata(workspaceContent, sessionId); + return matchesProject(metadata, projectPath); + } catch { + return false; // Missing/unreadable metadata → fail-closed + } + } + + /** Check if a remote-fs error indicates a benign not-found/permission case vs an unexpected SSH failure. */ + private isExpectedRemoteError(error?: string): boolean { + if (!error) return false; + const lower = error.toLowerCase(); + return ( + lower.includes('not found') || + lower.includes('not accessible') || + lower.includes('no such file') || + lower.includes('permission denied') || + lower.includes('does not exist') + ); + } + + /** List all session directory names from the session state directory. */ + private async listSessionIds(sshConfig?: SshRemoteConfig): Promise { + const sessionStateDir = this.getSessionStateDir(sshConfig); + if (sshConfig) { + const result = await readDirRemote(sessionStateDir, sshConfig); + if (!result.success || !result.data) { + if (!this.isExpectedRemoteError(result.error)) { + logger.warn( + `Unexpected SSH failure listing Copilot sessions: ${result.error}`, + LOG_CONTEXT + ); + captureException(new Error(result.error || 'readDirRemote failed'), { + operation: 'copilotStorage:listSessionIds:remote', + sessionStateDir, + }); + } + return []; + } + return result.data.filter((entry) => entry.isDirectory).map((entry) => entry.name); + } + + try { + const entries = await fs.readdir(sessionStateDir, { withFileTypes: true }); + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name); + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT' || code === 'EACCES' || code === 'EPERM') { + return []; + } + captureException(error, { operation: 'copilotStorage:listSessionIds' }); + return []; + } + } + + /** Load session metadata and event statistics for a single session. Returns null if the session doesn't match the project or lacks meaningful content. */ + private async loadSessionInfo( + projectPath: string, + sessionId: string, + sshConfig?: SshRemoteConfig + ): Promise { + const sessionDir = this.getSessionDir(sessionId, sshConfig); + const workspacePath = this.getWorkspacePath(sessionId, sshConfig); + try { + const workspaceContent = sshConfig + ? await this.readRemoteFile(workspacePath, sshConfig) + : await fs.readFile(workspacePath, 'utf8'); + if (!workspaceContent) { + return null; + } + + const metadata = parseWorkspaceMetadata(workspaceContent, sessionId); + + if (!matchesProject(metadata, projectPath)) { + return null; + } + + const eventsContent = await this.readEventsFile(sessionId, sshConfig); + if (!eventsContent?.trim()) { + logger.debug(`Skipping Copilot session ${sessionId} with empty events log`, LOG_CONTEXT); + return null; + } + + const parsedEvents = parseEvents(eventsContent); + if (!parsedEvents.hasMeaningfulContent) { + logger.debug( + `Skipping Copilot session ${sessionId} without meaningful event content`, + LOG_CONTEXT, + { + parsedEventCount: parsedEvents.parsedEventCount, + malformedEventCount: parsedEvents.malformedEventCount, + } + ); + return null; + } + + const sizeBytes = sshConfig + ? await this.getRemoteDirectorySize(sessionDir, sshConfig) + : await getLocalDirectorySize(sessionDir); + const projectRoot = metadata.git_root || metadata.cwd || projectPath; + + // Prefer metadata timestamps; fall back to workspace file mtime (local only) + // before using current time as a last resort. + let fallbackTimestamp: string | undefined; + if (!metadata.created_at && !metadata.updated_at && !sshConfig) { + try { + const workspaceStat = await fs.stat(workspacePath); + fallbackTimestamp = new Date(workspaceStat.mtimeMs).toISOString(); + } catch { + // stat failure is non-critical + } + } + const timestamp = + metadata.created_at || metadata.updated_at || fallbackTimestamp || new Date().toISOString(); + const modifiedAt = metadata.updated_at || timestamp; + const preview = + parsedEvents.firstAssistantMessage || + parsedEvents.firstUserMessage || + metadata.summary || + 'Copilot session'; + + return { + sessionId: metadata.id, + projectPath: projectRoot, + timestamp, + modifiedAt, + firstMessage: preview.slice(0, 200), + messageCount: parsedEvents.messages.length, + sizeBytes, + inputTokens: parsedEvents.stats.inputTokens, + outputTokens: parsedEvents.stats.outputTokens, + cacheReadTokens: parsedEvents.stats.cacheReadTokens, + cacheCreationTokens: parsedEvents.stats.cacheCreationTokens, + durationSeconds: parsedEvents.stats.durationSeconds, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT' || code === 'EACCES' || code === 'EPERM') { + logger.debug(`Expected failure loading Copilot session ${sessionId}: ${code}`, LOG_CONTEXT); + } else { + logger.warn(`Unexpected failure loading Copilot session ${sessionId}`, LOG_CONTEXT, { + error, + }); + captureException(error, { operation: 'copilotStorage:loadSessionInfo', sessionId }); + } + return null; + } + } + + /** Read the events.jsonl file content for a session. Returns null on missing/unreadable files. */ + private async readEventsFile( + sessionId: string, + sshConfig?: SshRemoteConfig + ): Promise { + const eventsPath = this.getEventsPath(sessionId, sshConfig); + + try { + return sshConfig + ? await this.readRemoteFile(eventsPath, sshConfig) + : await fs.readFile(eventsPath, 'utf8'); + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT' || code === 'EACCES' || code === 'EPERM') { + return null; + } + captureException(error, { operation: 'copilotStorage:readEventsFile', sessionId }); + return null; + } + } + + /** Read a file from a remote host via SSH. Returns null on not-found; reports unexpected failures to Sentry. */ + private async readRemoteFile( + filePath: string, + sshConfig: SshRemoteConfig + ): Promise { + const result = await readFileRemote(filePath, sshConfig); + if (result.success && result.data != null) return result.data; + if (!this.isExpectedRemoteError(result.error)) { + logger.warn(`Unexpected SSH failure reading ${filePath}: ${result.error}`, LOG_CONTEXT); + captureException(new Error(result.error || 'readFileRemote failed'), { + operation: 'copilotStorage:readRemoteFile', + filePath, + }); + } + return null; + } + + /** Calculate the total size of a session directory on a remote host. Returns 0 on not-found; reports unexpected failures. */ + private async getRemoteDirectorySize( + sessionDir: string, + sshConfig: SshRemoteConfig + ): Promise { + const result = await directorySizeRemote(sessionDir, sshConfig); + if (result.success && result.data != null) return result.data; + if (!this.isExpectedRemoteError(result.error)) { + logger.warn(`Unexpected SSH failure sizing ${sessionDir}: ${result.error}`, LOG_CONTEXT); + captureException(new Error(result.error || 'directorySizeRemote failed'), { + operation: 'copilotStorage:getRemoteDirectorySize', + sessionDir, + }); + } + return 0; + } +} diff --git a/src/main/storage/index.ts b/src/main/storage/index.ts index 71981b0301..cf84761f91 100644 --- a/src/main/storage/index.ts +++ b/src/main/storage/index.ts @@ -9,6 +9,7 @@ export { ClaudeSessionStorage, ClaudeSessionOriginsData } from './claude-session export { OpenCodeSessionStorage } from './opencode-session-storage'; export { CodexSessionStorage } from './codex-session-storage'; export { FactoryDroidSessionStorage } from './factory-droid-session-storage'; +export { CopilotSessionStorage } from './copilot-session-storage'; import Store from 'electron-store'; import { registerSessionStorage } from '../agents'; @@ -16,6 +17,7 @@ import { ClaudeSessionStorage, ClaudeSessionOriginsData } from './claude-session import { OpenCodeSessionStorage } from './opencode-session-storage'; import { CodexSessionStorage } from './codex-session-storage'; import { FactoryDroidSessionStorage } from './factory-droid-session-storage'; +import { CopilotSessionStorage } from './copilot-session-storage'; /** * Options for initializing session storages @@ -36,4 +38,5 @@ export function initializeSessionStorages(options?: InitializeSessionStoragesOpt registerSessionStorage(new OpenCodeSessionStorage()); registerSessionStorage(new CodexSessionStorage()); registerSessionStorage(new FactoryDroidSessionStorage()); + registerSessionStorage(new CopilotSessionStorage()); } diff --git a/src/main/utils/agent-args.ts b/src/main/utils/agent-args.ts index 222487120e..e965b85d06 100644 --- a/src/main/utils/agent-args.ts +++ b/src/main/utils/agent-args.ts @@ -28,6 +28,7 @@ type AgentConfigResolution = { modelSource: 'session' | 'agent' | 'default'; }; +/** Parse a space-separated custom args string into an array, respecting quoted segments. */ function parseCustomArgs(customArgs?: string): string[] { if (!customArgs || typeof customArgs !== 'string') { return []; @@ -42,6 +43,34 @@ function parseCustomArgs(customArgs?: string): string[] { }); } +/** Check whether jsonOutputArgs (exact sequence or flag key) are already present in the args list. */ +function hasJsonOutputFlag(haystack: string[], jsonOutputArgs: string[]): boolean { + if (jsonOutputArgs.length === 0) return true; + + // Check if the exact arg sequence is already present + for (let i = 0; i <= haystack.length - jsonOutputArgs.length; i++) { + let match = true; + for (let j = 0; j < jsonOutputArgs.length; j++) { + if (haystack[i + j] !== jsonOutputArgs[j]) { + match = false; + break; + } + } + if (match) return true; + } + + // Also check if the flag key (e.g., --format, --output-format) is already + // present with a different value — avoid appending a conflicting duplicate + // that the dedup step would mangle. + const flagKey = jsonOutputArgs[0]; + if (flagKey?.startsWith('-') && jsonOutputArgs.length > 1) { + return haystack.includes(flagKey); + } + + return false; +} + +/** Build the final CLI arguments for an agent process based on mode, config, and user options. */ export function buildAgentArgs( agent: AgentConfig | null | undefined, options: BuildAgentArgsOptions @@ -66,7 +95,15 @@ export function buildAgentArgs( } } - if (agent.jsonOutputArgs && !finalArgs.some((arg) => agent.jsonOutputArgs!.includes(arg))) { + // Only inject JSON output args when a prompt is provided (batch/non-interactive mode). + // Interactive sessions must not receive these flags (e.g., Copilot rejects --output-format json + // in interactive mode). Agents that need JSON output in interactive mode should include + // the relevant flags in their base `args` or `batchModeArgs` instead. + if ( + agent.jsonOutputArgs && + options.prompt && + !hasJsonOutputFlag(finalArgs, agent.jsonOutputArgs) + ) { finalArgs = [...finalArgs, ...agent.jsonOutputArgs]; } @@ -115,6 +152,7 @@ export function buildAgentArgs( return dedupedArgs; } +/** Apply agent configuration overrides (custom args, env vars, model selection) to base args. */ export function applyAgentConfigOverrides( agent: AgentConfig | null | undefined, baseArgs: string[], @@ -202,6 +240,7 @@ export function applyAgentConfigOverrides( }; } +/** Resolve the effective context window size from session, agent config, or defaults. */ export function getContextWindowValue( agent: AgentConfig | null | undefined, agentConfigValues: Record, diff --git a/src/main/utils/remote-fs.ts b/src/main/utils/remote-fs.ts index ec1fd5dbde..1b3cbfb439 100644 --- a/src/main/utils/remote-fs.ts +++ b/src/main/utils/remote-fs.ts @@ -11,7 +11,7 @@ import { SshRemoteConfig } from '../../shared/types'; import { execFileNoThrow, ExecResult } from './execFile'; -import { shellEscape } from './shell-escape'; +import { shellEscape, shellEscapeForDoubleQuotes } from './shell-escape'; import { sshRemoteManager } from '../ssh-remote-manager'; import { logger } from './logger'; import { resolveSshPath } from './cliDetection'; @@ -191,6 +191,26 @@ async function execRemoteCommand( return lastResult!; } +function shellEscapeRemotePath(filePath: string): string { + if (filePath === '~') { + return '"$HOME"'; + } + + if (filePath.startsWith('~/')) { + return `"$HOME/${shellEscapeForDoubleQuotes(filePath.slice(2))}"`; + } + + if (filePath === '$HOME') { + return '"$HOME"'; + } + + if (filePath.startsWith('$HOME/')) { + return `"$HOME/${shellEscapeForDoubleQuotes(filePath.slice('$HOME/'.length))}"`; + } + + return shellEscape(filePath); +} + /** * Read directory contents from a remote host via SSH. * @@ -217,7 +237,7 @@ export async function readDirRemote( // -F: Append indicator (/ for dirs, @ for symlinks, * for executables) // --color=never: Disable color codes in output // We avoid -l because parsing long format is complex and locale-dependent - const escapedPath = shellEscape(dirPath); + const escapedPath = shellEscapeRemotePath(dirPath); const remoteCommand = `ls -1AF --color=never ${escapedPath} 2>/dev/null || echo "__LS_ERROR__"`; const result = await execRemoteCommand(sshRemote, remoteCommand, deps); @@ -297,7 +317,7 @@ export async function readFileRemote( sshRemote: SshRemoteConfig, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(filePath); + const escapedPath = shellEscapeRemotePath(filePath); // Use cat with explicit error handling const remoteCommand = `cat ${escapedPath}`; @@ -343,7 +363,7 @@ export async function statRemote( sshRemote: SshRemoteConfig, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(filePath); + const escapedPath = shellEscapeRemotePath(filePath); // Use stat with format string: // %s = size in bytes // %F = file type (regular file, directory, symbolic link, etc.) @@ -421,7 +441,7 @@ export async function directorySizeRemote( sshRemote: SshRemoteConfig, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(dirPath); + const escapedPath = shellEscapeRemotePath(dirPath); // Use du with: // -s: summarize (total only) // -b: apparent size in bytes (GNU) @@ -490,7 +510,7 @@ export async function writeFileRemote( sshRemote: SshRemoteConfig, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(filePath); + const escapedPath = shellEscapeRemotePath(filePath); // Use base64 encoding to safely transfer the content // This avoids issues with special characters, quotes, and newlines @@ -532,7 +552,7 @@ export async function existsRemote( sshRemote: SshRemoteConfig, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(remotePath); + const escapedPath = shellEscapeRemotePath(remotePath); const remoteCommand = `test -e ${escapedPath} && echo "EXISTS" || echo "NOT_EXISTS"`; const result = await execRemoteCommand(sshRemote, remoteCommand, deps); @@ -565,7 +585,7 @@ export async function mkdirRemote( recursive: boolean = true, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(dirPath); + const escapedPath = shellEscapeRemotePath(dirPath); const mkdirFlag = recursive ? '-p' : ''; const remoteCommand = `mkdir ${mkdirFlag} ${escapedPath}`; @@ -601,8 +621,8 @@ export async function renameRemote( sshRemote: SshRemoteConfig, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedOldPath = shellEscape(oldPath); - const escapedNewPath = shellEscape(newPath); + const escapedOldPath = shellEscapeRemotePath(oldPath); + const escapedNewPath = shellEscapeRemotePath(newPath); const remoteCommand = `mv ${escapedOldPath} ${escapedNewPath}`; const result = await execRemoteCommand(sshRemote, remoteCommand, deps); @@ -637,7 +657,7 @@ export async function deleteRemote( recursive: boolean = true, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(targetPath); + const escapedPath = shellEscapeRemotePath(targetPath); // Use rm -rf for recursive delete (directories), rm -f for files // The -f flag prevents errors if file doesn't exist const rmFlags = recursive ? '-rf' : '-f'; @@ -673,7 +693,7 @@ export async function countItemsRemote( sshRemote: SshRemoteConfig, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(dirPath); + const escapedPath = shellEscapeRemotePath(dirPath); // Use find to count files and directories separately // -type f for files, -type d for directories (excluding the root dir itself) const remoteCommand = `echo "FILES:$(find ${escapedPath} -type f 2>/dev/null | wc -l)" && echo "DIRS:$(find ${escapedPath} -mindepth 1 -type d 2>/dev/null | wc -l)"`; @@ -745,7 +765,7 @@ export async function incrementalScanRemote( sinceTimestamp: number, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(dirPath); + const escapedPath = shellEscapeRemotePath(dirPath); const scanTime = Math.floor(Date.now() / 1000); // Use find with -newermt to find files modified after the given timestamp @@ -805,7 +825,7 @@ export async function listAllFilesRemote( maxDepth: number = 10, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(dirPath); + const escapedPath = shellEscapeRemotePath(dirPath); // Use find with -maxdepth to list all files // Exclude node_modules and __pycache__ diff --git a/src/main/utils/ssh-command-builder.ts b/src/main/utils/ssh-command-builder.ts index 555be34073..94137b5cab 100644 --- a/src/main/utils/ssh-command-builder.ts +++ b/src/main/utils/ssh-command-builder.ts @@ -300,6 +300,8 @@ export async function buildSshCommandWithStdin( images?: string[]; /** Function to build CLI args for each image path (e.g., (path) => ['-i', path]) */ imageArgs?: (imagePath: string) => string[]; + /** Function to embed image references into the prompt/stdinInput (e.g., Copilot @mentions). */ + imagePromptBuilder?: (imagePaths: string[]) => string; /** When set to 'prompt-embed', embed image paths in the prompt/stdinInput instead of adding -i CLI args. * Used for resumed Codex sessions where the resume command doesn't support -i flag. */ imageResumeMode?: 'prompt-embed'; @@ -374,7 +376,11 @@ export async function buildSshCommandWithStdin( const remoteImagePaths: string[] = []; /** All remote temp file paths created during image decoding (for cleanup) */ const allRemoteTempPaths: string[] = []; - if (remoteOptions.images && remoteOptions.images.length > 0 && remoteOptions.imageArgs) { + if ( + remoteOptions.images && + remoteOptions.images.length > 0 && + (remoteOptions.imageArgs || remoteOptions.imagePromptBuilder) + ) { const timestamp = Date.now(); for (let i = 0; i < remoteOptions.images.length; i++) { const parsed = parseDataUrl(remoteOptions.images[i]); @@ -388,10 +394,13 @@ export async function buildSshCommandWithStdin( scriptLines.push(`base64 -d > ${shellEscape(remoteTempPath)} <<'MAESTRO_IMG_${i}_EOF'`); scriptLines.push(parsed.base64); scriptLines.push(`MAESTRO_IMG_${i}_EOF`); - if (remoteOptions.imageResumeMode === 'prompt-embed') { + if (remoteOptions.imagePromptBuilder || remoteOptions.imageResumeMode === 'prompt-embed') { // Resume mode: collect paths for prompt embedding instead of CLI args remoteImagePaths.push(remoteTempPath); } else { + if (!remoteOptions.imageArgs) { + continue; + } // Normal mode: add -i (or equivalent) CLI args imageArgParts.push( ...remoteOptions.imageArgs(remoteTempPath).map((arg) => shellEscape(arg)) @@ -410,7 +419,9 @@ export async function buildSshCommandWithStdin( // For prompt-embed mode (resumed sessions), prepend image paths to stdinInput/prompt if (remoteImagePaths.length > 0) { - const imagePrefix = buildImagePromptPrefix(remoteImagePaths); + const imagePrefix = remoteOptions.imagePromptBuilder + ? remoteOptions.imagePromptBuilder(remoteImagePaths) + : buildImagePromptPrefix(remoteImagePaths); if (remoteOptions.stdinInput !== undefined) { remoteOptions.stdinInput = imagePrefix + remoteOptions.stdinInput; } else if (remoteOptions.prompt) { diff --git a/src/renderer/components/NewInstanceModal.tsx b/src/renderer/components/NewInstanceModal.tsx index 7bb5ca81e8..4ee6bfdede 100644 --- a/src/renderer/components/NewInstanceModal.tsx +++ b/src/renderer/components/NewInstanceModal.tsx @@ -78,7 +78,7 @@ interface EditAgentModalProps { } // Supported agents that are fully implemented -const SUPPORTED_AGENTS = ['claude-code', 'opencode', 'codex', 'factory-droid']; +const SUPPORTED_AGENTS = ['claude-code', 'opencode', 'codex', 'factory-droid', 'copilot']; export function NewInstanceModal({ isOpen, diff --git a/src/renderer/components/TerminalOutput.tsx b/src/renderer/components/TerminalOutput.tsx index d59867e835..27da80622d 100644 --- a/src/renderer/components/TerminalOutput.tsx +++ b/src/renderer/components/TerminalOutput.tsx @@ -98,6 +98,9 @@ const summarizeToolInput = (input: Record): string | null => { return joined.length > TOOL_DETAIL_MAX ? joined.substring(0, TOOL_DETAIL_MAX) + '\u2026' : joined; }; +const isHiddenProgressEntry = (log: LogEntry): boolean => + log.source === 'system' && log.id.startsWith('hidden-progress:'); + // ============================================================================ // LogItem - Memoized component for individual log entries // ============================================================================ @@ -569,6 +572,52 @@ const LogItemComponent = memo( )} + {isHiddenProgressEntry(log) && ( +
+
+ + {log.metadata?.hiddenProgress?.kind === 'tool' + ? log.metadata.hiddenProgress.toolName || 'working' + : 'thinking'} + + {log.metadata?.toolState?.status === 'completed' ? ( + + ✓ + + ) : log.metadata?.toolState?.status === 'failed' || + log.metadata?.toolState?.status === 'error' ? ( + + ! + + ) : ( + + ● + + )} + + {log.text} + +
+
+ )} {/* Special rendering for tool execution events (shown alongside thinking) */} {log.source === 'tool' && (() => { @@ -609,6 +658,11 @@ const LogItemComponent = memo( ✓ )} + {log.metadata?.toolState?.status === 'failed' && ( + + ! + + )} {toolDetail && ( ); })()} - {log.source !== 'error' && + {!isHiddenProgressEntry(log) && + log.source !== 'error' && log.source !== 'thinking' && log.source !== 'tool' && (hasNoMatches ? ( @@ -955,6 +1010,8 @@ const LogItemComponent = memo( prevProps.log.text === nextProps.log.text && prevProps.log.delivered === nextProps.log.delivered && prevProps.log.readOnly === nextProps.log.readOnly && + prevProps.log.metadata?.hiddenProgress === nextProps.log.metadata?.hiddenProgress && + prevProps.log.metadata?.toolState?.status === nextProps.log.metadata?.toolState?.status && prevProps.isExpanded === nextProps.isExpanded && prevProps.localFilterQuery === nextProps.localFilterQuery && prevProps.filterMode.mode === nextProps.filterMode.mode && diff --git a/src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx b/src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx index 2c4b73b3b7..dfafae2157 100644 --- a/src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx +++ b/src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx @@ -58,6 +58,7 @@ function formatAgentName(agent: string): string { 'gemini-cli': 'Gemini CLI', 'qwen3-coder': 'Qwen3 Coder', 'factory-droid': 'Factory Droid', + copilot: 'GitHub Copilot', terminal: 'Terminal', }; return names[agent] || agent; diff --git a/src/renderer/components/UsageDashboard/LongestAutoRunsTable.tsx b/src/renderer/components/UsageDashboard/LongestAutoRunsTable.tsx index 9d14323233..60775eda84 100644 --- a/src/renderer/components/UsageDashboard/LongestAutoRunsTable.tsx +++ b/src/renderer/components/UsageDashboard/LongestAutoRunsTable.tsx @@ -75,6 +75,7 @@ function formatAgentName(agentType: string): string { 'gemini-cli': 'Gemini CLI', 'qwen3-coder': 'Qwen3 Coder', 'factory-droid': 'Factory Droid', + copilot: 'GitHub Copilot', terminal: 'Terminal', }; return names[agentType] || agentType; diff --git a/src/renderer/components/UsageDashboard/SessionStats.tsx b/src/renderer/components/UsageDashboard/SessionStats.tsx index e4e6a48388..bccdc79adc 100644 --- a/src/renderer/components/UsageDashboard/SessionStats.tsx +++ b/src/renderer/components/UsageDashboard/SessionStats.tsx @@ -94,6 +94,7 @@ function formatAgentName(toolType: ToolType): string { 'gemini-cli': 'Gemini CLI', 'qwen3-coder': 'Qwen3 Coder', 'factory-droid': 'Factory Droid', + copilot: 'GitHub Copilot', terminal: 'Terminal', }; return names[toolType] || toolType; diff --git a/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx b/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx index c31ca84a64..baee2d158b 100644 --- a/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx +++ b/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx @@ -38,7 +38,7 @@ export interface AgentTile { /** * Define the agents to display in the grid - * Supported agents: Claude Code, Codex, OpenCode (shown first) + * Supported agents: Claude Code, Codex, OpenCode, Factory Droid, Copilot (shown first) * Unsupported agents: shown ghosted with "Coming soon" (at bottom) */ export const AGENT_TILES: AgentTile[] = [ @@ -71,6 +71,13 @@ export const AGENT_TILES: AgentTile[] = [ description: "Factory's AI coding assistant", brandColor: '#3B82F6', // Factory blue }, + { + id: 'copilot', + name: 'GitHub Copilot', + supported: true, + description: "GitHub's AI coding assistant", + brandColor: '#24292F', // GitHub dark gray + }, // Coming soon agents at the bottom { id: 'gemini-cli', @@ -88,9 +95,9 @@ export const AGENT_TILES: AgentTile[] = [ }, ]; -// Grid dimensions for keyboard navigation (3 cols for 6 items) +// Grid dimensions for keyboard navigation const GRID_COLS = 3; -const GRID_ROWS = 2; +const GRID_ROWS = Math.ceil(AGENT_TILES.length / GRID_COLS); /** * Get SVG logo for an agent with brand colors @@ -275,6 +282,27 @@ export function AgentLogo({ ); + case 'copilot': + return ( + + + + + + + ); + default: return (
diff --git a/src/renderer/components/Wizard/services/conversationManager.ts b/src/renderer/components/Wizard/services/conversationManager.ts index f71a6ae866..d5ce415102 100644 --- a/src/renderer/components/Wizard/services/conversationManager.ts +++ b/src/renderer/components/Wizard/services/conversationManager.ts @@ -738,6 +738,21 @@ class ConversationManager { return args; } + case 'copilot': { + // Copilot: base args + JSON output format + read-only enforcement + const args = [...(agent.args || [])]; + + if (agent.jsonOutputArgs) { + args.push(...agent.jsonOutputArgs); + } + + if (agent.readOnlyArgs) { + args.push(...agent.readOnlyArgs); + } + + return args; + } + default: { // For unknown agents, use base args return [...(agent.args || [])]; @@ -790,6 +805,7 @@ class ConversationManager { * Extract the result text from agent JSON output. * Handles different agent output formats: * - Claude Code: stream-json with { type: 'result', result: '...' } + * - Copilot: JSONL with { type: 'assistant.message', data: { phase: 'final_answer', content: '...' } } * - OpenCode: JSONL with { type: 'text', part: { text: '...' } } * - Codex: JSONL with { type: 'message', content: '...' } or similar */ @@ -847,6 +863,21 @@ class ConversationManager { } } + // For Copilot: look for the final assistant message + if (agentType === 'copilot') { + for (const line of lines) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + if (msg.type === 'assistant.message' && msg.data?.phase === 'final_answer') { + return typeof msg.data?.content === 'string' ? msg.data.content : null; + } + } catch { + // Ignore non-JSON lines + } + } + } + // For Claude Code: look for result message for (const line of lines) { if (!line.trim()) continue; diff --git a/src/renderer/constants/agentIcons.ts b/src/renderer/constants/agentIcons.ts index e84a0344d1..5e9be0ffef 100644 --- a/src/renderer/constants/agentIcons.ts +++ b/src/renderer/constants/agentIcons.ts @@ -45,6 +45,9 @@ export const AGENT_ICONS: Record = { // Enterprise 'factory-droid': '🏭', + // GitHub + copilot: '✈️', + // Terminal/shell (internal) terminal: '💻', }; diff --git a/src/renderer/constants/app.ts b/src/renderer/constants/app.ts index ec32890313..43732fa5ab 100644 --- a/src/renderer/constants/app.ts +++ b/src/renderer/constants/app.ts @@ -101,17 +101,37 @@ export const OPENCODE_BUILTIN_COMMANDS: Record = { models: 'Switch models interactively', }; +/** + * Built-in GitHub Copilot CLI slash commands with their descriptions + */ +export const COPILOT_BUILTIN_COMMANDS: Record = { + help: 'Show available commands and their usage', + clear: 'Clear conversation history and start fresh', + compact: 'Summarize conversation to reduce context usage', + context: 'Show current context window and token usage', + model: 'Switch to a different AI model', + usage: 'Show token and premium request usage', + session: 'Display session details and metrics', + share: 'Export session as markdown or Gist', + mcp: 'Manage MCP server configurations', + fleet: 'Run tasks in parallel with multiple subagents', + tasks: 'Monitor and manage fleet subtask progress', + delegate: 'Delegate a task to another Copilot agent', + review: 'Review code changes', +}; + /** * Agent-specific built-in command maps, keyed by agent ID */ const AGENT_BUILTIN_COMMANDS: Record> = { 'claude-code': CLAUDE_BUILTIN_COMMANDS, opencode: OPENCODE_BUILTIN_COMMANDS, + copilot: COPILOT_BUILTIN_COMMANDS, }; /** * Get description for agent slash commands. - * Checks all known agent built-in command maps, then falls back to generic description. + * Checks agent-specific built-in command maps first, then falls back to generic description. */ export function getSlashCommandDescription(cmd: string, agentId?: string): string { // Remove leading slash if present @@ -122,12 +142,10 @@ export function getSlashCommandDescription(cmd: string, agentId?: string): strin return AGENT_BUILTIN_COMMANDS[agentId][cmdName]; } - // Check all agent command maps only when no specific agent was requested - if (!agentId) { - for (const commands of Object.values(AGENT_BUILTIN_COMMANDS)) { - if (commands[cmdName]) { - return commands[cmdName]; - } + // Check all agent command maps (for backwards compatibility when agentId is not provided) + for (const commands of Object.values(AGENT_BUILTIN_COMMANDS)) { + if (commands[cmdName]) { + return commands[cmdName]; } } diff --git a/src/renderer/hooks/agent/useAgentListeners.ts b/src/renderer/hooks/agent/useAgentListeners.ts index 1bd66c73fa..959fc79ea1 100644 --- a/src/renderer/hooks/agent/useAgentListeners.ts +++ b/src/renderer/hooks/agent/useAgentListeners.ts @@ -50,11 +50,189 @@ import { parseSynopsis } from '../../../shared/synopsis'; import { autorunSynopsisPrompt } from '../../../prompts'; import type { RightPanelHandle } from '../../components/RightPanel'; import { useGroupChatStore } from '../../stores/groupChatStore'; +import { buildHiddenProgressLogId } from '../../utils/hiddenProgress'; // ============================================================================ // Types // ============================================================================ +type HiddenProgressKind = 'thinking' | 'tool'; +type ToolProgressState = NonNullable['toolState']; + +function truncateProgressText(value: string, maxLength = 80): string { + const trimmed = value.trim().replace(/\s+/g, ' '); + if (!trimmed) return ''; + return trimmed.length <= maxLength ? trimmed : `${trimmed.slice(0, maxLength - 3)}...`; +} + +function safeProgressString(value: unknown, maxLength = 80): string | null { + return typeof value === 'string' && value.trim() ? truncateProgressText(value, maxLength) : null; +} + +function summarizeTodoProgress(todos: unknown): string | null { + if (!Array.isArray(todos) || todos.length === 0) { + return null; + } + + const total = todos.length; + const inProgress = todos.find( + (todo): todo is { activeForm?: string; content?: string; status?: string } => + typeof todo === 'object' && todo !== null && 'status' in todo && todo.status === 'in_progress' + ); + + const label = + (typeof inProgress?.activeForm === 'string' && inProgress.activeForm.trim()) || + (typeof inProgress?.content === 'string' && inProgress.content.trim()) || + (typeof todos[0] === 'object' && + todos[0] !== null && + 'content' in todos[0] && + typeof todos[0].content === 'string' + ? todos[0].content + : null); + + return label + ? `${truncateProgressText(label, 60)} (${total} todo${total === 1 ? '' : 's'})` + : null; +} + +function extractToolProgressDetail(input: unknown): string | null { + if (!input || typeof input !== 'object') { + return null; + } + + const toolInput = input as Record; + + return ( + safeProgressString(toolInput.command, 90) || + safeProgressString(toolInput.pattern, 70) || + safeProgressString(toolInput.file_path, 70) || + safeProgressString(toolInput.filePath, 70) || + safeProgressString(toolInput.path, 70) || + safeProgressString(toolInput.query, 70) || + safeProgressString(toolInput.description, 70) || + safeProgressString(toolInput.prompt, 70) || + safeProgressString(toolInput.task_id, 70) || + safeProgressString(toolInput.cmd, 90) || + safeProgressString(toolInput.code, 90) || + safeProgressString(toolInput.content, 90) || + summarizeTodoProgress(toolInput.todos) + ); +} + +function formatToolProgressText(toolName: string, toolState?: ToolProgressState): string { + const normalizedToolName = toolName.trim() || 'tool'; + const toolNameLower = normalizedToolName.toLowerCase(); + const detail = extractToolProgressDetail(toolState?.input); + const status = toolState?.status; + + const withDetail = (prefix: string, fallbackSuffix = '...') => + detail ? `${prefix} ${detail}` : `${prefix}${fallbackSuffix}`; + + if (status === 'failed' || status === 'error') { + if (toolNameLower === 'bash' || toolNameLower === 'shell') { + return detail ? `Command failed: ${detail}` : 'Command failed'; + } + return detail ? `${normalizedToolName} failed: ${detail}` : `${normalizedToolName} failed`; + } + + if (status === 'completed') { + if (toolNameLower === 'view' || toolNameLower === 'read') { + return detail ? `Read ${detail}` : 'Finished reading'; + } + if ( + toolNameLower === 'rg' || + toolNameLower === 'grep' || + toolNameLower === 'glob' || + toolNameLower === 'search' + ) { + return detail ? `Searched ${detail}` : 'Search complete'; + } + if (toolNameLower === 'bash' || toolNameLower === 'shell') { + return detail ? `Ran ${detail}` : 'Command completed'; + } + if ( + toolNameLower === 'write' || + toolNameLower === 'edit' || + toolNameLower === 'apply_patch' || + toolNameLower === 'create' + ) { + return detail ? `Updated ${detail}` : 'Update complete'; + } + return detail + ? `Completed ${normalizedToolName}: ${detail}` + : `Completed ${normalizedToolName}`; + } + + if (toolNameLower === 'view' || toolNameLower === 'read') { + return withDetail('Reading'); + } + if ( + toolNameLower === 'rg' || + toolNameLower === 'grep' || + toolNameLower === 'glob' || + toolNameLower === 'search' + ) { + return withDetail('Searching'); + } + if (toolNameLower === 'bash' || toolNameLower === 'shell') { + return withDetail('Running'); + } + if ( + toolNameLower === 'write' || + toolNameLower === 'edit' || + toolNameLower === 'apply_patch' || + toolNameLower === 'create' + ) { + return withDetail('Editing'); + } + if (toolNameLower === 'task') { + return withDetail('Delegating'); + } + if (toolNameLower === 'todowrite') { + return withDetail('Updating'); + } + + return detail ? `Using ${normalizedToolName}: ${detail}` : `Using ${normalizedToolName}...`; +} + +function upsertHiddenProgressLog( + logs: LogEntry[], + tabId: string, + text: string, + timestamp: number, + kind: HiddenProgressKind, + toolState?: ToolProgressState, + toolName?: string +): LogEntry[] { + const hiddenLogId = buildHiddenProgressLogId(tabId); + const existingIndex = logs.findIndex((log) => log.id === hiddenLogId); + const hiddenLog: LogEntry = { + id: hiddenLogId, + timestamp, + source: 'system', + text, + metadata: { + toolState, + hiddenProgress: { + kind, + toolName, + }, + }, + }; + + if (existingIndex === -1) { + return [...logs, hiddenLog]; + } + + return logs.map((log, index) => (index === existingIndex ? hiddenLog : log)); +} + +function removeHiddenProgressLog(logs: LogEntry[], tabId: string): LogEntry[] { + const hiddenLogId = buildHiddenProgressLogId(tabId); + const updatedLogs = logs.filter((log) => log.id !== hiddenLogId); + return updatedLogs.length === logs.length ? logs : updatedLogs; +} + /** Batched updater interface (subset used by IPC listeners) */ export interface BatchedUpdater { appendLog: ( @@ -132,6 +310,29 @@ export interface UseAgentListenersDeps { // Helpers // ============================================================================ +function isMatchingAgentErrorLog(log: LogEntry, agentError: AgentError): boolean { + if (log.source !== 'error' || !log.agentError) { + return false; + } + + return ( + log.agentError.timestamp === agentError.timestamp && + log.agentError.type === agentError.type && + log.agentError.message === agentError.message && + log.agentError.agentId === agentError.agentId + ); +} + +function removeMatchingAgentErrorLog(logs: LogEntry[], agentError: AgentError): LogEntry[] { + for (let index = logs.length - 1; index >= 0; index -= 1) { + if (isMatchingAgentErrorLog(logs[index], agentError)) { + return [...logs.slice(0, index), ...logs.slice(index + 1)]; + } + } + + return logs; +} + /** * Get a human-readable title for an agent error type. * Used for toast notifications and history entries. @@ -174,10 +375,14 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { // Internal refs — only used by IPC listeners, not needed outside this hook const thinkingChunkBufferRef = useRef>(new Map()); const thinkingChunkRafIdRef = useRef(null); + const activeHiddenToolRef = useRef< + Map + >(new Map()); useEffect(() => { // Copy ref value to local variable for cleanup (React ESLint rule) const thinkingChunkBuffer = thinkingChunkBufferRef.current; + const activeHiddenTools = activeHiddenToolRef.current; // Stable references from stores (Zustand actions are referentially stable) const setSessions = useSessionStore.getState().setSessions; @@ -240,6 +445,23 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { return; } + activeHiddenTools.delete(`${actualSessionId}:${targetTabId}`); + + setSessions((prev) => + prev.map((s) => { + if (s.id !== actualSessionId) return s; + let didChange = false; + const updatedTabs = s.aiTabs.map((tab) => { + if (tab.id !== targetTabId) return tab; + const updatedLogs = removeHiddenProgressLog(tab.logs, targetTabId); + if (updatedLogs === tab.logs) return tab; + didChange = true; + return { ...tab, logs: updatedLogs }; + }); + return didChange ? { ...s, aiTabs: updatedTabs } : s; + }) + ); + // Batch the log append, delivery mark, unread mark, and byte tracking deps.batchedUpdater.appendLog(actualSessionId, targetTabId, true, data); deps.batchedUpdater.markDelivered(actualSessionId, targetTabId); @@ -248,11 +470,23 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { // Clear error state if session had an error but is now receiving successful data const sessionForErrorCheck = getSessions().find((s) => s.id === actualSessionId); if (sessionForErrorCheck?.agentError) { + const activeAgentError = sessionForErrorCheck.agentError; + const errorTabId = sessionForErrorCheck.agentErrorTabId ?? targetTabId; + setSessions((prev) => prev.map((s) => { if (s.id !== actualSessionId) return s; const updatedAiTabs = s.aiTabs.map((tab) => - tab.id === targetTabId ? { ...tab, agentError: undefined } : tab + tab.id === targetTabId || tab.id === errorTabId + ? { + ...tab, + logs: + tab.id === errorTabId + ? removeMatchingAgentErrorLog(tab.logs, activeAgentError) + : tab.logs, + agentError: undefined, + } + : tab ); return { ...s, @@ -318,6 +552,10 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { isFromAi = false; } + if (isFromAi && tabIdFromSession) { + activeHiddenTools.delete(`${actualSessionId}:${tabIdFromSession}`); + } + // SAFETY CHECK: Verify the process is actually gone if (isFromAi) { try { @@ -516,6 +754,7 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { return tab.id === tabIdFromSession ? { ...tab, + logs: removeHiddenProgressLog(tab.logs, tab.id), state: 'idle' as const, thinkingStartTime: undefined, } @@ -524,6 +763,7 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { return tab.state === 'busy' ? { ...tab, + logs: removeHiddenProgressLog(tab.logs, tab.id), state: 'idle' as const, thinkingStartTime: undefined, } @@ -570,6 +810,7 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { if (tabIdFromSession && tab.id === tabIdFromSession) { return { ...tab, + logs: removeHiddenProgressLog(tab.logs, tab.id), state: 'idle' as const, }; } @@ -613,6 +854,7 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { return tab.id === tabIdFromSession ? { ...tab, + logs: removeHiddenProgressLog(tab.logs, tab.id), state: 'idle' as const, thinkingStartTime: undefined, } @@ -621,6 +863,7 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { return tab.state === 'busy' ? { ...tab, + logs: removeHiddenProgressLog(tab.logs, tab.id), state: 'idle' as const, thinkingStartTime: undefined, } @@ -974,7 +1217,7 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { ); // ================================================================ - // onSlashCommands — Handle slash commands from Claude Code init + // onSlashCommands — Handle slash commands from agent init/discovery // ================================================================ const unsubscribeSlashCommands = window.maestro.process.onSlashCommands( (sessionId: string, slashCommands: string[]) => { @@ -983,19 +1226,11 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { setSessions((prev) => prev.map((s) => { if (s.id !== actualSessionId) return s; - const newCommands = slashCommands.map((cmd) => ({ + const commands = slashCommands.map((cmd) => ({ command: cmd.startsWith('/') ? cmd : `/${cmd}`, description: getSlashCommandDescription(cmd, s.toolType), })); - // Merge with existing commands, preserving prompt data from - // disk-discovered commands (e.g., OpenCode .md files) - const existingByName = new Map((s.agentCommands || []).map((c) => [c.command, c])); - for (const cmd of newCommands) { - if (!existingByName.has(cmd.command)) { - existingByName.set(cmd.command, cmd); - } - } - return { ...s, agentCommands: Array.from(existingByName.values()) }; + return { ...s, agentCommands: commands }; }) ); } @@ -1212,6 +1447,10 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { agentError: isSessionNotFound ? undefined : agentError, }; + if (tabIdFromSession) { + activeHiddenTools.delete(`${actualSessionId}:${tabIdFromSession}`); + } + setSessions((prev) => prev.map((s) => { if (s.id !== actualSessionId) return s; @@ -1224,7 +1463,7 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { tab.id === targetTab.id ? { ...tab, - logs: [...tab.logs, errorLogEntry], + logs: [...removeHiddenProgressLog(tab.logs, tab.id), errorLogEntry], agentError: isSessionNotFound ? undefined : agentError, } : tab @@ -1362,7 +1601,28 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { const targetTab = updatedTabs.find((t) => t.id === chunkTabId); if (!targetTab) continue; - if (!targetTab.showThinking || targetTab.showThinking === 'off') continue; + if (!targetTab.showThinking || targetTab.showThinking === 'off') { + const activeTool = activeHiddenTools.get(key); + if (activeTool?.toolState?.status === 'running') { + continue; + } + + updatedTabs = updatedTabs.map((tab) => + tab.id === chunkTabId + ? { + ...tab, + logs: upsertHiddenProgressLog( + tab.logs, + chunkTabId, + 'Thinking through the next step...', + Date.now(), + 'thinking' + ), + } + : tab + ); + continue; + } if (isLikelyConcatenatedToolNames(bufferedContent)) { console.warn( @@ -1524,7 +1784,49 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { if (s.id !== actualSessionId) return s; const targetTab = s.aiTabs.find((t) => t.id === tabId); - if (!targetTab?.showThinking || targetTab.showThinking === 'off') return s; + if (!targetTab) return s; + + if (!targetTab.showThinking || targetTab.showThinking === 'off') { + const incomingToolState = toolEvent.state as ToolProgressState | undefined; + const hiddenToolKey = `${actualSessionId}:${tabId}`; + const previousHiddenTool = activeHiddenTools.get(hiddenToolKey); + const toolState: ToolProgressState | undefined = incomingToolState + ? { + ...incomingToolState, + input: incomingToolState.input ?? previousHiddenTool?.toolState?.input, + } + : previousHiddenTool?.toolState; + const statusText = formatToolProgressText(toolEvent.toolName, toolState); + + if (toolState?.status === 'running') { + activeHiddenTools.set(hiddenToolKey, { + toolName: toolEvent.toolName, + toolState, + }); + } else { + activeHiddenTools.delete(hiddenToolKey); + } + + return { + ...s, + aiTabs: s.aiTabs.map((tab) => + tab.id === tabId + ? { + ...tab, + logs: upsertHiddenProgressLog( + tab.logs, + tabId, + statusText, + toolEvent.timestamp, + 'tool', + toolState, + toolEvent.toolName + ), + } + : tab + ), + }; + } const toolLog: LogEntry = { id: `tool-${Date.now()}-${toolEvent.toolName}`, @@ -1573,6 +1875,7 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { thinkingChunkRafIdRef.current = null; } thinkingChunkBuffer.clear(); + activeHiddenTools.clear(); }; }, []); } diff --git a/src/renderer/hooks/wizard/useWizardHandlers.ts b/src/renderer/hooks/wizard/useWizardHandlers.ts index 53cb4a8300..f3122816d9 100644 --- a/src/renderer/hooks/wizard/useWizardHandlers.ts +++ b/src/renderer/hooks/wizard/useWizardHandlers.ts @@ -180,7 +180,12 @@ export function useWizardHandlers(deps: UseWizardHandlersDeps): UseWizardHandler .getState() .sessions.find((s) => s.id === activeSession?.id); if (!currentSession) return; - if (currentSession.toolType !== 'claude-code' && currentSession.toolType !== 'opencode') return; + if ( + currentSession.toolType !== 'claude-code' && + currentSession.toolType !== 'opencode' && + currentSession.toolType !== 'copilot' + ) + return; if (currentSession.agentCommands && currentSession.agentCommands.length > 0) return; const sessionId = currentSession.id; diff --git a/src/renderer/services/inlineWizardConversation.ts b/src/renderer/services/inlineWizardConversation.ts index 40f5845052..1e4ee02ad6 100644 --- a/src/renderer/services/inlineWizardConversation.ts +++ b/src/renderer/services/inlineWizardConversation.ts @@ -365,9 +365,8 @@ export function parseWizardResponse(response: string): WizardResponse | null { } /** - * Extract the agent session ID (session_id) from Claude Code JSON output. - * This is the Claude-side session ID that can be used to resume the session. - * Returns the first session_id found in init or result messages. + * Extract the provider session ID from agent JSON output. + * Returns the first session identifier found in init or result-style messages. */ function extractAgentSessionIdFromOutput(output: string): string | null { try { @@ -376,10 +375,15 @@ function extractAgentSessionIdFromOutput(output: string): string | null { if (!line.trim()) continue; try { const msg = JSON.parse(line); - // session_id appears in init and result messages if (msg.session_id) { return msg.session_id; } + if (msg.sessionId) { + return msg.sessionId; + } + if (msg.data?.sessionId) { + return msg.data.sessionId; + } } catch { // Ignore non-JSON lines } @@ -392,7 +396,7 @@ function extractAgentSessionIdFromOutput(output: string): string | null { /** * Extract the result text from agent JSON output. - * Handles different agent output formats (Claude Code stream-json, etc.) + * Handles different agent output formats (Claude Code, Copilot, OpenCode, Codex). */ function extractResultFromStreamJson(output: string, agentType: ToolType): string | null { try { @@ -443,6 +447,21 @@ function extractResultFromStreamJson(output: string, agentType: ToolType): strin } } + // For Copilot: final answers arrive as assistant.message with phase=final_answer + if (agentType === 'copilot') { + for (const line of lines) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + if (msg.type === 'assistant.message' && msg.data?.phase === 'final_answer') { + return typeof msg.data?.content === 'string' ? msg.data.content : null; + } + } catch { + // Ignore non-JSON lines + } + } + } + // For Claude Code: look for result message for (const line of lines) { if (!line.trim()) continue; @@ -511,6 +530,14 @@ function buildArgsForAgent(agent: any): string[] { return args; } + case 'copilot': { + const args = [...(agent.args || [])]; + if (agent.readOnlyArgs) { + args.push(...agent.readOnlyArgs); + } + return args; + } + default: { return [...(agent.args || [])]; } diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index 9ee8766a29..158af23107 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -197,10 +197,14 @@ export interface LogEntry { // For tool execution entries - stores tool state and details metadata?: { toolState?: { - status?: 'running' | 'completed' | 'error'; + status?: 'running' | 'completed' | 'error' | 'failed'; input?: unknown; output?: unknown; }; + hiddenProgress?: { + kind: 'thinking' | 'tool'; + toolName?: string; + }; }; } diff --git a/src/renderer/utils/hiddenProgress.ts b/src/renderer/utils/hiddenProgress.ts new file mode 100644 index 0000000000..b13e847883 --- /dev/null +++ b/src/renderer/utils/hiddenProgress.ts @@ -0,0 +1,11 @@ +import type { LogEntry } from '../types'; + +export const HIDDEN_PROGRESS_LOG_PREFIX = 'hidden-progress:'; + +export function buildHiddenProgressLogId(tabId: string): string { + return `${HIDDEN_PROGRESS_LOG_PREFIX}${tabId}`; +} + +export function isHiddenProgressLog(log: Pick): boolean { + return log.source === 'system' && log.id.startsWith(HIDDEN_PROGRESS_LOG_PREFIX); +} diff --git a/src/shared/agentConstants.ts b/src/shared/agentConstants.ts index 38cc1ba03e..d5724c3bef 100644 --- a/src/shared/agentConstants.ts +++ b/src/shared/agentConstants.ts @@ -18,6 +18,7 @@ export const DEFAULT_CONTEXT_WINDOWS: Partial> = { codex: 200000, // OpenAI o3/o4-mini context window opencode: 128000, // OpenCode (depends on model, 128k is conservative default) 'factory-droid': 200000, // Factory Droid (varies by model, defaults to Claude Opus) + copilot: 200000, // GitHub Copilot (varies by model, defaults to Claude Sonnet) terminal: 0, // Terminal has no context window }; diff --git a/src/shared/agentIds.ts b/src/shared/agentIds.ts index c6716d4dfd..673efadb61 100644 --- a/src/shared/agentIds.ts +++ b/src/shared/agentIds.ts @@ -22,6 +22,7 @@ export const AGENT_IDS = [ 'opencode', 'factory-droid', 'aider', + 'copilot', ] as const; /** diff --git a/src/shared/agentMetadata.ts b/src/shared/agentMetadata.ts index 81b24d9f3d..3025104a52 100644 --- a/src/shared/agentMetadata.ts +++ b/src/shared/agentMetadata.ts @@ -21,6 +21,7 @@ export const AGENT_DISPLAY_NAMES: Record = { opencode: 'OpenCode', 'factory-droid': 'Factory Droid', aider: 'Aider', + copilot: 'GitHub Copilot', }; /** @@ -42,6 +43,7 @@ export const BETA_AGENTS: ReadonlySet = new Set([ 'codex', 'opencode', 'factory-droid', + 'copilot', ]); /**