//! Tests for how `bxctl chat` prints what the model wrote and what `brokerd` denied. Do not edit. use bxctl::chat::Printer; use proto::{DataClass, DenyReason, Timestamp, TurnEvent}; const BACKSLASH: char = '\\'; /// The escape for one code point, built and never spelled out (see `tests/escape.rs`). fn esc(code: u32) -> String { format!("{BACKSLASH}u{code:04x}") } fn print(printer: &mut Printer, events: &[TurnEvent]) -> String { let mut out = Vec::new(); for e in events { printer.event(&mut out, e).unwrap(); } printer.end_reasoning(&mut out).unwrap(); String::from_utf8(out).unwrap() } fn denied(name: &str, reason: DenyReason) -> TurnEvent { TurnEvent::ToolDenied { name: name.to_string(), reason, } } #[test] fn a_denial_shows_its_reason_by_its_wire_name() { let mut p = Printer::new(true, false); assert_eq!( print(&mut p, &[denied("read_file", DenyReason::NoGrant)]), "[denied read_file: no_grant]\n" ); } /// Walks all ten reasons: the three that mean the harness is refusing to work carry their /// runbook entry on the next line, and the other seven carry nothing. #[test] fn the_three_fail_closed_denials_point_to_the_runbook_and_no_other_does() { let cases = [ (DenyReason::NoGrant, "no_grant", None), (DenyReason::GrantExpired, "grant_expired", None), (DenyReason::TaintTooHigh, "taint_too_high", None), (DenyReason::DeniedByGrant, "denied_by_grant", None), (DenyReason::ApprovalRefused, "approval_refused", None), (DenyReason::ApprovalExpired, "approval_expired", None), (DenyReason::InvalidArguments, "invalid_arguments", None), ( DenyReason::GrantsInvalid, "grants_invalid", Some("see docs/runbook.md#grants-invalid"), ), ( DenyReason::AuditUnavailable, "audit_unavailable", Some("see docs/runbook.md#audit-unavailable"), ), ( DenyReason::StateUnreadable, "state_unreadable", Some("see docs/runbook.md#broker-state-damaged"), ), ]; for (reason, name, pointer) in cases { let mut p = Printer::new(true, false); let got = print(&mut p, &[denied("shell", reason)]); let want = match pointer { Some(pointer) => format!("[denied shell: {name}]\n{pointer}\n"), None => format!("[denied shell: {name}]\n"), }; assert_eq!(got, want); } } #[test] fn a_denial_ends_an_open_reasoning_block_first() { let mut p = Printer::new(true, false); let got = print( &mut p, &[ TurnEvent::Reasoning { text: "hm".to_string(), }, denied("shell", DenyReason::NoGrant), ], ); assert_eq!(got, "\x1b[2mhm\x1b[0m\n[denied shell: no_grant]\n"); } #[test] fn reasoning_and_content_are_printed_as_data() { let hostile = "a\x1b[8mb\x07c\rd"; let mut p = Printer::new(true, false); let got = print( &mut p, &[ TurnEvent::Reasoning { text: hostile.to_string(), }, TurnEvent::Content { text: hostile.to_string(), }, ], ); let shown = format!("a{}[8mb{}c{}d", esc(0x1b), esc(0x07), esc(0x0d)); // The only escape sequences left are the printer's own: dim on, dim off. assert_eq!(got, format!("\x1b[2m{shown}\x1b[0m\n{shown}")); } #[test] fn invisible_and_direction_changing_characters_are_shown() { let rlo = char::from_u32(0x202e).unwrap(); let mut p = Printer::new(true, false); let got = print( &mut p, &[TurnEvent::Content { text: format!("see {rlo}txt.exe"), }], ); assert_eq!(got, format!("see {}txt.exe", esc(0x202e))); } #[test] fn newlines_and_tabs_in_model_text_pass_through() { let mut p = Printer::new(true, false); let got = print( &mut p, &[TurnEvent::Content { text: "one\n\ttwo\n".to_string(), }], ); assert_eq!(got, "one\n\ttwo\n"); } /// The model chooses tool names too: every place a name is printed escapes it. #[test] fn tool_names_are_printed_as_data_everywhere() { let name = "sh\x1b[2Jell"; let shown = format!("sh{}[2Jell", esc(0x1b)); let mut p = Printer::new(true, false); let got = print( &mut p, &[ TurnEvent::ToolCallStarted { name: name.to_string(), }, TurnEvent::ToolResult { name: name.to_string(), class: DataClass::Public, truncated: false, }, TurnEvent::ToolResult { name: name.to_string(), class: DataClass::Private, truncated: true, }, denied(name, DenyReason::NoGrant), ], ); assert_eq!( got, format!( "[tool {shown}]\n[{shown}: Public]\n[{shown}: Private, truncated]\n\ [denied {shown}: no_grant]\n" ) ); assert!(!got.contains('\x1b')); } /// The printer shows nothing for a pending approval: the block comes from `brokerd`, through /// `handle_pending`. It still ends an open reasoning block, so the block is not dimmed. #[test] fn a_pending_approval_prints_nothing_itself_but_ends_the_reasoning_block() { let pending = TurnEvent::ApprovalPending { approval: 41, tool: "read_file".to_string(), expires: Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap(), }; let mut p = Printer::new(true, false); assert_eq!(print(&mut p, std::slice::from_ref(&pending)), ""); let mut p = Printer::new(true, false); let got = print( &mut p, &[ TurnEvent::Reasoning { text: "hm".to_string(), }, pending, ], ); assert_eq!(got, "\x1b[2mhm\x1b[0m\n"); } #[test] fn json_mode_prints_the_new_events_as_json_lines() { let mut p = Printer::new(true, true); let got = print( &mut p, &[ TurnEvent::ApprovalPending { approval: 41, tool: "read_file".to_string(), expires: Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap(), }, denied("shell", DenyReason::GrantsInvalid), ], ); let lines: Vec = got .lines() .map(|l| serde_json::from_str(l).unwrap()) .collect(); assert_eq!( lines, vec![ serde_json::json!({"event": "approval_pending", "approval": 41, "tool": "read_file", "expires": "2026-09-18T12:15:00.000Z"}), serde_json::json!({"event": "tool_denied", "name": "shell", "reason": "grants_invalid"}), ] ); assert!(!got.contains("runbook"), "json mode adds no prose"); }