Skip to main content

orch/cli/
ndjson.rs

1//! Renders agent NDJSON as a Claude Code style transcript for `orch stream`, `orch task watch`
2//! and session export. Each agent's runner turns its own lines into [`StreamEvent`]s
3
4use crate::engine::runner::agents::{
5    AgentRunner, StreamEvent, get_runner, json_error_message, json_str,
6};
7use regex::Regex;
8use serde_json::Value;
9use std::collections::{BTreeSet, HashMap};
10use std::env;
11use std::fmt;
12use std::fmt::Debug;
13use std::sync::LazyLock;
14
15/// Prefix of a tool result line, printed right under its tool call
16pub const RESULT_PREFIX: &str = "  ⎿  ";
17
18static ANSI: LazyLock<Regex> = LazyLock::new(|| {
19    Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07|\r").expect("ansi regex is valid")
20});
21static WORKTREE: LazyLock<Regex> = LazyLock::new(|| {
22    Regex::new(r"\S*/\.orch/worktrees/[^/\s]+/[^/\s]+/").expect("worktree regex is valid")
23});
24
25const BOLD: &str = "1";
26const DIM: &str = "2";
27const THINK: &str = "2;3";
28const RED: &str = "31";
29const GREEN: &str = "32";
30
31/// Turns one agent's NDJSON lines into transcript entries, picking the runner from the first line
32#[derive(Default)]
33pub struct Formatter {
34    color: bool,
35    runner: Option<Box<dyn AgentRunner>>,
36    tools: HashMap<String, String>,
37    changed: BTreeSet<String>,
38}
39
40impl Debug for Formatter {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        f.debug_struct("Formatter")
43            .field("color", &self.color)
44            .field("tools", &self.tools)
45            .field("changed", &self.changed)
46            .finish_non_exhaustive()
47    }
48}
49
50impl Formatter {
51    pub fn new(color: bool) -> Self {
52        Self {
53            color,
54            ..Self::default()
55        }
56    }
57
58    /// Entries for one output line. A step starts with a marker, a result with [`RESULT_PREFIX`]
59    pub fn format(&mut self, line: &str) -> Vec<String> {
60        let line = ANSI.replace_all(line, "");
61        let line = line.trim();
62        if line.is_empty() {
63            return vec![];
64        }
65
66        let Ok(v) = serde_json::from_str::<Value>(line) else {
67            return vec![self.paint(DIM, line)];
68        };
69
70        if self.runner.is_none() {
71            self.runner = detect_agent(&v).map(get_runner);
72        }
73
74        let events = match &self.runner {
75            Some(runner) => runner.stream_events(&v),
76            None if json_str(&v, "/type") == "error" => {
77                vec![StreamEvent::Error(json_error_message(&v))]
78            }
79            None => vec![],
80        };
81        events.into_iter().flat_map(|e| self.render(e)).collect()
82    }
83
84    /// Files the agent wrote or edited so far, as the agent named them
85    pub fn changed_files(&self) -> Vec<String> {
86        self.changed.iter().cloned().collect()
87    }
88
89    fn render(&mut self, event: StreamEvent) -> Vec<String> {
90        match event {
91            StreamEvent::Started { model } => {
92                vec![self.paint(DIM, &format!("⏺ Session started · {model}"))]
93            }
94            StreamEvent::Text(text) => {
95                let text = text.trim();
96                if text.is_empty() {
97                    return vec![];
98                }
99
100                let text = pretty_json(text).unwrap_or_else(|| text.to_string());
101                vec![format!("⏺ {}", text.replace('\n', "\n  "))]
102            }
103            StreamEvent::Thinking(text) => {
104                let Some(first) = text.lines().map(str::trim).find(|l| !l.is_empty()) else {
105                    return vec![];
106                };
107                let first = truncate(&first.replace("**", ""), 120);
108                vec![self.paint(THINK, &format!("✻ {first}"))]
109            }
110            StreamEvent::ToolCall { id, name, input } => {
111                let name = display_tool(&name).to_string();
112                self.record_change(&name, &input);
113                let call = self.tool_call(&name, &input);
114                self.tools.insert(id, name);
115                vec![call]
116            }
117            StreamEvent::ToolResult {
118                id,
119                name,
120                output,
121                is_error,
122            } => {
123                let called = self.tools.remove(&id).unwrap_or_default();
124                let name = if name.is_empty() { called } else { name };
125                vec![self.result(&name, &output, is_error)]
126            }
127            StreamEvent::Done(detail) => self.done(&detail),
128            StreamEvent::Error(text) => {
129                let text = text.trim();
130                let text = if text.is_empty() {
131                    "unknown error"
132                } else {
133                    text
134                };
135                vec![self.paint(RED, &format!("✗ {}", truncate(text, 300)))]
136            }
137        }
138    }
139
140    fn record_change(&mut self, name: &str, input: &Value) {
141        let edits = matches!(
142            name.to_ascii_lowercase().as_str(),
143            "write" | "edit" | "multiedit" | "notebookedit"
144        );
145        if !edits {
146            return;
147        }
148
149        let path = ["file_path", "filePath", "notebook_path", "path"]
150            .iter()
151            .find_map(|k| input[k].as_str().filter(|s| !s.is_empty()));
152        if let Some(path) = path {
153            self.changed.insert(path.to_string());
154        }
155    }
156
157    fn tool_call(&self, name: &str, input: &Value) -> String {
158        let name = self.paint(BOLD, &capitalize(name));
159        let summary = summarize_input(input);
160        let call = if summary.is_empty() {
161            name
162        } else {
163            format!("{name}({summary})")
164        };
165        format!("{} {call}", self.paint(GREEN, "⏺"))
166    }
167
168    fn result(&self, tool: &str, output: &str, is_error: bool) -> String {
169        let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
170        let first = lines
171            .first()
172            .map(|l| truncate(&shorten(l.trim()), 100))
173            .unwrap_or_default();
174        if is_error {
175            let first = if first.is_empty() {
176                "Error".to_string()
177            } else {
178                first
179            };
180            return format!("{RESULT_PREFIX}{}", self.paint(RED, &first));
181        }
182
183        let body = if lines.is_empty() {
184            "(no output)".to_string()
185        } else if tool.eq_ignore_ascii_case("read") {
186            format!("Read {} lines", lines.len())
187        } else if lines.len() > 1 {
188            format!("{first} … +{} lines", lines.len() - 1)
189        } else {
190            first
191        };
192        format!("{RESULT_PREFIX}{}", self.paint(DIM, &body))
193    }
194
195    fn done(&self, detail: &str) -> Vec<String> {
196        let text = if detail.is_empty() {
197            "✓ Done".to_string()
198        } else {
199            format!("✓ Done · {detail}")
200        };
201        let mut out = vec![self.paint(GREEN, &text)];
202        if !self.changed.is_empty() {
203            let files: Vec<String> = self.changed.iter().map(|f| shorten(f)).collect();
204            let noun = if files.len() == 1 { "file" } else { "files" };
205            let body = format!("Changed {} {noun}: {}", files.len(), files.join(", "));
206            out.push(format!("{RESULT_PREFIX}{}", self.paint(DIM, &body)));
207        }
208        out
209    }
210
211    fn paint(&self, code: &str, text: &str) -> String {
212        if self.color {
213            format!("\x1b[{code}m{text}\x1b[0m")
214        } else {
215            text.to_string()
216        }
217    }
218}
219
220/// Runner name for the output format a line belongs to. kimi and minimax emit Claude's format
221fn detect_agent(v: &Value) -> Option<&'static str> {
222    let kind = json_str(v, "/type");
223    if matches!(kind, "system" | "assistant" | "user" | "result") {
224        Some("claude")
225    } else if ["thread.", "turn.", "item."]
226        .iter()
227        .any(|p| kind.starts_with(p))
228    {
229        Some("codex")
230    } else if v.get("part").is_some() || v.get("sessionID").is_some() {
231        Some("opencode")
232    } else {
233        None
234    }
235}
236
237/// Print entries with a blank line before each step, keeping results right under their call
238pub fn print(entries: &[String]) {
239    for entry in entries {
240        if entry.starts_with(RESULT_PREFIX) {
241            println!("{entry}");
242        } else {
243            println!("\n{entry}");
244        }
245    }
246}
247
248/// Files written or edited in raw NDJSON output, the fallback when the agent reports none
249pub fn extract_files_changed(content: &str) -> Vec<String> {
250    let mut formatter = Formatter::new(false);
251    for line in content.lines() {
252        formatter.format(line);
253    }
254    formatter.changed_files()
255}
256
257/// Indented JSON when a message is a JSON object or array, bare or in a ```json fence
258fn pretty_json(text: &str) -> Option<String> {
259    let body = text
260        .strip_prefix("```json")
261        .or_else(|| text.strip_prefix("```"))
262        .and_then(|t| t.strip_suffix("```"))
263        .unwrap_or(text)
264        .trim();
265    if !body.starts_with(['{', '[']) {
266        return None;
267    }
268
269    let value: Value = serde_json::from_str(body).ok()?;
270    serde_json::to_string_pretty(&value).ok()
271}
272
273/// One line for a tool call's input: the command, path, pattern or query it acts on
274fn summarize_input(input: &Value) -> String {
275    const KEYS: [&str; 9] = [
276        "command",
277        "file_path",
278        "filePath",
279        "pattern",
280        "path",
281        "query",
282        "url",
283        "description",
284        "prompt",
285    ];
286    let value = KEYS
287        .iter()
288        .find_map(|k| input[k].as_str().filter(|s| !s.is_empty()))
289        .or_else(|| {
290            input
291                .as_object()?
292                .values()
293                .find_map(|v| v.as_str().filter(|s| !s.is_empty()))
294        })
295        .unwrap_or("");
296    let mut lines = value.lines();
297    let first = shorten(lines.next().unwrap_or(""));
298    let more = if lines.next().is_some() { " …" } else { "" };
299    truncate(&format!("{first}{more}"), 100)
300}
301
302/// Drop the worktree root from paths and abbreviate the home dir
303fn shorten(text: &str) -> String {
304    let text = WORKTREE.replace_all(text, "");
305    match env::var("HOME") {
306        Ok(home) if !home.is_empty() => text.replace(&home, "~"),
307        _ => text.into_owned(),
308    }
309}
310
311/// Grok tool ids shown with the same names as the other agents
312fn display_tool(name: &str) -> &str {
313    match name {
314        "read_file" | "hashline_read" => "Read",
315        "search_replace" | "hashline_edit" | "strreplace" => "Edit",
316        "run_terminal_cmd" | "run_terminal_command" => "Bash",
317        "list_dir" => "List",
318        "grep" | "hashline_grep" => "Grep",
319        "web_search" => "WebSearch",
320        "web_fetch" => "WebFetch",
321        "write" => "Write",
322        other => other,
323    }
324}
325
326fn capitalize(name: &str) -> String {
327    let mut chars = name.chars();
328    match chars.next() {
329        Some(c) => c.to_uppercase().chain(chars).collect(),
330        None => "Tool".to_string(),
331    }
332}
333
334/// Truncate a string to at most `max` chars, appending `…` if cut
335pub(crate) fn truncate(s: &str, max: usize) -> String {
336    for (char_count, (byte_idx, _ch)) in s.char_indices().enumerate() {
337        if char_count >= max {
338            return format!("{}…", &s[..byte_idx]);
339        }
340    }
341    s.to_string()
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    fn fmt(lines: &[&str]) -> Vec<String> {
349        let mut f = Formatter::new(false);
350        lines.iter().flat_map(|l| f.format(l)).collect()
351    }
352
353    #[test]
354    fn claude_transcript() {
355        let lines = [
356            r#"{"type":"system","subtype":"init","model":"claude-sonnet-5"}"#,
357            r#"{"type":"system","subtype":"thinking_tokens","tokens":12}"#,
358            r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"","signature":"x"}]}}"#,
359            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Let me read it.\nThen fix."},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/x/.orch/worktrees/orch/br/src/main.rs"}}]}}"#,
360            r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"1\tfn a() {}\n2\tfn b() {}\n"}]}}"#,
361            r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t2","name":"Edit","input":{"file_path":"/x/.orch/worktrees/orch/br/src/main.rs","old_string":"a","new_string":"b"}}]}}"#,
362            r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t2","content":"Exit code 1\nboom","is_error":true}]}}"#,
363            r#"{"type":"result","subtype":"success","is_error":false,"num_turns":3,"duration_ms":42000}"#,
364        ];
365        assert_eq!(
366            fmt(&lines),
367            vec![
368                "⏺ Session started · claude-sonnet-5",
369                "⏺ Let me read it.\n  Then fix.",
370                "⏺ Read(src/main.rs)",
371                "  ⎿  Read 2 lines",
372                "⏺ Edit(src/main.rs)",
373                "  ⎿  Exit code 1",
374                "✓ Done · 3 turns · 42s",
375                "  ⎿  Changed 1 file: src/main.rs",
376            ]
377        );
378    }
379
380    #[test]
381    fn codex_transcript() {
382        let lines = [
383            r#"{"type":"thread.started","thread_id":"t"}"#,
384            r#"{"type":"error","message":"Reconnecting... 1/5"}"#,
385            r#"{"type":"item.completed","item":{"id":"i0","type":"reasoning","text":"**Checking tests**\n\nbody"}}"#,
386            r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"I will fix the bug."}}"#,
387            r#"{"type":"item.started","item":{"id":"i2","type":"command_execution","command":"ls"}}"#,
388            r#"{"type":"item.completed","item":{"id":"i2","type":"command_execution","command":"/bin/zsh -lc 'cargo test'","aggregated_output":"running 3 tests\nok\nok\n","exit_code":0}}"#,
389            r#"{"type":"item.completed","item":{"id":"i3","type":"file_change","changes":[{"path":"src/x.rs","kind":"update"}]}}"#,
390            r#"{"type":"turn.completed","usage":{}}"#,
391        ];
392        assert_eq!(
393            fmt(&lines),
394            vec![
395                "✗ Reconnecting... 1/5",
396                "✻ Checking tests",
397                "⏺ I will fix the bug.",
398                "⏺ Bash(cargo test)",
399                "  ⎿  running 3 tests … +2 lines",
400                "⏺ Edit(src/x.rs)",
401                "✓ Done",
402                "  ⎿  Changed 1 file: src/x.rs",
403            ]
404        );
405    }
406
407    #[test]
408    fn opencode_transcript() {
409        let lines = [
410            r#"{"type":"step_start","sessionID":"s","part":{"type":"step-start"}}"#,
411            r#"{"type":"text","sessionID":"s","part":{"type":"text","text":"Reviewing PR."}}"#,
412            r#"{"type":"tool_use","sessionID":"s","part":{"tool":"read","callID":"c1","state":{"status":"completed","input":{"filePath":"src/lib.rs"},"output":"a\nb\nc"}}}"#,
413            r#"{"type":"tool_use","sessionID":"s","part":{"tool":"bash","callID":"c2","state":{"status":"completed","input":{"command":"git status"},"output":""}}}"#,
414            r#"{"type":"step_finish","sessionID":"s","part":{"reason":"tool-calls"}}"#,
415            r#"{"type":"step_finish","sessionID":"s","part":{"reason":"stop"}}"#,
416        ];
417        assert_eq!(
418            fmt(&lines),
419            vec![
420                "⏺ Reviewing PR.",
421                "⏺ Read(src/lib.rs)",
422                "  ⎿  Read 3 lines",
423                "⏺ Bash(git status)",
424                "  ⎿  (no output)",
425                "✓ Done",
426            ]
427        );
428    }
429
430    #[test]
431    fn detects_each_format() {
432        let detect = |line: &str| detect_agent(&serde_json::from_str(line).unwrap());
433        assert_eq!(
434            detect(r#"{"type":"assistant","message":{}}"#),
435            Some("claude")
436        );
437        assert_eq!(detect(r#"{"type":"thread.started"}"#), Some("codex"));
438        assert_eq!(detect(r#"{"type":"text","part":{}}"#), Some("opencode"));
439        assert_eq!(detect(r#"{"type":"error","message":"x"}"#), None);
440    }
441
442    #[test]
443    fn error_before_detection_is_shown() {
444        assert_eq!(
445            fmt(&[r#"{"type":"error","message":"usage limit"}"#]),
446            vec!["✗ usage limit"]
447        );
448    }
449
450    #[test]
451    fn json_message_is_pretty_printed() {
452        let msg = r#"{"type":"item.completed","item":{"type":"agent_message","text":"{\"decision\":\"approve\",\"issues\":[]}"}}"#;
453        let fenced = r#"{"type":"item.completed","item":{"type":"agent_message","text":"```json\n{\"decision\":\"approve\"}\n```"}}"#;
454        let plain =
455            r#"{"type":"item.completed","item":{"type":"agent_message","text":"{not json} here"}}"#;
456        let start = r#"{"type":"thread.started"}"#;
457        assert_eq!(
458            fmt(&[start, msg, fenced, plain]),
459            vec![
460                "⏺ {\n    \"decision\": \"approve\",\n    \"issues\": []\n  }",
461                "⏺ {\n    \"decision\": \"approve\"\n  }",
462                "⏺ {not json} here",
463            ]
464        );
465    }
466
467    #[test]
468    fn long_command_is_cut_to_one_line() {
469        let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t","name":"Bash","input":{"command":"cargo test\necho ok"}}]}}"#;
470        assert_eq!(fmt(&[line]), vec!["⏺ Bash(cargo test …)"]);
471    }
472
473    #[test]
474    fn non_json_passed_through_without_escapes() {
475        assert_eq!(
476            fmt(&["\x1b[?25hfatal: not a git repository\r"]),
477            vec!["fatal: not a git repository"]
478        );
479        assert_eq!(fmt(&["\x1b[?25h"]), [] as [String; 0]);
480        assert_eq!(fmt(&[" "]), [] as [String; 0]);
481    }
482
483    #[test]
484    fn color_wraps_markers() {
485        let mut f = Formatter::new(true);
486        f.format(r#"{"type":"thread.started"}"#);
487        assert_eq!(
488            f.format(r#"{"type":"turn.completed"}"#),
489            vec!["\x1b[32m✓ Done\x1b[0m"]
490        );
491    }
492
493    #[test]
494    fn truncate_adds_ellipsis() {
495        assert_eq!(truncate("hello", 10), "hello");
496        assert_eq!(truncate("abcdefghij", 5), "abcde…");
497    }
498
499    #[test]
500    fn extract_files_changed_from_claude_and_opencode() {
501        let claude = concat!(
502            r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"a","name":"Write","input":{"file_path":"src/b.rs"}}]}}"#,
503            "\n",
504            r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"b","name":"Edit","input":{"file_path":"src/a.rs"}}]}}"#,
505            "\n",
506            r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"c","name":"Read","input":{"file_path":"src/c.rs"}}]}}"#,
507        );
508        assert_eq!(extract_files_changed(claude), vec!["src/a.rs", "src/b.rs"]);
509        let opencode = r#"{"type":"tool_use","part":{"tool":"edit","callID":"c","state":{"status":"completed","input":{"filePath":"src/bar.rs"}}}}"#;
510        assert_eq!(extract_files_changed(opencode), vec!["src/bar.rs"]);
511        let none = r#"{"type":"result","subtype":"success","result":"done"}"#;
512        assert_eq!(extract_files_changed(none), [] as [String; 0]);
513    }
514}