1use serde_json::Value;
12
13use crate::{
14 normalize_process_event, ByteRange, ParsedLine, ProcessEvent, RunEvent, SessionInfo,
15 SuggestedEdit, ToolCallEnd, ToolCallStart, ToolKind, UsageInfo,
16};
17
18pub 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
33pub fn normalize_bob_event(event: ProcessEvent) -> Vec<RunEvent> {
36 normalize_process_event(event, parse_bob_line)
37}
38
39pub 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 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 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 Some("tool_use") => {
95 let name = pick_string(record, "tool_name").unwrap_or_else(|| "tool".to_owned());
96 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 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 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 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 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 _ => {
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#[derive(Debug, Default)]
191pub struct BobStreamParser {
192 in_thinking: bool,
193 suppressing_echo: bool,
198}
199
200impl BobStreamParser {
201 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 let visible = visible.and_then(|t| self.narration_after_echo(t));
211 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 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 fn narration_after_echo(&mut self, text: String) -> Option<String> {
285 if self.suppressing_echo {
286 if text.contains(']') {
288 self.suppressing_echo = false;
289 }
290 return None;
291 }
292 if text.trim_start().starts_with("[using tool") {
293 if !text.contains(']') {
296 self.suppressing_echo = true;
297 }
298 return None;
299 }
300 Some(text)
301 }
302}
303
304fn 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
320fn value_to_display_string(value: &Value) -> String {
326 match value {
327 Value::String(s) => s.clone(),
328 other => other.to_string(),
329 }
330}
331
332fn 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
340fn 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 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 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 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 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, ""); 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let open = parser.parse_line(&msg("<thinking>\n"));
629 assert_eq!(open.thinking.as_deref(), Some("\n"));
630 assert!(open.text.is_none());
631 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 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 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 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 let mut parser = BobStreamParser::default();
657 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 assert!(parser
666 .parse_line(r#"{"type":"message","role":"user","content":"list files"}"#)
667 .is_empty());
668 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 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 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 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 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}