fix: harden structured replay
This commit is contained in:
@@ -110,24 +110,49 @@ impl FakeAcp {
|
||||
}
|
||||
|
||||
fn env(&self) -> HashMap<String, String> {
|
||||
HashMap::from([(
|
||||
"PATH".into(),
|
||||
format!(
|
||||
"{}:{}",
|
||||
self.dir.path().join("bin").display(),
|
||||
std::env::var("PATH").unwrap()
|
||||
HashMap::from([
|
||||
(
|
||||
"PATH".into(),
|
||||
format!(
|
||||
"{}:{}",
|
||||
self.dir.path().join("bin").display(),
|
||||
std::env::var("PATH").unwrap()
|
||||
),
|
||||
),
|
||||
)])
|
||||
(
|
||||
"FAKE_ACP_LOG".into(),
|
||||
self.dir.path().join("agent.log").display().to_string(),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn pid(&self) -> u32 {
|
||||
std::fs::read_to_string(self.dir.path().join("agent.log"))
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
line.strip_prefix("start pid=")?
|
||||
.split(' ')
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
})
|
||||
.next_back()
|
||||
.expect("fake ACP started")
|
||||
}
|
||||
}
|
||||
|
||||
async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
|
||||
structured_session_with_env(h, fake.env()).await
|
||||
}
|
||||
|
||||
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
|
||||
h.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: SessionView::Structured,
|
||||
cwd: "/tmp".into(),
|
||||
command: "claude".into(),
|
||||
env: fake.env(),
|
||||
env,
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
@@ -183,7 +208,9 @@ async fn structured_ws_sends_snapshot_then_live_projection() {
|
||||
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fake = FakeAcp::new();
|
||||
let id = structured_session(&h, &fake).await;
|
||||
let mut env = fake.env();
|
||||
env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "200".into());
|
||||
let id = structured_session_with_env(&h, env).await;
|
||||
let client = reqwest::Client::new();
|
||||
let first = client
|
||||
.post(url(
|
||||
@@ -229,6 +256,32 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
|
||||
assert_eq!(next.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_prompt_failure_clears_the_turn() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fake = FakeAcp::new();
|
||||
let mut env = fake.env();
|
||||
env.insert("FAKE_ACP_PROMPT_FAIL".into(), "1".into());
|
||||
let id = structured_session_with_env(&h, env).await;
|
||||
let client = reqwest::Client::new();
|
||||
let path = format!("/api/sessions/{id}/structured/prompt");
|
||||
let first = client
|
||||
.post(url(h.addr, &path))
|
||||
.json(&serde_json::json!({"prompt":"fails"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let second = client
|
||||
.post(url(h.addr, &path))
|
||||
.json(&serde_json::json!({"prompt":"available"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(second.status(), reqwest::StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_replay_honors_cursors_and_limit() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
@@ -258,7 +311,8 @@ async fn structured_replay_honors_cursors_and_limit() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0]["seq"], 1);
|
||||
assert_eq!(events[0]["cursor"], 1);
|
||||
assert!(events[0].get("notification").is_none());
|
||||
|
||||
let invalid = client
|
||||
.get(url(
|
||||
@@ -271,6 +325,92 @@ async fn structured_replay_honors_cursors_and_limit() {
|
||||
assert_eq!(invalid.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_replay_pages_projected_events_in_cursor_order() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fake = FakeAcp::new();
|
||||
let id = structured_session(&h, &fake).await;
|
||||
let client = reqwest::Client::new();
|
||||
unsafe { libc::kill(fake.pid() as libc::pid_t, libc::SIGUSR1) };
|
||||
let events: Vec<serde_json::Value> = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let events: Vec<serde_json::Value> = client
|
||||
.get(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{id}/structured/replay?before=99&limit=2"),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
if events.len() == 2 {
|
||||
return events;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.map(|event| event["cursor"].as_u64().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 2]
|
||||
);
|
||||
let boundary: Vec<serde_json::Value> = client
|
||||
.get(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{id}/structured/replay?before=2&limit=10"),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(boundary.len(), 1);
|
||||
assert_eq!(boundary[0]["cursor"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_replay_rejects_unknown_and_terminal_sessions() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let client = reqwest::Client::new();
|
||||
let unknown = client
|
||||
.get(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{}/structured/replay", Uuid::new_v4()),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unknown.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
let terminal = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: SessionView::Terminal,
|
||||
cwd: "/tmp".into(),
|
||||
command: "sh".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let response = client
|
||||
.get(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{}/structured/replay", terminal.id),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn info_returns_daemon_identity() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
|
||||
Reference in New Issue
Block a user