Файли
openidea/crates/ide-agent/src/config.rs
T
l0ng-ai 2eaa6cb0c7 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.
2026-10-27 14:44:28 +08:00

225 рядки
9.1 KiB
Rust

//! Session settings (model, mode, thinking effort…), slash commands and context use, as
//! the agent reports them over ACP.
use crate::model::{AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentUsage};
use serde_json::{Value, json};
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
/// plus the older `modes` / `models` fields for what those don't cover.
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
let mut out = result.get("configOptions").map(parse_options).unwrap_or_default();
let has = |out: &[AgentConfigOption], category: &str| out.iter().any(|o| o.category.as_deref() == Some(category));
if !has(&out, "mode")
&& let Some(modes) = result.get("modes")
{
out.push(legacy(modes, "mode", "Mode", "availableModes", "id", "currentModeId", AgentConfigVia::Mode));
}
if !has(&out, "model")
&& let Some(models) = result.get("models")
{
out.push(legacy(
models,
"model",
"Model",
"availableModels",
"modelId",
"currentModelId",
AgentConfigVia::Model,
));
}
out.retain(|o| !o.values.is_empty());
out
}
/// A `configOptions` array; only select options are kept (the UI shows them as dropdowns).
pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
let Some(list) = options.as_array() else { return Vec::new() };
list.iter()
.filter(|o| o.get("type").and_then(Value::as_str).is_none_or(|t| t == "select"))
.filter_map(|o| {
let id = o.get("id").or_else(|| o.get("configId")).and_then(Value::as_str)?.to_owned();
let mut values = Vec::new();
for v in o.get("options").and_then(Value::as_array).into_iter().flatten() {
// Grouped options: `{ group, name, options: [...] }`.
match v.get("options").and_then(Value::as_array) {
Some(group) => values.extend(group.iter().filter_map(value_of)),
None => values.extend(value_of(v)),
}
}
Some(AgentConfigOption {
name: str_of(o, "name").unwrap_or_else(|| id.clone()),
description: str_of(o, "description"),
category: str_of(o, "category").or_else(|| matches!(id.as_str(), "model" | "mode").then(|| id.clone())),
current: str_of(o, "currentValue"),
values,
via: AgentConfigVia::Option,
id,
})
})
.collect()
}
/// A `config_option_update`: its options replace the config-option ones; `modes` / `models`
/// stand-ins stay.
pub(crate) fn merge_update(config: &mut Vec<AgentConfigOption>, options: &Value) {
let fresh = parse_options(options);
let covered = |o: &AgentConfigOption| fresh.iter().any(|f| f.category.is_some() && f.category == o.category);
config.retain(|o| o.via != AgentConfigVia::Option && !covered(o));
let legacy = std::mem::take(config);
config.extend(fresh);
config.extend(legacy);
config.retain(|o| !o.values.is_empty());
}
/// `current_mode_update`.
pub(crate) fn set_mode(config: &mut [AgentConfigOption], mode: &str) {
if let Some(o) = config.iter_mut().find(|o| o.category.as_deref() == Some("mode")) {
o.current = Some(mode.to_owned());
}
}
/// The request (method, params without the session id) that sets `option` to `value`.
pub(crate) fn set_request(option: &AgentConfigOption, value: &str) -> (&'static str, Value) {
match option.via {
AgentConfigVia::Option => ("session/set_config_option", json!({ "configId": option.id, "value": value })),
AgentConfigVia::Mode => ("session/set_mode", json!({ "modeId": value })),
AgentConfigVia::Model => ("session/set_model", json!({ "modelId": value })),
}
}
/// `available_commands_update`.
pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
update
.get("availableCommands")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|c| {
Some(AgentCommand {
name: c.get("name")?.as_str()?.trim_start_matches('/').to_owned(),
description: str_of(c, "description").unwrap_or_default(),
hint: c.pointer("/input/hint").and_then(Value::as_str).filter(|h| !h.is_empty()).map(str::to_owned),
})
})
.collect()
}
/// `usage_update`: `{ used, size, cost?: { amount, currency } }`.
pub(crate) fn usage_of(update: &Value) -> Option<AgentUsage> {
let tokens = |key: &str| update.get(key)?.as_f64().map(|n| n.clamp(0.0, f64::from(u32::MAX)) as u32);
let used = tokens("used")?;
let size = tokens("size").filter(|s| *s > 0)?;
Some(AgentUsage {
used,
size,
cost: update.pointer("/cost/amount").and_then(Value::as_f64),
currency: update.pointer("/cost/currency").and_then(Value::as_str).map(str::to_owned),
})
}
fn legacy(
v: &Value,
id: &str,
name: &str,
list: &str,
key: &str,
current: &str,
via: AgentConfigVia,
) -> AgentConfigOption {
let values = v
.get(list)
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|m| {
let value = m.get(key)?.as_str()?.to_owned();
Some(AgentConfigValue {
name: str_of(m, "name").unwrap_or_else(|| value.clone()),
description: str_of(m, "description"),
value,
})
})
.collect();
AgentConfigOption {
id: id.to_owned(),
name: name.to_owned(),
description: None,
category: Some(id.to_owned()),
current: str_of(v, current),
values,
via,
}
}
fn value_of(v: &Value) -> Option<AgentConfigValue> {
let value = v.get("value")?.as_str()?.to_owned();
Some(AgentConfigValue {
name: str_of(v, "name").unwrap_or_else(|| value.clone()),
description: str_of(v, "description"),
value,
})
}
fn str_of(v: &Value, key: &str) -> Option<String> {
v.get(key).and_then(Value::as_str).map(str::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(c: &[AgentConfigOption]) -> Vec<(&str, Option<&str>)> {
c.iter().map(|o| (o.id.as_str(), o.current.as_deref())).collect()
}
#[test]
fn config_options_win_over_the_older_fields() {
let r = json!({
"modes": {"currentModeId": "default", "availableModes": [{"id": "default", "name": "Manual"}]},
"configOptions": [
{"id": "mode", "category": "mode", "type": "select", "currentValue": "plan",
"options": [{"value": "default", "name": "Manual"}, {"value": "plan", "name": "Plan"}]},
{"id": "effort", "category": "thought_level", "type": "select", "currentValue": "medium",
"options": [{"group": "g", "name": "G", "options": [{"value": "low"}, {"value": "medium", "name": "Medium"}]}]},
{"id": "fast", "type": "boolean", "currentValue": false}
],
"models": {"currentModelId": "m1", "availableModels": [{"modelId": "m1", "name": "One"}]}
});
let c = options_of(&r);
assert_eq!(ids(&c), [("mode", Some("plan")), ("effort", Some("medium")), ("model", Some("m1"))]);
assert_eq!(c[1].values.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["low", "Medium"]);
assert_eq!(set_request(&c[0], "default").0, "session/set_config_option");
assert_eq!(set_request(&c[2], "m1"), ("session/set_model", json!({"modelId": "m1"})));
}
#[test]
fn updates_replace_options_and_track_the_mode() {
let mut c = options_of(&json!({
"modes": {"currentModeId": "ask", "availableModes": [{"id": "ask"}, {"id": "code"}]},
"configOptions": [{"id": "model", "type": "select", "currentValue": "a", "options": [{"value": "a"}]}]
}));
assert_eq!(c[0].category.as_deref(), Some("model"), "the category defaults from the id");
set_mode(&mut c, "code");
assert_eq!(c[1].current.as_deref(), Some("code"));
assert_eq!(set_request(&c[1], "ask").0, "session/set_mode");
merge_update(
&mut c,
&json!([{"id": "model", "type": "select", "currentValue": "b", "options": [{"value": "b"}]}]),
);
assert_eq!(ids(&c), [("model", Some("b")), ("mode", Some("code"))]);
}
#[test]
fn commands_and_usage() {
let cmds = commands_of(&json!({"availableCommands": [
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
]}));
assert_eq!(cmds[0].hint.as_deref(), Some("what to review"));
assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None));
let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap();
assert_eq!((u.used, u.cost, u.currency.as_deref()), (1200, Some(0.42), Some("USD")));
assert!(usage_of(&json!({"used": 1, "size": 0})).is_none());
}
}