122 lines
5.3 KiB
Rust
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"]);
|
|
}
|