Show and answer approvals in bxctl chat

Implemented-By: Grok 4.6
This commit is contained in:
2026-09-22 20:25:40 -07:00
parent 0a9df81fe4
commit f1f17a171f
5 changed files with 906 additions and 66 deletions
+224
View File
@@ -0,0 +1,224 @@
//! 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<serde_json::Value> = 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");
}