2026-04-28 01:26:21 +02:00
|
|
|
use super::*;
|
|
|
|
|
|
2026-04-29 00:52:27 +02:00
|
|
|
fn dispatch_events(events: impl IntoIterator<Item = serde_json::Value>) -> Vec<AiAgentStreamEvent> {
|
|
|
|
|
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();
|
|
|
|
|
|
2026-04-29 01:04:13 +02:00
|
|
|
let read_error = parse_line(Err(std::io::Error::other("broken pipe")), &mut |event| {
|
|
|
|
|
events.push(event)
|
|
|
|
|
});
|
2026-04-29 00:52:27 +02:00
|
|
|
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")
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 01:26:21 +02:00
|
|
|
#[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"
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 22:55:49 +02:00
|
|
|
#[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"
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 03:24:11 +02:00
|
|
|
#[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 01:26:21 +02:00
|
|
|
#[test]
|
|
|
|
|
fn dispatch_maps_tool_events() {
|
2026-04-29 00:52:27 +02:00
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
]);
|
2026-04-28 01:26:21 +02:00
|
|
|
|
2026-04-29 00:52:27 +02:00
|
|
|
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"),
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-04-28 01:26:21 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-28 22:55:49 +02:00
|
|
|
#[test]
|
2026-04-29 00:52:27 +02:00
|
|
|
fn dispatch_maps_error_events() {
|
2026-04-28 22:55:49 +02:00
|
|
|
let mut events = Vec::new();
|
2026-04-29 00:52:27 +02:00
|
|
|
let error = serde_json::json!({ "type": "error", "message": "provider failed" });
|
2026-04-28 22:55:49 +02:00
|
|
|
|
2026-04-29 00:52:27 +02:00
|
|
|
dispatch_event(&error, &mut |event| events.push(event));
|
2026-04-28 22:55:49 +02:00
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
&events[0],
|
2026-04-29 00:52:27 +02:00
|
|
|
AiAgentStreamEvent::Error { message } if message == "provider failed"
|
2026-04-28 22:55:49 +02:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 01:26:21 +02:00
|
|
|
#[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"));
|
|
|
|
|
}
|
2026-04-29 00:52:27 +02:00
|
|
|
|
|
|
|
|
#[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");
|
|
|
|
|
}
|