use std::sync::Arc;
use hotl_engine::{spawn_session, EngineConfig, SessionDeps};
use hotl_platform::SystemClock;
use hotl_provider::ScriptedProvider;
use hotl_store::{Masker, SessionLog};
use hotl_tools::{rules::Rules, Registry};
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
#[path = "../src/acp.rs"]
#[allow(dead_code)]
mod acp;
fn scripted_factory() -> acp::SessionFactory {
Box::new(|spec| {
let name = match spec {
acp::SessionSpec::New { name } => name,
acp::SessionSpec::Load { name, .. } => name,
};
let dir = tempfile::tempdir().expect("tmp");
let log = SessionLog::create(dir.path(), "m", None, Masker::empty(), 0).expect("log");
let provider = Arc::new(ScriptedProvider::new(vec![
ScriptedProvider::tool_call("t1", "bash", json!({"command": "echo hi"})),
ScriptedProvider::text_reply("all done via acp"),
]));
std::mem::forget(dir);
Ok(acp::SessionOpen {
handle: spawn_session(SessionDeps {
provider,
registry: Arc::new(Registry::builtin()),
rules: Arc::new(Rules::default()),
sandbox_enforced: false,
clock: Arc::new(SystemClock),
log,
system: "sys".into(),
cwd: std::env::temp_dir(),
snapshots: None,
hooks: None,
initial_items: Vec::new(),
config: EngineConfig {
max_turns: 6,
..Default::default()
},
}),
name,
})
})
}
async fn send(w: &mut (impl AsyncWriteExt + Unpin), v: Value) {
let mut line = v.to_string();
line.push('\n');
w.write_all(line.as_bytes()).await.unwrap();
w.flush().await.unwrap();
}
#[tokio::test]
async fn initialize_new_prompt_permission_and_result() {
let (client, server) = tokio::io::duplex(64 * 1024);
let (sread, swrite) = tokio::io::split(server);
tokio::spawn(acp::serve(sread, swrite, scripted_factory()));
let (cread, mut cwrite) = tokio::io::split(client);
let mut lines = BufReader::new(cread).lines();
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":1,"method":"initialize"}),
)
.await;
let init = next(&mut lines).await;
assert_eq!(init["result"]["schemaVersion"], acp::UPDATE_SCHEMA_VERSION);
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":2,"method":"session/new"}),
)
.await;
let new = next(&mut lines).await;
let session_id = new["result"]["sessionId"]
.as_str()
.expect("session id")
.to_string();
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"text":"go"}}),
)
.await;
let mut saw_tool_start = false;
let mut prompt_result: Option<Value> = None;
while prompt_result.is_none() {
let msg = next(&mut lines).await;
match msg.get("method").and_then(Value::as_str) {
Some("session/request_permission") => {
assert_eq!(msg["params"]["sessionId"], session_id);
let rid = msg["id"].clone();
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":rid,"result":{"allow":true}}),
)
.await;
}
Some("session/update") => {
assert_eq!(msg["params"]["schemaVersion"], acp::UPDATE_SCHEMA_VERSION);
if msg["params"]["update"]["type"] == "tool_start" {
saw_tool_start = true;
}
}
_ if msg.get("id") == Some(&json!(3)) => prompt_result = Some(msg),
_ => {}
}
}
let result = prompt_result.unwrap();
assert_eq!(result["result"]["outcome"]["kind"], "done");
assert_eq!(result["result"]["outcome"]["text"], "all done via acp");
assert_eq!(
result["result"]["schemaVersion"],
acp::UPDATE_SCHEMA_VERSION
);
assert!(
result["result"].get("usage").is_some(),
"usage rides in the stable result"
);
assert!(saw_tool_start, "tool status streamed as an update");
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":9,"method":"bogus/method"}),
)
.await;
let err = read_until_id(&mut lines, 9).await;
assert!(err["error"]["message"]
.as_str()
.unwrap()
.contains("unknown method"));
}
#[tokio::test]
async fn overlapping_prompts_resolve_in_order() {
let factory: acp::SessionFactory = Box::new(|_spec| {
let dir = tempfile::tempdir().expect("tmp");
let log = SessionLog::create(dir.path(), "m", None, Masker::empty(), 0).expect("log");
std::mem::forget(dir);
let provider = Arc::new(ScriptedProvider::new(vec![
ScriptedProvider::text_reply("first turn"),
ScriptedProvider::text_reply("second turn"),
]));
Ok(acp::SessionOpen {
handle: spawn_session(SessionDeps {
provider,
registry: Arc::new(Registry::builtin()),
rules: Arc::new(Rules::default()),
sandbox_enforced: false,
clock: Arc::new(SystemClock),
log,
system: "sys".into(),
cwd: std::env::temp_dir(),
snapshots: None,
hooks: None,
initial_items: Vec::new(),
config: EngineConfig {
max_turns: 6,
..Default::default()
},
}),
name: None,
})
});
let (client, server) = tokio::io::duplex(64 * 1024);
let (sread, swrite) = tokio::io::split(server);
tokio::spawn(acp::serve(sread, swrite, factory));
let (cread, mut cwrite) = tokio::io::split(client);
let mut lines = BufReader::new(cread).lines();
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":1,"method":"session/new"}),
)
.await;
read_until_id(&mut lines, 1).await;
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"text":"a"}}),
)
.await;
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"text":"b"}}),
)
.await;
let first = read_until_id(&mut lines, 2).await;
assert_eq!(first["result"]["outcome"]["text"], "first turn");
let second = read_until_id(&mut lines, 3).await;
assert_eq!(second["result"]["outcome"]["text"], "second turn");
}
#[tokio::test]
async fn replacing_a_session_clears_parked_state() {
let (client, server) = tokio::io::duplex(64 * 1024);
let (sread, swrite) = tokio::io::split(server);
tokio::spawn(acp::serve(sread, swrite, scripted_factory()));
let (cread, mut cwrite) = tokio::io::split(client);
let mut lines = BufReader::new(cread).lines();
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":1,"method":"session/new"}),
)
.await;
let first = read_until_id(&mut lines, 1).await;
let first_sid = first["result"]["sessionId"]
.as_str()
.expect("session id")
.to_string();
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"text":"go"}}),
)
.await;
loop {
let msg = next(&mut lines).await;
if msg.get("method").and_then(Value::as_str) == Some("session/request_permission") {
break;
}
}
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":3,"method":"session/new"}),
)
.await;
let second = read_until_id(&mut lines, 3).await;
let second_sid = second["result"]["sessionId"]
.as_str()
.expect("session id")
.to_string();
assert_ne!(first_sid, second_sid);
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"text":"again"}}),
)
.await;
let result = loop {
let msg = next(&mut lines).await;
assert_ne!(
msg.get("id"),
Some(&json!(2)),
"stale prompt answered: {msg}"
);
if msg.get("method").and_then(Value::as_str) == Some("session/request_permission") {
assert_eq!(msg["params"]["sessionId"], second_sid);
let rid = msg["id"].clone();
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":rid,"result":{"allow":true}}),
)
.await;
} else if msg.get("id") == Some(&json!(4)) {
break msg;
}
};
assert_eq!(result["result"]["outcome"]["kind"], "done");
assert_eq!(result["result"]["outcome"]["text"], "all done via acp");
}
#[tokio::test]
async fn steer_is_acknowledged_and_reaches_engine() {
let (client, server) = tokio::io::duplex(64 * 1024);
let (sread, swrite) = tokio::io::split(server);
tokio::spawn(acp::serve(sread, swrite, scripted_factory()));
let (cread, mut cwrite) = tokio::io::split(client);
let mut lines = BufReader::new(cread).lines();
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":1,"method":"session/steer","params":{"text":"go left"}}),
)
.await;
let err = read_until_id(&mut lines, 1).await;
assert!(err["error"]["message"]
.as_str()
.unwrap()
.contains("session"));
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":2,"method":"session/new"}),
)
.await;
read_until_id(&mut lines, 2).await;
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":3,"method":"session/steer","params":{"text":"go left"}}),
)
.await;
let ack = read_until_id(&mut lines, 3).await;
assert_eq!(ack["result"], json!({"queued": true}));
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":4,"method":"session/steer"}),
)
.await;
let err = read_until_id(&mut lines, 4).await;
assert!(err["error"]["message"].as_str().unwrap().contains("text"));
}
async fn read_until_id(
lines: &mut tokio::io::Lines<BufReader<impl tokio::io::AsyncRead + Unpin>>,
id: u64,
) -> Value {
loop {
let m = next(lines).await;
if m.get("id") == Some(&json!(id)) {
return m;
}
}
}
#[tokio::test]
async fn named_open_and_rename() {
let (client, server) = tokio::io::duplex(64 * 1024);
let (sread, swrite) = tokio::io::split(server);
tokio::spawn(acp::serve(sread, swrite, scripted_factory()));
let (cread, mut cwrite) = tokio::io::split(client);
let mut lines = BufReader::new(cread).lines();
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":1,"method":"initialize"}),
)
.await;
next(&mut lines).await;
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":2,"method":"session/rename","params":{"name":"x"}}),
)
.await;
assert!(
next(&mut lines).await["error"].is_object(),
"no session yet"
);
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":3,"method":"session/new","params":{"name":" fix-auth "}}),
)
.await;
let open = next(&mut lines).await;
assert_eq!(open["result"]["name"], "fix-auth");
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":4,"method":"session/rename","params":{"name":" "}}),
)
.await;
assert!(
next(&mut lines).await["error"].is_object(),
"blank name rejected"
);
send(
&mut cwrite,
json!({"jsonrpc":"2.0","id":5,"method":"session/rename","params":{"name":"better-name"}}),
)
.await;
assert_eq!(next(&mut lines).await["result"]["ok"], true);
}
async fn next(lines: &mut tokio::io::Lines<BufReader<impl tokio::io::AsyncRead + Unpin>>) -> Value {
let line = tokio::time::timeout(std::time::Duration::from_secs(5), lines.next_line())
.await
.expect("acp frame timeout")
.expect("io")
.expect("eof");
serde_json::from_str(&line).expect("valid json frame")
}