feat(agents): Dispatch and supervise coding agents over ACP (#72)

## What

An Agents feature: hand a task to a coding agent and follow it from the IDE. Agents speak the [Agent Client Protocol](https://agentclientprotocol.com). Built in: Claude Code, Codex, Gemini CLI, GitHub Copilot, Cursor, OpenCode, Qwen Code, Kimi CLI, Goose, Auggie, Kiro, Mistral Vibe. You can add your own in `<config>/agents/<id>.toml` (`name`, `command`, `args`, `[env]`). An agent whose command isn't on `PATH` is listed under "Not installed".

- **New Task page.** An editor page with a prompt box. You pick the agent, model, mode and thinking effort, and whether it runs in a new worktree (from a chosen branch) or in the checkout.
- **Agents tool window.** Tasks are grouped by what they need: Needs Input, Failed, Running, Ready to Review, Archived. It also shows a stripe badge, a status bar item and notifications.
- **Session tab.**
  - The conversation: Markdown, thinking, tool calls with diff stats, the plan.
  - Permission requests appear as an editor banner.
  - The changed files are compared against the task's base commit and open in the diff viewer.
  - A context-usage meter.
- **Composer.**
  - `@` references files from the project index. A referenced file's text is sent when the agent accepts embedded context, otherwise a `resource_link`.
  - `/` lists the agent's commands; ↑/↓ recall earlier messages.
  - Images can be pasted, dropped or picked; Shift+Tab cycles the mode.
  - While the agent works, Enter queues the message and ⌘↵ interrupts the turn and sends it now. Queued messages can be sent now, edited or removed.
- **Send to Agent.** In the editor and Project view context menus. It adds `@file:10-20` references to the message you typed last.
- Landing the work is left to the existing Git features: the worktree is a normal branch.

## How

- **`crates/ide-agent`**: one ACP agent process per task, running in the task's directory.
  - It folds `session/update` into a timeline and answers `session/request_permission`.
  - It tracks the session's settings: `configOptions`, falling back to the older `modes` / `models` fields. The user's choices are applied again on every new or reloaded session.
  - Tasks are saved as JSON per task. Sessions resume with `session/load` without duplicating history, and old task files are upgraded when read.
- **`ide-jsonrpc`**: adds newline-delimited framing (ACP) next to Content-Length (LSP).
- **`ide-vcs`**: adds `numstat_with_worktree` for the changes list.
- **Editor**:
  - The Switcher (⌃⇥) lists every open editor, as IDEA does, not just files.
  - `EditorKindContribution.restorable` lets a kind's tabs be saved with the editor session, so agent tabs survive reloads.
- **UI**: `Select` popups now size to fit short option details (they used to cut them off).

## Testing

- `cargo test -p ide-agent` (14 tests). They use an in-memory ACP agent and cover permissions, restart with `session/load`, settings, the queue with send-now and stop, and prompt blocks.
- There is also an ignored end-to-end test against the real Claude Code adapter: `OPENIDEA_AGENT_E2E=1 cargo test -p ide-agent real_claude -- --ignored`.
- `pnpm vitest run`: 2052 tests.
- `cargo clippy -p ide-agent -p openidea --all-targets -- -D warnings` and typecheck are clean.
- Driven in the running app with Claude Code:
  - A task in a worktree, with permission answers, changes listed and the diff opened.
  - A follow-up after restarting the IDE.
  - `@` reference with embedded content, `/` commands, Shift+Tab mode changes.
  - A queued message and send-now, an image prompt, Send to Agent from a selection.
  - The Switcher and restored tabs after a reload.

Not covered yet: only Claude Code was driven end to end. The other agents go through the same ACP path but weren't run here.


Merged on the maintainer's go-ahead. Merge with main: kept both sides in Cargo.toml (ide-agent + ide-clipboard), the feature registry (agents + vim) and zh.json (no duplicate keys). After the merge: typecheck clean, full vitest 268 files / 2247 tests passed, ide-agent/ide-jsonrpc/ide-vcs 144 passed, bindings unchanged after regeneration.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 14:44:28 +08:00
1 джерело b4742b730b
коміт 2eaa6cb0c7
75 files changed
+6377 -63

No files matched your search

+150
Переглянути файл
@@ -0,0 +1,150 @@
use crate::state::AppState;
use ide_agent::{
AgentChange, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask,
};
use ide_core::{IdeResult, IpcPath};
use std::sync::Arc;
use tauri::ipc::Channel;
use tauri::{State, Window};
/// Streams the project's agent events to this window; returns every task.
#[tauri::command]
#[specta::specta]
pub fn agent_subscribe(
window: Window,
state: State<'_, AppState>,
on_event: Channel<AgentEvent>,
) -> IdeResult<Vec<AgentTask>> {
let project = state.project(window.label())?;
Ok(project.agents.subscribe(Arc::new(move |e| {
let _ = on_event.send(e);
})))
}
#[tauri::command]
#[specta::specta]
pub fn agent_providers(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<AgentProvider>> {
Ok(state.project(window.label())?.agents.providers())
}
#[tauri::command]
#[specta::specta]
pub fn agent_entries(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<Vec<AgentEntry>> {
state.project(window.label())?.agents.entries(&task)
}
#[tauri::command]
#[specta::specta]
pub async fn agent_create(window: Window, state: State<'_, AppState>, request: NewAgentTask) -> IdeResult<AgentTask> {
let project = state.project(window.label())?;
project.agents.create(request).await
}
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
#[tauri::command]
#[specta::specta]
pub async fn agent_prompt(
window: Window,
state: State<'_, AppState>,
task: String,
prompt: AgentPrompt,
now: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
project.agents.prompt(&task, prompt, now).await
}
/// Drops a queued message and returns it (to edit it).
#[tauri::command]
#[specta::specta]
pub fn agent_unqueue(
window: Window,
state: State<'_, AppState>,
task: String,
queued: String,
) -> IdeResult<AgentQueued> {
state.project(window.label())?.agents.unqueue(&task, &queued)
}
/// Sends a queued message now, interrupting the current turn.
#[tauri::command]
#[specta::specta]
pub fn agent_send_queued_now(
window: Window,
state: State<'_, AppState>,
task: String,
queued: String,
) -> IdeResult<()> {
state.project(window.label())?.agents.send_queued_now(&task, &queued)
}
/// Changes a session setting (model, mode, thinking effort…).
#[tauri::command]
#[specta::specta]
pub async fn agent_set_config(
window: Window,
state: State<'_, AppState>,
task: String,
option: String,
value: String,
) -> IdeResult<()> {
let project = state.project(window.label())?;
project.agents.set_config(&task, &option, value).await
}
#[tauri::command]
#[specta::specta]
pub fn agent_cancel(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> {
state.project(window.label())?.agents.cancel(&task)
}
/// Answers a permission request; `option: null` cancels it.
#[tauri::command]
#[specta::specta]
pub fn agent_respond(
window: Window,
state: State<'_, AppState>,
task: String,
permission: u32,
option: Option<String>,
) -> IdeResult<()> {
state.project(window.label())?.agents.respond(&task, permission, option)
}
#[tauri::command]
#[specta::specta]
pub fn agent_archive(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> {
state.project(window.label())?.agents.archive(&task)
}
#[tauri::command]
#[specta::specta]
pub async fn agent_delete(
window: Window,
state: State<'_, AppState>,
task: String,
remove_worktree: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
project.agents.delete(&task, remove_worktree).await
}
#[tauri::command]
#[specta::specta]
pub async fn agent_changes(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<Vec<AgentChange>> {
let project = state.project(window.label())?;
project.agents.changes(&task).await
}
/// Content of `path` when the task started (left side of its diff).
#[tauri::command]
#[specta::specta]
pub async fn agent_base_text(
window: Window,
state: State<'_, AppState>,
task: String,
path: IpcPath,
) -> IdeResult<Option<String>> {
let project = state.project(window.label())?;
project.agents.base_text(&task, path.into_path_buf()).await
}
+15
Переглянути файл
@@ -6,6 +6,7 @@
//! * streaming results use a `Channel<T>` argument; cancellable requests take a
//! UI-allocated `RequestId` and are cancelled via `cancel_request`.
pub mod agent;
pub mod analysis;
pub mod app;
pub mod build;
@@ -59,6 +60,20 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
app::project_roots,
app::cancel_request,
app::log_frontend,
agent::agent_subscribe,
agent::agent_providers,
agent::agent_entries,
agent::agent_create,
agent::agent_prompt,
agent::agent_unqueue,
agent::agent_send_queued_now,
agent::agent_set_config,
agent::agent_cancel,
agent::agent_respond,
agent::agent_archive,
agent::agent_delete,
agent::agent_changes,
agent::agent_base_text,
fs::fs_read_dir,
bytes::fs_probe,
bytes::fs_read_range,
+19
Переглянути файл
@@ -74,6 +74,8 @@ pub struct Project {
pub db: Arc<ide_db::DbManager>,
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
pub http_history: Arc<ide_http::History>,
/// Coding agent tasks (Agents tool window).
pub agents: Arc<ide_agent::AgentManager>,
watcher: Watcher,
/// Watchers of the Project Structure content roots outside the project folder.
content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
@@ -403,6 +405,21 @@ impl AppState {
self.data_dir.join("db-history.json"),
));
let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(&project_key)));
let agents = Arc::new(ide_agent::AgentManager::new(
ide_agent::AgentPaths {
project_root: root.clone(),
store_dir: self.data_dir.join("agents").join(&project_key),
// Outside the app data dir: its path has spaces on macOS, which trips up build scripts.
worktrees_dir: dirs::home_dir()
.unwrap_or_else(|| self.data_dir.clone())
.join(".openidea")
.join("worktrees")
.join(&project_key),
models_file: self.data_dir.join("agents").join("models.json"),
custom_agents_dir: Some(self.config_dir.join("agents")),
},
tauri::async_runtime::handle().inner().clone(),
));
let project = Arc::new(Project {
root,
sink,
@@ -420,6 +437,7 @@ impl AppState {
cache_dir: self.data_dir.join("cache").join(&project_key),
db,
http_history,
agents,
watcher,
content_watchers: Mutex::new(content_watchers),
_scratch_watcher: scratch_watcher,
@@ -430,6 +448,7 @@ impl AppState {
pub fn close_project(&self, window: &str) {
if let Some(project) = self.projects.write().remove(window) {
project.agents.shutdown();
let lsp = project.lsp.clone();
tauri::async_runtime::spawn(async move { lsp.shutdown_all().await });
}