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