Files
2026-10-15 02:24:15 -03:00

122 lines
5.3 KiB
Rust

//! Happy-path ACP flow tests: exact event order, permission round trip,
//! cancel — against the stub agent.
mod common;
use std::time::Duration;
use common::{collect_until, handshake, next_event, spawn_stub};
use notescraft_acp::{Event, PromptBlock, RequestId, SessionUpdate, ToolCallInfo};
const TIMEOUT: Duration = Duration::from_secs(15);
#[test]
fn happy_path_event_order_and_permission_answer() {
let process = spawn_stub("normal");
let session_id = handshake(&process);
process.prompt(&session_id, &[PromptBlock::Text("hello agent".to_string())]).unwrap_or_else(|e| panic!("prompt failed: {e}"));
// Drain until the turn ends, answering the permission request in-flight:
// the stub waits for our answer before it sends the ack and the turn end.
let first = collect_until(process.events(), TIMEOUT, |event| matches!(event, Event::PermissionRequest { .. }));
for event in &first {
if let Event::PermissionRequest { request_id, options, .. } = event {
let allowed = options.first().map(|option| option.id.clone()).unwrap_or_default();
process.answer_permission(request_id, Some(&allowed)).unwrap_or_else(|e| panic!("answer_permission failed: {e}"));
}
}
let rest = collect_until(process.events(), TIMEOUT, |event| matches!(event, Event::PromptDone { .. }));
let mut all = first;
all.extend(rest);
assert!(matches!(all.last(), Some(Event::PromptDone { .. })), "expected prompt done: {all:?}");
// Exact expected order.
let expected: Vec<String> = [
"chunk:Hello ",
"chunk:world",
"tool_call:t1:pending",
"tool_call_update:t1:completed",
"permission_request:perm-1",
"ack:allow",
"prompt_done:end_turn",
]
.iter()
.map(ToString::to_string)
.collect();
assert_eq!(described(&all), expected, "event order mismatch: {all:?}");
// Tool call details.
let Some(Event::Update(SessionUpdate::ToolCall(info))) = all.iter().find(|e| matches!(e, Event::Update(SessionUpdate::ToolCall(_)))) else {
panic!("missing tool call event");
};
assert_eq!(info.id, "t1");
assert_eq!(info.kind, "read");
assert_eq!(info.locations.first().map(String::as_str), Some("notes/a.md"));
assert!(info.raw_input.is_some());
}
fn described(events: &[Event]) -> Vec<String> {
events
.iter()
.filter_map(|event| match event {
Event::Update(SessionUpdate::AgentMessageChunk(text)) => Some(format!("chunk:{text}")),
Event::Update(SessionUpdate::ToolCall(info)) => Some(format!("tool_call:{}:{}", info.id, info.status)),
Event::Update(SessionUpdate::ToolCallUpdate { id, status, .. }) => Some(format!("tool_call_update:{}:{}", id, status.clone().unwrap_or_default())),
Event::Update(SessionUpdate::Other(value)) => Some(format!("ack:{}", value.get("optionId").and_then(serde_json::Value::as_str).unwrap_or("?"))),
Event::PermissionRequest { request_id, .. } => match request_id {
RequestId::Text(name) => Some(format!("permission_request:{name}")),
RequestId::Number(n) => Some(format!("permission_request:#{n}")),
},
Event::PromptDone { stop_reason } => Some(format!("prompt_done:{stop_reason}")),
_ => None,
})
.collect()
}
#[test]
fn cancelled_turn_reports_stop_reason() {
let process = spawn_stub("cancel");
let session_id = handshake(&process);
process.prompt(&session_id, &[PromptBlock::Text("long task".to_string())]).unwrap_or_else(|e| panic!("prompt failed: {e}"));
// Give the stub a moment to see the prompt, then cancel.
std::thread::sleep(Duration::from_millis(200));
process.cancel(&session_id).unwrap_or_else(|e| panic!("cancel failed: {e}"));
let all = collect_until(process.events(), TIMEOUT, |event| matches!(event, Event::PromptDone { .. }));
assert_eq!(described(&all), vec!["chunk:cancelled", "prompt_done:cancelled"]);
}
#[test]
fn permission_cancel_reports_option_none() {
let process = spawn_stub("normal");
let session = handshake(&process);
process.prompt(&session, &[PromptBlock::Text("ask me".to_string())]).unwrap_or_else(|e| panic!("prompt failed: {e}"));
let mut request_id: Option<RequestId> = None;
let mut tool_call: Option<ToolCallInfo> = None;
while request_id.is_none() {
match next_event(process.events(), TIMEOUT) {
Event::PermissionRequest { request_id: id, tool_call: call, .. } => {
request_id = Some(id);
tool_call = Some(call);
}
Event::Exited { .. } => panic!("agent exited before permission round trip"),
_ => {}
}
}
let Some(request) = request_id.as_ref() else {
panic!("no permission request id");
};
process.answer_permission(request, None).unwrap_or_else(|e| panic!("answer failed: {e}"));
let Some(call) = tool_call else {
panic!("permission request missing tool call info");
};
assert_eq!(call.id, "t1");
assert_eq!(call.kind, "edit");
let all = collect_until(process.events(), TIMEOUT, |event| matches!(event, Event::PromptDone { .. }));
let acks: Vec<String> = described(&all).into_iter().filter(|text| text.starts_with("ack:")).collect();
assert_eq!(acks, vec!["ack:cancelled"]);
}