дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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.
Цей коміт міститься в:
1 джерело
b4742b730b
коміт
2eaa6cb0c7
75 files changed
+6377
-63
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in new issue
Заблокувати користувача