1use crate::common::truncate_text;
34use crate::config::Verbosity;
35use crate::logger::{Colors, CHECK, CROSS};
36use serde::{Deserialize, Serialize};
37use std::cell::RefCell;
38use std::fmt::Write as _;
39use std::io::{self, BufRead, Write};
40use std::rc::Rc;
41
42use super::delta_display::{DeltaRenderer, TextDeltaRenderer};
43use super::health::HealthMonitor;
44#[cfg(feature = "test-utils")]
45use super::health::StreamingQualityMetrics;
46use super::printer::SharedPrinter;
47use super::streaming_state::StreamingSession;
48use super::terminal::TerminalMode;
49use super::types::{format_tool_input, format_unknown_json_event, ContentType};
50
51#[derive(Debug, Clone, Deserialize, Serialize)]
61pub struct OpenCodeEvent {
62 #[serde(rename = "type")]
63 pub(crate) event_type: String,
64 pub(crate) timestamp: Option<u64>,
65 #[serde(rename = "sessionID")]
66 pub(crate) session_id: Option<String>,
67 pub(crate) part: Option<OpenCodePart>,
68}
69
70#[derive(Debug, Clone, Deserialize, Serialize)]
72pub struct OpenCodePart {
73 pub(crate) id: Option<String>,
74 #[serde(rename = "sessionID")]
75 pub(crate) session_id: Option<String>,
76 #[serde(rename = "messageID")]
77 pub(crate) message_id: Option<String>,
78 #[serde(rename = "type")]
79 pub(crate) part_type: Option<String>,
80 pub(crate) snapshot: Option<String>,
82 pub(crate) reason: Option<String>,
84 pub(crate) cost: Option<f64>,
85 pub(crate) tokens: Option<OpenCodeTokens>,
86 #[serde(rename = "callID")]
88 pub(crate) call_id: Option<String>,
89 pub(crate) tool: Option<String>,
90 pub(crate) state: Option<OpenCodeToolState>,
91 pub(crate) text: Option<String>,
93 pub(crate) time: Option<OpenCodeTime>,
95}
96
97#[derive(Debug, Clone, Deserialize, Serialize)]
99pub struct OpenCodeToolState {
100 pub(crate) status: Option<String>,
101 pub(crate) input: Option<serde_json::Value>,
102 pub(crate) output: Option<serde_json::Value>,
103 pub(crate) title: Option<String>,
104 pub(crate) metadata: Option<serde_json::Value>,
105 pub(crate) time: Option<OpenCodeTime>,
106}
107
108#[derive(Debug, Clone, Deserialize, Serialize)]
110pub struct OpenCodeTokens {
111 pub(crate) input: Option<u64>,
112 pub(crate) output: Option<u64>,
113 pub(crate) reasoning: Option<u64>,
114 pub(crate) cache: Option<OpenCodeCache>,
115}
116
117#[derive(Debug, Clone, Deserialize, Serialize)]
119pub struct OpenCodeCache {
120 pub(crate) read: Option<u64>,
121 pub(crate) write: Option<u64>,
122}
123
124#[derive(Debug, Clone, Deserialize, Serialize)]
126pub struct OpenCodeTime {
127 pub(crate) start: Option<u64>,
128 pub(crate) end: Option<u64>,
129}
130
131pub struct OpenCodeParser {
133 colors: Colors,
134 verbosity: Verbosity,
135 log_file: Option<String>,
136 display_name: String,
137 streaming_session: Rc<RefCell<StreamingSession>>,
139 terminal_mode: RefCell<TerminalMode>,
141 show_streaming_metrics: bool,
143 printer: SharedPrinter,
145}
146
147impl OpenCodeParser {
148 pub(crate) fn new(colors: Colors, verbosity: Verbosity) -> Self {
149 Self::with_printer(colors, verbosity, super::printer::shared_stdout())
150 }
151
152 pub(crate) fn with_printer(
164 colors: Colors,
165 verbosity: Verbosity,
166 printer: SharedPrinter,
167 ) -> Self {
168 let verbose_warnings = matches!(verbosity, Verbosity::Debug);
169 let streaming_session = StreamingSession::new().with_verbose_warnings(verbose_warnings);
170
171 let _printer_is_terminal = printer.borrow().is_terminal();
173
174 Self {
175 colors,
176 verbosity,
177 log_file: None,
178 display_name: "OpenCode".to_string(),
179 streaming_session: Rc::new(RefCell::new(streaming_session)),
180 terminal_mode: RefCell::new(TerminalMode::detect()),
181 show_streaming_metrics: false,
182 printer,
183 }
184 }
185
186 pub(crate) const fn with_show_streaming_metrics(mut self, show: bool) -> Self {
187 self.show_streaming_metrics = show;
188 self
189 }
190
191 pub(crate) fn with_display_name(mut self, display_name: &str) -> Self {
192 self.display_name = display_name.to_string();
193 self
194 }
195
196 pub(crate) fn with_log_file(mut self, path: &str) -> Self {
197 self.log_file = Some(path.to_string());
198 self
199 }
200
201 #[cfg(test)]
202 pub fn with_terminal_mode(self, mode: TerminalMode) -> Self {
203 *self.terminal_mode.borrow_mut() = mode;
204 self
205 }
206
207 #[cfg(any(test, feature = "test-utils"))]
212 pub fn with_printer_for_test(
213 colors: Colors,
214 verbosity: Verbosity,
215 printer: SharedPrinter,
216 ) -> Self {
217 Self::with_printer(colors, verbosity, printer)
218 }
219
220 #[cfg(any(test, feature = "test-utils"))]
224 pub fn with_log_file_for_test(mut self, path: &str) -> Self {
225 self.log_file = Some(path.to_string());
226 self
227 }
228
229 #[cfg(any(test, feature = "test-utils"))]
233 pub fn parse_stream_for_test<R: std::io::BufRead>(&self, reader: R) -> std::io::Result<()> {
234 self.parse_stream(reader)
235 }
236
237 #[cfg(feature = "test-utils")]
247 pub fn printer(&self) -> SharedPrinter {
248 Rc::clone(&self.printer)
249 }
250
251 #[cfg(feature = "test-utils")]
260 pub fn streaming_metrics(&self) -> StreamingQualityMetrics {
261 self.streaming_session
262 .borrow()
263 .get_streaming_quality_metrics()
264 }
265
266 pub(crate) fn parse_event(&self, line: &str) -> Option<String> {
274 let event: OpenCodeEvent = if let Ok(e) = serde_json::from_str(line) {
275 e
276 } else {
277 let trimmed = line.trim();
278 if !trimmed.is_empty() && !trimmed.starts_with('{') {
279 return Some(format!("{trimmed}\n"));
280 }
281 return None;
282 };
283 let c = &self.colors;
284 let prefix = &self.display_name;
285
286 let output = match event.event_type.as_str() {
287 "step_start" => self.format_step_start_event(&event),
288 "step_finish" => self.format_step_finish_event(&event),
289 "tool_use" => self.format_tool_use_event(&event),
290 "text" => self.format_text_event(&event),
291 _ => {
292 format_unknown_json_event(line, prefix, *c, self.verbosity.is_verbose())
294 }
295 };
296
297 if output.is_empty() {
298 None
299 } else {
300 Some(output)
301 }
302 }
303
304 fn format_step_start_event(&self, event: &OpenCodeEvent) -> String {
306 let c = &self.colors;
307 let prefix = &self.display_name;
308
309 self.streaming_session.borrow_mut().on_message_start();
311
312 let step_id = event.part.as_ref().map_or_else(
315 || {
316 event
317 .session_id
318 .clone()
319 .unwrap_or_else(|| "unknown".to_string())
320 },
321 |part| {
322 part.message_id.as_ref().map_or_else(
323 || {
324 let session = event.session_id.as_deref().unwrap_or("unknown");
325 let part_id = part.id.as_deref().unwrap_or("step");
326 format!("{session}:{part_id}")
327 },
328 std::clone::Clone::clone,
329 )
330 },
331 );
332 self.streaming_session
333 .borrow_mut()
334 .set_current_message_id(Some(step_id));
335
336 let snapshot = event
337 .part
338 .as_ref()
339 .and_then(|p| p.snapshot.as_ref())
340 .map(|s| format!("({s:.8}...)"))
341 .unwrap_or_default();
342 format!(
343 "{}[{}]{} {}Step started{} {}{}{}\n",
344 c.dim(),
345 prefix,
346 c.reset(),
347 c.cyan(),
348 c.reset(),
349 c.dim(),
350 snapshot,
351 c.reset()
352 )
353 }
354
355 fn format_step_finish_event(&self, event: &OpenCodeEvent) -> String {
357 let c = &self.colors;
358 let prefix = &self.display_name;
359
360 let session = self.streaming_session.borrow();
362 let is_duplicate = session.get_current_message_id().map_or_else(
363 || session.has_any_streamed_content(),
364 |message_id| session.is_duplicate_final_message(message_id),
365 );
366 let was_streaming = session.has_any_streamed_content();
367 let metrics = session.get_streaming_quality_metrics();
368 drop(session);
369
370 let _was_in_block = self.streaming_session.borrow_mut().on_message_stop();
372
373 event.part.as_ref().map_or_else(String::new, |part| {
374 let reason = part.reason.as_deref().unwrap_or("unknown");
375 let cost = part.cost.unwrap_or(0.0);
376
377 let tokens_str = part.tokens.as_ref().map_or_else(String::new, |tokens| {
378 let input = tokens.input.unwrap_or(0);
379 let output = tokens.output.unwrap_or(0);
380 let reasoning = tokens.reasoning.unwrap_or(0);
381 let cache_read = tokens.cache.as_ref().and_then(|c| c.read).unwrap_or(0);
382 if reasoning > 0 {
383 format!("in:{input} out:{output} reason:{reasoning} cache:{cache_read}")
384 } else if cache_read > 0 {
385 format!("in:{input} out:{output} cache:{cache_read}")
386 } else {
387 format!("in:{input} out:{output}")
388 }
389 });
390
391 let is_success = reason == "tool-calls" || reason == "end_turn";
392 let icon = if is_success { CHECK } else { CROSS };
393 let color = if is_success { c.green() } else { c.yellow() };
394
395 let terminal_mode = *self.terminal_mode.borrow();
397 let newline_prefix = if is_duplicate || was_streaming {
398 let completion = TextDeltaRenderer::render_completion(terminal_mode);
399 let show_metrics = (self.verbosity.is_debug() || self.show_streaming_metrics)
400 && metrics.total_deltas > 0;
401 if show_metrics {
402 format!("{}\n{}", completion, metrics.format(*c))
403 } else {
404 completion
405 }
406 } else {
407 String::new()
408 };
409
410 let mut out = format!(
411 "{}{}[{}]{} {}{} Step finished{} {}({}",
412 newline_prefix,
413 c.dim(),
414 prefix,
415 c.reset(),
416 color,
417 icon,
418 c.reset(),
419 c.dim(),
420 reason
421 );
422 if !tokens_str.is_empty() {
423 let _ = write!(out, ", {tokens_str}");
424 }
425 if cost > 0.0 {
426 let _ = write!(out, ", ${cost:.4}");
427 }
428 let _ = writeln!(out, "){}", c.reset());
429 out
430 })
431 }
432
433 fn format_tool_use_event(&self, event: &OpenCodeEvent) -> String {
435 let c = &self.colors;
436 let prefix = &self.display_name;
437
438 event.part.as_ref().map_or_else(String::new, |part| {
439 let tool_name = part.tool.as_deref().unwrap_or("unknown");
440 let status = part
441 .state
442 .as_ref()
443 .and_then(|s| s.status.as_deref())
444 .unwrap_or("pending");
445 let title = part.state.as_ref().and_then(|s| s.title.as_deref());
446
447 let is_completed = status == "completed";
448 let icon = if is_completed { CHECK } else { '…' };
449 let color = if is_completed { c.green() } else { c.yellow() };
450
451 let mut out = format!(
452 "{}[{}]{} {}Tool{}: {}{}{} {}{}{}\n",
453 c.dim(),
454 prefix,
455 c.reset(),
456 c.magenta(),
457 c.reset(),
458 c.bold(),
459 tool_name,
460 c.reset(),
461 color,
462 icon,
463 c.reset()
464 );
465
466 if let Some(t) = title {
468 let limit = self.verbosity.truncate_limit("text");
469 let preview = truncate_text(t, limit);
470 let _ = writeln!(
471 out,
472 "{}[{}]{} {} └─ {}{}",
473 c.dim(),
474 prefix,
475 c.reset(),
476 c.dim(),
477 preview,
478 c.reset()
479 );
480 }
481
482 if self.verbosity.show_tool_input() {
484 if let Some(ref state) = part.state {
485 if let Some(ref input_val) = state.input {
486 let input_str = format_tool_input(input_val);
487 let limit = self.verbosity.truncate_limit("tool_input");
488 let preview = truncate_text(&input_str, limit);
489 if !preview.is_empty() {
490 let _ = writeln!(
491 out,
492 "{}[{}]{} {} └─ {}{}",
493 c.dim(),
494 prefix,
495 c.reset(),
496 c.dim(),
497 preview,
498 c.reset()
499 );
500 }
501 }
502 }
503 }
504
505 if self.verbosity.is_verbose() && is_completed {
507 if let Some(ref state) = part.state {
508 if let Some(ref output_val) = state.output {
509 let output_str = match output_val {
510 serde_json::Value::String(s) => s.as_str(),
511 _ => "",
512 };
513 let output_str = if output_str.is_empty() {
514 output_val.to_string()
515 } else {
516 output_str.to_string()
517 };
518 let limit = self.verbosity.truncate_limit("tool_result");
519 let preview = truncate_text(&output_str, limit);
520 if !preview.is_empty() {
521 let _ = writeln!(
522 out,
523 "{}[{}]{} {} └─ Output: {}{}",
524 c.dim(),
525 prefix,
526 c.reset(),
527 c.dim(),
528 preview,
529 c.reset()
530 );
531 }
532 }
533 }
534 }
535 out
536 })
537 }
538
539 fn format_text_event(&self, event: &OpenCodeEvent) -> String {
541 let c = &self.colors;
542 let prefix = &self.display_name;
543
544 if let Some(ref part) = event.part {
545 if let Some(ref text) = part.text {
546 let (show_prefix, accumulated_text) = {
548 let mut session = self.streaming_session.borrow_mut();
549 let show_prefix = session.on_text_delta_key("main", text);
550 let accumulated_text = session
552 .get_accumulated(ContentType::Text, "main")
553 .unwrap_or("")
554 .to_string();
555 (show_prefix, accumulated_text)
556 };
557
558 let limit = self.verbosity.truncate_limit("text");
560 let preview = truncate_text(&accumulated_text, limit);
561
562 let terminal_mode = *self.terminal_mode.borrow();
564 if show_prefix {
565 return TextDeltaRenderer::render_first_delta(
567 &preview,
568 prefix,
569 *c,
570 terminal_mode,
571 );
572 }
573 return TextDeltaRenderer::render_subsequent_delta(
575 &preview,
576 prefix,
577 *c,
578 terminal_mode,
579 );
580 }
581 }
582 String::new()
583 }
584
585 fn is_control_event(event: &OpenCodeEvent) -> bool {
591 match event.event_type.as_str() {
592 "step_start" | "step_finish" => true,
594 _ => false,
595 }
596 }
597
598 fn is_partial_event(event: &OpenCodeEvent) -> bool {
603 match event.event_type.as_str() {
604 "text" => true,
606 _ => false,
607 }
608 }
609
610 pub(crate) fn parse_stream<R: BufRead>(&self, mut reader: R) -> io::Result<()> {
612 use super::incremental_parser::IncrementalNdjsonParser;
613
614 let c = &self.colors;
615 let monitor = HealthMonitor::new("OpenCode");
616 let mut log_writer = self.log_file.as_ref().and_then(|log_path| {
617 std::fs::OpenOptions::new()
618 .create(true)
619 .append(true)
620 .open(log_path)
621 .ok()
622 .map(std::io::BufWriter::new)
623 });
624
625 let mut incremental_parser = IncrementalNdjsonParser::new();
628 let mut byte_buffer = Vec::new();
629
630 loop {
631 byte_buffer.clear();
633 let chunk = reader.fill_buf()?;
634 if chunk.is_empty() {
635 break;
636 }
637
638 byte_buffer.extend_from_slice(chunk);
640 let consumed = chunk.len();
641 reader.consume(consumed);
642
643 let json_events = incremental_parser.feed(&byte_buffer);
645
646 for line in json_events {
648 let trimmed = line.trim();
649 if trimmed.is_empty() {
650 continue;
651 }
652
653 if self.verbosity.is_debug() {
654 let mut printer = self.printer.borrow_mut();
655 writeln!(
656 printer,
657 "{}[DEBUG]{} {}{}{}",
658 c.dim(),
659 c.reset(),
660 c.dim(),
661 &line,
662 c.reset()
663 )?;
664 printer.flush()?;
665 }
666
667 match self.parse_event(&line) {
669 Some(output) => {
670 if trimmed.starts_with('{') {
672 if let Ok(event) = serde_json::from_str::<OpenCodeEvent>(&line) {
673 if Self::is_partial_event(&event) {
674 monitor.record_partial_event();
675 } else {
676 monitor.record_parsed();
677 }
678 } else {
679 monitor.record_parsed();
680 }
681 } else {
682 monitor.record_parsed();
683 }
684 let mut printer = self.printer.borrow_mut();
686 write!(printer, "{output}")?;
687 printer.flush()?;
688 }
689 None => {
690 if trimmed.starts_with('{') {
692 if let Ok(event) = serde_json::from_str::<OpenCodeEvent>(&line) {
693 if Self::is_control_event(&event) {
694 monitor.record_control_event();
695 } else {
696 monitor.record_unknown_event();
698 }
699 } else {
700 monitor.record_parse_error();
702 }
703 } else {
704 monitor.record_ignored();
705 }
706 }
707 }
708
709 if let Some(ref mut file) = log_writer {
710 writeln!(file, "{line}")?;
711 }
712 }
713 }
714
715 if let Some(ref mut file) = log_writer {
716 file.flush()?;
717 let _ = file.get_mut().sync_all();
720 }
721 if let Some(warning) = monitor.check_and_warn(*c) {
722 let mut printer = self.printer.borrow_mut();
723 writeln!(printer, "{warning}")?;
724 }
725 Ok(())
726 }
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 #[test]
734 fn test_opencode_step_start() {
735 let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
736 let json = r#"{"type":"step_start","timestamp":1768191337567,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06aa45c001","sessionID":"ses_44f9562d4ffe","messageID":"msg_bb06a9dc1001","type":"step-start","snapshot":"5d36aa035d4df6edb73a68058733063258114ed5"}}"#;
737 let output = parser.parse_event(json);
738 assert!(output.is_some());
739 let out = output.unwrap();
740 assert!(out.contains("Step started"));
741 assert!(out.contains("5d36aa03"));
742 }
743
744 #[test]
745 fn test_opencode_step_finish() {
746 let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
747 let json = r#"{"type":"step_finish","timestamp":1768191347296,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06aca1d001","sessionID":"ses_44f9562d4ffe","messageID":"msg_bb06a9dc1001","type":"step-finish","reason":"tool-calls","snapshot":"5d36aa035d4df6edb73a68058733063258114ed5","cost":0,"tokens":{"input":108,"output":151,"reasoning":0,"cache":{"read":11236,"write":0}}}}"#;
748 let output = parser.parse_event(json);
749 assert!(output.is_some());
750 let out = output.unwrap();
751 assert!(out.contains("Step finished"));
752 assert!(out.contains("tool-calls"));
753 assert!(out.contains("in:108"));
754 assert!(out.contains("out:151"));
755 assert!(out.contains("cache:11236"));
756 }
757
758 #[test]
759 fn test_opencode_tool_use_completed() {
760 let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
761 let json = r#"{"type":"tool_use","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06ac80c001","sessionID":"ses_44f9562d4ffe","messageID":"msg_bb06a9dc1001","type":"tool","callID":"call_8a2985d92e63","tool":"read","state":{"status":"completed","input":{"filePath":"/test/PLAN.md"},"output":"<file>\n00001| # Implementation Plan\n</file>","title":"PLAN.md"}}}"#;
762 let output = parser.parse_event(json);
763 assert!(output.is_some());
764 let out = output.unwrap();
765 assert!(out.contains("Tool"));
766 assert!(out.contains("read"));
767 assert!(out.contains("✓")); assert!(out.contains("PLAN.md"));
769 }
770
771 #[test]
772 fn test_opencode_tool_use_pending() {
773 let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
774 let json = r#"{"type":"tool_use","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06ac80c001","sessionID":"ses_44f9562d4ffe","messageID":"msg_bb06a9dc1001","type":"tool","callID":"call_8a2985d92e63","tool":"bash","state":{"status":"pending","input":{"command":"ls -la"}}}}"#;
775 let output = parser.parse_event(json);
776 assert!(output.is_some());
777 let out = output.unwrap();
778 assert!(out.contains("Tool"));
779 assert!(out.contains("bash"));
780 assert!(out.contains("…")); }
782
783 #[test]
784 fn test_opencode_tool_use_shows_input() {
785 let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
786 let json = r#"{"type":"tool_use","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06ac80c001","sessionID":"ses_44f9562d4ffe","messageID":"msg_bb06a9dc1001","type":"tool","callID":"call_8a2985d92e63","tool":"read","state":{"status":"completed","input":{"filePath":"/Users/test/file.rs"}}}}"#;
787 let output = parser.parse_event(json);
788 assert!(output.is_some());
789 let out = output.unwrap();
790 assert!(out.contains("Tool"));
791 assert!(out.contains("read"));
792 assert!(out.contains("filePath=/Users/test/file.rs"));
793 }
794
795 #[test]
796 fn test_opencode_text_event() {
797 let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
798 let json = r#"{"type":"text","timestamp":1768191347231,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06ac63300","sessionID":"ses_44f9562d4ffe","messageID":"msg_bb06a9dc1001","type":"text","text":"I'll start by reading the plan and requirements to understand what needs to be implemented.","time":{"start":1768191347226,"end":1768191347226}}}"#;
799 let output = parser.parse_event(json);
800 assert!(output.is_some());
801 let out = output.unwrap();
802 assert!(out.contains("I'll start by reading the plan"));
803 }
804
805 #[test]
806 fn test_opencode_unknown_event_ignored() {
807 let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
808 let json = r#"{"type":"unknown_event","timestamp":1768191347231,"sessionID":"ses_44f9562d4ffe","part":{}}"#;
809 let output = parser.parse_event(json);
810 assert!(output.is_none());
812 }
813
814 #[test]
815 fn test_opencode_parser_non_json_passthrough() {
816 let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
817 let output = parser.parse_event("Error: something went wrong");
818 assert!(output.is_some());
819 assert!(output.unwrap().contains("Error: something went wrong"));
820 }
821
822 #[test]
823 fn test_opencode_parser_malformed_json_ignored() {
824 let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
825 let output = parser.parse_event("{invalid json here}");
826 assert!(output.is_none());
827 }
828
829 #[test]
830 fn test_opencode_step_finish_with_cost() {
831 let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Normal);
832 let json = r#"{"type":"step_finish","timestamp":1768191347296,"sessionID":"ses_44f9562d4ffe","part":{"type":"step-finish","reason":"end_turn","cost":0.0025,"tokens":{"input":1000,"output":500,"reasoning":0,"cache":{"read":0,"write":0}}}}"#;
833 let output = parser.parse_event(json);
834 assert!(output.is_some());
835 let out = output.unwrap();
836 assert!(out.contains("Step finished"));
837 assert!(out.contains("end_turn"));
838 assert!(out.contains("$0.0025"));
839 }
840
841 #[test]
842 fn test_opencode_tool_verbose_shows_output() {
843 let parser = OpenCodeParser::new(Colors { enabled: false }, Verbosity::Verbose);
844 let json = r#"{"type":"tool_use","timestamp":1768191346712,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06ac80c001","type":"tool","tool":"read","state":{"status":"completed","input":{"filePath":"/test.rs"},"output":"fn main() { println!(\"Hello\"); }"}}}"#;
845 let output = parser.parse_event(json);
846 assert!(output.is_some());
847 let out = output.unwrap();
848 assert!(out.contains("Tool"));
849 assert!(out.contains("read"));
850 assert!(out.contains("Output"));
851 assert!(out.contains("fn main"));
852 }
853}