use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use ocserve_http::{AppState, LlmRegistry, Payloads, Wires};
use serde_json::{Value, json};
use std::io::Read as _;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tower::ServiceExt;
const STEP1_SSE: &str = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
cache-control: no-cache\r\nconnection: close\r\n\r\n\
data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\
\"id\":\"call_rv1\",\"type\":\"function\",\"function\":{\
\"name\":\"edit\",\"arguments\":\"{\\\"filePath\\\":\\\"a.txt\\\",\\\"oldString\\\":\\\"one\\\",\\\"newString\\\":\\\"two\\\"}\"\
}}]}}]}\n\n\
data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n\
data: {\"choices\":[],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":5,\"total_tokens\":105}}\n\n\
data: [DONE]\n\n";
const STEP2_SSE: &str = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
cache-control: no-cache\r\nconnection: close\r\n\r\n\
data: {\"choices\":[{\"delta\":{\"content\":\"edited\"}}]}\n\n\
data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n\
data: {\"choices\":[],\"usage\":{\"prompt_tokens\":120,\"completion_tokens\":6,\"total_tokens\":126}}\n\n\
data: [DONE]\n\n";
fn spawn_seq_provider() -> std::net::SocketAddr {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let n = AtomicUsize::new(0);
for sock in listener.incoming().flatten() {
let body = if n.fetch_add(1, Ordering::SeqCst) == 0 {
STEP1_SSE
} else {
STEP2_SSE
};
std::thread::spawn(move || {
let mut sock = sock;
let _ = sock.set_read_timeout(Some(std::time::Duration::from_secs(5)));
let mut got = Vec::new();
let mut buf = [0u8; 8192];
let head_end = loop {
match sock.read(&mut buf) {
Ok(0) => return,
Ok(k) => got.extend_from_slice(&buf[..k]),
Err(_) => return,
}
if let Some(pos) = got.windows(4).position(|w| w == b"\r\n\r\n") {
break pos + 4;
}
};
let head = String::from_utf8_lossy(&got[..head_end]).to_string();
let clen = head
.lines()
.find_map(|l| {
let (k, v) = l.split_once(':')?;
k.eq_ignore_ascii_case("content-length")
.then(|| v.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
while got.len() < head_end + clen {
match sock.read(&mut buf) {
Ok(0) => break,
Ok(k) => got.extend_from_slice(&buf[..k]),
Err(_) => break,
}
}
let _ = std::io::Write::write_all(&mut sock, body.as_bytes());
let _ = std::io::Write::flush(&mut sock);
});
}
});
addr
}
fn setup(
addr: std::net::SocketAddr,
sid: &str,
) -> (Arc<AppState>, std::path::PathBuf, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("data");
std::fs::create_dir_all(&dir).unwrap();
let wt = tmp.path().join("worktree");
std::fs::create_dir_all(&wt).unwrap();
let g = |args: &[&str]| {
let o = std::process::Command::new("git")
.current_dir(&wt)
.args(args)
.output()
.unwrap();
assert!(
o.status.success(),
"git {:?}: {}",
args,
String::from_utf8_lossy(&o.stderr)
);
};
g(&["init", "-q"]);
g(&["config", "user.email", "t@t"]);
g(&["config", "user.name", "t"]);
std::fs::write(wt.join("a.txt"), "one\n").unwrap();
g(&["add", "."]);
g(&["commit", "-qm", "init"]);
let db = ocserve_store::writer::db_path(&dir);
let writer = Arc::new(ocserve_store::Writer::spawn(db.clone()).unwrap());
let blobs = Arc::new(ocserve_store::BlobStore::new(dir.join("blobs")).unwrap());
let mut limits = std::collections::HashMap::new();
limits.insert(
("fake".to_string(), "m".to_string()),
json!({"context": 1_000_000}),
);
let llm = LlmRegistry {
limits,
endpoints: [("fake".into(), (format!("http://{addr}"), String::new()))]
.into_iter()
.collect(),
pricing: Default::default(),
default_model: ("fake".into(), "m".into()),
systems: Default::default(),
default_agent: "build".into(),
};
let st = AppState::with_wiring(
None,
Payloads::default(),
Wires {
db,
blobs,
writer,
llm,
},
);
st.payloads.write().agent = vec![json!({
"name": "build",
"permission": [
{"permission": "edit", "pattern": "*", "action": "allow"},
{"permission": "write", "pattern": "*", "action": "allow"},
],
})];
ocserve_store::insert_session(
&st.writer,
&json!({
"id": sid, "projectID": "global", "directory": wt.display().to_string(),
"path": sid, "slug": sid, "title": "t", "version": "1",
"time": {"created": 1, "updated": 2},
}),
)
.unwrap();
(st, wt, tmp)
}
async fn post(app: &axum::Router, uri: &str, body: Value) -> axum::response::Response {
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap()
}
async fn body_json(resp: axum::response::Response) -> Value {
let b = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&b).unwrap_or(Value::Null)
}
async fn wait_finished(st: &Arc<AppState>) {
for _ in 0..120 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let conn = rusqlite::Connection::open(&st.db).unwrap();
let n: i64 = conn
.query_row(
"SELECT COUNT(*) FROM msg WHERE info LIKE '%\"finish\":\"stop\"%'",
[],
|r| r.get(0),
)
.unwrap_or(0);
if n > 0 {
return;
}
}
panic!("turn did not finish");
}
#[tokio::test]
async fn revert_restores_files_and_slices_history() {
let addr = spawn_seq_provider();
let sid = "ses_rev000000000000000000001";
let (st, wt, _tmp) = setup(addr, sid);
let app = ocserve_http::router(st.clone());
let user_mid = "msg_rev000000000000000000001";
let r = post(
&app,
&format!("/session/{sid}/prompt_async"),
json!({"messageID": user_mid, "parts": [{"type": "text", "text": "edit the file"}], "model": {"providerID": "fake", "modelID": "m"}, "agent": "build"}),
)
.await;
assert!(matches!(
r.status(),
StatusCode::NO_CONTENT | StatusCode::OK
));
wait_finished(&st).await;
assert_eq!(
std::fs::read_to_string(wt.join("a.txt")).unwrap(),
"two\n",
"the edit tool must have run"
);
let r = post(
&app,
&format!("/session/{sid}/revert"),
json!({"messageID": user_mid}),
)
.await;
assert_eq!(r.status(), StatusCode::OK, "revert status");
let info = body_json(r).await;
assert_eq!(info["revert"]["messageID"], user_mid, "marker set: {info}");
assert_eq!(
std::fs::read_to_string(wt.join("a.txt")).unwrap(),
"one\n",
"revert must restore the file to the pre-turn snapshot"
);
let r = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/session/{sid}/diff?messageID={user_mid}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::OK);
let diff = body_json(r).await;
let r = post(
&app,
&format!("/session/{sid}/prompt_async"),
json!({"messageID": "msg_rev_second000000000000001", "parts": [{"type": "text", "text": "again"}], "model": {"providerID": "fake", "modelID": "m"}, "agent": "build"}),
)
.await;
assert!(matches!(
r.status(),
StatusCode::NO_CONTENT | StatusCode::OK
));
let mut cleared = false;
for _ in 0..60 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let r = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/session/{sid}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let s = body_json(r).await;
if s["revert"].is_null() {
cleared = true;
break;
}
}
assert!(
cleared,
"P5 cleanup must clear the marker within the prompt"
);
let _ = diff;
}
#[tokio::test]
async fn unrevert_restores_the_snapshot() {
let addr = spawn_seq_provider();
let sid = "ses_rev000000000000000000002";
let (st, wt, _tmp) = setup(addr, sid);
let app = ocserve_http::router(st.clone());
let user_mid = "msg_rev000000000000000000002";
let r = post(
&app,
&format!("/session/{sid}/prompt_async"),
json!({"messageID": user_mid, "parts": [{"type": "text", "text": "edit"}], "model": {"providerID": "fake", "modelID": "m"}, "agent": "build"}),
)
.await;
assert!(matches!(
r.status(),
StatusCode::NO_CONTENT | StatusCode::OK
));
wait_finished(&st).await;
assert_eq!(std::fs::read_to_string(wt.join("a.txt")).unwrap(), "two\n");
post(
&app,
&format!("/session/{sid}/revert"),
json!({"messageID": user_mid}),
)
.await;
assert_eq!(
std::fs::read_to_string(wt.join("a.txt")).unwrap(),
"one\n",
"reverted"
);
let r = post(&app, &format!("/session/{sid}/unrevert"), json!({})).await;
assert_eq!(r.status(), StatusCode::OK);
let info = body_json(r).await;
assert!(
info["revert"].is_null(),
"unrevert clears the marker: {info}"
);
}
#[tokio::test]
async fn revert_without_snapshots_does_not_restore_files() {
let addr = spawn_seq_provider();
let sid = "ses_rev000000000000000000003";
let (st, wt, _tmp) = setup(addr, sid);
st.payloads.write().config = json!({"snapshot": false});
let app = ocserve_http::router(st.clone());
let user_mid = "msg_rev000000000000000000003";
let r = post(
&app,
&format!("/session/{sid}/prompt_async"),
json!({"messageID": user_mid, "parts": [{"type": "text", "text": "edit"}], "model": {"providerID": "fake", "modelID": "m"}, "agent": "build"}),
)
.await;
assert!(matches!(
r.status(),
StatusCode::NO_CONTENT | StatusCode::OK
));
wait_finished(&st).await;
assert_eq!(std::fs::read_to_string(wt.join("a.txt")).unwrap(), "two\n");
post(
&app,
&format!("/session/{sid}/revert"),
json!({"messageID": user_mid}),
)
.await;
assert_eq!(
std::fs::read_to_string(wt.join("a.txt")).unwrap(),
"two\n",
"snapshots disabled ⇒ revert must not touch the worktree"
);
}
#[tokio::test]
async fn v2_revert_stage_clear_commit_have_effects() {
let addr = spawn_seq_provider();
let sid = "ses_rev000000000000000000004";
let (st, wt, _tmp) = setup(addr, sid);
let app = ocserve_http::router(st.clone());
let user_mid = "msg_rev000000000000000000004";
let r = post(
&app,
&format!("/session/{sid}/prompt_async"),
json!({"messageID": user_mid, "parts": [{"type": "text", "text": "edit"}], "model": {"providerID": "fake", "modelID": "m"}, "agent": "build"}),
)
.await;
assert!(matches!(
r.status(),
StatusCode::NO_CONTENT | StatusCode::OK
));
wait_finished(&st).await;
assert_eq!(std::fs::read_to_string(wt.join("a.txt")).unwrap(), "two\n");
let r = post(
&app,
&format!("/api/session/{sid}/revert/stage"),
json!({"messageID": user_mid}),
)
.await;
assert_eq!(r.status(), StatusCode::OK, "stage");
let v = body_json(r).await;
assert_eq!(v["data"]["messageID"], user_mid, "{v}");
assert_eq!(
std::fs::read_to_string(wt.join("a.txt")).unwrap(),
"one\n",
"stage restores files"
);
let r = post(&app, &format!("/api/session/{sid}/revert/clear"), json!({})).await;
assert_eq!(r.status(), StatusCode::NO_CONTENT, "clear");
assert_eq!(
std::fs::read_to_string(wt.join("a.txt")).unwrap(),
"two\n",
"clear restores the snapshot"
);
let r = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/session/{sid}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(
body_json(r).await["revert"].is_null(),
"clear drops the marker"
);
let before: i64 = {
let conn = rusqlite::Connection::open(&st.db).unwrap();
conn.query_row(
&format!("SELECT COUNT(*) FROM msg WHERE session_id='{sid}'"),
[],
|r| r.get(0),
)
.unwrap()
};
post(
&app,
&format!("/api/session/{sid}/revert/stage"),
json!({"messageID": user_mid}),
)
.await;
let r = post(
&app,
&format!("/api/session/{sid}/revert/commit"),
json!({}),
)
.await;
assert_eq!(r.status(), StatusCode::NO_CONTENT, "commit");
let after: i64 = {
let conn = rusqlite::Connection::open(&st.db).unwrap();
conn.query_row(
&format!("SELECT COUNT(*) FROM msg WHERE session_id='{sid}'"),
[],
|r| r.get(0),
)
.unwrap()
};
assert!(
after < before,
"commit must delete the reverted range: {before} -> {after}"
);
let r = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/session/{sid}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(
body_json(r).await["revert"].is_null(),
"commit clears the marker"
);
}