use super::*; fn dispatch_events(events: impl IntoIterator) -> Vec { let mut mapped = Vec::new(); for event in events { dispatch_event(&event, &mut |event| mapped.push(event)); } mapped } struct ToolExpectation<'a> { tool_id: &'a str, tool_name: &'a str, input: Option<&'a str>, output: Option<&'a str>, } fn assert_tool_pair(events: &[AiAgentStreamEvent], expected: ToolExpectation<'_>) { assert!(matches!( &events[0], AiAgentStreamEvent::ToolStart { tool_name: actual_name, tool_id: actual_id, input: actual_input, } if actual_name == expected.tool_name && actual_id == expected.tool_id && actual_input.as_deref() == expected.input )); assert!(matches!( &events[1], AiAgentStreamEvent::ToolDone { tool_id: actual_id, output: actual_output, } if actual_id == expected.tool_id && actual_output.as_deref() == expected.output )); } #[test] fn parse_line_reports_read_errors_and_skips_blank_or_invalid_lines() { let mut events = Vec::new(); let read_error = parse_line(Err(std::io::Error::other("broken pipe")), &mut |event| { events.push(event) }); let blank = parse_line(Ok(" ".into()), &mut |event| events.push(event)); let invalid = parse_line(Ok("not json".into()), &mut |event| events.push(event)); assert!(read_error.is_none()); assert!(blank.is_none()); assert!(invalid.is_none()); assert!(matches!( &events[0], AiAgentStreamEvent::Error { message } if message.contains("broken pipe") )); } #[test] fn dispatch_maps_session_reasoning_and_text() { let mut events = Vec::new(); let started = serde_json::json!({ "type": "session", "sessionID": "ses_1" }); let reasoning = serde_json::json!({ "type": "reasoning", "text": "Checking links" }); let text = serde_json::json!({ "type": "message", "text": "Done" }); for event in [started, reasoning, text] { dispatch_event(&event, &mut |event| events.push(event)); } assert!(matches!( &events[0], AiAgentStreamEvent::Init { session_id } if session_id == "ses_1" )); assert!(matches!( &events[1], AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links" )); assert!(matches!( &events[2], AiAgentStreamEvent::TextDelta { text } if text == "Done" )); } #[test] fn dispatch_maps_part_backed_reasoning_and_text() { let mut events = Vec::new(); let reasoning = serde_json::json!({ "type": "reasoning", "part": { "type": "reasoning", "text": "Checking links" } }); let text = serde_json::json!({ "type": "text", "part": { "type": "text", "text": "Done from OpenCode" } }); for event in [reasoning, text] { dispatch_event(&event, &mut |event| events.push(event)); } assert!(matches!( &events[0], AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links" )); assert!(matches!( &events[1], AiAgentStreamEvent::TextDelta { text } if text == "Done from OpenCode" )); } #[test] fn dispatch_maps_final_text_after_tool_part_wrappers() { let events = dispatch_events([ serde_json::json!({ "type": "part", "part": { "id": "prt_tool_1", "type": "tool", "tool": "webfetch", "input": { "url": "https://example.com" } } }), serde_json::json!({ "type": "part", "part": { "id": "prt_text_1", "type": "text", "text": "Final answer after tool output." } }), serde_json::json!({ "type": "part", "part": { "id": "prt_finish_1", "type": "step-finish", "reason": "stop" } }), ]); assert!(matches!( &events[0], AiAgentStreamEvent::ToolStart { tool_name, tool_id, input, } if tool_name == "webfetch" && tool_id == "prt_tool_1" && input.as_deref() == Some(r#"{"url":"https://example.com"}"#) )); assert!(matches!( &events[1], AiAgentStreamEvent::TextDelta { text } if text == "Final answer after tool output." )); assert_eq!(events.len(), 2); } #[test] fn dispatch_maps_tool_events() { let direct = dispatch_events([ serde_json::json!({ "type": "tool_use", "id": "tool_1", "name": "read", "input": { "path": "Note.md" } }), serde_json::json!({ "type": "tool_result", "id": "tool_1", "output": "ok" }), ]); let part_backed = dispatch_events([ serde_json::json!({ "type": "tool_use", "part": { "id": "prt_tool_1", "tool": "read", "input": { "path": "Note.md" } } }), serde_json::json!({ "type": "tool_result", "part": { "id": "prt_tool_1", "output": "ok" } }), ]); assert_tool_pair( &direct, ToolExpectation { tool_id: "tool_1", tool_name: "read", input: Some(r#"{"path":"Note.md"}"#), output: Some("ok"), }, ); assert_tool_pair( &part_backed, ToolExpectation { tool_id: "prt_tool_1", tool_name: "read", input: Some(r#"{"path":"Note.md"}"#), output: Some("ok"), }, ); } #[test] fn dispatch_maps_error_events() { let mut events = Vec::new(); let error = serde_json::json!({ "type": "error", "message": "provider failed" }); dispatch_event(&error, &mut |event| events.push(event)); assert!(matches!( &events[0], AiAgentStreamEvent::Error { message } if message == "provider failed" )); } #[test] fn format_error_explains_missing_auth_or_provider_setup() { let message = format_error( "provider auth failed: please login".into(), "exit status: 1".into(), ); assert!(message.contains("OpenCode CLI is not authenticated")); assert!(message.contains("opencode auth login")); } #[test] fn format_error_uses_status_or_first_stderr_lines() { let empty = format_error(String::new(), "exit status: 2".into()); let truncated = format_error("line 1\nline 2\nline 3\nline 4".into(), "ignored".into()); assert_eq!(empty, "opencode exited with status exit status: 2"); assert_eq!(truncated, "line 1\nline 2\nline 3"); }