дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
## 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.
354 рядки
15 KiB
Rust
354 рядки
15 KiB
Rust
use super::*;
|
|
use std::time::Duration;
|
|
use tokio::io::{AsyncWriteExt, duplex};
|
|
|
|
mod framing_tests {
|
|
use super::framing::*;
|
|
use tokio::io::BufReader;
|
|
|
|
async fn read_all(input: &[u8]) -> std::io::Result<Vec<Vec<u8>>> {
|
|
let mut reader = BufReader::new(input);
|
|
let mut out = Vec::new();
|
|
while let Some(msg) = read_message(&mut reader).await? {
|
|
out.push(msg);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn parses_multiple_messages_and_ignores_extra_headers() {
|
|
let input = b"Content-Length: 2\r\n\r\n{}content-length: 7\r\nContent-Type: application/vscode-jsonrpc; charset=utf-8\r\n\r\n[1,2,3]";
|
|
let msgs = read_all(input).await.unwrap();
|
|
assert_eq!(msgs, vec![b"{}".to_vec(), b"[1,2,3]".to_vec()]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn accepts_bare_newlines_and_leading_blank_lines() {
|
|
let msgs = read_all(b"\r\n\nCONTENT-LENGTH: 4\n\nnull").await.unwrap();
|
|
assert_eq!(msgs, vec![b"null".to_vec()]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clean_eof_is_none() {
|
|
assert!(read_all(b"").await.unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_bad_input() {
|
|
assert!(read_all(b"Content-Length: 10\r\n\r\n{}").await.is_err(), "truncated body");
|
|
assert!(read_all(b"Content-Length: 2\r\n").await.is_err(), "truncated header");
|
|
assert!(read_all(b"Content-Type: x\r\n\r\n{}").await.is_err(), "missing length");
|
|
assert!(read_all(b"Content-Length: abc\r\n\r\n").await.is_err(), "bad length");
|
|
assert!(read_all(b"Content-Length: 99999999999\r\n\r\n").await.is_err(), "absurd length");
|
|
let long = format!("X-Junk: {}\r\n", "a".repeat(20_000));
|
|
assert!(read_all(long.as_bytes()).await.is_err(), "header too long");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn handles_partial_reads() {
|
|
let (mut client, server) = tokio::io::duplex(4);
|
|
let writer = tokio::spawn(async move {
|
|
use tokio::io::AsyncWriteExt;
|
|
for chunk in [&b"Conte"[..], b"nt-Len", b"gth: 5\r", b"\n\r\nhel", b"lo"] {
|
|
client.write_all(chunk).await.unwrap();
|
|
tokio::task::yield_now().await;
|
|
}
|
|
});
|
|
let mut reader = BufReader::new(server);
|
|
assert_eq!(read_message(&mut reader).await.unwrap().unwrap(), b"hello");
|
|
writer.await.unwrap();
|
|
assert!(read_message(&mut reader).await.unwrap().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_then_read_round_trips() {
|
|
let mut buf = Vec::new();
|
|
write_message(&mut buf, "{\"a\":\"é\"}".as_bytes()).await.unwrap();
|
|
assert!(buf.starts_with(b"Content-Length: 10\r\n\r\n"));
|
|
assert_eq!(read_all(&buf).await.unwrap(), vec!["{\"a\":\"é\"}".as_bytes().to_vec()]);
|
|
}
|
|
}
|
|
|
|
/// Records notifications and answers `echo` / `fail` / `slow` requests.
|
|
#[derive(Default)]
|
|
struct Recorder {
|
|
notifications: Mutex<Vec<(String, Value)>>,
|
|
}
|
|
|
|
impl Handler for Recorder {
|
|
fn on_notification(&self, method: &str, params: Value) {
|
|
self.notifications.lock().push((method.to_owned(), params));
|
|
}
|
|
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
|
match method {
|
|
"echo" => Ok(params),
|
|
"slow" => {
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
Ok(json!("slow-done"))
|
|
}
|
|
_ => Err((codes::METHOD_NOT_FOUND, method.to_owned())),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn pair(a: Arc<dyn Handler>, b: Arc<dyn Handler>) -> (Peer, Peer) {
|
|
let (a_io, b_io) = duplex(64 * 1024);
|
|
let (ar, aw) = tokio::io::split(a_io);
|
|
let (br, bw) = tokio::io::split(b_io);
|
|
(Peer::connect(ar, aw, a), Peer::connect(br, bw, b))
|
|
}
|
|
|
|
async fn eventually(mut f: impl FnMut() -> bool) {
|
|
for _ in 0..200 {
|
|
if f() {
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
panic!("condition not reached");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn request_response_and_errors() {
|
|
let (client, _server) = pair(Arc::new(NoopHandler), Arc::new(Recorder::default()));
|
|
let v: Value = client.request("echo", json!({"x": 1}), None).await.unwrap();
|
|
assert_eq!(v, json!({"x": 1}));
|
|
let err = client.request::<_, Value>("nope", (), None).await.unwrap_err();
|
|
assert!(matches!(err, RpcError::Server { code: codes::METHOD_NOT_FOUND, .. }), "{err:?}");
|
|
// Result type mismatch is a protocol error.
|
|
let err = client.request::<_, u32>("echo", "str", None).await.unwrap_err();
|
|
assert!(matches!(err, RpcError::Protocol(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn line_framing_round_trips_and_skips_blank_lines() {
|
|
let mut buf = Vec::new();
|
|
framing::write_line_message(&mut buf, br#"{"a":1}"#).await.unwrap();
|
|
buf.extend_from_slice(b"\r\n\n{\"b\":2}\r\n");
|
|
let mut reader = tokio::io::BufReader::new(&buf[..]);
|
|
assert_eq!(framing::read_line_message(&mut reader).await.unwrap().unwrap(), br#"{"a":1}"#);
|
|
assert_eq!(framing::read_line_message(&mut reader).await.unwrap().unwrap(), br#"{"b":2}"#);
|
|
assert!(framing::read_line_message(&mut reader).await.unwrap().is_none());
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn line_framed_peers_talk() {
|
|
let (a_io, b_io) = duplex(64 * 1024);
|
|
let (ar, aw) = tokio::io::split(a_io);
|
|
let (br, bw) = tokio::io::split(b_io);
|
|
let client = Peer::connect_framed(ar, aw, Arc::new(NoopHandler), Framing::Lines);
|
|
let _server = Peer::connect_framed(br, bw, Arc::new(Recorder::default()), Framing::Lines);
|
|
let v: Value = client.request("echo", json!({"x": "line\nbreak"}), None).await.unwrap();
|
|
assert_eq!(v, json!({"x": "line\nbreak"}));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn notifications_arrive_in_order() {
|
|
let rec = Arc::new(Recorder::default());
|
|
let (client, _server) = pair(Arc::new(NoopHandler), rec.clone());
|
|
for i in 0..50 {
|
|
client.notify("tick", json!(i)).unwrap();
|
|
}
|
|
client.notify("bare", ()).unwrap();
|
|
eventually(|| rec.notifications.lock().len() == 51).await;
|
|
let got = rec.notifications.lock();
|
|
for (i, (m, p)) in got.iter().take(50).enumerate() {
|
|
assert_eq!(m, "tick");
|
|
assert_eq!(p, &json!(i));
|
|
}
|
|
assert_eq!(got[50], ("bare".to_owned(), Value::Null));
|
|
}
|
|
|
|
/// A raw scripted server: reads frames, answers out of order, sends string ids.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn out_of_order_responses_and_string_ids() {
|
|
let (client_io, server_io) = duplex(64 * 1024);
|
|
let (cr, cw) = tokio::io::split(client_io);
|
|
let client = Peer::connect(cr, cw, Arc::new(NoopHandler));
|
|
let (sr, mut sw) = tokio::io::split(server_io);
|
|
let server = tokio::spawn(async move {
|
|
let mut reader = tokio::io::BufReader::new(sr);
|
|
let mut ids = Vec::new();
|
|
for _ in 0..3 {
|
|
let body = framing::read_message(&mut reader).await.unwrap().unwrap();
|
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
|
ids.push((v["id"].clone(), v["params"].clone()));
|
|
}
|
|
for (id, params) in ids.into_iter().rev() {
|
|
// Answer the second request with a stringified id.
|
|
let id = if params == json!(2) { json!(id.as_i64().unwrap().to_string()) } else { id };
|
|
let msg = json!({"jsonrpc": "2.0", "id": id, "result": params});
|
|
framing::write_message(&mut sw, msg.to_string().as_bytes()).await.unwrap();
|
|
}
|
|
sw
|
|
});
|
|
let (a, b, c) = tokio::join!(
|
|
client.request::<_, i32>("m", 1, None),
|
|
client.request::<_, i32>("m", 2, None),
|
|
client.request::<_, i32>("m", 3, None),
|
|
);
|
|
assert_eq!((a.unwrap(), b.unwrap(), c.unwrap()), (1, 2, 3));
|
|
let _keep = server.await.unwrap();
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn server_to_client_request() {
|
|
struct Client;
|
|
impl Handler for Client {
|
|
fn on_notification(&self, _: &str, _: Value) {}
|
|
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
|
match method {
|
|
"workspace/configuration" => Ok(json!([params["items"][0]["section"]])),
|
|
_ => Err((codes::METHOD_NOT_FOUND, "no".into())),
|
|
}
|
|
}
|
|
}
|
|
let (_client, server) = pair(Arc::new(Client), Arc::new(NoopHandler));
|
|
let v: Value =
|
|
server.request("workspace/configuration", json!({"items": [{"section": "rust"}]}), None).await.unwrap();
|
|
assert_eq!(v, json!(["rust"]));
|
|
let err = server.request::<_, Value>("other", (), None).await.unwrap_err();
|
|
assert!(matches!(err, RpcError::Server { code: codes::METHOD_NOT_FOUND, .. }));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn slow_handler_does_not_block_responses() {
|
|
// The client handles a slow server→client request while its own request completes.
|
|
let (client, server) = pair(Arc::new(Recorder::default()), Arc::new(Recorder::default()));
|
|
let slow = tokio::spawn({
|
|
let server = server.clone();
|
|
async move { server.request::<_, Value>("slow", (), None).await }
|
|
});
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
let started = std::time::Instant::now();
|
|
let v: Value = client.request("echo", 5, None).await.unwrap();
|
|
assert_eq!(v, json!(5));
|
|
assert!(started.elapsed() < Duration::from_millis(150));
|
|
assert_eq!(slow.await.unwrap().unwrap(), json!("slow-done"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn cancellation_sends_cancel_request() {
|
|
// A server that never answers "hang" and records `$/cancelRequest`.
|
|
struct Hang(Arc<Recorder>);
|
|
impl Handler for Hang {
|
|
fn on_notification(&self, method: &str, params: Value) {
|
|
self.0.on_notification(method, params)
|
|
}
|
|
fn on_request(&self, _: &str, _: Value) -> Result<Value, (i64, String)> {
|
|
std::thread::sleep(Duration::from_secs(2));
|
|
Ok(Value::Null)
|
|
}
|
|
}
|
|
let rec = Arc::new(Recorder::default());
|
|
let (client, _server) = pair(Arc::new(NoopHandler), Arc::new(Hang(rec.clone())));
|
|
|
|
let token = CancellationToken::new();
|
|
let fut = client.request::<_, Value>("hang", (), Some(token.clone()));
|
|
let canceller = tokio::spawn(async move {
|
|
tokio::time::sleep(Duration::from_millis(30)).await;
|
|
token.cancel();
|
|
});
|
|
assert!(matches!(fut.await, Err(RpcError::Cancelled)));
|
|
canceller.await.unwrap();
|
|
eventually(|| rec.notifications.lock().iter().any(|(m, p)| m == "$/cancelRequest" && p["id"] == json!(1))).await;
|
|
|
|
// Dropping the future (e.g. a timeout) also cancels.
|
|
let r = tokio::time::timeout(Duration::from_millis(30), client.request::<_, Value>("hang", (), None)).await;
|
|
assert!(r.is_err());
|
|
eventually(|| rec.notifications.lock().iter().any(|(m, p)| m == "$/cancelRequest" && p["id"] == json!(2))).await;
|
|
assert!(client.inner.pending.lock().is_empty());
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn server_cancelled_error_maps_to_cancelled() {
|
|
let (client_io, server_io) = duplex(4096);
|
|
let (cr, cw) = tokio::io::split(client_io);
|
|
let client = Peer::connect(cr, cw, Arc::new(NoopHandler));
|
|
let (sr, mut sw) = tokio::io::split(server_io);
|
|
tokio::spawn(async move {
|
|
let mut reader = tokio::io::BufReader::new(sr);
|
|
let body = framing::read_message(&mut reader).await.unwrap().unwrap();
|
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
|
let msg = json!({"jsonrpc": "2.0", "id": v["id"], "error": {"code": -32800, "message": "cancelled"}});
|
|
framing::write_message(&mut sw, msg.to_string().as_bytes()).await.unwrap();
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
});
|
|
assert!(matches!(client.request::<_, Value>("x", (), None).await, Err(RpcError::Cancelled)));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn closed_fails_pending_and_resolves() {
|
|
let (client_io, server_io) = duplex(4096);
|
|
let (cr, cw) = tokio::io::split(client_io);
|
|
let client = Peer::connect(cr, cw, Arc::new(NoopHandler));
|
|
let pending = tokio::spawn({
|
|
let client = client.clone();
|
|
async move { client.request::<_, Value>("x", (), None).await }
|
|
});
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
drop(server_io);
|
|
assert!(matches!(pending.await.unwrap(), Err(RpcError::Closed)));
|
|
assert_eq!(tokio::time::timeout(Duration::from_secs(1), client.closed()).await.unwrap(), None);
|
|
assert!(client.is_closed());
|
|
assert!(matches!(client.notify("x", ()), Err(RpcError::Closed)));
|
|
assert!(matches!(client.request::<_, Value>("x", (), None).await, Err(RpcError::Closed)));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn kill_closes_both_sides() {
|
|
let (client, server) = pair(Arc::new(NoopHandler), Arc::new(Recorder::default()));
|
|
server.kill();
|
|
tokio::time::timeout(Duration::from_secs(1), client.closed()).await.unwrap();
|
|
tokio::time::timeout(Duration::from_secs(1), server.closed()).await.unwrap();
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn garbage_message_is_skipped() {
|
|
let (client_io, server_io) = duplex(4096);
|
|
let (cr, cw) = tokio::io::split(client_io);
|
|
let client = Peer::connect(cr, cw, Arc::new(NoopHandler));
|
|
let (sr, mut sw) = tokio::io::split(server_io);
|
|
tokio::spawn(async move {
|
|
let mut reader = tokio::io::BufReader::new(sr);
|
|
let body = framing::read_message(&mut reader).await.unwrap().unwrap();
|
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
|
framing::write_message(&mut sw, b"not json").await.unwrap();
|
|
framing::write_message(&mut sw, json!({"jsonrpc":"2.0","id":v["id"],"result":7}).to_string().as_bytes())
|
|
.await
|
|
.unwrap();
|
|
sw.flush().await.unwrap();
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
});
|
|
assert_eq!(client.request::<_, i32>("x", (), None).await.unwrap(), 7);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn spawn_process_reports_exit_code_and_stderr() {
|
|
let lines = Arc::new(Mutex::new(Vec::new()));
|
|
let sink = lines.clone();
|
|
let peer = Peer::spawn(
|
|
"sh",
|
|
&["-c".into(), "echo oops >&2; exit 3".into()],
|
|
&std::env::temp_dir(),
|
|
&[],
|
|
Arc::new(NoopHandler),
|
|
Box::new(move |l| sink.lock().push(l)),
|
|
)
|
|
.unwrap();
|
|
assert!(peer.pid().is_some());
|
|
assert_eq!(tokio::time::timeout(Duration::from_secs(5), peer.closed()).await.unwrap(), Some(3));
|
|
eventually(|| lines.lock().iter().any(|l| l == "oops")).await;
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn kill_terminates_process() {
|
|
let peer =
|
|
Peer::spawn("sleep", &["30".into()], &std::env::temp_dir(), &[], Arc::new(NoopHandler), Box::new(|_| {}))
|
|
.unwrap();
|
|
peer.kill();
|
|
tokio::time::timeout(Duration::from_secs(5), peer.closed()).await.unwrap();
|
|
}
|