1use crate::domain::report::OutputFormat;
7use serde_json::Value;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Extracted {
12 pub text: String,
13 pub source: String,
14}
15
16const TEXT_KEYS: &[&str] = &["result", "text", "message", "output", "content", "response"];
18
19pub fn extract(stdout: &str, fmt: OutputFormat) -> Option<Extracted> {
23 match fmt {
24 OutputFormat::Text => extract_text(stdout),
25 OutputFormat::Json => extract_json(stdout),
26 OutputFormat::StreamJson => extract_stream_json(stdout),
27 }
28}
29
30fn extract_text(stdout: &str) -> Option<Extracted> {
31 let t = stdout.trim();
32 (!t.is_empty()).then(|| Extracted {
33 text: t.to_string(),
34 source: "raw".to_string(),
35 })
36}
37
38fn extract_json(stdout: &str) -> Option<Extracted> {
39 if let Ok(value) = serde_json::from_str::<Value>(stdout.trim()) {
42 if let Some((text, key)) = json_text(&value) {
43 return Some(Extracted {
44 text,
45 source: format!("json:{key}"),
46 });
47 }
48 }
49 extract_opencode_parts(stdout)
53 .or_else(|| extract_codex_agent_message(stdout))
56 .or_else(|| extract_content_block_text(stdout))
59}
60
61fn extract_codex_agent_message(stdout: &str) -> Option<Extracted> {
65 let mut last: Option<String> = None;
66 for value in json_lines(stdout) {
67 if value.get("type").and_then(Value::as_str) != Some("item.completed") {
68 continue;
69 }
70 let Some(item) = value.get("item") else {
71 continue;
72 };
73 if item.get("type").and_then(Value::as_str) != Some("agent_message") {
74 continue;
75 }
76 if let Some(t) = item.get("text").and_then(Value::as_str) {
77 if !t.trim().is_empty() {
78 last = Some(t.to_string());
79 }
80 }
81 }
82 last.map(|text| Extracted {
83 text,
84 source: "json:codex-agent-message".to_string(),
85 })
86}
87
88fn extract_content_block_text(stdout: &str) -> Option<Extracted> {
93 let mut last: Option<String> = None;
94 for value in json_lines(stdout) {
95 let content = value
96 .get("message")
97 .and_then(|m| m.get("content"))
98 .or_else(|| value.get("content"))
99 .and_then(Value::as_array);
100 let Some(blocks) = content else { continue };
101 let joined = blocks
102 .iter()
103 .filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
104 .filter_map(|b| b.get("text").and_then(Value::as_str))
105 .filter(|t| !t.trim().is_empty())
106 .collect::<Vec<_>>()
107 .join("\n");
108 if !joined.is_empty() {
109 last = Some(joined);
110 }
111 }
112 last.map(|text| Extracted {
113 text,
114 source: "content-blocks:text".to_string(),
115 })
116}
117
118fn json_lines(stdout: &str) -> Vec<Value> {
122 if let Ok(Value::Array(items)) = serde_json::from_str::<Value>(stdout.trim()) {
123 return items;
124 }
125 stdout
126 .lines()
127 .filter_map(|line| serde_json::from_str::<Value>(line.trim()).ok())
128 .collect()
129}
130
131fn extract_opencode_parts(stdout: &str) -> Option<Extracted> {
140 let mut texts: Vec<String> = Vec::new();
141 for line in stdout.lines() {
142 let line = line.trim();
143 if line.is_empty() {
144 continue;
145 }
146 let Ok(value) = serde_json::from_str::<Value>(line) else {
147 continue;
148 };
149 let Some(part) = value.get("part").and_then(Value::as_object) else {
150 continue;
151 };
152 if part.get("type").and_then(Value::as_str) != Some("text") {
153 continue;
154 }
155 if let Some(text) = part.get("text").and_then(Value::as_str) {
156 if !text.trim().is_empty() {
157 texts.push(text.to_string());
158 }
159 }
160 }
161 (!texts.is_empty()).then(|| Extracted {
162 text: texts.join("\n"),
163 source: "json:opencode-parts".to_string(),
164 })
165}
166
167fn extract_stream_json(stdout: &str) -> Option<Extracted> {
170 let mut last: Option<(String, &'static str)> = None;
171 let mut last_result: Option<String> = None;
172 for line in stdout.lines() {
173 let line = line.trim();
174 if line.is_empty() {
175 continue;
176 }
177 let Ok(value) = serde_json::from_str::<Value>(line) else {
178 continue;
179 };
180 if let Some((text, key)) = json_text(&value) {
181 if key == "result" {
182 last_result = Some(text.clone());
183 }
184 last = Some((text, key));
185 }
186 }
187 if let Some(text) = last_result {
188 return Some(Extracted {
189 text,
190 source: "stream-json:result".to_string(),
191 });
192 }
193 if let Some(extracted) = extract_content_block_text(stdout) {
197 return Some(extracted);
198 }
199 last.map(|(text, key)| Extracted {
200 text,
201 source: format!("stream-json:{key}"),
202 })
203}
204
205fn json_text(value: &Value) -> Option<(String, &'static str)> {
208 match value {
209 Value::String(s) if !s.trim().is_empty() => Some((s.clone(), "string")),
210 Value::Object(map) => {
211 for key in TEXT_KEYS {
212 if let Some(Value::String(s)) = map.get(*key) {
213 if !s.trim().is_empty() {
214 return Some((s.clone(), key));
215 }
216 }
217 }
218 None
219 }
220 _ => None,
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn text_format_trims_raw_stdout() {
230 let got = extract(" hi there\n\n", OutputFormat::Text).unwrap();
231 assert_eq!(got.text, "hi there");
232 assert_eq!(got.source, "raw");
233 }
234
235 #[test]
236 fn empty_text_yields_none() {
237 assert!(extract(" \n", OutputFormat::Text).is_none());
238 }
239
240 #[test]
241 fn json_prefers_result_field() {
242 let raw = r#"{"type":"result","result":"the answer","is_error":false}"#;
243 let got = extract(raw, OutputFormat::Json).unwrap();
244 assert_eq!(got.text, "the answer");
245 assert_eq!(got.source, "json:result");
246 }
247
248 #[test]
249 fn json_falls_back_to_other_known_keys() {
250 let got = extract(r#"{"message":"hello"}"#, OutputFormat::Json).unwrap();
251 assert_eq!(got.text, "hello");
252 assert_eq!(got.source, "json:message");
253 }
254
255 #[test]
256 fn bare_json_string_is_extracted() {
257 let got = extract(r#""just a string""#, OutputFormat::Json).unwrap();
258 assert_eq!(got.text, "just a string");
259 assert_eq!(got.source, "json:string");
260 }
261
262 #[test]
263 fn unparseable_json_yields_none() {
264 assert!(extract("not json at all", OutputFormat::Json).is_none());
265 }
266
267 #[test]
268 fn stream_json_takes_terminal_result_event() {
269 let raw = concat!(
270 "{\"type\":\"system\"}\n",
271 "{\"type\":\"assistant\",\"text\":\"thinking\"}\n",
272 "{\"type\":\"result\",\"result\":\"final\"}\n",
273 );
274 let got = extract(raw, OutputFormat::StreamJson).unwrap();
275 assert_eq!(got.text, "final");
276 assert_eq!(got.source, "stream-json:result");
277 }
278
279 #[test]
280 fn stream_json_falls_back_to_last_text_without_result() {
281 let raw = "{\"type\":\"assistant\",\"text\":\"first\"}\n{\"type\":\"assistant\",\"text\":\"second\"}\n";
282 let got = extract(raw, OutputFormat::StreamJson).unwrap();
283 assert_eq!(got.text, "second");
284 assert_eq!(got.source, "stream-json:text");
285 }
286
287 const OPENCODE_RUN_JSONL: &str = concat!(
293 r#"{"type":"step_start","timestamp":1781179518088,"sessionID":"ses_1496d7d5effenFlBINeoCqBVk8","part":{"id":"prt_eb6928c85001I7CgUE9rTv6VJ0","messageID":"msg_eb69284ee001f9gKyD7PZudPCV","sessionID":"ses_1496d7d5effenFlBINeoCqBVk8","type":"step-start"}}"#,
294 "\n",
295 r#"{"type":"text","timestamp":1781179518140,"sessionID":"ses_1496d7d5effenFlBINeoCqBVk8","part":{"id":"prt_eb6928c87001tfso5uqcn63dxP","messageID":"msg_eb69284ee001f9gKyD7PZudPCV","sessionID":"ses_1496d7d5effenFlBINeoCqBVk8","type":"text","text":"PING-123","time":{"start":1781179518087,"end":1781179518139}}}"#,
296 "\n",
297 r#"{"type":"step_finish","timestamp":1781179518188,"sessionID":"ses_1496d7d5effenFlBINeoCqBVk8","part":{"id":"prt_eb6928ce6001n1g7PPB9g6pC3D","reason":"stop","messageID":"msg_eb69284ee001f9gKyD7PZudPCV","sessionID":"ses_1496d7d5effenFlBINeoCqBVk8","type":"step-finish","tokens":{"total":8186,"input":3,"output":7,"reasoning":0,"cache":{"write":8176,"read":0}},"cost":0.010258}}"#,
298 "\n",
299 );
300
301 #[test]
302 fn opencode_jsonl_extracts_text_part_under_json_format() {
303 let got = extract(OPENCODE_RUN_JSONL, OutputFormat::Json).unwrap();
306 assert_eq!(got.text, "PING-123");
307 assert_eq!(got.source, "json:opencode-parts");
308 }
309
310 #[test]
311 fn opencode_jsonl_joins_text_parts_and_skips_tool_and_step_parts() {
312 let raw = concat!(
316 r#"{"type":"step_start","sessionID":"ses_x","part":{"id":"p0","type":"step-start"}}"#,
317 "\n",
318 r#"{"type":"text","sessionID":"ses_x","part":{"id":"p1","type":"text","text":"I'll run that shell command for you."}}"#,
319 "\n",
320 r#"{"type":"tool_use","sessionID":"ses_x","part":{"id":"p2","type":"tool","tool":"bash","state":{"status":"completed","output":"HELLO-FROM-TOOL"}}}"#,
321 "\n",
322 r#"{"type":"text","sessionID":"ses_x","part":{"id":"p3","type":"text","text":"The command printed: `HELLO-FROM-TOOL`\n\nFINI-42"}}"#,
323 "\n",
324 r#"{"type":"step_finish","sessionID":"ses_x","part":{"id":"p4","type":"step-finish","cost":0.01,"tokens":{"input":3,"output":7}}}"#,
325 "\n",
326 );
327 let got = extract(raw, OutputFormat::Json).unwrap();
328 assert_eq!(
329 got.text,
330 "I'll run that shell command for you.\nThe command printed: `HELLO-FROM-TOOL`\n\nFINI-42"
331 );
332 assert_eq!(got.source, "json:opencode-parts");
333 assert!(!got.text.contains("step-finish"));
335 }
336
337 #[test]
338 fn opencode_jsonl_with_no_text_parts_yields_none() {
339 let raw = concat!(
343 r#"{"type":"step_start","sessionID":"ses_x","part":{"id":"p0","type":"step-start"}}"#,
344 "\n",
345 r#"{"type":"step_finish","sessionID":"ses_x","part":{"id":"p1","type":"step-finish","cost":0.01}}"#,
346 "\n",
347 );
348 assert!(extract(raw, OutputFormat::Json).is_none());
349 }
350
351 #[test]
352 fn claude_single_document_still_wins_over_jsonl_fallback() {
353 let raw = r#"{"type":"result","result":"the answer","is_error":false}"#;
356 let got = extract(raw, OutputFormat::Json).unwrap();
357 assert_eq!(got.text, "the answer");
358 assert_eq!(got.source, "json:result");
359 }
360
361 #[test]
362 fn opencode_parts_skip_blank_unparseable_and_partless_lines() {
363 let raw = concat!(
368 "\n",
369 " \n",
370 "not json at all\n",
371 r#"{"type":"noise"}"#,
372 "\n",
373 r#"{"type":"reasoning","part":{"type":"reasoning","text":"thinking out loud"}}"#,
374 "\n",
375 r#"{"type":"text","part":{"type":"text","text":" "}}"#,
376 "\n",
377 r#"{"type":"text","part":{"type":"text","text":"REAL-ANSWER"}}"#,
378 "\n",
379 );
380 let got = extract(raw, OutputFormat::Json).unwrap();
381 assert_eq!(got.text, "REAL-ANSWER");
382 assert_eq!(got.source, "json:opencode-parts");
383 }
384
385 #[test]
386 fn stream_json_skips_blank_and_unparseable_lines() {
387 let raw = concat!(
390 "\n",
391 "<<< not json >>>\n",
392 "{\"type\":\"assistant\",\"text\":\"only-line\"}\n",
393 );
394 let got = extract(raw, OutputFormat::StreamJson).unwrap();
395 assert_eq!(got.text, "only-line");
396 assert_eq!(got.source, "stream-json:text");
397 }
398
399 #[test]
400 fn stream_json_with_no_text_yields_none() {
401 let raw = "{\"type\":\"system\"}\n{\"type\":\"ping\"}\n";
404 assert!(extract(raw, OutputFormat::StreamJson).is_none());
405 }
406
407 #[test]
408 fn json_text_ignores_non_string_and_empty_key_values() {
409 assert!(extract(r#"{"result":42}"#, OutputFormat::Json).is_none());
412 assert!(extract(r#"{"result":" "}"#, OutputFormat::Json).is_none());
413 assert!(extract("12345", OutputFormat::Json).is_none());
414 }
415}