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}
194
195impl BobStreamParser {
196 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 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
256fn value_to_display_string(value: &Value) -> String {
262 match value {
263 Value::String(s) => s.clone(),
264 other => other.to_string(),
265 }
266}
267
268fn 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
276fn 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 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 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 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 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, ""); 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 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 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 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 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 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 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 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 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 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 let open = parser.parse_line(&msg("<thinking>\n"));
534 assert_eq!(open.thinking.as_deref(), Some("\n"));
535 assert!(open.text.is_none());
536 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 let close = parser.parse_line(&msg("</thinking>Hello!"));
542 assert!(close.thinking.is_none());
543 assert_eq!(close.text.as_deref(), Some("Hello!"));
544 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 let mut parser = BobStreamParser::default();
555 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 assert!(parser
564 .parse_line(r#"{"type":"message","role":"user","content":"list files"}"#)
565 .is_empty());
566 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 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 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 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 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}