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 (text, thinking) = self.route_thinking(&content);
208 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 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 fn narration_after_echo(&mut self, text: String) -> Option<String> {
269 if self.suppressing_echo {
270 if text.contains(']') {
272 self.suppressing_echo = false;
273 }
274 return None;
275 }
276 if text.trim_start().starts_with("[using tool") {
277 if !text.contains(']') {
280 self.suppressing_echo = true;
281 }
282 return None;
283 }
284 Some(text)
285 }
286}
287
288fn value_to_display_string(value: &Value) -> String {
294 match value {
295 Value::String(s) => s.clone(),
296 other => other.to_string(),
297 }
298}
299
300fn 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
308fn 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 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 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 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 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, ""); 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let open = parser.parse_line(&msg("<thinking>\n"));
593 assert_eq!(open.thinking.as_deref(), Some("\n"));
594 assert!(open.text.is_none());
595 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 let close = parser.parse_line(&msg("</thinking>Hello!"));
601 assert!(close.thinking.is_none());
602 assert_eq!(close.text.as_deref(), Some("Hello!"));
603 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 let mut parser = BobStreamParser::default();
614 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 assert!(parser
623 .parse_line(r#"{"type":"message","role":"user","content":"list files"}"#)
624 .is_empty());
625 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 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 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 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 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}