Skip to main content

harness/bob/
parser.rs

1//! bob's stream-json parser — bob's wire format → the neutral
2//! [`crate::RunEvent`] vocabulary.
3//!
4//! The normalized event types and the generic `normalize_process_event`
5//! skeleton live in [`crate::events`]; this module is bob's
6//! adapter-side decoder on top of them. bob emits one JSON object per
7//! line with a snake_case `type` discriminator — see [`parse_bob_line`]
8//! for the grounded schema. Reasoning is streamed inline as
9//! `<thinking>…</thinking>` and routed by the stateful [`BobStreamParser`].
10
11use serde_json::Value;
12
13use crate::{
14    normalize_process_event, ByteRange, ParsedLine, ProcessEvent, RunEvent, SessionInfo,
15    SuggestedEdit, ToolCallEnd, ToolCallStart, ToolKind, UsageInfo,
16};
17
18/// Classify a bob (Roo-style) tool name into the neutral [`ToolKind`].
19/// Public so a consumer that interprets bob's *raw* events directly — rather
20/// than through `RunEvent` — can share this one table instead of re-encoding
21/// bob's vocabulary downstream.
22pub fn bob_tool_kind(name: &str) -> ToolKind {
23    match name {
24        "read_file" => ToolKind::Read,
25        "write_file" | "write_to_file" => ToolKind::Write,
26        "apply_diff" | "insert_content" | "search_and_replace" => ToolKind::Edit,
27        "search_files" | "list_files" | "list_code_definition_names" => ToolKind::Search,
28        "execute_command" => ToolKind::Execute,
29        _ => ToolKind::Other,
30    }
31}
32
33/// bob's adapter-side normalization: parse bob's `--output-format
34/// stream-json` stdout via [`parse_bob_line`].
35pub fn normalize_bob_event(event: ProcessEvent) -> Vec<RunEvent> {
36    normalize_process_event(event, parse_bob_line)
37}
38
39/// Parse one line of bob's `--output-format stream-json` into the shared
40/// [`ParsedLine`]. Grounded in bob's *empirical* event schema (the
41/// `bob-agents` reference + "bob shell usage" findings), not guessed: bob
42/// emits one JSON object per line with a snake_case `type` discriminator —
43/// `init` / `message{role,content,delta}` / `tool_use{tool_id,tool_name,
44/// parameters}` / `tool_result{tool_id,status,output}` / `result{stats}`.
45///
46/// Mapping: an assistant `message` → text (the echoed `user` prompt is
47/// skipped — a real fix vs. the old role-blind heuristic); `tool_use` →
48/// a structured [`ToolCallStart`] (bob's edit tools — write_file /
49/// apply_diff / insert_content — surface as tool-cards too; reconstructing
50/// previewable diffs from their `parameters` is a separate follow-up);
51/// `tool_result` → [`ToolCallEnd`] (ok unless `status == "error"`).
52/// `init` / `result` are lifecycle (process start/exit drives
53/// Started/Exited). A non-JSON line passes through as raw text.
54/// Unrecognized shapes fall back to the legacy suggested-edits heuristic
55/// so a bob build that emits edit arrays still surfaces them.
56pub fn parse_bob_line(line: &str) -> ParsedLine {
57    let trimmed = line.trim();
58    if trimmed.is_empty() {
59        return ParsedLine::default();
60    }
61
62    let payload: Value = match serde_json::from_str(trimmed) {
63        Ok(value) => value,
64        // Not JSON — bob occasionally prints prose / stderr-ish lines.
65        // Pass the raw (untrimmed) line through as text.
66        Err(_) => {
67            return ParsedLine {
68                text: Some(line.to_owned()),
69                ..ParsedLine::default()
70            }
71        }
72    };
73
74    let Some(record) = payload.as_object() else {
75        return ParsedLine::default();
76    };
77
78    match record.get("type").and_then(Value::as_str) {
79        // Assistant text (`delta: true` marks a streaming chunk; both
80        // chunk and full message carry the text in `content`). The echoed
81        // user prompt (role "user") is not surfaced.
82        Some("message") => {
83            if record.get("role").and_then(Value::as_str) == Some("assistant") {
84                if let Some(content) = pick_string(record, "content") {
85                    return ParsedLine {
86                        text: Some(content),
87                        ..ParsedLine::default()
88                    };
89                }
90            }
91            ParsedLine::default()
92        }
93        // Tool call start → structured ToolStart (tool_id + tool_name).
94        Some("tool_use") => {
95            let name = pick_string(record, "tool_name").unwrap_or_else(|| "tool".to_owned());
96            // bob delivers its final answer via the `attempt_completion`
97            // tool (grounded in a real run) — surface its `result` as the
98            // answer text, not a bare tool-card.
99            if name == "attempt_completion" {
100                return match record
101                    .get("parameters")
102                    .and_then(Value::as_object)
103                    .and_then(|p| p.get("result"))
104                    .and_then(Value::as_str)
105                    .filter(|s| !s.is_empty())
106                {
107                    Some(result) => ParsedLine {
108                        text: Some(result.to_owned()),
109                        ..ParsedLine::default()
110                    },
111                    None => ParsedLine::default(),
112                };
113            }
114            let tool_call_id = pick_string(record, "tool_id").unwrap_or_default();
115            // The call's arguments, lifted verbatim (parameters object →
116            // compact JSON) so the UI can show what the tool was asked to do.
117            let input = record.get("parameters").map(value_to_display_string);
118            let tool_kind = bob_tool_kind(&name);
119            ParsedLine {
120                tool_start: Some(ToolCallStart { tool_call_id, name, input, tool_kind }),
121                ..ParsedLine::default()
122            }
123        }
124        // Tool call end → ToolEnd, matched by tool_id; ok unless the
125        // status is explicitly "error". `output` carries the tool's result.
126        Some("tool_result") => {
127            let tool_call_id = pick_string(record, "tool_id").unwrap_or_default();
128            let ok = record.get("status").and_then(Value::as_str) != Some("error");
129            let output = record
130                .get("output")
131                .map(value_to_display_string)
132                .filter(|s| !s.is_empty());
133            ParsedLine {
134                tool_end: Some(ToolCallEnd { tool_call_id, ok, output }),
135                ..ParsedLine::default()
136            }
137        }
138        // init → session identity (id + model), arriving a beat after the
139        // engine's `Started`.
140        Some("init") => ParsedLine {
141            session: Some(SessionInfo {
142                session_id: pick_string(record, "session_id"),
143                model: pick_string(record, "model"),
144            }),
145            ..ParsedLine::default()
146        },
147        // result → token usage. bob reports a single `total_tokens` in
148        // `stats` (no input/output split); coins (`session_costs`) are
149        // bob-specific and intentionally NOT lifted into the neutral Usage.
150        Some("result") => {
151            let total_tokens = record
152                .get("stats")
153                .and_then(Value::as_object)
154                .and_then(|s| s.get("total_tokens"))
155                .and_then(Value::as_u64);
156            ParsedLine {
157                usage: total_tokens.map(|t| UsageInfo {
158                    total_tokens: Some(t),
159                    ..UsageInfo::default()
160                }),
161                ..ParsedLine::default()
162            }
163        }
164        // Anything else: unknown. Fall back to the legacy suggested-edits
165        // heuristic so a bob build that emits edit arrays still surfaces them.
166        _ => {
167            let edits = parse_suggested_edits(record);
168            if edits.is_empty() {
169                ParsedLine::default()
170            } else {
171                let n = edits.len();
172                ParsedLine {
173                    edits,
174                    activity: Some(format!("{n} suggested edit{}", if n == 1 { "" } else { "s" })),
175                    ..ParsedLine::default()
176                }
177            }
178        }
179    }
180}
181
182/// Stateful wrapper over [`parse_bob_line`] for a single bob run. bob
183/// streams its reasoning inline as `<thinking>…</thinking>` within the
184/// assistant `message` content (grounded in a real run — the tags arrive
185/// as their own deltas), so routing that reasoning to the Thinking
186/// stream requires tracking the open/closed state *across* lines. The
187/// per-line dispatch (text / tool events / answer) stays in
188/// `parse_bob_line`; this only re-routes assistant text through the
189/// thinking-tag state machine. One instance per run (see `BobHarness`).
190#[derive(Debug, Default)]
191pub struct BobStreamParser {
192    in_thinking: bool,
193    /// True while inside a `[using tool …]` narration echo whose closing `]`
194    /// has not yet arrived — bob prints that echo inline right before each
195    /// structured `tool_use`, so it's redundant with the ToolStart card and is
196    /// dropped. The echo can span deltas, so the state carries across lines.
197    suppressing_echo: bool,
198}
199
200impl BobStreamParser {
201    /// Parse one stdout line, routing any assistant text through the
202    /// `<thinking>` state machine into [`ParsedLine::text`] /
203    /// [`ParsedLine::thinking`].
204    pub fn parse_line(&mut self, line: &str) -> ParsedLine {
205        let mut parsed = parse_bob_line(line);
206        if let Some(content) = parsed.text.take() {
207            let (visible, mut thinking) = self.route_thinking(&content);
208            // Drop bob's `[using tool …]` narration echo (redundant with the
209            // structured ToolStart card; may span deltas).
210            let visible = visible.and_then(|t| self.narration_after_echo(t));
211            // bob's *answer* is always the `attempt_completion` result (verified
212            // in code AND ask mode — the assistant `message` carries only
213            // `<thinking>` + preamble prose, e.g. "I'll read … then enrich …").
214            // So an assistant message's leftover prose is narration, not the
215            // answer: fold it into the collapsed thinking trace, leaving only the
216            // attempt_completion text as the visible message. A `tool_use` /
217            // non-message line (the answer) keeps its text.
218            if line_is_assistant_message(line) {
219                if let Some(t) = visible {
220                    thinking = Some(match thinking {
221                        Some(a) => a + &t,
222                        None => t,
223                    });
224                }
225            } else {
226                parsed.text = visible;
227            }
228            parsed.thinking = match (thinking, parsed.thinking.take()) {
229                (Some(a), Some(b)) => Some(a + &b),
230                (a, b) => a.or(b),
231            };
232        }
233        parsed
234    }
235
236    /// Split an assistant content chunk into (visible text, thinking),
237    /// honoring `<thinking>`/`</thinking>` markers and the carried-over
238    /// `in_thinking` state. Handles tags split across chunks and multiple
239    /// tags within one chunk.
240    fn route_thinking(&mut self, content: &str) -> (Option<String>, Option<String>) {
241        const OPEN: &str = "<thinking>";
242        const CLOSE: &str = "</thinking>";
243        let mut text = String::new();
244        let mut thinking = String::new();
245        let mut rest = content;
246        loop {
247            if self.in_thinking {
248                match rest.find(CLOSE) {
249                    Some(i) => {
250                        thinking.push_str(&rest[..i]);
251                        self.in_thinking = false;
252                        rest = &rest[i + CLOSE.len()..];
253                    }
254                    None => {
255                        thinking.push_str(rest);
256                        break;
257                    }
258                }
259            } else {
260                match rest.find(OPEN) {
261                    Some(i) => {
262                        text.push_str(&rest[..i]);
263                        self.in_thinking = true;
264                        rest = &rest[i + OPEN.len()..];
265                    }
266                    None => {
267                        text.push_str(rest);
268                        break;
269                    }
270                }
271            }
272        }
273        (
274            (!text.is_empty()).then_some(text),
275            (!thinking.is_empty()).then_some(thinking),
276        )
277    }
278
279    /// Drop bob's `[using tool …]` narration echo. bob prints it inline right
280    /// before the structured `tool_use` event, so it's redundant with the
281    /// ToolStart card. The echo can span deltas (its `]` arriving in a later
282    /// chunk), so `suppressing_echo` carries the drop across lines. Ported from
283    /// the pre-migration `BobChatMapper`.
284    fn narration_after_echo(&mut self, text: String) -> Option<String> {
285        if self.suppressing_echo {
286            // Still inside an unterminated echo — drop until its `]`.
287            if text.contains(']') {
288                self.suppressing_echo = false;
289            }
290            return None;
291        }
292        if text.trim_start().starts_with("[using tool") {
293            // Start of the echo. If its `]` isn't in this delta, keep dropping
294            // subsequent deltas until it arrives.
295            if !text.contains(']') {
296                self.suppressing_echo = true;
297            }
298            return None;
299        }
300        Some(text)
301    }
302}
303
304/// Is this stdout line bob's assistant `message` (its narration / preamble
305/// prose) — as opposed to a `tool_use` (incl. `attempt_completion`, the answer),
306/// a `tool_result`, or a lifecycle line? Used to fold the message prose into the
307/// collapsed thinking trace: bob's answer is always the `attempt_completion`
308/// result, never the message text. A cheap re-parse of the line envelope (bob's
309/// stdout is bounded, not a hot loop).
310fn line_is_assistant_message(line: &str) -> bool {
311    serde_json::from_str::<Value>(line.trim())
312        .ok()
313        .map(|v| {
314            v.get("type").and_then(Value::as_str) == Some("message")
315                && v.get("role").and_then(Value::as_str) == Some("assistant")
316        })
317        .unwrap_or(false)
318}
319
320/// Render a JSON value as a display string for a tool's `input`/`output`:
321/// a JSON string is taken verbatim (no surrounding quotes); any other shape
322/// (object / array / number) is serialized compactly. Lets the adapter lift
323/// bob's `parameters` / `tool_result.output` into the neutral layer without
324/// imposing a schema on them.
325fn value_to_display_string(value: &Value) -> String {
326    match value {
327        Value::String(s) => s.clone(),
328        other => other.to_string(),
329    }
330}
331
332/// Non-empty string field, else `None` (mirrors TS `pickString`).
333fn pick_string(record: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
334    match record.get(key) {
335        Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
336        _ => None,
337    }
338}
339
340/// String field allowing empty (mirrors TS `pickStringValue` — used
341/// for replacements, which may legitimately be the empty string for
342/// a deletion).
343fn pick_string_value(record: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
344    match record.get(key) {
345        Some(Value::String(s)) => Some(s.clone()),
346        _ => None,
347    }
348}
349
350fn parse_suggested_edits(record: &serde_json::Map<String, Value>) -> Vec<SuggestedEdit> {
351    let mut edits = Vec::new();
352    if let Some(direct) = parse_suggested_edit(record) {
353        edits.push(direct);
354    }
355    for key in ["edits", "suggestedEdits", "suggestions"] {
356        let Some(Value::Array(items)) = record.get(key) else {
357            continue;
358        };
359        for item in items {
360            if let Some(obj) = item.as_object() {
361                if let Some(parsed) = parse_suggested_edit(obj) {
362                    edits.push(parsed);
363                }
364            }
365        }
366    }
367    edits
368}
369
370fn parse_suggested_edit(record: &serde_json::Map<String, Value>) -> Option<SuggestedEdit> {
371    let file_path = pick_string(record, "filePath")
372        .or_else(|| pick_string(record, "path"))
373        .or_else(|| pick_string(record, "file"))?;
374
375    // Range may be nested under `range` or flat on the record.
376    let range_record = match record.get("range").and_then(Value::as_object) {
377        Some(nested) => nested,
378        None => record,
379    };
380    let start = range_record.get("start").and_then(Value::as_u64)?;
381    let end = range_record.get("end").and_then(Value::as_u64)?;
382
383    let replacement = pick_string_value(record, "replacement")
384        .or_else(|| pick_string_value(record, "replaceWith"))
385        .or_else(|| pick_string_value(record, "insert"))
386        .or_else(|| pick_string_value(record, "newText"))?;
387
388    let title = pick_string(record, "title")
389        .or_else(|| pick_string(record, "summary"))
390        .or_else(|| pick_string(record, "description"));
391
392    Some(SuggestedEdit {
393        file_path,
394        range: ByteRange { start, end },
395        replacement,
396        title,
397    })
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn blank_line_yields_nothing() {
406        assert!(parse_bob_line("   ").is_empty());
407    }
408
409    #[test]
410    fn non_json_passes_through_as_text() {
411        let parsed = parse_bob_line("hello world");
412        assert_eq!(parsed.text.as_deref(), Some("hello world"));
413        assert!(parsed.edits.is_empty());
414    }
415
416    #[test]
417    fn assistant_message_becomes_text() {
418        let parsed =
419            parse_bob_line(r#"{"type":"message","role":"assistant","content":"hi there"}"#);
420        assert_eq!(parsed.text.as_deref(), Some("hi there"));
421        assert!(parsed.activity.is_none());
422    }
423
424    #[test]
425    fn user_message_is_skipped() {
426        // The echoed user prompt must not surface as assistant text.
427        let parsed = parse_bob_line(r#"{"type":"message","role":"user","content":"my prompt"}"#);
428        assert!(parsed.is_empty());
429    }
430
431    #[test]
432    fn assistant_delta_chunk_becomes_text() {
433        // `delta: true` marks a streaming chunk; the text is still in
434        // `content`.
435        let parsed = parse_bob_line(
436            r#"{"type":"message","role":"assistant","content":"chunk","delta":true}"#,
437        );
438        assert_eq!(parsed.text.as_deref(), Some("chunk"));
439    }
440
441    #[test]
442    fn flat_suggested_edit_parses() {
443        let line = r#"{"filePath":"notes/a.md","start":3,"end":7,"replacement":"X","title":"fix"}"#;
444        let parsed = parse_bob_line(line);
445        assert_eq!(parsed.edits.len(), 1);
446        let edit = &parsed.edits[0];
447        assert_eq!(edit.file_path, "notes/a.md");
448        assert_eq!(edit.range, ByteRange { start: 3, end: 7 });
449        assert_eq!(edit.replacement, "X");
450        assert_eq!(edit.title.as_deref(), Some("fix"));
451        // No text → activity reports the edit count.
452        assert_eq!(parsed.activity.as_deref(), Some("1 suggested edit"));
453    }
454
455    #[test]
456    fn nested_range_and_array_edits_parse() {
457        let line = r#"{"edits":[{"path":"a.md","range":{"start":0,"end":1},"newText":""},
458                                 {"file":"b.md","range":{"start":2,"end":4},"insert":"yo"}]}"#;
459        let parsed = parse_bob_line(line);
460        assert_eq!(parsed.edits.len(), 2);
461        assert_eq!(parsed.edits[0].replacement, ""); // empty replacement = deletion, allowed
462        assert_eq!(parsed.edits[1].replacement, "yo");
463        assert_eq!(parsed.activity.as_deref(), Some("2 suggested edits"));
464    }
465
466    #[test]
467    fn tool_use_becomes_tool_start() {
468        let parsed = parse_bob_line(
469            r#"{"type":"tool_use","tool_id":"tool-1","tool_name":"execute_command","parameters":{"command":"ls"}}"#,
470        );
471        let start = parsed.tool_start.expect("tool_start");
472        assert_eq!(start.tool_call_id, "tool-1");
473        assert_eq!(start.name, "execute_command");
474        // The parameters object is lifted verbatim as the call's input.
475        assert_eq!(start.input.as_deref(), Some(r#"{"command":"ls"}"#));
476        assert!(parsed.activity.is_none());
477    }
478
479    #[test]
480    fn edit_tools_surface_as_tool_start() {
481        // bob's edit tools (apply_diff / insert_content / write_file) flow
482        // through as tool-cards too.
483        let start = parse_bob_line(
484            r#"{"type":"tool_use","tool_id":"t9","tool_name":"apply_diff","parameters":{"path":"a.md"}}"#,
485        )
486        .tool_start
487        .expect("tool_start");
488        assert_eq!(start.name, "apply_diff");
489    }
490
491    #[test]
492    fn tool_result_becomes_tool_end() {
493        let ok = parse_bob_line(
494            r#"{"type":"tool_result","tool_id":"tool-1","status":"success","output":"done"}"#,
495        )
496        .tool_end
497        .expect("tool_end");
498        assert_eq!(ok.tool_call_id, "tool-1");
499        assert!(ok.ok);
500        // The tool's result is lifted as the end event's output.
501        assert_eq!(ok.output.as_deref(), Some("done"));
502
503        let err = parse_bob_line(
504            r#"{"type":"tool_result","tool_id":"tool-2","status":"error","output":"boom"}"#,
505        )
506        .tool_end
507        .expect("tool_end");
508        assert!(!err.ok);
509    }
510
511    #[test]
512    fn init_yields_session_and_result_yields_usage() {
513        // init → Session (id + model); no text/tool content.
514        let init = parse_bob_line(r#"{"type":"init","session_id":"s1","model":"premium"}"#);
515        let session = init.session.expect("session");
516        assert_eq!(session.session_id.as_deref(), Some("s1"));
517        assert_eq!(session.model.as_deref(), Some("premium"));
518        assert!(init.text.is_none() && init.tool_start.is_none());
519
520        // result → Usage (neutral tokens only; coins stay bob-specific).
521        let result = parse_bob_line(
522            r#"{"type":"result","status":"success","stats":{"total_tokens":1280,"session_costs":3,"tool_calls":2}}"#,
523        );
524        let usage = result.usage.expect("usage");
525        assert_eq!(usage.total_tokens, Some(1280));
526        assert_eq!(usage.input_tokens, None);
527        assert_eq!(usage.output_tokens, None);
528
529        // A result with no token count → no usage (nothing to report).
530        assert!(parse_bob_line(r#"{"type":"result","status":"success","stats":{"tool_calls":2}}"#)
531            .is_empty());
532    }
533
534    #[test]
535    fn incomplete_edit_is_ignored() {
536        // Missing `end` → not a valid edit.
537        let parsed = parse_bob_line(r#"{"filePath":"a.md","start":3,"replacement":"X"}"#);
538        assert!(parsed.edits.is_empty());
539    }
540
541    #[test]
542    fn normalize_stdout_text_event() {
543        let events = normalize_bob_event(ProcessEvent::Stdout {
544            run_id: "r1".to_owned(),
545            line: r#"{"type":"message","role":"assistant","content":"hi"}"#.to_owned(),
546        });
547        assert_eq!(events.len(), 1);
548        assert!(matches!(
549            &events[0],
550            RunEvent::Text { run_id, delta } if run_id == "r1" && delta == "hi"
551        ));
552    }
553
554    #[test]
555    fn normalize_bob_tool_events() {
556        let start = normalize_bob_event(ProcessEvent::Stdout {
557            run_id: "r1".to_owned(),
558            line: r#"{"type":"tool_use","tool_id":"t1","tool_name":"write_file"}"#.to_owned(),
559        });
560        assert!(matches!(
561            start.as_slice(),
562            [RunEvent::ToolStart { tool_call_id, name, .. }]
563                if tool_call_id == "t1" && name == "write_file"
564        ));
565        let end = normalize_bob_event(ProcessEvent::Stdout {
566            run_id: "r1".to_owned(),
567            line: r#"{"type":"tool_result","tool_id":"t1","status":"success"}"#.to_owned(),
568        });
569        assert!(matches!(
570            end.as_slice(),
571            [RunEvent::ToolEnd { tool_call_id, ok, .. }] if tool_call_id == "t1" && *ok
572        ));
573    }
574
575    #[test]
576    fn attempt_completion_becomes_answer_text() {
577        // bob's final answer is delivered via the attempt_completion tool,
578        // not plain message content — surface it as text, not a card.
579        let parsed = parse_bob_line(
580            r#"{"type":"tool_use","tool_id":"tool-2","tool_name":"attempt_completion","parameters":{"result":"The answer is 42."}}"#,
581        );
582        assert_eq!(parsed.text.as_deref(), Some("The answer is 42."));
583        assert!(parsed.tool_start.is_none());
584    }
585
586    #[test]
587    fn using_tool_echo_is_dropped() {
588        // bob narrates each tool call inline as `[using tool …]` right before
589        // the structured tool_use; that echo is redundant with the ToolStart
590        // card and must not appear in the message text.
591        let mut parser = BobStreamParser::default();
592        let msg =
593            |c: &str| serde_json::json!({"type":"message","role":"assistant","content":c}).to_string();
594        assert!(parser.parse_line(&msg("[using tool write_to_file: notes/x.md]")).text.is_none());
595        // Surrounding narration still flows — to the thinking trace (only the
596        // attempt_completion result is the visible answer).
597        assert_eq!(parser.parse_line(&msg("All set.")).thinking.as_deref(), Some("All set."));
598    }
599
600    #[test]
601    fn chunked_using_tool_echo_is_dropped_across_deltas() {
602        // The echo's closing `]` can arrive in a later delta; suppression
603        // carries across lines until it does.
604        let mut parser = BobStreamParser::default();
605        let msg =
606            |c: &str| serde_json::json!({"type":"message","role":"assistant","content":c}).to_string();
607        assert!(parser.parse_line(&msg("[using tool read_file: a/very")).text.is_none());
608        assert!(parser.parse_line(&msg("/long/path.md")).text.is_none());
609        assert!(parser.parse_line(&msg("]")).text.is_none());
610        // Suppression ended; narration resumes — into the thinking trace.
611        assert_eq!(parser.parse_line(&msg("ok")).thinking.as_deref(), Some("ok"));
612    }
613
614    #[test]
615    fn message_prose_routes_to_thinking_only_attempt_completion_is_text() {
616        // Neither bob's <thinking> reasoning NOR its plain preamble prose (both
617        // ride inside assistant `message`s) is the answer — bob answers via the
618        // attempt_completion tool. So the persistent parser folds ALL message
619        // content into `thinking`, and only the attempt_completion text becomes
620        // the visible message. (The <thinking> tags arrive as their own deltas;
621        // state carries across them.)
622        let mut parser = BobStreamParser::default();
623        let msg = |content: &str| {
624            serde_json::json!({ "type": "message", "role": "assistant", "content": content, "delta": true })
625                .to_string()
626        };
627        // Opening tag (its own delta): the content after <thinking> is reasoning.
628        let open = parser.parse_line(&msg("<thinking>\n"));
629        assert_eq!(open.thinking.as_deref(), Some("\n"));
630        assert!(open.text.is_none());
631        // Mid-block chunk → thinking (state carried across deltas).
632        let mid = parser.parse_line(&msg("the user wants X"));
633        assert_eq!(mid.thinking.as_deref(), Some("the user wants X"));
634        assert!(mid.text.is_none());
635        // Close tag + trailing PROSE in one delta: the prose is narration, not
636        // the answer → it also folds into thinking, never text.
637        let close = parser.parse_line(&msg("</thinking>I'll update the file."));
638        assert_eq!(close.thinking.as_deref(), Some("I'll update the file."));
639        assert!(close.text.is_none());
640        // Further plain message prose → thinking too.
641        let more = parser.parse_line(&msg(" Working on it."));
642        assert_eq!(more.thinking.as_deref(), Some(" Working on it."));
643        assert!(more.text.is_none());
644        // Only the attempt_completion result is the visible answer.
645        let answer = parser.parse_line(
646            r#"{"type":"tool_use","tool_id":"t","tool_name":"attempt_completion","parameters":{"result":"Done."}}"#,
647        );
648        assert_eq!(answer.text.as_deref(), Some("Done."));
649        assert!(answer.thinking.is_none());
650    }
651
652    #[test]
653    fn grounded_against_real_bob_capture() {
654        // Verbatim shapes captured from `bob 1.0.4 -o stream-json`
655        // (timestamps trimmed) — locks the parser to bob's real format.
656        let mut parser = BobStreamParser::default();
657        // init → Session (id + model), not empty.
658        let session = parser
659            .parse_line(r#"{"type":"init","session_id":"s","model":"premium"}"#)
660            .session
661            .expect("session");
662        assert_eq!(session.session_id.as_deref(), Some("s"));
663        assert_eq!(session.model.as_deref(), Some("premium"));
664        // Echoed user prompt → skipped.
665        assert!(parser
666            .parse_line(r#"{"type":"message","role":"user","content":"list files"}"#)
667            .is_empty());
668        // Assistant reasoning wrapped in <thinking> → thinking, not text.
669        assert_eq!(
670            parser
671                .parse_line(
672                    r#"{"type":"message","role":"assistant","content":"<thinking>\n","delta":true}"#
673                )
674                .thinking
675                .as_deref(),
676            Some("\n")
677        );
678        let _ = parser.parse_line(
679            r#"{"type":"message","role":"assistant","content":"</thinking>\n","delta":true}"#,
680        );
681        // Real tool_use (list_files) → ToolStart, parameters lifted as input.
682        let start = parser
683            .parse_line(r#"{"type":"tool_use","tool_name":"list_files","tool_id":"tool-1","parameters":{"dir_path":"/x/docs"}}"#)
684            .tool_start
685            .expect("tool_start");
686        assert_eq!(start.tool_call_id, "tool-1");
687        assert_eq!(start.name, "list_files");
688        assert_eq!(start.input.as_deref(), Some(r#"{"dir_path":"/x/docs"}"#));
689        // Real tool_result → ToolEnd(ok), output lifted.
690        let end = parser
691            .parse_line(r#"{"type":"tool_result","tool_id":"tool-1","status":"success","output":"Listed 11 item(s)."}"#)
692            .tool_end
693            .expect("tool_end");
694        assert!(end.ok);
695        assert_eq!(end.output.as_deref(), Some("Listed 11 item(s)."));
696        // attempt_completion → the answer text.
697        let answer = parser.parse_line(
698            r#"{"type":"tool_use","tool_id":"tool-2","tool_name":"attempt_completion","parameters":{"result":"The docs directory contains 10 files."}}"#,
699        );
700        assert_eq!(answer.text.as_deref(), Some("The docs directory contains 10 files."));
701        assert!(answer.tool_start.is_none());
702        // result → ignored.
703        assert!(parser
704            .parse_line(r#"{"type":"result","status":"success","stats":{"tool_calls":2}}"#)
705            .is_empty());
706    }
707
708    #[test]
709    fn normalize_passes_through_lifecycle_events() {
710        assert!(matches!(
711            normalize_bob_event(ProcessEvent::Started { run_id: "r".into() }).as_slice(),
712            [RunEvent::Started { .. }]
713        ));
714        assert!(matches!(
715            normalize_bob_event(ProcessEvent::Exited {
716                run_id: "r".into(),
717                exit_code: Some(0),
718                cancelled: false
719            })
720            .as_slice(),
721            [RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }]
722        ));
723    }
724}