//! 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 = [ "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 { 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 = None; let mut tool_call: Option = 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 = described(&all).into_iter().filter(|text| text.starts_with("ack:")).collect(); assert_eq!(acks, vec!["ack:cancelled"]); }