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 (text, thinking) = self.route_thinking(&content);
208            // Drop bob's `[using tool …]` narration echo (redundant with the
209            // structured ToolStart card; may span deltas). Applied to the
210            // post-thinking visible text only.
211            parsed.text = text.and_then(|t| self.narration_after_echo(t));
212            parsed.thinking = match (thinking, parsed.thinking.take()) {
213                (Some(a), Some(b)) => Some(a + &b),
214                (a, b) => a.or(b),
215            };
216        }
217        parsed
218    }
219
220    /// Split an assistant content chunk into (visible text, thinking),
221    /// honoring `<thinking>`/`</thinking>` markers and the carried-over
222    /// `in_thinking` state. Handles tags split across chunks and multiple
223    /// tags within one chunk.
224    fn route_thinking(&mut self, content: &str) -> (Option<String>, Option<String>) {
225        const OPEN: &str = "<thinking>";
226        const CLOSE: &str = "</thinking>";
227        let mut text = String::new();
228        let mut thinking = String::new();
229        let mut rest = content;
230        loop {
231            if self.in_thinking {
232                match rest.find(CLOSE) {
233                    Some(i) => {
234                        thinking.push_str(&rest[..i]);
235                        self.in_thinking = false;
236                        rest = &rest[i + CLOSE.len()..];
237                    }
238                    None => {
239                        thinking.push_str(rest);
240                        break;
241                    }
242                }
243            } else {
244                match rest.find(OPEN) {
245                    Some(i) => {
246                        text.push_str(&rest[..i]);
247                        self.in_thinking = true;
248                        rest = &rest[i + OPEN.len()..];
249                    }
250                    None => {
251                        text.push_str(rest);
252                        break;
253                    }
254                }
255            }
256        }
257        (
258            (!text.is_empty()).then_some(text),
259            (!thinking.is_empty()).then_some(thinking),
260        )
261    }
262
263    /// Drop bob's `[using tool …]` narration echo. bob prints it inline right
264    /// before the structured `tool_use` event, so it's redundant with the
265    /// ToolStart card. The echo can span deltas (its `]` arriving in a later
266    /// chunk), so `suppressing_echo` carries the drop across lines. Ported from
267    /// the pre-migration `BobChatMapper`.
268    fn narration_after_echo(&mut self, text: String) -> Option<String> {
269        if self.suppressing_echo {
270            // Still inside an unterminated echo — drop until its `]`.
271            if text.contains(']') {
272                self.suppressing_echo = false;
273            }
274            return None;
275        }
276        if text.trim_start().starts_with("[using tool") {
277            // Start of the echo. If its `]` isn't in this delta, keep dropping
278            // subsequent deltas until it arrives.
279            if !text.contains(']') {
280                self.suppressing_echo = true;
281            }
282            return None;
283        }
284        Some(text)
285    }
286}
287
288/// Render a JSON value as a display string for a tool's `input`/`output`:
289/// a JSON string is taken verbatim (no surrounding quotes); any other shape
290/// (object / array / number) is serialized compactly. Lets the adapter lift
291/// bob's `parameters` / `tool_result.output` into the neutral layer without
292/// imposing a schema on them.
293fn value_to_display_string(value: &Value) -> String {
294    match value {
295        Value::String(s) => s.clone(),
296        other => other.to_string(),
297    }
298}
299
300/// Non-empty string field, else `None` (mirrors TS `pickString`).
301fn pick_string(record: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
302    match record.get(key) {
303        Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
304        _ => None,
305    }
306}
307
308/// String field allowing empty (mirrors TS `pickStringValue` — used
309/// for replacements, which may legitimately be the empty string for
310/// a deletion).
311fn pick_string_value(record: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
312    match record.get(key) {
313        Some(Value::String(s)) => Some(s.clone()),
314        _ => None,
315    }
316}
317
318fn parse_suggested_edits(record: &serde_json::Map<String, Value>) -> Vec<SuggestedEdit> {
319    let mut edits = Vec::new();
320    if let Some(direct) = parse_suggested_edit(record) {
321        edits.push(direct);
322    }
323    for key in ["edits", "suggestedEdits", "suggestions"] {
324        let Some(Value::Array(items)) = record.get(key) else {
325            continue;
326        };
327        for item in items {
328            if let Some(obj) = item.as_object() {
329                if let Some(parsed) = parse_suggested_edit(obj) {
330                    edits.push(parsed);
331                }
332            }
333        }
334    }
335    edits
336}
337
338fn parse_suggested_edit(record: &serde_json::Map<String, Value>) -> Option<SuggestedEdit> {
339    let file_path = pick_string(record, "filePath")
340        .or_else(|| pick_string(record, "path"))
341        .or_else(|| pick_string(record, "file"))?;
342
343    // Range may be nested under `range` or flat on the record.
344    let range_record = match record.get("range").and_then(Value::as_object) {
345        Some(nested) => nested,
346        None => record,
347    };
348    let start = range_record.get("start").and_then(Value::as_u64)?;
349    let end = range_record.get("end").and_then(Value::as_u64)?;
350
351    let replacement = pick_string_value(record, "replacement")
352        .or_else(|| pick_string_value(record, "replaceWith"))
353        .or_else(|| pick_string_value(record, "insert"))
354        .or_else(|| pick_string_value(record, "newText"))?;
355
356    let title = pick_string(record, "title")
357        .or_else(|| pick_string(record, "summary"))
358        .or_else(|| pick_string(record, "description"));
359
360    Some(SuggestedEdit {
361        file_path,
362        range: ByteRange { start, end },
363        replacement,
364        title,
365    })
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn blank_line_yields_nothing() {
374        assert!(parse_bob_line("   ").is_empty());
375    }
376
377    #[test]
378    fn non_json_passes_through_as_text() {
379        let parsed = parse_bob_line("hello world");
380        assert_eq!(parsed.text.as_deref(), Some("hello world"));
381        assert!(parsed.edits.is_empty());
382    }
383
384    #[test]
385    fn assistant_message_becomes_text() {
386        let parsed =
387            parse_bob_line(r#"{"type":"message","role":"assistant","content":"hi there"}"#);
388        assert_eq!(parsed.text.as_deref(), Some("hi there"));
389        assert!(parsed.activity.is_none());
390    }
391
392    #[test]
393    fn user_message_is_skipped() {
394        // The echoed user prompt must not surface as assistant text.
395        let parsed = parse_bob_line(r#"{"type":"message","role":"user","content":"my prompt"}"#);
396        assert!(parsed.is_empty());
397    }
398
399    #[test]
400    fn assistant_delta_chunk_becomes_text() {
401        // `delta: true` marks a streaming chunk; the text is still in
402        // `content`.
403        let parsed = parse_bob_line(
404            r#"{"type":"message","role":"assistant","content":"chunk","delta":true}"#,
405        );
406        assert_eq!(parsed.text.as_deref(), Some("chunk"));
407    }
408
409    #[test]
410    fn flat_suggested_edit_parses() {
411        let line = r#"{"filePath":"notes/a.md","start":3,"end":7,"replacement":"X","title":"fix"}"#;
412        let parsed = parse_bob_line(line);
413        assert_eq!(parsed.edits.len(), 1);
414        let edit = &parsed.edits[0];
415        assert_eq!(edit.file_path, "notes/a.md");
416        assert_eq!(edit.range, ByteRange { start: 3, end: 7 });
417        assert_eq!(edit.replacement, "X");
418        assert_eq!(edit.title.as_deref(), Some("fix"));
419        // No text → activity reports the edit count.
420        assert_eq!(parsed.activity.as_deref(), Some("1 suggested edit"));
421    }
422
423    #[test]
424    fn nested_range_and_array_edits_parse() {
425        let line = r#"{"edits":[{"path":"a.md","range":{"start":0,"end":1},"newText":""},
426                                 {"file":"b.md","range":{"start":2,"end":4},"insert":"yo"}]}"#;
427        let parsed = parse_bob_line(line);
428        assert_eq!(parsed.edits.len(), 2);
429        assert_eq!(parsed.edits[0].replacement, ""); // empty replacement = deletion, allowed
430        assert_eq!(parsed.edits[1].replacement, "yo");
431        assert_eq!(parsed.activity.as_deref(), Some("2 suggested edits"));
432    }
433
434    #[test]
435    fn tool_use_becomes_tool_start() {
436        let parsed = parse_bob_line(
437            r#"{"type":"tool_use","tool_id":"tool-1","tool_name":"execute_command","parameters":{"command":"ls"}}"#,
438        );
439        let start = parsed.tool_start.expect("tool_start");
440        assert_eq!(start.tool_call_id, "tool-1");
441        assert_eq!(start.name, "execute_command");
442        // The parameters object is lifted verbatim as the call's input.
443        assert_eq!(start.input.as_deref(), Some(r#"{"command":"ls"}"#));
444        assert!(parsed.activity.is_none());
445    }
446
447    #[test]
448    fn edit_tools_surface_as_tool_start() {
449        // bob's edit tools (apply_diff / insert_content / write_file) flow
450        // through as tool-cards too.
451        let start = parse_bob_line(
452            r#"{"type":"tool_use","tool_id":"t9","tool_name":"apply_diff","parameters":{"path":"a.md"}}"#,
453        )
454        .tool_start
455        .expect("tool_start");
456        assert_eq!(start.name, "apply_diff");
457    }
458
459    #[test]
460    fn tool_result_becomes_tool_end() {
461        let ok = parse_bob_line(
462            r#"{"type":"tool_result","tool_id":"tool-1","status":"success","output":"done"}"#,
463        )
464        .tool_end
465        .expect("tool_end");
466        assert_eq!(ok.tool_call_id, "tool-1");
467        assert!(ok.ok);
468        // The tool's result is lifted as the end event's output.
469        assert_eq!(ok.output.as_deref(), Some("done"));
470
471        let err = parse_bob_line(
472            r#"{"type":"tool_result","tool_id":"tool-2","status":"error","output":"boom"}"#,
473        )
474        .tool_end
475        .expect("tool_end");
476        assert!(!err.ok);
477    }
478
479    #[test]
480    fn init_yields_session_and_result_yields_usage() {
481        // init → Session (id + model); no text/tool content.
482        let init = parse_bob_line(r#"{"type":"init","session_id":"s1","model":"premium"}"#);
483        let session = init.session.expect("session");
484        assert_eq!(session.session_id.as_deref(), Some("s1"));
485        assert_eq!(session.model.as_deref(), Some("premium"));
486        assert!(init.text.is_none() && init.tool_start.is_none());
487
488        // result → Usage (neutral tokens only; coins stay bob-specific).
489        let result = parse_bob_line(
490            r#"{"type":"result","status":"success","stats":{"total_tokens":1280,"session_costs":3,"tool_calls":2}}"#,
491        );
492        let usage = result.usage.expect("usage");
493        assert_eq!(usage.total_tokens, Some(1280));
494        assert_eq!(usage.input_tokens, None);
495        assert_eq!(usage.output_tokens, None);
496
497        // A result with no token count → no usage (nothing to report).
498        assert!(parse_bob_line(r#"{"type":"result","status":"success","stats":{"tool_calls":2}}"#)
499            .is_empty());
500    }
501
502    #[test]
503    fn incomplete_edit_is_ignored() {
504        // Missing `end` → not a valid edit.
505        let parsed = parse_bob_line(r#"{"filePath":"a.md","start":3,"replacement":"X"}"#);
506        assert!(parsed.edits.is_empty());
507    }
508
509    #[test]
510    fn normalize_stdout_text_event() {
511        let events = normalize_bob_event(ProcessEvent::Stdout {
512            run_id: "r1".to_owned(),
513            line: r#"{"type":"message","role":"assistant","content":"hi"}"#.to_owned(),
514        });
515        assert_eq!(events.len(), 1);
516        assert!(matches!(
517            &events[0],
518            RunEvent::Text { run_id, delta } if run_id == "r1" && delta == "hi"
519        ));
520    }
521
522    #[test]
523    fn normalize_bob_tool_events() {
524        let start = normalize_bob_event(ProcessEvent::Stdout {
525            run_id: "r1".to_owned(),
526            line: r#"{"type":"tool_use","tool_id":"t1","tool_name":"write_file"}"#.to_owned(),
527        });
528        assert!(matches!(
529            start.as_slice(),
530            [RunEvent::ToolStart { tool_call_id, name, .. }]
531                if tool_call_id == "t1" && name == "write_file"
532        ));
533        let end = normalize_bob_event(ProcessEvent::Stdout {
534            run_id: "r1".to_owned(),
535            line: r#"{"type":"tool_result","tool_id":"t1","status":"success"}"#.to_owned(),
536        });
537        assert!(matches!(
538            end.as_slice(),
539            [RunEvent::ToolEnd { tool_call_id, ok, .. }] if tool_call_id == "t1" && *ok
540        ));
541    }
542
543    #[test]
544    fn attempt_completion_becomes_answer_text() {
545        // bob's final answer is delivered via the attempt_completion tool,
546        // not plain message content — surface it as text, not a card.
547        let parsed = parse_bob_line(
548            r#"{"type":"tool_use","tool_id":"tool-2","tool_name":"attempt_completion","parameters":{"result":"The answer is 42."}}"#,
549        );
550        assert_eq!(parsed.text.as_deref(), Some("The answer is 42."));
551        assert!(parsed.tool_start.is_none());
552    }
553
554    #[test]
555    fn using_tool_echo_is_dropped() {
556        // bob narrates each tool call inline as `[using tool …]` right before
557        // the structured tool_use; that echo is redundant with the ToolStart
558        // card and must not appear in the message text.
559        let mut parser = BobStreamParser::default();
560        let msg =
561            |c: &str| serde_json::json!({"type":"message","role":"assistant","content":c}).to_string();
562        assert!(parser.parse_line(&msg("[using tool write_to_file: notes/x.md]")).text.is_none());
563        // Surrounding narration still flows.
564        assert_eq!(parser.parse_line(&msg("All set.")).text.as_deref(), Some("All set."));
565    }
566
567    #[test]
568    fn chunked_using_tool_echo_is_dropped_across_deltas() {
569        // The echo's closing `]` can arrive in a later delta; suppression
570        // carries across lines until it does.
571        let mut parser = BobStreamParser::default();
572        let msg =
573            |c: &str| serde_json::json!({"type":"message","role":"assistant","content":c}).to_string();
574        assert!(parser.parse_line(&msg("[using tool read_file: a/very")).text.is_none());
575        assert!(parser.parse_line(&msg("/long/path.md")).text.is_none());
576        assert!(parser.parse_line(&msg("]")).text.is_none());
577        // Suppression ended; normal text resumes.
578        assert_eq!(parser.parse_line(&msg("ok")).text.as_deref(), Some("ok"));
579    }
580
581    #[test]
582    fn bob_stream_parser_routes_thinking_across_deltas() {
583        // bob streams reasoning as <thinking>…</thinking> with the tags
584        // arriving as their own deltas; a persistent parser routes the
585        // between-tags content to `thinking`, the rest to `text`.
586        let mut parser = BobStreamParser::default();
587        let msg = |content: &str| {
588            serde_json::json!({ "type": "message", "role": "assistant", "content": content, "delta": true })
589                .to_string()
590        };
591        // Opening tag (its own delta): the text after <thinking> is reasoning.
592        let open = parser.parse_line(&msg("<thinking>\n"));
593        assert_eq!(open.thinking.as_deref(), Some("\n"));
594        assert!(open.text.is_none());
595        // Mid-block chunk → thinking (state carried across deltas).
596        let mid = parser.parse_line(&msg("the user wants X"));
597        assert_eq!(mid.thinking.as_deref(), Some("the user wants X"));
598        assert!(mid.text.is_none());
599        // Close tag + trailing answer in one delta → split.
600        let close = parser.parse_line(&msg("</thinking>Hello!"));
601        assert!(close.thinking.is_none());
602        assert_eq!(close.text.as_deref(), Some("Hello!"));
603        // After closing, plain content → text.
604        let after = parser.parse_line(&msg(" more"));
605        assert_eq!(after.text.as_deref(), Some(" more"));
606        assert!(after.thinking.is_none());
607    }
608
609    #[test]
610    fn grounded_against_real_bob_capture() {
611        // Verbatim shapes captured from `bob 1.0.4 -o stream-json`
612        // (timestamps trimmed) — locks the parser to bob's real format.
613        let mut parser = BobStreamParser::default();
614        // init → Session (id + model), not empty.
615        let session = parser
616            .parse_line(r#"{"type":"init","session_id":"s","model":"premium"}"#)
617            .session
618            .expect("session");
619        assert_eq!(session.session_id.as_deref(), Some("s"));
620        assert_eq!(session.model.as_deref(), Some("premium"));
621        // Echoed user prompt → skipped.
622        assert!(parser
623            .parse_line(r#"{"type":"message","role":"user","content":"list files"}"#)
624            .is_empty());
625        // Assistant reasoning wrapped in <thinking> → thinking, not text.
626        assert_eq!(
627            parser
628                .parse_line(
629                    r#"{"type":"message","role":"assistant","content":"<thinking>\n","delta":true}"#
630                )
631                .thinking
632                .as_deref(),
633            Some("\n")
634        );
635        let _ = parser.parse_line(
636            r#"{"type":"message","role":"assistant","content":"</thinking>\n","delta":true}"#,
637        );
638        // Real tool_use (list_files) → ToolStart, parameters lifted as input.
639        let start = parser
640            .parse_line(r#"{"type":"tool_use","tool_name":"list_files","tool_id":"tool-1","parameters":{"dir_path":"/x/docs"}}"#)
641            .tool_start
642            .expect("tool_start");
643        assert_eq!(start.tool_call_id, "tool-1");
644        assert_eq!(start.name, "list_files");
645        assert_eq!(start.input.as_deref(), Some(r#"{"dir_path":"/x/docs"}"#));
646        // Real tool_result → ToolEnd(ok), output lifted.
647        let end = parser
648            .parse_line(r#"{"type":"tool_result","tool_id":"tool-1","status":"success","output":"Listed 11 item(s)."}"#)
649            .tool_end
650            .expect("tool_end");
651        assert!(end.ok);
652        assert_eq!(end.output.as_deref(), Some("Listed 11 item(s)."));
653        // attempt_completion → the answer text.
654        let answer = parser.parse_line(
655            r#"{"type":"tool_use","tool_id":"tool-2","tool_name":"attempt_completion","parameters":{"result":"The docs directory contains 10 files."}}"#,
656        );
657        assert_eq!(answer.text.as_deref(), Some("The docs directory contains 10 files."));
658        assert!(answer.tool_start.is_none());
659        // result → ignored.
660        assert!(parser
661            .parse_line(r#"{"type":"result","status":"success","stats":{"tool_calls":2}}"#)
662            .is_empty());
663    }
664
665    #[test]
666    fn normalize_passes_through_lifecycle_events() {
667        assert!(matches!(
668            normalize_bob_event(ProcessEvent::Started { run_id: "r".into() }).as_slice(),
669            [RunEvent::Started { .. }]
670        ));
671        assert!(matches!(
672            normalize_bob_event(ProcessEvent::Exited {
673                run_id: "r".into(),
674                exit_code: Some(0),
675                cancelled: false
676            })
677            .as_slice(),
678            [RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }]
679        ));
680    }
681}