Files
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

5.0 KiB

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

// 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.