From 5454d7ea1b867f1fff38659b237e6a1a112a98ba Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Mon, 28 Sep 2026 01:04:27 -0700 Subject: [PATCH] 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) --- docs/plans/M5c1/01-wire-speaker-state.md | 6 +- docs/plans/M5c1/04-bxctl-speaker.md | 418 +++++++++++++++ .../M5c1/files/crates/bxctl/tests/chat.rs | 7 +- .../files/crates/bxctl/tests/chat.rs-task01 | 470 +++++++++++++++++ .../crates/bxctl/tests/chat_approvals.rs | 488 ++++++++++++++++++ .../files/crates/bxctl/tests/chat_speaker.rs | 298 +++++++++++ .../M5c1/files/crates/bxctl/tests/cli.rs | 366 +++++++++++++ 7 files changed, 2048 insertions(+), 5 deletions(-) create mode 100644 docs/plans/M5c1/04-bxctl-speaker.md create mode 100644 docs/plans/M5c1/files/crates/bxctl/tests/chat.rs-task01 create mode 100644 docs/plans/M5c1/files/crates/bxctl/tests/chat_approvals.rs create mode 100644 docs/plans/M5c1/files/crates/bxctl/tests/chat_speaker.rs create mode 100644 docs/plans/M5c1/files/crates/bxctl/tests/cli.rs diff --git a/docs/plans/M5c1/01-wire-speaker-state.md b/docs/plans/M5c1/01-wire-speaker-state.md index 002d7eb..e7e87af 100644 --- a/docs/plans/M5c1/01-wire-speaker-state.md +++ b/docs/plans/M5c1/01-wire-speaker-state.md @@ -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/ diff --git a/docs/plans/M5c1/04-bxctl-speaker.md b/docs/plans/M5c1/04-bxctl-speaker.md new file mode 100644 index 0000000..2deb708 --- /dev/null +++ b/docs/plans/M5c1/04-bxctl-speaker.md @@ -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`, 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`; + `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` (exists) | + | `parse_speaker` | `fn parse_speaker(value: &str) -> Result` (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` to `Option<&str>` | + | `Option::map` | `speaker.map(str::to_string)`: `Option<&str>` to `Option` | + | `run_turn` | `pub fn run_turn(socket: &Path, session: &SessionId, content: &str, resume: bool, speaker: Option<&str>, on_event: &mut dyn FnMut(&TurnEvent)) -> Result` (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>` (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, + pub json: bool, +} +``` + +become + +```rust + pub say: Option, + 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, +} +``` + +**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 { + 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 { +``` + +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 { +``` + +**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> { + 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> { + 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. diff --git a/docs/plans/M5c1/files/crates/bxctl/tests/chat.rs b/docs/plans/M5c1/files/crates/bxctl/tests/chat.rs index 67388fa..14b5580 100644 --- a/docs/plans/M5c1/files/crates/bxctl/tests/chat.rs +++ b/docs/plans/M5c1/files/crates/bxctl/tests/chat.rs @@ -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 = - 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(_)) )); } diff --git a/docs/plans/M5c1/files/crates/bxctl/tests/chat.rs-task01 b/docs/plans/M5c1/files/crates/bxctl/tests/chat.rs-task01 new file mode 100644 index 0000000..67388fa --- /dev/null +++ b/docs/plans/M5c1/files/crates/bxctl/tests/chat.rs-task01 @@ -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>>, + _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, 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 { + 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 = + 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); +} diff --git a/docs/plans/M5c1/files/crates/bxctl/tests/chat_approvals.rs b/docs/plans/M5c1/files/crates/bxctl/tests/chat_approvals.rs new file mode 100644 index 0000000..0690a66 --- /dev/null +++ b/docs/plans/M5c1/files/crates/bxctl/tests/chat_approvals.rs @@ -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 { + 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, + 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 = 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::::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::(line).is_ok(), + "not JSON: {line}" + ); + } + assert_eq!(stderr.lines().count(), 2, "the event and the done frame"); + assert_eq!(fake.requests(), Vec::::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)) + ); +} diff --git a/docs/plans/M5c1/files/crates/bxctl/tests/chat_speaker.rs b/docs/plans/M5c1/files/crates/bxctl/tests/chat_speaker.rs new file mode 100644 index 0000000..75bfa5b --- /dev/null +++ b/docs/plans/M5c1/files/crates/bxctl/tests/chat_speaker.rs @@ -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>>, + _home: temp::TempDir, +} + +impl FakeLoopd { + fn turns(&self) -> Vec { + 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, + stdout: String, + stderr: String, +} + +/// Runs `bxctl chat --socket `, 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> { + fake.turns().into_iter().map(|turn| turn.speaker).collect() +} + +fn some(name: &str) -> Option { + 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 "), "{args:?}"); + } + assert!(fake.turns().is_empty(), "{:?}", fake.turns()); +} diff --git a/docs/plans/M5c1/files/crates/bxctl/tests/cli.rs b/docs/plans/M5c1/files/crates/bxctl/tests/cli.rs new file mode 100644 index 0000000..491fb46 --- /dev/null +++ b/docs/plans/M5c1/files/crates/bxctl/tests/cli.rs @@ -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 { + 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::` 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 ", + "bxctl refuse ", + "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 "), + "{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}"); +}