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:
@@ -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}");
|
||||
}
|
||||
Reference in New Issue
Block a user