Files
2026-10-15 02:24:15 -03:00

224 lines
8.3 KiB
Rust

//! Test-only ACP agent stub for the integration tests (std + `serde_json`).
//!
//! One reader loop, no nested stdin locks: an agent request that expects a
//! client answer (the permission request) is *remembered* in `State` and
//! resolved when the matching response arrives.
//!
//! Modes via `STUB_AGENT_MODE`:
//! - unset/`normal`: streams two message chunks, a tool call
//! (pending -> completed) and a permission request, then ends the turn once
//! the client answers.
//! - `cancel`: stays silent until `session/cancel`, then reports
//! `stopReason: cancelled`.
//! - `garbage`: injects malformed lines before the normal turn.
//! - `giant`: writes one >4 MiB line before the normal turn.
//! - `exit`: exits immediately with code 0.
//! - `silent`: never answers anything (handshake timeout).
//! - `crash`: streams one chunk, then exits with code 3 mid-prompt.
//! - `hold`: prints `PID <pid>` on stderr and then blocks on stdin.
//! - `unsupported`: probes the client with `fs/read_text_file` and reports
//! the JSON-RPC error code the client answered with.
use std::io::{BufRead, Write};
use std::process::exit;
use serde_json::{Value, json};
/// What the stub is waiting for between client messages.
#[derive(Default)]
struct State {
/// Id of an agent request the client still has to answer (the permission
/// request, or the `fs/*` probe of the `unsupported` mode).
awaiting: Option<Value>,
/// Id of the `session/prompt` request being served.
prompt: Option<Value>,
}
fn main() {
let mode = std::env::var("STUB_AGENT_MODE").unwrap_or_else(|_| "normal".to_string());
if mode == "exit" {
exit(0);
}
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let mut out = stdout.lock();
if mode == "hold" {
let _ = writeln!(std::io::stderr(), "PID {}", std::process::id());
}
let mut state = State::default();
let mut line = String::new();
loop {
line.clear();
match stdin.lock().read_line(&mut line) {
Ok(0) | Err(_) => return,
Ok(_) => {}
}
let Some(object) = parse_object(&line) else { continue };
if resolve_awaited(&mut state, &mut out, &mode, &object) {
continue;
}
handle_request(&mut state, &mut out, &mode, &object);
}
}
/// Answers the pending agent request when `object` is the client response.
fn resolve_awaited(state: &mut State, out: &mut std::io::StdoutLock<'_>, mode: &str, object: &serde_json::Map<String, Value>) -> bool {
let Some(pending) = state.awaiting.as_ref() else {
return false;
};
if object.get("id") != Some(pending) {
return false;
}
let option_id = object
.get("result")
.and_then(|result| result.get("outcome"))
.and_then(|outcome| outcome.get("optionId"))
.and_then(Value::as_str)
.unwrap_or("cancelled")
.to_string();
let code = object.get("error").and_then(|error| error.get("code")).and_then(Value::as_i64).unwrap_or(0);
state.awaiting = None;
send_json(
out,
mode,
&json!({
"jsonrpc": "2.0", "method": "session/update",
"params": {"sessionId": "sess-1", "update": {
"sessionUpdate": "ack", "optionId": option_id, "code": code
}}
}),
);
if let Some(prompt_id) = state.prompt.take() {
send_json(out, mode, &response(&prompt_id, &json!({"stopReason": "end_turn"})));
}
true
}
/// Dispatches one client request or notification.
fn handle_request(state: &mut State, out: &mut std::io::StdoutLock<'_>, mode: &str, object: &serde_json::Map<String, Value>) {
let id = object.get("id").cloned();
match object.get("method").and_then(Value::as_str) {
Some("initialize") if id.is_some() => {
send_json(out, mode, &response(id.as_ref().unwrap_or(&Value::Null), &json!({"protocolVersion": 1})));
}
Some("session/new") if id.is_some() => {
send_json(out, mode, &response(id.as_ref().unwrap_or(&Value::Null), &json!({"sessionId": "sess-1"})));
}
Some("session/prompt") if id.is_some() => {
state.prompt = id;
start_prompt(out, state, mode);
}
Some("session/cancel") => {
state.awaiting = None;
send_json(out, mode, &update_json("agent_message_chunk", "cancelled"));
if let Some(prompt_id) = state.prompt.take() {
send_json(out, mode, &response(&prompt_id, &json!({"stopReason": "cancelled"})));
}
}
_ => {}
}
}
/// Starts one turn: emits the mode-specific prelude and, in the default
/// modes, the streaming updates plus the permission request.
fn start_prompt(out: &mut std::io::StdoutLock<'_>, state: &mut State, mode: &str) {
match mode {
"silent" | "cancel" | "hold" => return,
"unsupported" => {
send_json(out, mode, &update_json("agent_message_chunk", "probing"));
state.awaiting = Some(Value::String("fs-1".to_string()));
send_json(
out,
mode,
&json!({
"jsonrpc": "2.0", "id": "fs-1", "method": "fs/read_text_file",
"params": {"path": "notes/a.md"}
}),
);
return;
}
"garbage" => {
let _ = writeln!(out, "not json {{{{");
let _ = writeln!(out, "[1, 2, 3]");
let _ = out.flush();
}
"giant" => {
let giant = "x".repeat(5 * 1024 * 1024);
let _ = writeln!(out, "{giant}");
let _ = out.flush();
}
"crash" => {
send_json(out, mode, &update_json("agent_message_chunk", "dying"));
exit(3);
}
_ => {}
}
send_json(out, mode, &update_json("agent_message_chunk", "Hello "));
send_json(out, mode, &update_json("agent_message_chunk", "world"));
send_json(
out,
mode,
&json!({
"jsonrpc": "2.0", "method": "session/update",
"params": {"sessionId": "sess-1", "update": {
"sessionUpdate": "tool_call", "toolCallId": "t1",
"title": "Read file", "kind": "read", "status": "pending",
"locations": [{"path": "notes/a.md"}],
"rawInput": {"path": "notes/a.md"}
}}
}),
);
send_json(
out,
mode,
&json!({
"jsonrpc": "2.0", "method": "session/update",
"params": {"sessionId": "sess-1", "update": {
"sessionUpdate": "tool_call_update", "toolCallId": "t1",
"status": "completed", "title": "Read file",
"content": [{"type": "text", "text": "Read 1 file"}]
}}
}),
);
let permission = json!({
"jsonrpc": "2.0", "id": "perm-1", "method": "session/request_permission",
"params": {"sessionId": "sess-1",
"toolCall": {"toolCallId": "t1", "title": "Write file", "kind": "edit", "status": "pending"},
"options": [{"id": "allow", "name": "Allow", "kind": "allow_once"},
{"id": "deny", "name": "Deny", "kind": "reject_once"}]
}
});
state.awaiting = Some(Value::String("perm-1".to_string()));
send_json(out, mode, &permission);
}
/// Parses one client line into a JSON object, ignoring anything else.
fn parse_object(line: &str) -> Option<serde_json::Map<String, Value>> {
serde_json::from_str::<Value>(line.trim()).ok()?.as_object().cloned()
}
/// Builds a JSON-RPC response object.
fn response(id: &Value, result: &Value) -> Value {
json!({"jsonrpc": "2.0", "id": id.clone(), "result": result.clone()})
}
/// Builds a `session/update` notification carrying one content chunk.
fn update_json(kind: &str, text: &str) -> Value {
json!({
"jsonrpc": "2.0", "method": "session/update",
"params": {"sessionId": "sess-1", "update": {
"sessionUpdate": kind, "content": {"type": "text", "text": text}
}}
})
}
/// Serialises and sends one JSON line (silent mode never answers).
fn send_json(out: &mut std::io::StdoutLock<'_>, mode: &str, value: &Value) {
if mode == "silent" {
return;
}
let Ok(line) = serde_json::to_string(value) else { return };
let _ = writeln!(out, "{line}");
let _ = out.flush();
}