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