pub fn extract_final_assistant_text(lines: &[String], cli_tool: &str) -> Option<String> {
let cli = cli_tool.to_ascii_lowercase();
if cli.contains("pi-rust") || cli.contains("kimi") {
if let Some(t) = extract_pi_rust(lines) {
return Some(t);
}
} else if cli.contains("claude") {
if let Some(t) = extract_claude_stream_json(lines) {
return Some(t);
}
} else if cli.contains("opencode")
&& let Some(t) = extract_opencode(lines)
{
return Some(t);
}
extract_pass_through(lines)
}
fn extract_pi_rust(lines: &[String]) -> Option<String> {
let mut last_full_text: Option<String> = None;
let mut last_delta_text: Option<String> = None;
for line in lines {
let line = line.trim();
if line.is_empty() || line.starts_with("# ") {
continue;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let event_type = value.get("type").and_then(|v| v.as_str()).unwrap_or("");
let msg = match value.get("message") {
Some(m) if m.is_object() => m,
_ => continue,
};
if msg.get("role").and_then(|v| v.as_str()) != Some("assistant") {
continue;
}
let content = match msg.get("content") {
Some(c) if c.is_array() => c.as_array().unwrap(),
_ => continue,
};
if matches!(event_type, "message_end" | "turn_end" | "message_start") {
if let Some(text) = join_text_parts(content)
&& !text.is_empty()
{
last_full_text = Some(text);
}
} else if event_type == "message_update" {
if let Some(text) = join_text_parts(content)
&& !text.is_empty()
{
last_delta_text = Some(text);
}
}
}
last_full_text.or(last_delta_text)
}
fn extract_claude_stream_json(lines: &[String]) -> Option<String> {
let mut accumulated = String::new();
let mut any = false;
for line in lines {
let line = line.trim();
if line.is_empty() || line.starts_with("# ") {
continue;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let event_type = value.get("type").and_then(|v| v.as_str()).unwrap_or("");
if event_type != "content_block_delta" {
continue;
}
if let Some(delta) = value.get("delta")
&& delta.get("type").and_then(|v| v.as_str()) == Some("text_delta")
&& let Some(text) = delta.get("text").and_then(|v| v.as_str())
&& !text.is_empty()
{
accumulated.push_str(text);
any = true;
}
}
if any { Some(accumulated) } else { None }
}
fn extract_opencode(lines: &[String]) -> Option<String> {
let mut accumulated = String::new();
let mut any = false;
for line in lines {
let line = line.trim();
if line.is_empty() || line.starts_with("# ") {
continue;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if value.get("type").and_then(|v| v.as_str()) != Some("text") {
continue;
}
if let Some(text) = value
.get("part")
.and_then(|p| p.get("text"))
.and_then(|t| t.as_str())
&& !text.is_empty()
{
accumulated.push_str(text);
any = true;
}
}
if any { Some(accumulated) } else { None }
}
fn extract_pass_through(lines: &[String]) -> Option<String> {
let mut accumulated = String::new();
let mut any = false;
for line in lines {
let line = line.trim();
if line.is_empty() {
continue;
}
if line.starts_with("# ") {
continue;
}
if serde_json::from_str::<serde_json::Value>(line).is_ok() {
continue;
}
if !accumulated.is_empty() {
accumulated.push('\n');
}
accumulated.push_str(line);
any = true;
}
if any { Some(accumulated) } else { None }
}
fn join_text_parts(content: &[serde_json::Value]) -> Option<String> {
let mut s = String::new();
let mut any = false;
for c in content {
if c.get("type").and_then(|v| v.as_str()) == Some("text")
&& let Some(text) = c.get("text").and_then(|v| v.as_str())
{
s.push_str(text);
any = true;
}
}
if any { Some(s) } else { None }
}
#[cfg(test)]
mod tests {
use super::*;
fn read_fixture(name: &str) -> Vec<String> {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/pr_review")
.join(name);
let raw = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read fixture {}: {e}", path.display()));
raw.lines().map(|s| s.to_string()).collect()
}
#[test]
fn extractor_returns_last_assistant_text_on_pi_rust_stream() {
let lines = read_fixture("drain_pi_rust_glm_review.log");
let out = extract_final_assistant_text(&lines, "/home/alex/.local/bin/pi-rust")
.expect("extractor should return Some on a real pi-rust stream");
assert!(
!out.is_empty(),
"extracted text should not be empty on a real stream"
);
assert!(
out.contains("Handoff envelope") || out.len() > 50,
"extracted text should be the agent's final summary (got len={})",
out.len()
);
}
#[test]
fn extractor_returns_verdict_block_on_pi_rust_with_verdict() {
let lines = read_fixture("drain_pi_rust_glm_with_verdict.log");
let out = extract_final_assistant_text(&lines, "/home/alex/.local/bin/pi-rust")
.expect("extractor should return Some");
assert!(out.contains("### Summary"));
assert!(out.contains("### Confidence Score: 5/5"));
assert!(out.contains("### Inline Findings"));
assert!(out.contains("<sub>Last reviewed commit: 2a3b4c5 | Reviews (1)</sub>"));
}
#[test]
fn extractor_accumulates_claude_stream_json_text_deltas() {
let lines = read_fixture("drain_claude_stream_json_assistant_only.log");
let out = extract_final_assistant_text(&lines, "/home/alex/.local/bin/claude")
.expect("extractor should return Some");
assert!(out.contains("SHA-bind"));
assert!(out.contains("Confidence Score: 5/5"));
assert!(out.contains("Reviews (2)"));
}
#[test]
fn extractor_concatenates_opencode_text_parts() {
let lines = read_fixture("drain_opencode_text.log");
let out = extract_final_assistant_text(&lines, "/home/alex/.bun/bin/opencode")
.expect("extractor should return Some");
assert!(out.contains("Removes the four openai/*"));
assert!(out.contains("Confidence Score: 5/5"));
assert!(out.contains("Last reviewed commit: c6d8567"));
}
#[test]
fn extractor_passes_through_non_json_lines() {
let lines = read_fixture("drain_pass_through.log");
let out = extract_final_assistant_text(
&lines,
"/home/alex/.local/bin/claude --output-format text",
)
.expect("extractor should return Some on free-form output");
assert!(out.contains("### Summary"));
assert!(out.contains("Confidence Score: 5/5"));
assert!(out.contains("received"));
}
#[test]
fn extractor_returns_none_on_empty_stream() {
let lines = read_fixture("drain_empty.log");
let out = extract_final_assistant_text(&lines, "/home/alex/.local/bin/pi-rust");
assert_eq!(out, None);
}
#[test]
fn extractor_returns_none_on_header_only_stream() {
let lines: Vec<String> = vec![
"# agent: pr-reviewer".to_string(),
"# exit_code: Some(0)".to_string(),
"# exit_class: empty_success".to_string(),
];
let out = extract_final_assistant_text(&lines, "/home/alex/.local/bin/pi-rust");
assert_eq!(out, None);
}
#[test]
fn extractor_routes_by_cli_tool_substring() {
let lines = read_fixture("drain_pi_rust_glm_with_verdict.log");
assert!(extract_final_assistant_text(&lines, "/home/alex/.local/bin/PI-RUST").is_some());
assert!(extract_final_assistant_text(&lines, "pi-rust-extra-arg").is_some());
assert!(extract_final_assistant_text(&lines, "kimi-via-pi-rust").is_some());
let out = extract_final_assistant_text(&lines, "/bin/cat");
assert_eq!(out, None);
}
}