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
+4 -2
View File
@@ -70,7 +70,8 @@ This is the complete list. Each one is covered by an edit below or by a given fi
`crates/loopd/tests/baseline.rs`, `channel.rs`, `instance.rs`, `memory.rs`,
`neutralise_replay.rs`, `session.rs`, `turn_done_untrusted.rs`;
`crates/memoryd/tests/serve.rs`, `crates/memoryd/tests/support/logs.rs`. Your `loopd`
`memory.rs` is the given `memory.rs-task01`: the given `memory.rs` is task 02's.
`memory.rs` is the given `memory.rs-task01`: the given `memory.rs` is task 02's. Your `bxctl`
`chat.rs` is the given `chat.rs-task01`: the given `chat.rs` is task 04's.
- Do not copy anything under `docs/plans/M5c1/files/crates/memoryd/tests/fixtures/honcho/`:
it is for later tasks.
- Modify: `crates/bxcore/src/wire.rs`, `crates/bxcore/src/log.rs`, `crates/bxcore/src/memory.rs`,
@@ -426,7 +427,8 @@ becomes
cp docs/plans/M5c1/files/crates/bxcore/tests/records.rs crates/bxcore/tests/
cp docs/plans/M5c1/files/crates/bxcore/tests/strict.rs crates/bxcore/tests/
cp docs/plans/M5c1/files/crates/brokerd/tests/broker.rs crates/brokerd/tests/
cp docs/plans/M5c1/files/crates/bxctl/tests/chat.rs crates/bxctl/tests/
cp docs/plans/M5c1/files/crates/bxctl/tests/chat.rs-task01 \
crates/bxctl/tests/chat.rs
cp docs/plans/M5c1/files/crates/loopd/tests/baseline.rs crates/loopd/tests/
cp docs/plans/M5c1/files/crates/loopd/tests/channel.rs crates/loopd/tests/
cp docs/plans/M5c1/files/crates/loopd/tests/instance.rs crates/loopd/tests/
+418
View File
@@ -0,0 +1,418 @@
# M5c-1 task 04: `bxctl chat --speaker NAME`
**Branch:** `m5c1` (run `git switch m5c1`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `bxctl: chat --speaker NAME, the speaker on every turn of the chat`
**Log row:** the Task cell is `M5c1/04-bxctl-speaker`.
## Goal
Task 01 gave `Turn` a field `speaker: Option<String>`, task 02 made `loopd` log it, and task 03
made `gatewayd` set it. `memoryd` will send a turn to Honcho only when it has a speaker (M5c spec,
sections 4 and 11). `bxctl chat`, the owner's console, still sends `speaker: None` on every turn.
This task gives it a flag, `--speaker NAME`.
The owner's rule: **the speaker is opt-in, with no default.** Console turns are mostly tests, and
Honcho cannot forget a turn once it has it. So:
| The command line | `Turn.speaker` on the chat's turns |
|---|---|
| `bxctl chat --speaker kyle` (interactive) | `Some("kyle")` on **every** turn, not only the first |
| `bxctl chat --speaker kyle --say hi` | `Some("kyle")`, also on the turn sent again when a named session did not exist |
| `bxctl chat --speaker first.last` | `Some("first.last")`: the name as typed, a `.` is **not** changed |
| `bxctl chat` or `bxctl chat --say hi`, no flag | `None`, as today |
No default, no environment variable, no configuration: only the flag.
**The name's rule, exactly.** `bxctl` refuses only what cannot be a name at all. The value is
refused when it is **empty**, or when **any** character in it is whitespace
(`char::is_whitespace`, which includes the tab, the line feed, the no-break space U+00A0 and the
other Unicode spaces) or a control character (`char::is_control`: U+0000 to U+001F, U+007F and
U+0080 to U+009F). Anything else is kept exactly as typed: letters outside ASCII, `.`, a leading
`-`. `memoryd` checks the name against Honcho's own pattern in a later task. A second `--speaker`
is refused too, even with the same name: one of two names would be the wrong person.
**A refused value is a usage error**, like every other bad argument of `bxctl`: `parse` returns
`UsageError`, and `main` (unchanged) prints the usage text on stderr and exits with status 2,
before it connects to anything. So nothing is sent.
**The usage text** gains the flag at the end of the chat line and one line under "Talk to loopd."
that says what it does. Every explanation line of the usage text is indented by exactly six
spaces, every command line by exactly two; a given test checks every line.
## Every place that changes
This is the complete list.
| Place | What happens there |
|---|---|
| `crates/bxctl/src/cli.rs`, `USAGE` | the flag in the chat line, one line explaining it (edit C1) |
| `crates/bxctl/src/cli.rs`, `ChatOptions` and `ChatOptions::default` | new field `speaker`, `None` by default (edits C2, C3) |
| `crates/bxctl/src/cli.rs`, `parse_chat`, and a new `parse_speaker` | the flag, its check, a second one refused (edits C4, C5) |
| `crates/bxctl/src/chat.rs`, `run_turn` | a new parameter `speaker: Option<&str>`, after `resume`, put on the `Turn` (edits R1, R2) |
| `crates/bxctl/src/chat.rs`, `stream_turn` | the same parameter, passed to `run_turn` (edit R3) |
| `crates/bxctl/src/main.rs`, `run_turn_twice` and `run_interactive` | pass `opts.speaker.as_deref()` at all three calls of `stream_turn` (edits M1 to M3) |
| `crates/bxctl/src/main.rs`, `main` | **no change**: a `UsageError` already prints the usage text and exits 2 |
| tests that build a `ChatOptions` or call `run_turn` or `stream_turn` | new versions are given |
## Files
- Copy, from `docs/plans/M5c1/files/` (the same path under it):
- new: `crates/bxctl/tests/chat_speaker.rs`
- **new versions** of (they replace the files in the tree): `crates/bxctl/tests/chat.rs`,
`chat_approvals.rs` and `cli.rs`. Do not copy `chat.rs-task01`: it is the older version.
- Modify: `crates/bxctl/src/cli.rs`, `crates/bxctl/src/chat.rs`, `crates/bxctl/src/main.rs`,
`docs/implementer-log.md`.
- Do not touch: any other source file (`main`'s handling of a usage error in particular), any
file of another crate, any `Cargo.toml`, `crates/bxctl/tests/support/`.
## Facts you need
- **How `parse_chat` reads a flag with a value.** Its loop matches `args[i]`. An arm like
`"--say" => opts.say = Some(value_at(args, i)?)` takes the value, and the `i += 2` after the
`match` steps over the flag and its value. `value_at(args, i)` is `args[i + 1]`, or `UsageError`
when there is none; it takes the next argument even when it looks like a flag, so
`--speaker --json` is the name `--json` (a given test says so). The new arm is a block, because
it first refuses a second `--speaker`, and it falls through to the same `i += 2`: no `continue`.
- **Why the parameter is `Option<&str>`.** `ChatOptions` owns the name as `Option<String>`;
`opts.speaker.as_deref()` lends it as `Option<&str>`, and `run_turn` makes the `Turn`'s own
copy with `speaker.map(str::to_string)`, as `loopd` does since task 02.
- **The three calls in `main.rs`.** `run_turn_twice` (the `--say` path) calls `stream_turn`
twice: once, and again with `resume` false when `loopd` answers that the named session does not
exist. `run_interactive` calls it once per line. All three pass the speaker.
- **Seven arguments.** `stream_turn` goes from six parameters to seven. Clippy's
`too_many_arguments` fires only above seven, so there is no warning and no `#[allow]`.
- **The usage line is long.** The chat line of `USAGE` is already over 100 characters, inside a
string; `cargo fmt` does not touch a string's contents, and the line-length check of the gate
counts lines per file, not their width. Copy C1 exactly: its spaces are checked.
- **The given tests' processes and paths.** `chat_speaker.rs` starts `bxctl` with a 20-second
deadline, and every socket and directory it makes is removed by a guard (`support/temp.rs`).
- The calls the edits use:
| Call | Signature, and what it gives |
|---|---|
| `value_at` | `fn value_at(args: &[String], i: usize) -> Result<String, UsageError>` (exists) |
| `parse_speaker` | `fn parse_speaker(value: &str) -> Result<String, UsageError>` (new, C5) |
| `str::is_empty` | `value.is_empty() -> bool` |
| `str::chars`, `Iterator::any` | `value.chars().any(\|c\| …) -> bool`: true when one character matches |
| `char::is_whitespace`, `char::is_control` | `c.is_whitespace() -> bool`, `c.is_control() -> bool` (Unicode, not only ASCII) |
| `Option::as_deref` | `opts.speaker.as_deref()`: `Option<String>` to `Option<&str>` |
| `Option::map` | `speaker.map(str::to_string)`: `Option<&str>` to `Option<String>` |
| `run_turn` | `pub fn run_turn(socket: &Path, session: &SessionId, content: &str, resume: bool, speaker: Option<&str>, on_event: &mut dyn FnMut(&TurnEvent)) -> Result<TurnDone, ChatError>` (R1) |
| `stream_turn` | `pub fn stream_turn(socket: &Path, session: &SessionId, text: &str, resume: bool, speaker: Option<&str>, approvals: &Approvals<'_>, io: &mut TurnIo<'_>) -> std::io::Result<Result<TurnDone, ChatError>>` (R3) |
## The edits, exactly
Nothing else in these files changes. Each quoted "before" block occurs exactly once in its file.
### `crates/bxctl/src/cli.rs`
**C1.** In `USAGE`, the lines
```rust
bxctl chat [--socket PATH] [--admin-socket PATH] [--session ID] [--say TEXT] [--no-thinking] [--json]
Talk to loopd.
```
become
```rust
bxctl chat [--socket PATH] [--admin-socket PATH] [--session ID] [--say TEXT] [--no-thinking] [--json] [--speaker NAME]
Talk to loopd.
With --speaker NAME (no spaces), the turns are NAME's and are synced to Honcho as that person.
```
The chat line starts with two spaces; each of the two lines under it with six.
**C2.** In `struct ChatOptions`, the lines
```rust
pub say: Option<String>,
pub json: bool,
}
```
become
```rust
pub say: Option<String>,
pub json: bool,
/// Who is typing, from `--speaker`, sent on every turn (M5c spec, section 11). There is no
/// default: without the flag it is `None`, and a turn without a speaker is not synced.
pub speaker: Option<String>,
}
```
**C3.** In `ChatOptions::default`, the lines
```rust
say: None,
json: false,
}
```
become
```rust
say: None,
json: false,
speaker: None,
}
```
**C4.** In `parse_chat`, the lines
```rust
"--say" => opts.say = Some(value_at(args, i)?),
"--no-thinking" => {
```
become
```rust
"--say" => opts.say = Some(value_at(args, i)?),
// Given twice, it is refused rather than one of them chosen: the turns would be synced
// as the wrong person.
"--speaker" => {
if opts.speaker.is_some() {
return Err(UsageError);
}
opts.speaker = Some(parse_speaker(&value_at(args, i)?)?);
}
"--no-thinking" => {
```
**C5.** The line
```rust
// A refuse reason must be present; its content is unvalidated on the client.
```
becomes
```rust
// A speaker must be a name: not empty, and no whitespace or control character anywhere. The rest,
// Honcho's pattern, is `memoryd`'s to check (M5c spec, section 3); a `.` is kept as it is.
fn parse_speaker(value: &str) -> Result<String, UsageError> {
if value.is_empty() || value.chars().any(|c| c.is_whitespace() || c.is_control()) {
return Err(UsageError);
}
Ok(value.to_string())
}
// A refuse reason must be present; its content is unvalidated on the client.
```
### `crates/bxctl/src/chat.rs`
**R1.** The lines
```rust
/// Sends one turn and reads the reply. `on_event` sees every event frame as it arrives.
pub fn run_turn(
socket: &Path,
session: &SessionId,
content: &str,
resume: bool,
on_event: &mut dyn FnMut(&TurnEvent),
) -> Result<TurnDone, ChatError> {
```
become
```rust
/// Sends one turn and reads the reply. `on_event` sees every event frame as it arrives. `speaker`
/// is `--speaker`'s value, sent as it is; `None` sends none.
pub fn run_turn(
socket: &Path,
session: &SessionId,
content: &str,
resume: bool,
speaker: Option<&str>,
on_event: &mut dyn FnMut(&TurnEvent),
) -> Result<TurnDone, ChatError> {
```
**R2.** In `run_turn`, the lines
```rust
// Set in M5c-1 task 04, from `--speaker`. Until then no turn has a speaker.
speaker: None,
```
become
```rust
speaker: speaker.map(str::to_string),
```
**R3.** In `stream_turn`, the lines
```rust
resume: bool,
approvals: &Approvals<'_>,
io: &mut TurnIo<'_>,
) -> std::io::Result<Result<TurnDone, ChatError>> {
let mut err = None;
let outcome = run_turn(socket, session, text, resume, &mut |event| {
```
become
```rust
resume: bool,
speaker: Option<&str>,
approvals: &Approvals<'_>,
io: &mut TurnIo<'_>,
) -> std::io::Result<Result<TurnDone, ChatError>> {
let mut err = None;
let outcome = run_turn(socket, session, text, resume, speaker, &mut |event| {
```
### `crates/bxctl/src/main.rs`
**M1.** In `run_turn_twice`, the line
```rust
match stream_turn(&opts.socket, session, text, resume, &approvals, &mut io) {
```
becomes
```rust
match stream_turn(
&opts.socket,
session,
text,
resume,
opts.speaker.as_deref(),
&approvals,
&mut io,
) {
```
**M2.** In `run_turn_twice`, the line
```rust
match stream_turn(&opts.socket, session, text, false, &approvals, &mut io) {
```
becomes
```rust
match stream_turn(
&opts.socket,
session,
text,
false,
opts.speaker.as_deref(),
&approvals,
&mut io,
) {
```
(M1 and M2 are how `cargo fmt` lays the calls out once they have seven arguments.)
**M3.** In `run_interactive`, the lines
```rust
text,
resume,
&approvals,
&mut io,
) {
```
become
```rust
text,
resume,
opts.speaker.as_deref(),
&approvals,
&mut io,
) {
```
## Steps
- [ ] **1. Copy the given files.** From the repository root, four commands:
```sh
cp docs/plans/M5c1/files/crates/bxctl/tests/chat_speaker.rs crates/bxctl/tests/
cp docs/plans/M5c1/files/crates/bxctl/tests/chat.rs crates/bxctl/tests/
cp docs/plans/M5c1/files/crates/bxctl/tests/chat_approvals.rs crates/bxctl/tests/
cp docs/plans/M5c1/files/crates/bxctl/tests/cli.rs crates/bxctl/tests/
```
Then `git status --short` lists 3 modified files and 1 new one, all under
`crates/bxctl/tests/`.
- [ ] **2. See them fail.** `cargo build --tests --keep-going -p bxctl`: four test targets do not
compile: `chat`, `chat_approvals`, `chat_speaker` and `cli`. Every error is about a name this
task adds or changes: `E0061` (``this function takes 5 arguments but 6 arguments were
supplied``, for `run_turn`; ``this function takes 6 arguments but 7 arguments were supplied``,
for `stream_turn`), `E0282` (``type annotations needed``, once, in `chat.rs`, on the closure
passed to `run_turn`), `E0560` (``struct `ChatOptions` has no field named `speaker` ``) or
`E0609` (``no field `speaker` on type `ChatOptions` ``).
- [ ] **3. `cli.rs`.** Make edits C1 to C5. `cargo check -p bxctl`: no error. Then
`cargo test -p bxctl --test cli`: 15 passed.
- [ ] **4. `chat.rs`.** Make edits R1, R2 and R3. `cargo check -p bxctl` now fails in `main.rs`
only, with three errors, one at each call of `stream_turn`: `E0061` (``this function takes 7
arguments but 6 arguments were supplied``). The notes under them also point into `chat.rs`, at
`stream_turn`'s definition; that is where the call is compared, not a second error.
- [ ] **5. `main.rs`.** Make edits M1, M2 and M3. `cargo check -p bxctl`: no error and no
warning.
- [ ] **6. See them pass.** `cargo test -p bxctl --test chat_speaker`: 7 passed. Then
`cargo test -p bxctl`: every suite passes. The new tests:
| Test | What it checks |
|---|---|
| `chat_speaker_is_the_value_after_the_flag` (`cli.rs`) | C4, C5: the name as typed (`first.last`, a name outside ASCII, `--json`) |
| `a_bad_speaker_is_a_usage_error` (`cli.rs`) | C4, C5: empty, whitespace or a control character anywhere, no value, a second `--speaker` |
| `usage_explains_the_speaker_flag` (`cli.rs`) | C1: the chat lines exactly, and the indentation of every line |
| `run_turn_puts_the_speaker_on_the_turn` | R1, R2: `Some` and `None` on the `Turn` |
| `stream_turn_passes_the_speaker_on` | R3 |
| `say_sends_the_speaker_as_it_was_typed` | M1 end to end: `first.last`, and the text unchanged |
| `without_the_flag_no_turn_has_a_speaker` | C3: `--say` and interactive, `None` on every turn |
| `the_retried_turn_keeps_the_speaker` | M2: both turns of a `--say` on a session that does not exist |
| `every_interactive_turn_has_the_speaker` | M3: four turns, one of them failed, all with the speaker |
| `a_bad_speaker_is_a_usage_error_and_sends_nothing` | the binary: status 2, the usage text on stderr, nothing on stdout, no turn sent |
`chat_defaults_come_from_home` and `chat_takes_every_flag_in_any_order` in `cli.rs` also check
`speaker`: `None` by default, and the flag among all the others.
- [ ] **7. Check.** Three commands:
- `grep -rn 'speaker: None' crates/bxctl/src` prints one line, the default in
`ChatOptions::default` (C3): the placeholder in `chat.rs` is gone.
- `git diff --stat -- crates/bxctl/src` lists exactly `chat.rs`, `cli.rs` and `main.rs`.
- `git diff --stat -- crates/bxcore crates/loopd crates/memoryd crates/brokerd crates/gatewayd`
prints nothing.
- [ ] **8. Run the gate.** `cargo fmt --all`, then `make gate`, with a shell timeout of 600000 ms.
Expected last line: `gate: ok`, with 1158 tests: 1148 before this task, plus 10 new ones (three
in `cli.rs`, seven in `chat_speaker.rs`; the new versions of `chat.rs` and `chat_approvals.rs`
keep their counts).
- [ ] **9. Log and commit.** Add your row to `docs/implementer-log.md`; its Task cell is
`M5c1/04-bxctl-speaker`. `git status --short` lists the three sources, the three replaced test
files and your log as modified, and `chat_speaker.rs` as new. Stage with
`git add crates/bxctl docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says.
`Cargo.lock` does not change in this task; the `git add` is harmless.
## Done when
- `make gate` prints `gate: ok` with 1158 tests.
- `git diff HEAD~1 --stat` lists only the three source files of "Modify", the four copied files
and `docs/implementer-log.md`.
## Stop and report if
- A "before" block of any edit is not in its file exactly as shown, or is there more than once.
- Step 2 shows a different result: other test targets, or an error of another kind.
- Step 3's `cargo check` fails, or step 4's fails anywhere other than the three calls of
`stream_turn` in `main.rs`, or with other errors.
- After step 3 or 6, a test fails. Do not change a test: stop, and give the failing tests' names
and their messages.
- The fix seems to need a change anywhere else: in `main`, in another crate, a default speaker, a
speaker from the environment or a configuration, a change to the name (such as `.` to `_`), or
a new kind of error with its own message. It does not; stop and say why you think so.
- A test in `inferproxy`, `toolkit` or `memoryd` fails in the gate: check once whether it fails
without your change, then stop and report both results.
- The gate passes with a count other than 1158.
- A comment in an edit does not fit the code under it. Quote it and stop.
@@ -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}");
}