mod helpers {
pub mod mock_llm_server {}
}
#[path = "mock_llm_server.rs"]
mod mock_llm_server;
use mock_llm_server::{start_mock_server, MockScenario};
use reqwest::Client;
use serde_json::Value;
use std::collections::HashSet;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::Mutex;
fn make_test_state(mock_addr: SocketAddr) -> Arc<xcodeai::http::AppState> {
use xcodeai::config::{AgentConfig, Config, ProviderConfig, SandboxConfig};
use xcodeai::session::store::SessionStore;
let config = Config {
provider: ProviderConfig {
api_base: format!("http://127.0.0.1:{}/v1", mock_addr.port()),
api_key: "test-key".to_string(),
},
model: "gpt-4o".to_string(),
project_dir: Some(std::env::temp_dir()),
sandbox: SandboxConfig {
enabled: false,
sbox_path: None,
},
agent: AgentConfig {
max_iterations: 5,
max_tool_calls_per_response: 5,
max_auto_continues: 3,
..Default::default()
},
lsp: Default::default(),
mcp_servers: vec![],
custom_tools: vec![],
permissions: vec![],
formatters: std::collections::HashMap::new(),
};
let store = SessionStore::new(std::path::Path::new(":memory:")).unwrap();
Arc::new(xcodeai::http::AppState {
store: Mutex::new(store),
config,
active_sessions: Mutex::new(HashSet::new()),
})
}
async fn start_http_server(state: Arc<xcodeai::http::AppState>) -> SocketAddr {
use axum::Router;
use tower_http::cors::CorsLayer;
use xcodeai::http::routes::session_router;
let app = Router::new()
.merge(session_router())
.layer(CorsLayer::permissive())
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind random port");
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
addr
}
async fn collect_sse_events(resp: reqwest::Response) -> Vec<(String, Value)> {
let body = resp.text().await.expect("read SSE body");
let mut events = Vec::new();
let mut current_event: Option<String> = None;
let mut current_data: Option<String> = None;
for line in body.lines() {
if let Some(event_name) = line.strip_prefix("event: ") {
current_event = Some(event_name.trim().to_string());
} else if let Some(data_str) = line.strip_prefix("data: ") {
current_data = Some(data_str.trim().to_string());
} else if line.is_empty() {
if let (Some(ev), Some(dat)) = (current_event.take(), current_data.take()) {
let json: Value =
serde_json::from_str(&dat).unwrap_or_else(|_| Value::String(dat.clone()));
events.push((ev, json));
}
}
}
events
}
async fn create_session(client: &Client, base: &str, title: &str) -> String {
let resp = client
.post(format!("{base}/sessions"))
.json(&serde_json::json!({ "title": title }))
.send()
.await
.expect("POST /sessions");
assert_eq!(
resp.status(),
reqwest::StatusCode::CREATED,
"expected 201 from POST /sessions"
);
let body: Value = resp.json().await.unwrap();
body["session_id"]
.as_str()
.expect("session_id in response")
.to_string()
}
#[tokio::test]
async fn test_http_session_not_found() {
let mock_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
let state = make_test_state(mock_addr);
let server_addr = start_http_server(state).await;
let base = format!("http://{server_addr}");
let client = Client::new();
let resp = client
.post(format!("{base}/sessions/does-not-exist/messages"))
.json(&serde_json::json!({ "content": "hello" }))
.send()
.await
.expect("POST to nonexistent session");
assert_eq!(
resp.status(),
reqwest::StatusCode::NOT_FOUND,
"missing session must return 404"
);
}
#[tokio::test]
async fn test_http_conflict_concurrent() {
let mock_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
let state = make_test_state(mock_addr);
let server_addr = start_http_server(Arc::clone(&state)).await;
let base = format!("http://{server_addr}");
let client = Client::new();
let session_id = create_session(&client, &base, "conflict test").await;
state
.active_sessions
.lock()
.await
.insert(session_id.clone());
let resp = client
.post(format!("{base}/sessions/{session_id}/messages"))
.json(&serde_json::json!({ "content": "concurrent!" }))
.send()
.await
.expect("POST to active session");
assert_eq!(
resp.status(),
reqwest::StatusCode::CONFLICT,
"concurrent POST must return 409"
);
}
#[tokio::test]
async fn test_http_full_lifecycle_text_response() {
let (mock_addr, _mock_state) = start_mock_server(vec![MockScenario::TextResponse(
"[TASK_COMPLETE] All done!".to_string(),
)])
.await;
let state = make_test_state(mock_addr);
let server_addr = start_http_server(Arc::clone(&state)).await;
let base = format!("http://{server_addr}");
let client = Client::new();
let session_id = create_session(&client, &base, "lifecycle test").await;
let resp = client
.post(format!("{base}/sessions/{session_id}/messages"))
.json(&serde_json::json!({ "content": "Say hello" }))
.send()
.await
.expect("POST /sessions/:id/messages");
assert_eq!(
resp.status(),
reqwest::StatusCode::OK,
"agent loop response must be 200"
);
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
content_type.starts_with("text/event-stream"),
"response must be SSE, got: {content_type}"
);
let events = collect_sse_events(resp).await;
let has_complete = events.iter().any(|(name, _)| name == "complete");
let has_done_status = events.iter().any(|(name, data)| {
name == "status" && data["msg"].as_str().unwrap_or("").contains("[DONE]")
});
assert!(
has_complete || has_done_status,
"expected a 'complete' or '[DONE]' event; got: {events:?}"
);
let get_resp = client
.get(format!("{base}/sessions/{session_id}"))
.send()
.await
.expect("GET session after agent run");
assert_eq!(
get_resp.status(),
reqwest::StatusCode::OK,
"session must still exist after agent run"
);
}
#[tokio::test]
async fn test_http_session_crud_over_tcp() {
let mock_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
let state = make_test_state(mock_addr);
let server_addr = start_http_server(state).await;
let base = format!("http://{server_addr}");
let client = Client::new();
let id1 = create_session(&client, &base, "first").await;
let id2 = create_session(&client, &base, "second").await;
let list_resp = client.get(format!("{base}/sessions")).send().await.unwrap();
assert_eq!(list_resp.status(), reqwest::StatusCode::OK);
let list: Vec<Value> = list_resp.json().await.unwrap();
assert!(
list.len() >= 2,
"expected at least 2 sessions, got {}: {list:?}",
list.len()
);
let get_resp = client
.get(format!("{base}/sessions/{id1}"))
.send()
.await
.unwrap();
assert_eq!(get_resp.status(), reqwest::StatusCode::OK);
let detail: Value = get_resp.json().await.unwrap();
assert_eq!(detail["id"].as_str().unwrap(), id1);
assert_eq!(detail["title"].as_str().unwrap_or(""), "first");
let del_resp = client
.delete(format!("{base}/sessions/{id2}"))
.send()
.await
.unwrap();
assert_eq!(del_resp.status(), reqwest::StatusCode::NO_CONTENT);
let get_after = client
.get(format!("{base}/sessions/{id2}"))
.send()
.await
.unwrap();
assert_eq!(get_after.status(), reqwest::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_http_cors_header_over_tcp() {
let mock_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
let state = make_test_state(mock_addr);
let server_addr = start_http_server(state).await;
let base = format!("http://{server_addr}");
let client = Client::new();
let resp = client
.get(format!("{base}/sessions"))
.header("origin", "http://localhost:3000")
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
assert!(
resp.headers().contains_key("access-control-allow-origin"),
"CORS header must be present"
);
}
#[tokio::test]
async fn test_http_sse_events_present() {
let (mock_addr, _) = start_mock_server(vec![MockScenario::TextResponse(
"[TASK_COMPLETE] Done.".to_string(),
)])
.await;
let state = make_test_state(mock_addr);
let server_addr = start_http_server(Arc::clone(&state)).await;
let base = format!("http://{server_addr}");
let client = Client::new();
let session_id = create_session(&client, &base, "sse events test").await;
let resp = client
.post(format!("{base}/sessions/{session_id}/messages"))
.json(&serde_json::json!({ "content": "Do the thing" }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
let events = collect_sse_events(resp).await;
assert!(!events.is_empty(), "SSE stream must not be empty");
let terminated = events.iter().any(|(name, data)| {
name == "complete"
|| (name == "status" && data["msg"].as_str().unwrap_or("").contains("[DONE]"))
});
assert!(
terminated,
"stream must terminate with complete/[DONE]; events: {events:?}"
);
}