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