M5c-1 plan: task 04, bxctl chat --speaker NAME, opt-in, no default

The speaker goes on every turn of the chat: interactive, --say and --say's retry. Without the
flag it is None. A name that is empty or holds whitespace or a control character (Unicode), or a
second --speaker, is a usage error: the usage text on stderr and exit status 2. The usage text
gains the flag and one line explaining it. Given: chat_speaker.rs (new) and new versions of
chat.rs, chat_approvals.rs and cli.rs. Task 01 now copies chat.rs-task01, the older chat.rs.

Checks: every before block found once at ref 03; replayed from the task text on a fresh ref-03
worktree with its own build directory, byte-identical to the reference, gate ok with 1158 tests
(1148 + 10); /tmp unchanged around the reference's and the replay's gate runs; 20 of 20
mutations caught; the new tests passed 40 of 40 runs under CPU load; given files pass fmt and
the 500-line check; no chat-template tag in the plan or the given files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 01:04:27 -07:00
co-authored by Claude Opus 5.5
parent 5ca50257d4
commit 5454d7ea1b
7 changed files with 2048 additions and 5 deletions
@@ -161,6 +161,7 @@ fn run_turn_delivers_every_event_in_order_then_the_answer() {
&id("s1"),
"what time is it?",
false,
None,
&mut |e| seen.push(e.clone()),
)
.unwrap();
@@ -189,7 +190,7 @@ fn an_error_frame_is_refused_with_its_code_and_detail() {
End::Error(ErrorCode::SessionFull, "this conversation is full"),
);
let mut seen = 0;
let err = run_turn(&fake.socket, &id("s1"), "x", true, &mut |_| seen += 1).unwrap_err();
let err = run_turn(&fake.socket, &id("s1"), "x", true, None, &mut |_| seen += 1).unwrap_err();
assert_eq!(seen, 1, "events before the error are still delivered");
match err {
ChatError::Refused(w) => {
@@ -199,7 +200,7 @@ fn an_error_frame_is_refused_with_its_code_and_detail() {
other => panic!("{other:?}"),
}
let e: Box<dyn std::error::Error> =
Box::new(run_turn(&fake.socket, &id("s1"), "x", true, &mut |_| {}).unwrap_err());
Box::new(run_turn(&fake.socket, &id("s1"), "x", true, None, &mut |_| {}).unwrap_err());
assert!(e.to_string().contains("session full"), "{e}");
}
@@ -207,7 +208,7 @@ fn an_error_frame_is_refused_with_its_code_and_detail() {
fn no_loopd_is_a_connect_error() {
let missing = std::env::temp_dir().join("bxctl-no-such-loopd.sock");
assert!(matches!(
run_turn(&missing, &id("s1"), "x", false, &mut |_| {}),
run_turn(&missing, &id("s1"), "x", false, None, &mut |_| {}),
Err(ChatError::Connect(_))
));
}
@@ -0,0 +1,470 @@
//! Tests for `bxctl chat`, against a fake `loopd` that speaks the channel protocol. Do not edit.
#[path = "support/temp.rs"]
mod temp;
use bxcore::{
DataClass, Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId, Turn, TurnDone,
TurnEvent, Usage, WireError, read_frame, write_frame,
};
use bxctl::chat::{ChatError, Printer, new_session_id, run_turn};
use std::os::unix::net::UnixListener;
use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::thread;
/// What the fake `loopd` sends back for one turn, after the events: done or an error.
#[derive(Clone)]
enum End {
Done(TurnDone),
Error(ErrorCode, &'static str),
}
struct FakeLoopd {
socket: PathBuf,
turns: Arc<Mutex<Vec<Turn>>>,
_dir: temp::TempDir,
}
fn usage() -> Usage {
Usage {
cache_n: 10,
prompt_n: 5,
predicted_n: 7,
reasoning_tokens: 3,
thinking_capped: false,
}
}
/// Serves every connection with the same script: the given events, then `end`. Records the
/// turns it received.
fn fake_loopd(events: Vec<TurnEvent>, end: End) -> FakeLoopd {
let dir = temp::TempDir::new("bxctl-test");
let socket = dir.join("loop.sock");
let listener = UnixListener::bind(&socket).unwrap();
let turns = Arc::new(Mutex::new(Vec::new()));
let seen = Arc::clone(&turns);
thread::spawn(move || {
for stream in listener.incoming() {
let mut stream = stream.unwrap();
let request = read_frame(&mut stream).unwrap();
let Message::Turn(turn) = request.msg else {
panic!("not a turn")
};
seen.lock().unwrap().push(turn.clone());
let id = request.id;
let end = if turn.resume && turn.content == "trigger-no-such-session" {
End::Error(ErrorCode::NoSuchSession, "session x does not exist")
} else if turn.content == "trigger-turn-limit" {
End::Error(ErrorCode::TurnLimit, "the turn hit a limit")
} else {
end.clone()
};
for e in &events {
write_frame(
&mut stream,
&Envelope {
v: PROTOCOL_VERSION,
id,
r#final: false,
msg: Message::TurnEvent(e.clone()),
},
)
.unwrap();
}
let last = match end {
End::Done(done) => Message::TurnDone(done),
End::Error(code, detail) => Message::Error(WireError {
code,
detail: detail.to_string(),
}),
};
write_frame(
&mut stream,
&Envelope {
v: PROTOCOL_VERSION,
id,
r#final: true,
msg: last,
},
)
.unwrap();
}
});
FakeLoopd {
socket,
turns,
_dir: dir,
}
}
fn events() -> Vec<TurnEvent> {
vec![
TurnEvent::Queued { ahead: 1 },
TurnEvent::Waiting { slot_busy: true },
TurnEvent::Progress {
total: 100,
cache: 50,
processed: 75,
},
TurnEvent::Reasoning {
text: "let me ".to_string(),
},
TurnEvent::Reasoning {
text: "think".to_string(),
},
TurnEvent::ToolCallStarted {
name: "clock".to_string(),
},
TurnEvent::ToolResult {
name: "clock".to_string(),
class: DataClass::Public,
truncated: true,
},
TurnEvent::ThinkingCapped { tokens: 4096 },
TurnEvent::Retrying {
attempt: 2,
after_ms: 1500,
error: "the server went silent".to_string(),
},
TurnEvent::CacheLoss {
expected: 500,
got: 20,
},
TurnEvent::Content {
text: "It is ".to_string(),
},
TurnEvent::Content {
text: "noon.".to_string(),
},
]
}
fn done() -> End {
End::Done(TurnDone {
content: "It is noon.".to_string(),
usage: usage(),
untrusted: false,
})
}
fn id(s: &str) -> SessionId {
SessionId::new(s).unwrap()
}
#[test]
fn run_turn_delivers_every_event_in_order_then_the_answer() {
let fake = fake_loopd(events(), done());
let mut seen = Vec::new();
let done = run_turn(
&fake.socket,
&id("s1"),
"what time is it?",
false,
&mut |e| seen.push(e.clone()),
)
.unwrap();
assert_eq!(seen, events());
assert_eq!(done.content, "It is noon.");
assert_eq!(done.usage, usage());
let turns = fake.turns.lock().unwrap();
assert_eq!(turns.len(), 1);
assert_eq!(
turns[0],
Turn {
session: id("s1"),
content: "what time is it?".to_string(),
resume: false,
speaker: None,
}
);
}
#[test]
fn an_error_frame_is_refused_with_its_code_and_detail() {
let fake = fake_loopd(
vec![TurnEvent::Content {
text: "partial".to_string(),
}],
End::Error(ErrorCode::SessionFull, "this conversation is full"),
);
let mut seen = 0;
let err = run_turn(&fake.socket, &id("s1"), "x", true, &mut |_| seen += 1).unwrap_err();
assert_eq!(seen, 1, "events before the error are still delivered");
match err {
ChatError::Refused(w) => {
assert_eq!(w.code, ErrorCode::SessionFull);
assert_eq!(w.detail, "this conversation is full");
}
other => panic!("{other:?}"),
}
let e: Box<dyn std::error::Error> =
Box::new(run_turn(&fake.socket, &id("s1"), "x", true, &mut |_| {}).unwrap_err());
assert!(e.to_string().contains("session full"), "{e}");
}
#[test]
fn no_loopd_is_a_connect_error() {
let missing = std::env::temp_dir().join("bxctl-no-such-loopd.sock");
assert!(matches!(
run_turn(&missing, &id("s1"), "x", false, &mut |_| {}),
Err(ChatError::Connect(_))
));
}
#[test]
fn new_session_ids_are_valid_and_distinct() {
let a = new_session_id();
let b = new_session_id();
assert!(a.as_str().starts_with("chat-"));
assert_ne!(a, b);
}
#[test]
fn the_printer_formats_each_event_kind() {
let mut out = Vec::new();
let mut p = Printer::new(true, false);
for e in events() {
p.event(&mut out, &e).unwrap();
}
p.end_reasoning(&mut out).unwrap();
let text = String::from_utf8(out).unwrap();
assert!(
text.contains("\x1b[2mlet me think\x1b[0m\n"),
"reasoning dimmed, joined, and ended once: {text:?}"
);
assert!(text.contains("[tool clock]\n"), "{text:?}");
assert!(text.contains("[clock: Public, truncated]\n"), "{text:?}");
assert!(text.contains("[waiting: slot busy]\n"), "{text:?}");
assert!(
text.contains("[retrying: attempt 2 in 1500 ms: the server went silent]\n"),
"{text:?}"
);
assert!(
text.contains("[thinking capped at 4096 tokens]\n"),
"{text:?}"
);
assert!(text.contains("[cache loss: 20 of 500]\n"), "{text:?}");
assert!(
text.ends_with("It is noon."),
"content streams as it is: {text:?}"
);
assert!(
!text.contains("Queued") && !text.contains("Progress"),
"queued and progress are silent: {text:?}"
);
let mut out = Vec::new();
let mut p = Printer::new(false, false);
for e in events() {
p.event(&mut out, &e).unwrap();
}
let text = String::from_utf8(out).unwrap();
assert!(
!text.contains("let me"),
"--no-thinking hides reasoning: {text:?}"
);
assert!(
!text.contains("\x1b["),
"and no escape codes are left: {text:?}"
);
let mut out = Vec::new();
let mut p = Printer::new(true, true);
for e in events() {
p.event(&mut out, &e).unwrap();
}
let text = String::from_utf8(out).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(
lines.len(),
events().len(),
"--json: one line per event, none skipped"
);
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(first, serde_json::json!({"event": "queued", "ahead": 1}));
}
#[test]
fn say_prints_only_the_answer_on_stdout() {
let fake = fake_loopd(events(), done());
let output = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--socket"])
.arg(&fake.socket)
.args(["--session", "scripted-1", "--say", "what time is it?"])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout), "It is noon.\n");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("[tool clock]"),
"events go to stderr: {stderr}"
);
let turns = fake.turns.lock().unwrap();
assert_eq!(turns.len(), 1);
assert_eq!(turns[0].session, id("scripted-1"));
assert!(
turns[0].resume,
"a session given on the command line is resumed"
);
}
#[test]
fn say_creates_a_named_session_that_does_not_exist_yet() {
let fake = fake_loopd(vec![], done());
let output = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--socket"])
.arg(&fake.socket)
.args(["--session", "fresh", "--say", "trigger-no-such-session"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let turns = fake.turns.lock().unwrap();
assert_eq!(turns.len(), 2, "resume was refused, so it was created");
assert!(turns[0].resume && !turns[1].resume);
}
#[test]
fn say_reports_an_error_on_stderr_with_status_1() {
let fake = fake_loopd(
vec![],
End::Error(ErrorCode::TurnLimit, "the turn hit a limit"),
);
let output = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--socket"])
.arg(&fake.socket)
.args(["--say", "x"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
assert_eq!(String::from_utf8_lossy(&output.stdout), "");
assert!(String::from_utf8_lossy(&output.stderr).contains("turn limit: the turn hit a limit"));
}
#[test]
fn json_mode_prints_frames_as_json_lines() {
let fake = fake_loopd(events(), done());
let output = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--socket"])
.arg(&fake.socket)
.args(["--session", "j", "--json", "--say", "x"])
.output()
.unwrap();
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
let lines: Vec<&str> = stderr.lines().collect();
assert_eq!(
lines.len(),
events().len() + 1,
"every event, then the done frame: {stderr}"
);
let last: serde_json::Value = serde_json::from_str(lines[lines.len() - 1]).unwrap();
assert_eq!(last["content"], "It is noon.");
assert_eq!(last["usage"]["cache_n"], 10);
assert_eq!(last["untrusted"], false, "the done frame is printed whole");
}
#[test]
fn interactive_mode_reads_lines_until_quit() {
let fake = fake_loopd(
vec![TurnEvent::Content {
text: "ok".to_string(),
}],
End::Done(TurnDone {
content: "ok".to_string(),
usage: usage(),
untrusted: false,
}),
);
let mut child = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--socket"])
.arg(&fake.socket)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
{
let mut stdin = child.stdin.take().unwrap();
std::io::Write::write_all(&mut stdin, b"first\n\nsecond\n/quit\nnever sent\n").unwrap();
}
let output = child.wait_with_output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.starts_with("session chat-"), "{stdout}");
assert_eq!(stdout.matches("ok").count(), 2, "{stdout}");
let turns = fake.turns.lock().unwrap();
assert_eq!(
turns.len(),
2,
"a blank line sends nothing, and /quit stops"
);
assert!(
!turns[0].resume && turns[1].resume,
"the first turn creates, the second resumes"
);
assert_eq!(turns[0].session, turns[1].session);
}
#[test]
fn bad_arguments_print_usage() {
let output = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--session", "Not Valid!"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
assert!(String::from_utf8_lossy(&output.stderr).contains("usage"));
let output = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["dance"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
}
/// A failed turn is reported, and the conversation goes on: the session still exists and the
/// next line is a new turn on it.
#[test]
fn interactive_mode_survives_a_failed_turn() {
let fake = fake_loopd(
vec![TurnEvent::Content {
text: "ok".to_string(),
}],
End::Done(TurnDone {
content: "ok".to_string(),
usage: usage(),
untrusted: false,
}),
);
let mut child = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--socket"])
.arg(&fake.socket)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
{
let mut stdin = child.stdin.take().unwrap();
std::io::Write::write_all(&mut stdin, b"first\ntrigger-turn-limit\nthird\n/quit\n")
.unwrap();
}
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"a failed turn does not end the chat"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("turn limit: the turn hit a limit"),
"{stderr}"
);
let turns = fake.turns.lock().unwrap();
assert_eq!(turns.len(), 3, "the turn after the failure was sent");
assert!(turns[2].resume, "and it resumed the same session");
assert_eq!(turns[2].session, turns[0].session);
}
@@ -0,0 +1,488 @@
//! Tests for approvals in `bxctl chat`, against a fake `loopd` and a fake `brokerd`. Do not edit.
//!
//! What the owner is shown comes from `brokerd`, never from `loopd`'s event, and only the
//! approval's id, typed in full, approves.
mod support;
use bxcore::{
Approve, DecisionRecord, Empty, ErrorCode, Message, Refuse, SessionId, Timestamp, TurnEvent,
};
use bxctl::chat::{Approvals, OnPending, Printer, TurnIo, handle_pending, stream_turn};
use std::io::{BufRead, Cursor, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use support::{FakeBrokerd, brokerd_with, fake_brokerd, fake_loopd, pending, ts, wire_error};
const BACKSLASH: char = '\\';
const NOW: &str = "2026-09-18T12:02:00.000Z";
const PROMPT: &str = "type 41 to approve, anything else refuses: ";
/// 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 approvals_request() -> Message {
Message::Approvals(Empty {})
}
fn approve_request() -> Message {
Message::Approve(Approve { approval: 41 })
}
fn refuse_request() -> Message {
Message::Refuse(Refuse {
approval: 41,
reason: None,
})
}
/// A `brokerd` with approval 41 pending, for `shell`.
fn broker() -> FakeBrokerd {
brokerd_with(
vec![pending(41, "shell", r#"{"command":"ls"}"#)],
DecisionRecord::Allowed {},
)
}
/// Runs `handle_pending` for approval 41 with `typed` waiting on stdin. Returns what was
/// printed and what was left unread.
fn handle(socket: &Path, ask: bool, typed: &str) -> (String, String) {
let mut input = Cursor::new(typed.as_bytes().to_vec());
let mut out = Vec::new();
handle_pending(socket, 41, ask, ts(NOW), &mut input, &mut out).unwrap();
let mut left = String::new();
std::io::Read::read_to_string(&mut input, &mut left).unwrap();
(String::from_utf8(out).unwrap(), left)
}
// ---- handle_pending ----
#[test]
fn the_block_then_the_question_and_the_id_approves() {
let fake = broker();
let (out, left) = handle(&fake.socket, true, "41\n");
assert_eq!(
out,
format!(
"\x1b[0m41 2 min ago expires in 13 min session chat-1758… grant shell-scratch \
taint private\n shell {{\"command\":\"ls\"}}\n{PROMPT}approved 41: runs\n"
),
"attributes reset, the block, the question, the outcome"
);
assert_eq!(left, "");
assert_eq!(
fake.requests(),
vec![approvals_request(), approve_request()]
);
}
#[test]
fn the_id_with_a_carriage_return_also_approves() {
let fake = broker();
handle(&fake.socket, true, "41\r\n");
assert_eq!(
fake.requests(),
vec![approvals_request(), approve_request()]
);
}
/// Everything that is not exactly the id refuses: `y`, a near miss, an empty line, the end of
/// the input.
#[test]
fn anything_else_refuses() {
for typed in [
"y\n",
"Y\n",
"yes\n",
"\n",
"",
" 41\n",
"41 \n",
"041\n",
"+41\n",
"4 1\n",
"42\n",
"approve 41\n",
"41",
] {
let fake = broker();
let (out, _) = handle(&fake.socket, true, typed);
if typed == "41" {
// The last line of the input need not end in a newline; it is still the id.
assert_eq!(
fake.requests(),
vec![approvals_request(), approve_request()]
);
continue;
}
assert_eq!(
fake.requests(),
vec![approvals_request(), refuse_request()],
"{typed:?}"
);
assert!(
out.ends_with(&format!("{PROMPT}refused 41\n")),
"{typed:?}: {out:?}"
);
}
}
/// A line typed while the turn ran is already waiting when the question is asked. It is the
/// answer, it is not the id, so it refuses; and only that one line is read.
#[test]
fn a_line_already_waiting_refuses_and_only_one_line_is_read() {
let fake = broker();
let (_, left) = handle(&fake.socket, true, "are you still there?\n41\n");
assert_eq!(fake.requests(), vec![approvals_request(), refuse_request()]);
assert_eq!(left, "41\n", "the next line is left for the chat");
}
#[test]
fn an_id_that_is_not_listed_is_no_longer_pending_and_nothing_is_asked() {
let fake = brokerd_with(
vec![pending(40, "shell", "{}"), pending(42, "shell", "{}")],
DecisionRecord::Allowed {},
);
let (out, left) = handle(&fake.socket, true, "41\n");
assert_eq!(out, "approval 41 is no longer pending\n");
assert_eq!(left, "41\n", "nothing was read");
assert_eq!(fake.requests(), vec![approvals_request()]);
}
#[test]
fn without_ask_the_block_is_shown_and_nothing_is_asked_or_read() {
let fake = broker();
let (out, left) = handle(&fake.socket, false, "41\n");
assert!(out.starts_with("\x1b[0m41 2 min ago "), "{out:?}");
assert!(out.ends_with(" shell {\"command\":\"ls\"}\n"), "{out:?}");
assert!(!out.contains("to approve"), "{out:?}");
assert_eq!(left, "41\n");
assert_eq!(fake.requests(), vec![approvals_request()]);
}
#[test]
fn a_brokerd_that_cannot_be_reached_is_reported_and_nothing_is_asked() {
let missing = support::temp_socket("nobody-listens.sock");
let (out, left) = handle(&missing, true, "41\n");
assert!(
out.starts_with("approval 41: cannot ask brokerd: cannot reach brokerd at "),
"{out:?}"
);
assert!(out.ends_with('\n') && out.lines().count() == 1, "{out:?}");
assert_eq!(left, "41\n");
}
/// The approval can expire between the list and the answer.
#[test]
fn an_answer_that_comes_too_late_is_reported() {
let fake = fake_brokerd(|msg| match msg {
Message::Approvals(_) => Message::ApprovalList(bxcore::ApprovalList {
items: vec![pending(41, "shell", "{}")],
}),
_ => wire_error(ErrorCode::NoSuchApproval, "no such approval"),
});
let (out, _) = handle(&fake.socket, true, "41\n");
assert!(
out.ends_with(&format!(
"{PROMPT}41: no such approval (already answered or expired)\n"
)),
"{out:?}"
);
let (out, _) = handle(&fake.socket, true, "no\n");
assert!(
out.ends_with(&format!(
"{PROMPT}41: no such approval (already answered or expired)\n"
)),
"{out:?}"
);
}
/// Any other failure of the answer is reported on one line, and the turn goes on: the call is
/// still pending at `brokerd`, and `bxctl approve` from another terminal can answer it.
#[test]
fn an_answer_that_fails_is_reported_and_is_not_an_error() {
let fake = fake_brokerd(|msg| match msg {
Message::Approvals(_) => Message::ApprovalList(bxcore::ApprovalList {
items: vec![pending(41, "shell", "{}")],
}),
_ => wire_error(ErrorCode::Internal, "boom"),
});
let (out, _) = handle(&fake.socket, true, "41\n");
assert!(
out.ends_with(&format!("{PROMPT}approval 41: internal: boom\n")),
"{out:?}"
);
}
/// What `brokerd` sends is printed as data here too.
#[test]
fn the_block_in_chat_is_escaped() {
let rlo = char::from_u32(0x202e).unwrap();
let arguments = format!("{{\"path\":\"/home/kyle/{rlo}dm\x1b[8m\"}}");
let fake = brokerd_with(
vec![pending(41, "read_file", &arguments)],
DecisionRecord::Allowed {},
);
let (out, _) = handle(&fake.socket, false, "");
assert_eq!(out.matches('\x1b').count(), 1, "only the reset: {out:?}");
assert!(!out.contains(rlo), "{out:?}");
assert!(
out.contains(&format!("/home/kyle/{}dm{}[8m", esc(0x202e), esc(0x1b))),
"{out:?}"
);
}
struct Broken;
impl Write for Broken {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("the terminal went away"))
}
fn flush(&mut self) -> std::io::Result<()> {
Err(std::io::Error::other("the terminal went away"))
}
}
/// Every write can fail, and none is ignored: not the "no longer pending" line, not the block,
/// not the question. Nothing is approved for an owner who was shown nothing.
#[test]
fn a_failed_write_is_an_error_and_nothing_is_answered() {
let fake = broker();
let mut input = Cursor::new(b"41\n".to_vec());
assert!(handle_pending(&fake.socket, 41, true, ts(NOW), &mut input, &mut Broken).is_err());
assert_eq!(fake.requests(), vec![approvals_request()]);
let none = brokerd_with(Vec::new(), DecisionRecord::Allowed {});
let mut input = Cursor::new(Vec::new());
assert!(handle_pending(&none.socket, 41, true, ts(NOW), &mut input, &mut Broken).is_err());
}
// ---- stream_turn ----
fn pending_event(tool: &str) -> TurnEvent {
TurnEvent::ApprovalPending {
approval: 41,
tool: tool.to_string(),
expires: Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap(),
}
}
/// Runs one turn against a fake `loopd` that sends `events`. Returns what was printed.
fn turn(
events: Vec<TurnEvent>,
admin_socket: &Path,
on_pending: OnPending,
json: bool,
typed: &str,
) -> String {
let loopd = fake_loopd(events, "done");
let mut printer = Printer::new(true, json);
let mut input: Box<dyn BufRead> = Box::new(Cursor::new(typed.as_bytes().to_vec()));
let mut out = Vec::new();
let approvals = Approvals {
admin_socket,
on_pending,
};
let mut io = TurnIo {
printer: &mut printer,
input: &mut *input,
out: &mut out,
};
let done = stream_turn(
&loopd.socket,
&SessionId::new("s1").unwrap(),
"go",
false,
None,
&approvals,
&mut io,
)
.unwrap()
.unwrap();
assert_eq!(done.content, "done");
String::from_utf8(out).unwrap()
}
/// A compromised `loopd` must not choose what the owner sees: the event says `read_file`, the
/// broker's entry says `shell`, and the block says `shell`.
#[test]
fn the_block_comes_from_brokerd_not_from_the_event() {
let fake = broker();
let out = turn(
vec![pending_event("read_file")],
&fake.socket,
OnPending::Ask,
false,
"41\n",
);
assert!(out.contains(" shell {\"command\":\"ls\"}\n"), "{out:?}");
assert!(!out.contains("read_file"), "{out:?}");
assert_eq!(
fake.requests(),
vec![approvals_request(), approve_request()]
);
}
#[test]
fn the_turn_goes_on_after_the_answer() {
let fake = broker();
let out = turn(
vec![
TurnEvent::Reasoning {
text: "hm".to_string(),
},
pending_event("shell"),
TurnEvent::Content {
text: "It ran.".to_string(),
},
],
&fake.socket,
OnPending::Ask,
false,
"41\n",
);
assert!(
out.starts_with("\x1b[2mhm\x1b[0m\n\x1b[0m41 "),
"reasoning is ended before the block: {out:?}"
);
assert!(out.ends_with("approved 41: runs\nIt ran."), "{out:?}");
}
#[test]
fn show_prints_the_block_and_answers_nothing() {
let fake = broker();
let out = turn(
vec![pending_event("shell")],
&fake.socket,
OnPending::Show,
false,
"41\n",
);
assert!(out.contains(" shell {\"command\":\"ls\"}\n"), "{out:?}");
assert!(!out.contains("to approve"), "{out:?}");
assert_eq!(fake.requests(), vec![approvals_request()]);
}
#[test]
fn event_only_prints_the_json_line_and_never_asks_brokerd() {
let fake = broker();
let out = turn(
vec![pending_event("shell")],
&fake.socket,
OnPending::EventOnly,
true,
"41\n",
);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 1, "{out:?}");
let event: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(event["event"], "approval_pending");
assert_eq!(event["approval"], 41);
assert_eq!(fake.requests(), Vec::<Message>::new());
}
// ---- the binary ----
fn chat(loopd: &Path, brokerd: &Path, extra: &[&str], typed: &str) -> std::process::Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--socket"])
.arg(loopd)
.arg("--admin-socket")
.arg(brokerd)
.args(extra)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
{
let mut stdin = child.stdin.take().unwrap();
stdin.write_all(typed.as_bytes()).unwrap();
}
child.wait_with_output().unwrap()
}
/// With a pipe, everything typed is in the reader's buffer before the first turn is sent. The
/// approval's answer must come from that same reader: a second reader on stdin would see the
/// end of the input, and refuse.
#[test]
fn interactive_mode_reads_the_answer_from_the_same_input_as_the_chat() {
let loopd = fake_loopd(vec![pending_event("shell")], "ok");
let fake = broker();
let output = chat(&loopd.socket, &fake.socket, &[], "go\n41\n/quit\n");
assert!(output.status.success());
assert_eq!(
fake.requests(),
vec![approvals_request(), approve_request()]
);
assert_eq!(
*loopd.turns.lock().unwrap(),
vec!["go".to_string()],
"the answer was not sent to the model as a message"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains(PROMPT), "{stderr}");
assert!(stderr.contains("approved 41: runs"), "{stderr}");
}
#[test]
fn interactive_mode_refuses_on_a_stray_line() {
let loopd = fake_loopd(vec![pending_event("shell")], "ok");
let fake = broker();
let output = chat(&loopd.socket, &fake.socket, &[], "go\ny\n/quit\n");
assert!(output.status.success());
assert_eq!(fake.requests(), vec![approvals_request(), refuse_request()]);
assert_eq!(*loopd.turns.lock().unwrap(), vec!["go".to_string()]);
}
#[test]
fn say_shows_the_block_and_answers_nothing() {
let loopd = fake_loopd(vec![pending_event("read_file")], "ok");
let fake = broker();
let output = chat(&loopd.socket, &fake.socket, &["--say", "go"], "41\n");
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains(" shell {\"command\":\"ls\"}\n"),
"{stderr}"
);
assert!(!stderr.contains("to approve"), "{stderr}");
assert_eq!(fake.requests(), vec![approvals_request()]);
}
#[test]
fn json_prints_only_json_and_never_asks_brokerd() {
let loopd = fake_loopd(vec![pending_event("shell")], "ok");
let fake = broker();
let output = chat(
&loopd.socket,
&fake.socket,
&["--json", "--say", "go"],
"41\n",
);
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
assert!(
serde_json::from_str::<serde_json::Value>(line).is_ok(),
"not JSON: {line}"
);
}
assert_eq!(stderr.lines().count(), 2, "the event and the done frame");
assert_eq!(fake.requests(), Vec::<Message>::new());
}
/// The answer on stdout is the model's text too.
#[test]
fn the_answer_on_stdout_is_printed_as_data() {
let loopd = fake_loopd(Vec::new(), "a\x1b[8mb\n\tc");
let fake = broker();
let output = chat(&loopd.socket, &fake.socket, &["--say", "go"], "");
assert!(output.status.success());
assert_eq!(
String::from_utf8_lossy(&output.stdout),
format!("a{}[8mb\n\tc\n", esc(0x1b))
);
}
@@ -0,0 +1,298 @@
//! `bxctl chat --speaker NAME` (M5c-1 task 04): the name goes on every turn of the chat, the
//! interactive ones and `--say`'s, the retried turn too; without the flag no turn has one; a bad
//! name is a usage error and nothing is sent. Every `bxctl` run has a deadline, and every path is
//! removed by a guard. Do not edit.
#[path = "support/temp.rs"]
mod temp;
use std::io::{ErrorKind, Write};
use std::os::unix::net::UnixListener;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use bxcore::{
Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId, Turn, TurnDone, Usage, WireError,
read_frame, write_frame,
};
use bxctl::chat::{Approvals, OnPending, Printer, TurnIo, run_turn, stream_turn};
/// A fake `loopd` that records every turn it is sent and answers `ok`. A resumed turn whose text
/// is `trigger-no-such-session` is refused with `no_such_session`, as `loopd` refuses a session
/// that does not exist. Its home, which holds the socket, is removed on drop.
struct FakeLoopd {
socket: PathBuf,
turns: Arc<Mutex<Vec<Turn>>>,
_home: temp::TempDir,
}
impl FakeLoopd {
fn turns(&self) -> Vec<Turn> {
self.turns.lock().unwrap().clone()
}
}
fn fake_loopd() -> FakeLoopd {
let home = temp::TempDir::new("bxctl-speaker");
let socket = home.join("loop.sock");
let listener = UnixListener::bind(&socket).unwrap();
let turns = Arc::new(Mutex::new(Vec::new()));
let seen = Arc::clone(&turns);
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
// A client that never writes cannot hold the fake forever.
let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
let Ok(request) = read_frame(&mut stream) else {
continue;
};
let Message::Turn(turn) = request.msg else {
continue;
};
seen.lock().unwrap().push(turn.clone());
let msg = if turn.resume && turn.content == "trigger-no-such-session" {
Message::Error(WireError {
code: ErrorCode::NoSuchSession,
detail: "no such session".to_string(),
})
} else {
Message::TurnDone(TurnDone {
content: "ok".to_string(),
usage: Usage {
cache_n: 1,
prompt_n: 1,
predicted_n: 1,
reasoning_tokens: 0,
thinking_capped: false,
},
untrusted: false,
})
};
let _ = write_frame(
&mut stream,
&Envelope {
v: PROTOCOL_VERSION,
id: request.id,
r#final: true,
msg,
},
);
}
});
FakeLoopd {
socket,
turns,
_home: home,
}
}
/// What a run of `bxctl` gave.
struct Run {
code: Option<i32>,
stdout: String,
stderr: String,
}
/// Runs `bxctl chat --socket <fake> <args>`, writing `stdin` to its standard input, with
/// `$BOXMAKER_HOME` an empty directory removed afterwards. Kills it and fails after 20 s.
fn chat(fake: &FakeLoopd, args: &[&str], stdin: &str) -> Run {
let home = temp::TempDir::new("bxctl-speaker-home");
let mut child = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.arg("chat")
.arg("--socket")
.arg(&fake.socket)
.args(args)
.env("BOXMAKER_HOME", &home)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
{
let mut input = child.stdin.take().unwrap();
match input.write_all(stdin.as_bytes()) {
Ok(()) => {}
// bxctl exited without reading it all: its exit status and output say why.
Err(e) if e.kind() == ErrorKind::BrokenPipe => {}
Err(e) => panic!("cannot write bxctl's standard input: {e}"),
}
}
let deadline = Instant::now() + Duration::from_secs(20);
while child.try_wait().unwrap().is_none() {
if Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
panic!("bxctl chat {args:?} did not exit within 20 s");
}
thread::sleep(Duration::from_millis(10));
}
let output = child.wait_with_output().unwrap();
Run {
code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
}
}
fn speakers(fake: &FakeLoopd) -> Vec<Option<String>> {
fake.turns().into_iter().map(|turn| turn.speaker).collect()
}
fn some(name: &str) -> Option<String> {
Some(name.to_string())
}
#[test]
fn run_turn_puts_the_speaker_on_the_turn() {
let fake = fake_loopd();
let session = SessionId::new("s1").unwrap();
run_turn(
&fake.socket,
&session,
"hi",
false,
Some("first.last"),
&mut |_| {},
)
.unwrap();
run_turn(&fake.socket, &session, "hi", true, None, &mut |_| {}).unwrap();
assert_eq!(
fake.turns(),
vec![
Turn {
session: session.clone(),
content: "hi".to_string(),
resume: false,
speaker: some("first.last"),
},
Turn {
session,
content: "hi".to_string(),
resume: true,
speaker: None,
},
]
);
}
#[test]
fn stream_turn_passes_the_speaker_on() {
let fake = fake_loopd();
let admin = fake.socket.with_file_name("admin.sock");
let approvals = Approvals {
admin_socket: &admin,
on_pending: OnPending::EventOnly,
};
let mut printer = Printer::new(true, false);
let mut input = std::io::Cursor::new(Vec::new());
let mut out = Vec::new();
let session = SessionId::new("s1").unwrap();
for speaker in [Some("kyle"), None] {
let mut io = TurnIo {
printer: &mut printer,
input: &mut input,
out: &mut out,
};
let done = stream_turn(
&fake.socket,
&session,
"hi",
false,
speaker,
&approvals,
&mut io,
)
.unwrap()
.unwrap();
assert_eq!(done.content, "ok");
}
assert_eq!(speakers(&fake), vec![some("kyle"), None]);
}
#[test]
fn say_sends_the_speaker_as_it_was_typed() {
let fake = fake_loopd();
let run = chat(&fake, &["--speaker", "first.last", "--say", "hi"], "");
assert_eq!(run.code, Some(0), "{}", run.stderr);
assert_eq!(run.stdout, "ok\n");
assert_eq!(speakers(&fake), vec![some("first.last")]);
assert_eq!(fake.turns()[0].content, "hi", "the text is not changed");
}
#[test]
fn without_the_flag_no_turn_has_a_speaker() {
let fake = fake_loopd();
let run = chat(&fake, &["--say", "hi"], "");
assert_eq!(run.code, Some(0), "{}", run.stderr);
let run = chat(&fake, &[], "first\nsecond\n/quit\n");
assert_eq!(run.code, Some(0), "{}", run.stderr);
assert_eq!(speakers(&fake), vec![None, None, None]);
}
/// `--say` on a named session that does not exist sends the turn twice: resumed, refused, then
/// created. Both carry the speaker.
#[test]
fn the_retried_turn_keeps_the_speaker() {
let fake = fake_loopd();
let run = chat(
&fake,
&[
"--session",
"fresh",
"--speaker",
"kyle",
"--say",
"trigger-no-such-session",
],
"",
);
assert_eq!(run.code, Some(0), "{}", run.stderr);
let turns = fake.turns();
assert_eq!(turns.len(), 2, "resumed, refused, then created");
assert!(turns[0].resume && !turns[1].resume);
assert_eq!(speakers(&fake), vec![some("kyle"), some("kyle")]);
}
/// Every turn of an interactive chat carries the speaker, not only the one that creates the
/// session, and one that follows a failed turn too.
#[test]
fn every_interactive_turn_has_the_speaker() {
let fake = fake_loopd();
let run = chat(
&fake,
&["--speaker", "kyle"],
"first\nsecond\ntrigger-no-such-session\nfourth\n/quit\n",
);
assert_eq!(run.code, Some(0), "{}", run.stderr);
assert!(run.stderr.contains("no such session"), "{}", run.stderr);
let turns = fake.turns();
assert_eq!(turns.len(), 4, "{turns:?}");
assert!(!turns[0].resume && turns[1].resume && turns[3].resume);
assert_eq!(speakers(&fake), vec![some("kyle"); 4]);
}
/// A bad name stops `bxctl` before it connects: the usage text on stderr, status 2, nothing on
/// stdout, and no turn sent.
#[test]
fn a_bad_speaker_is_a_usage_error_and_sends_nothing() {
let fake = fake_loopd();
for args in [
vec!["--speaker", "", "--say", "hi"],
vec!["--speaker", "first last", "--say", "hi"],
vec!["--speaker", "kyle\t", "--say", "hi"],
vec!["--speaker", "ky\u{1b}le", "--say", "hi"],
vec!["--speaker", "kyle", "--speaker", "eve", "--say", "hi"],
vec!["--say", "hi", "--speaker"],
vec!["--speaker", "first last"],
] {
let run = chat(&fake, &args, "hello\n/quit\n");
assert_eq!(run.code, Some(2), "{args:?}");
assert_eq!(run.stdout, "", "{args:?}");
assert!(run.stderr.contains("[--speaker NAME]"), "{args:?}");
assert!(run.stderr.contains("bxctl approve <id>"), "{args:?}");
}
assert!(fake.turns().is_empty(), "{:?}", fake.turns());
}
@@ -0,0 +1,366 @@
//! Tests for `bxctl`'s command line. Do not edit.
use bxcore::SessionId;
use bxctl::cli::{ChatOptions, Command, USAGE, UsageError, parse};
use std::path::{Path, PathBuf};
use std::process::Command as Process;
const HOME: &str = "/srv/bx";
fn args(words: &[&str]) -> Vec<String> {
words.iter().map(|w| w.to_string()).collect()
}
fn ok(words: &[&str]) -> Command {
parse(&args(words), Path::new(HOME)).unwrap_or_else(|_| panic!("{words:?} must parse"))
}
fn bad(words: &[&str]) {
assert_eq!(
parse(&args(words), Path::new(HOME)),
Err(UsageError),
"{words:?} must be a usage error"
);
}
fn default_admin() -> PathBuf {
PathBuf::from("/srv/bx/run/owner-broker/admin.sock")
}
#[test]
fn chat_defaults_come_from_home() {
assert_eq!(
ok(&["chat"]),
Command::Chat(ChatOptions {
socket: PathBuf::from("/srv/bx/run/loop/loop.sock"),
admin_socket: default_admin(),
session: None,
show_thinking: true,
say: None,
json: false,
speaker: None,
})
);
}
#[test]
fn chat_takes_every_flag_in_any_order() {
assert_eq!(
ok(&[
"chat",
"--json",
"--admin-socket",
"/tmp/a.sock",
"--say",
"hello there",
"--no-thinking",
"--session",
"s-1",
"--speaker",
"kyle",
"--socket",
"/tmp/l.sock",
]),
Command::Chat(ChatOptions {
socket: PathBuf::from("/tmp/l.sock"),
admin_socket: PathBuf::from("/tmp/a.sock"),
session: Some(SessionId::new("s-1").unwrap()),
show_thinking: false,
say: Some("hello there".to_string()),
json: true,
speaker: Some("kyle".to_string()),
})
);
}
#[test]
fn chat_usage_errors() {
bad(&["chat", "--session", "Not Valid!"]);
bad(&["chat", "--session"]);
bad(&["chat", "--socket"]);
bad(&["chat", "--admin-socket"]);
bad(&["chat", "--say"]);
bad(&["chat", "--dance"]);
bad(&["chat", "stray"]);
}
/// `chat` with only `--speaker NAME`: everything else at its default.
fn chat_as(name: &str) -> Command {
let mut opts = ChatOptions::default(Path::new(HOME));
opts.speaker = Some(name.to_string());
Command::Chat(opts)
}
/// The speaker is the argument after `--speaker`, kept as it is: a `.` is not mapped, letters
/// outside ASCII are kept (`memoryd` checks Honcho's pattern, not `bxctl`), and a value that
/// looks like a flag is a value.
#[test]
fn chat_speaker_is_the_value_after_the_flag() {
assert_eq!(ok(&["chat", "--speaker", "kyle"]), chat_as("kyle"));
assert_eq!(
ok(&["chat", "--speaker", "first.last"]),
chat_as("first.last")
);
assert_eq!(ok(&["chat", "--speaker", "zo\u{eb}"]), chat_as("zo\u{eb}"));
assert_eq!(ok(&["chat", "--speaker", "--json"]), chat_as("--json"));
assert_eq!(ok(&["chat", "--speaker", "k"]), chat_as("k"));
let Command::Chat(opts) = ok(&["chat", "--speaker", "kyle", "--json"]) else {
panic!("not chat")
};
assert_eq!(opts.speaker.as_deref(), Some("kyle"));
assert!(opts.json, "the flag after the name is read as a flag");
}
/// A speaker that is empty, or holds whitespace or a control character anywhere, is a usage
/// error; so are a missing value and a second `--speaker`, even with the same name.
#[test]
fn a_bad_speaker_is_a_usage_error() {
for name in [
"",
" ",
"first last",
" kyle",
"kyle ",
"kyle\t",
"kyle\n",
"\rkyle",
"ky\u{a0}le",
"ky\u{2003}le",
"ky\u{3000}le",
"ky\u{7}le",
"ky\u{1b}le",
"ky\u{7f}le",
"ky\u{85}le",
"ky\u{9b}le",
] {
bad(&["chat", "--speaker", name]);
}
bad(&["chat", "--speaker"]);
bad(&["chat", "--json", "--speaker"]);
bad(&["chat", "--speaker", "kyle", "--speaker", "eve"]);
bad(&["chat", "--speaker", "kyle", "--speaker", "kyle"]);
}
#[test]
fn approvals() {
assert_eq!(
ok(&["approvals"]),
Command::Approvals {
admin_socket: default_admin()
}
);
assert_eq!(
ok(&["approvals", "--admin-socket", "/tmp/a.sock"]),
Command::Approvals {
admin_socket: PathBuf::from("/tmp/a.sock")
}
);
bad(&["approvals", "41"]);
bad(&["approvals", "--admin-socket"]);
bad(&["approvals", "--reason", "x"]);
}
#[test]
fn approve() {
assert_eq!(
ok(&["approve", "41"]),
Command::Approve {
admin_socket: default_admin(),
approval: 41
}
);
// The flag may come before or after the id.
for words in [
["approve", "--admin-socket", "/tmp/a.sock", "41"],
["approve", "41", "--admin-socket", "/tmp/a.sock"],
] {
assert_eq!(
ok(&words),
Command::Approve {
admin_socket: PathBuf::from("/tmp/a.sock"),
approval: 41
}
);
}
assert_eq!(
ok(&["approve", "18446744073709551615"]),
Command::Approve {
admin_socket: default_admin(),
approval: u64::MAX
}
);
}
/// An id is decimal digits and nothing else. `str::parse::<u64>` alone would accept `+41`.
#[test]
fn an_approval_id_is_only_digits() {
bad(&["approve"]);
bad(&["approve", "41", "42"]);
bad(&["approve", "+41"]);
bad(&["approve", "-1"]);
bad(&["approve", "4 1"]);
bad(&["approve", " 41"]);
bad(&["approve", "0x29"]);
bad(&["approve", "forty-one"]);
bad(&["approve", ""]);
bad(&["approve", "18446744073709551616"]);
bad(&["approve", "41", "--reason", "x"]);
bad(&["refuse"]);
bad(&["refuse", "+41"]);
bad(&["refuse", "41", "42"]);
}
#[test]
fn refuse() {
assert_eq!(
ok(&["refuse", "41"]),
Command::Refuse {
admin_socket: default_admin(),
approval: 41,
reason: None
}
);
assert_eq!(
ok(&[
"refuse",
"41",
"--reason",
"not on a Friday",
"--admin-socket",
"/tmp/a.sock"
]),
Command::Refuse {
admin_socket: PathBuf::from("/tmp/a.sock"),
approval: 41,
reason: Some("not on a Friday".to_string())
}
);
// A value is a value, even when it looks like a flag.
assert_eq!(
ok(&["refuse", "--reason", "--admin-socket", "41"]),
Command::Refuse {
admin_socket: default_admin(),
approval: 41,
reason: Some("--admin-socket".to_string())
}
);
bad(&["refuse", "41", "--reason"]);
bad(&["refuse", "41", "--reason", "a", "--reason", "b"]);
}
#[test]
fn grants_check_and_audit_verify() {
assert_eq!(
ok(&["grants", "check"]),
Command::GrantsCheck {
admin_socket: default_admin()
}
);
assert_eq!(
ok(&["grants", "check", "--admin-socket", "/tmp/a.sock"]),
Command::GrantsCheck {
admin_socket: PathBuf::from("/tmp/a.sock")
}
);
assert_eq!(
ok(&["audit", "verify"]),
Command::AuditVerify {
home: PathBuf::from(HOME)
}
);
assert_eq!(
ok(&["audit", "verify", "--home", "/tmp/h"]),
Command::AuditVerify {
home: PathBuf::from("/tmp/h")
}
);
bad(&["grants"]);
bad(&["grants", "list"]);
bad(&["grants", "check", "extra"]);
bad(&["audit"]);
bad(&["audit", "verify", "--home"]);
bad(&["audit", "verify", "--admin-socket", "/tmp/a.sock"]);
}
#[test]
fn anything_else_is_a_usage_error() {
bad(&[]);
bad(&["dance"]);
bad(&["--admin-socket", "/tmp/a.sock", "approvals"]);
}
#[test]
fn usage_names_every_command() {
for word in [
"bxctl chat",
"bxctl approvals",
"bxctl approve <id>",
"bxctl refuse <id>",
"bxctl grants check",
"bxctl audit verify",
"--admin-socket",
"--reason",
"--home",
] {
assert!(USAGE.contains(word), "usage lacks {word:?}");
}
}
/// The chat lines of the usage text, exactly, and the indentation of every line: two spaces
/// before a command, six before its explanation.
#[test]
fn usage_explains_the_speaker_flag() {
let chat = concat!(
"\n bxctl chat [--socket PATH] [--admin-socket PATH] [--session ID] [--say TEXT] ",
"[--no-thinking] [--json] [--speaker NAME]\n",
" Talk to loopd.\n",
" With --speaker NAME (no spaces), the turns are NAME's and are synced to Honcho as ",
"that person.\n\n bxctl approvals ",
);
assert!(USAGE.contains(chat), "{USAGE}");
for line in USAGE.lines().skip(1) {
let command = line.starts_with(" bxctl ");
let explanation = line.starts_with(" ") && !line.starts_with(" ");
assert!(
line.is_empty() || command || explanation,
"badly indented: {line:?}"
);
}
}
#[test]
fn the_binary_prints_usage_and_exits_2() {
for words in [
vec![],
vec!["dance"],
vec!["approve", "+41"],
vec!["refuse"],
] {
let output = Process::new(env!("CARGO_BIN_EXE_bxctl"))
.args(&words)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2), "{words:?}");
assert_eq!(String::from_utf8_lossy(&output.stdout), "", "{words:?}");
assert!(
String::from_utf8_lossy(&output.stderr).contains("bxctl approve <id>"),
"{words:?}"
);
}
}
/// The default sockets are under `$BOXMAKER_HOME`. Nothing listens there, so the command fails
/// to connect, and says where it tried.
#[test]
fn the_binary_finds_the_admin_socket_under_boxmaker_home() {
let home = std::env::temp_dir().join(format!("bxctl-cli-home-{}", std::process::id()));
let output = Process::new(env!("CARGO_BIN_EXE_bxctl"))
.arg("approvals")
.env("BOXMAKER_HOME", &home)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
let want = home.join("run/owner-broker/admin.sock");
assert!(stderr.contains(want.to_str().unwrap()), "{stderr}");
}