use std::{
io::{Read, Write},
net::{TcpListener, TcpStream},
sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
thread,
time::{Duration, Instant},
};
use basis_tasks::{RunSpec, Tasks, WaitOutcome};
use serde_json::json;
fn stub_api_key() {
unsafe { std::env::set_var("BASIS_API_KEY", "test-key") };
}
#[tokio::test]
async fn spawn_ask_wait_and_list_from_rust_with_no_binary_involved() {
stub_api_key();
let endpoint = ScriptedEndpoint::start();
let data_dir = tempfile::tempdir().expect("tempdir");
let workspace = tempfile::tempdir().expect("tempdir");
let tasks =
Tasks::open_at(data_dir.path(), workspace.path()).expect("opens with no env dependency");
let spec = RunSpec::new("hello")
.with_base_url(&endpoint.base_url)
.with_model("test-model")
.with_deadline(Duration::from_secs(60))
.with_approve(basis_tasks::Approve::Always);
let handle = tasks.spawn(spec).expect("spawn mints a handle immediately");
assert!(
tasks
.terminal(&handle)
.expect("reads the terminal")
.is_none(),
"a freshly spawned task is resumable, not settled"
);
let reply = tasks
.ask(
&handle,
None,
"what should I do next?",
Duration::from_secs(30),
)
.await
.expect("ask completes");
let WaitOutcome::Terminal(reply_payload) = reply.outcome else {
panic!("a scripted endpoint always answers inside the timeout");
};
assert_eq!(reply_payload.raw["state"], "succeeded", "{reply_payload:?}");
assert_eq!(reply_payload.raw["message"], reply.message_id);
assert!(
reply_payload.raw["result"]
.as_str()
.is_some_and(|text| !text.is_empty()),
"{reply_payload:?}"
);
let outcome = tasks
.wait(&handle, None, Duration::from_secs(5), None)
.await
.expect("wait completes");
let WaitOutcome::Terminal(terminal) = outcome else {
panic!("a settled task's terminal is read straight off disk");
};
assert!(matches!(
terminal.terminal,
Some(basis_tasks::Terminal::Succeeded { .. })
));
let summaries = tasks.list().expect("list scans the workspace");
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].task, handle.as_str());
assert_eq!(summaries[0].state, "succeeded");
assert_eq!(
endpoint.served(),
2,
"the initial prompt, then the asked message"
);
}
#[tokio::test]
async fn a_wait_past_its_deadline_stops_between_turns_and_leaves_the_task_resumable() {
stub_api_key();
let endpoint = ScriptedEndpoint::start_held();
let data_dir = tempfile::tempdir().expect("tempdir");
let workspace = tempfile::tempdir().expect("tempdir");
let tasks = Tasks::open_at(data_dir.path(), workspace.path()).expect("opens");
let spec = RunSpec::new("hello")
.with_base_url(&endpoint.base_url)
.with_model("test-model")
.with_deadline(Duration::from_secs(60))
.with_approve(basis_tasks::Approve::Always);
let handle = tasks.spawn(spec).expect("spawn mints a handle immediately");
tasks.send(&handle, "one").expect("enqueue");
tasks.send(&handle, "two").expect("enqueue");
let timeout = Duration::from_secs(2);
let started = Instant::now();
let waiter = {
let tasks = tasks.clone();
let handle = handle.clone();
tokio::spawn(async move { tasks.wait(&handle, None, timeout, None).await })
};
while endpoint.arrived() == 0 {
assert!(
started.elapsed() < timeout,
"the first turn never reached the endpoint inside the wait's timeout"
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
tokio::time::sleep(timeout.saturating_sub(started.elapsed()) + Duration::from_millis(100))
.await;
endpoint.release();
let outcome = waiter
.await
.expect("waiter task ends")
.expect("wait completes");
let elapsed = started.elapsed();
assert_eq!(
outcome,
WaitOutcome::TimedOut { attached: false },
"the wait ends at its own deadline, and releases the lock before saying so"
);
assert!(
elapsed < Duration::from_secs(10),
"the wait returned at the first boundary after its deadline, not the task's \
(took {elapsed:?})"
);
assert_eq!(
endpoint.served(),
1,
"the in-flight turn finished; no other started"
);
assert!(
tasks.terminal(&handle).expect("reads").is_none(),
"a wait that timed out settles nothing: the task stays resumable"
);
assert!(
!tasks.is_attached(&handle).expect("probes"),
"the attach lock is released with the wait"
);
let outcome = tasks
.wait(&handle, None, Duration::from_secs(30), None)
.await
.expect("wait completes");
let WaitOutcome::Terminal(terminal) = outcome else {
panic!("a resumed task runs to its terminal inside a generous timeout");
};
assert!(matches!(
terminal.terminal,
Some(basis_tasks::Terminal::Succeeded { ref result }) if result == "reply-3"
));
assert_eq!(
endpoint.served(),
3,
"the two queued messages ran on the second attach, and nothing was rerun"
);
}
struct ScriptedEndpoint {
base_url: String,
arrived: Arc<AtomicUsize>,
served: Arc<AtomicUsize>,
held: Arc<AtomicBool>,
}
impl ScriptedEndpoint {
fn start() -> Self {
Self::start_with(false)
}
fn start_held() -> Self {
Self::start_with(true)
}
fn start_with(held: bool) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test endpoint");
let address = listener.local_addr().expect("read endpoint address");
let arrived = Arc::new(AtomicUsize::new(0));
let served = Arc::new(AtomicUsize::new(0));
let held = Arc::new(AtomicBool::new(held));
let turns = Turns {
arrived: Arc::clone(&arrived),
served: Arc::clone(&served),
held: Arc::clone(&held),
};
thread::spawn(move || {
while let Ok((stream, _)) = listener.accept() {
let turns = turns.clone();
thread::spawn(move || answer(stream, &turns));
}
});
Self {
base_url: format!("http://{address}/"),
arrived,
served,
held,
}
}
fn arrived(&self) -> usize {
self.arrived.load(Ordering::SeqCst)
}
fn served(&self) -> usize {
self.served.load(Ordering::SeqCst)
}
fn release(&self) {
self.held.store(false, Ordering::SeqCst);
}
}
#[derive(Clone)]
struct Turns {
arrived: Arc<AtomicUsize>,
served: Arc<AtomicUsize>,
held: Arc<AtomicBool>,
}
fn model_listing(request: &str) -> Option<String> {
let line = request.lines().next()?;
let target = line.split_whitespace().nth(1)?;
(line.starts_with("GET ") && target.ends_with("/models")).then(|| {
let body = r#"{"object":"list","data":[{"id":"test-model","object":"model"}]}"#;
format!(
"HTTP/1.1 200 OK\r\nconnection: close\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{body}",
body.len()
)
})
}
fn answer(mut stream: TcpStream, turns: &Turns) {
let request = read_http_request(&mut stream);
if let Some(listing) = model_listing(&request) {
let _ = stream.write_all(listing.as_bytes());
return;
}
turns.arrived.fetch_add(1, Ordering::SeqCst);
while turns.held.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(10));
}
let index = turns.served.fetch_add(1, Ordering::SeqCst) + 1;
let id = format!("chatcmpl_{index}");
let events = [
json!({
"id": id, "model": "test-model",
"choices": [{"index": 0, "delta": {"role": "assistant", "content": format!("reply-{index}")}}]
}),
json!({
"id": id,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]
}),
];
let body: String = events
.iter()
.map(|event| format!("data: {event}\n\n"))
.chain(std::iter::once("data: [DONE]\n\n".to_string()))
.collect();
let response = format!(
"HTTP/1.1 200 OK\r\nconnection: close\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes());
}
fn read_http_request(stream: &mut TcpStream) -> String {
let mut bytes = Vec::new();
let mut buffer = [0_u8; 4096];
let mut header_end = None;
let mut content_length = 0_usize;
while let Ok(read) = stream.read(&mut buffer) {
if read == 0 {
break;
}
bytes.extend_from_slice(&buffer[..read]);
if header_end.is_none()
&& let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n")
{
let end = index + 4;
header_end = Some(end);
content_length = String::from_utf8_lossy(&bytes[..end])
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().unwrap_or_default())
})
.unwrap_or_default();
}
if header_end.is_some_and(|end| bytes.len() >= end + content_length) {
break;
}
}
String::from_utf8_lossy(&bytes).into_owned()
}