1use serde_json::Value;
12
13use crate::{
14 normalize_process_event, ByteRange, ParsedLine, ProcessEvent, RunEvent, SessionInfo,
15 SuggestedEdit, ToolCallEnd, ToolCallStart, UsageInfo,
16};
17
18pub fn normalize_bob_event(event: ProcessEvent) -> Vec<RunEvent> {
21 normalize_process_event(event, parse_bob_line)
22}
23
24pub 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 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 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 Some("tool_use") => {
80 let name = pick_string(record, "tool_name").unwrap_or_else(|| "tool".to_owned());
81 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 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 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 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 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 _ => {
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#[derive(Debug, Default)]
175pub struct BobStreamParser {
176 in_thinking: bool,
177}
178
179impl BobStreamParser {
180 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 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
240fn value_to_display_string(value: &Value) -> String {
246 match value {
247 Value::String(s) => s.clone(),
248 other => other.to_string(),
249 }
250}
251
252fn 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
260fn 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 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 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 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 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, ""); 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 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 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 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 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 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 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 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 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 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 let open = parser.parse_line(&msg("<thinking>\n"));
518 assert_eq!(open.thinking.as_deref(), Some("\n"));
519 assert!(open.text.is_none());
520 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 let close = parser.parse_line(&msg("</thinking>Hello!"));
526 assert!(close.thinking.is_none());
527 assert_eq!(close.text.as_deref(), Some("Hello!"));
528 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 let mut parser = BobStreamParser::default();
539 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 assert!(parser
548 .parse_line(r#"{"type":"message","role":"user","content":"list files"}"#)
549 .is_empty());
550 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 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 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 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 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}