Files
boxmaker/docs/plans/M2b/09-bxctl-chat.md
T
kyleandClaude Fable 5.1 e156975649 Add M2b plan: ten tasks, tests, recordings and the first system prompt
The tasks build the agent loop on M2a's client: channel messages and
the usage record in proto, four config tables, the tool port and
registry with find_tool and call_tool, the baseline and replay, the
session store, the turn loop with its limits and the append-only
property test, the channel server, loopd serve, bxctl chat, and the
device checks including a four-turn conversation with a restart.

Checked against a private reference implementation: the gate passes
after every task in order, the new suites pass under CPU load, and the
reference passes make verify-device on straylight with no cache loss.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 17:19:02 -07:00

94 lines
5.0 KiB
Markdown

# M2b task 09: `bxctl chat`
**Branch:** `m2b` (run `git switch m2b`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `Add bxctl chat`
## Goal
The owner's chat client: a line in, a turn over `loop.sock`, the stream shown as it arrives. It
is also the scripting tool the device checks use (`--say`, `--json`).
## Files
- Copy: `crates/bxctl/tests/chat.rs`
- Create: `crates/bxctl/src/chat.rs`
- Modify: `crates/bxctl/Cargo.toml` (add `serde_json.workspace = true`), `crates/bxctl/src/lib.rs`
(add `pub mod chat;`), `crates/bxctl/src/main.rs`, `docs/implementer-log.md`
## Interfaces
```rust
// crates/bxctl/src/chat.rs
pub enum ChatError { Connect(std::io::Error), Frame(FrameError), Refused(WireError), Protocol(String) } // Debug; Display; Error
// Refused displays as "<code name>: <detail>", where the code name is lowercase words: "session full", "turn limit", "no such session", …
pub fn new_session_id() -> SessionId; // "chat-<unix seconds>-<nanos>"
/// 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>;
pub struct Printer { pub show_thinking: bool, pub json: bool, pub stream_content: bool, /* private: whether a dimmed block is open */ }
impl Printer {
pub fn new(show_thinking: bool, json: bool) -> Printer; // stream_content: true
pub fn event(&mut self, out: &mut impl Write, event: &TurnEvent) -> std::io::Result<()>;
pub fn end_reasoning(&mut self, out: &mut impl Write) -> std::io::Result<()>; // closes an open dimmed block
}
```
**`run_turn`:** connect; send `Turn { session, content, resume }` as an envelope with `id: 1`,
`final: true`; then read frames. A frame whose `id` is not 1 is `Protocol`. `TurnEvent` with
`final: false` goes to `on_event`; `TurnDone` with `final: true` is the result; `Error` with
`final: true` is `Refused`; anything else is `Protocol`.
**`Printer::event`**, per event, when `json` is false:
| Event | Output |
|---|---|
| `Reasoning { text }` | if `show_thinking`: open a dimmed block with `\x1b[2m` on the first, then the text as it is |
| `Content { text }` | close the dimmed block if open (`\x1b[0m` and a newline); then the text as it is, only if `stream_content` |
| `ToolCallStarted { name }` | close the block if open; `[tool <name>]` and a newline |
| `ToolResult { name, class, truncated }` | `[<name>: <class as Debug>]` or `[<name>: <class>, truncated]`, newline |
| `Waiting { slot_busy }` | `[waiting: slot busy]` or `[waiting: slot idle]` |
| `Retrying { attempt, after_ms, error }` | `[retrying: attempt <n> in <ms> ms: <error>]` |
| `ThinkingCapped { tokens }` | `[thinking capped at <n> tokens]` |
| `CacheLoss { expected, got }` | `[cache loss: <got> of <expected>]` |
| `Queued`, `Progress` | nothing |
Flush after every event. When `json` is true: every event is one line of JSON (`serde_json`
of the `TurnEvent`), nothing is skipped, no escape codes.
**`main`:** `bxctl chat [--socket <path>] [--session <id>] [--no-thinking] [--say <text>] [--json]`.
The socket defaults to `$BOXMAKER_HOME/run/loop/loop.sock`, or `/var/lib/boxmaker/…` when the
variable is unset. An invalid `--session`, an unknown option, or a first argument other than
`chat` prints a usage line and exits 2.
- With `--session`, the first turn is sent with `resume: true`; if `loopd` answers
`no_such_session`, it is sent again with `resume: false`. Without `--session`, a new id is made
and the first turn creates it. Every later turn resumes.
- `--say <text>`: one turn. Events go to **stderr**, with `stream_content` off; the answer goes to
stdout with a newline; exit 0. On error: `bxctl: <error>` on stderr, nothing on stdout, exit 1.
- Otherwise: print `session <id>`, then repeat: print `> `, read a line; EOF or `/quit` ends with
exit 0; a blank line is skipped; anything else is a turn, with events and content on stdout, and
a newline after the answer if it did not end with one. A failed turn prints `bxctl: <error>` and
the loop goes on.
- `--json` with `--say` also prints the `TurnDone` as one JSON line after the events.
## Steps
- [ ] **1. Copy.** `git switch m2b`, then
`mkdir -p crates/bxctl/tests && cp docs/plans/M2b/files/crates/bxctl/tests/chat.rs crates/bxctl/tests/`
- [ ] **2. See the test fail.** `cargo test -p bxctl --test chat`. Expected: it does not compile.
- [ ] **3. Write `chat.rs`, `main.rs`, and the two one-line changes.** Run `cargo fmt --all`.
- [ ] **4. See the tests pass.** `cargo test -p bxctl`. Expected: `11 passed`.
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/bxctl docs/implementer-log.md && git commit`
## Done when
- `cargo test -p bxctl` reports 11 passed; `make gate` prints `gate: ok`.
- `bxctl` depends on `proto` and `serde_json` only.
## Stop and report if
- A test in `chat.rs` needs the exact wording of an output line that this task does not give.