Task files, the files they copy in (byte-identical to the reference on m3a-ref), each area's check record, and a README with the per-task table of what each check exposed. The handoff note is done with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
225 lines
6.8 KiB
Rust
225 lines
6.8 KiB
Rust
//! 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");
|
|
}
|