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>
419 lines
17 KiB
Markdown
419 lines
17 KiB
Markdown
# 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.
|