bxctl: chat --speaker NAME, the speaker on every turn of the chat
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -73,12 +73,14 @@ pub fn new_session_id() -> SessionId {
|
||||
SessionId::new(&candidate).unwrap_or_else(|_| SessionId::new("chat-0-0").unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Sends one turn and reads the reply. `on_event` sees every event frame as it arrives.
|
||||
/// 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> {
|
||||
let mut stream = UnixStream::connect(socket).map_err(ChatError::Connect)?;
|
||||
@@ -87,8 +89,7 @@ pub fn run_turn(
|
||||
session: session.clone(),
|
||||
content: content.to_string(),
|
||||
resume,
|
||||
// Set in M5c-1 task 04, from `--speaker`. Until then no turn has a speaker.
|
||||
speaker: None,
|
||||
speaker: speaker.map(str::to_string),
|
||||
};
|
||||
write_frame(
|
||||
&mut stream,
|
||||
@@ -324,11 +325,12 @@ pub fn stream_turn(
|
||||
session: &SessionId,
|
||||
text: &str,
|
||||
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, &mut |event| {
|
||||
let outcome = run_turn(socket, session, text, resume, speaker, &mut |event| {
|
||||
if err.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
+23
-1
@@ -9,8 +9,9 @@ use bxcore::SessionId;
|
||||
pub const USAGE: &str = "\
|
||||
usage: bxctl <command> [options]
|
||||
|
||||
bxctl chat [--socket PATH] [--admin-socket PATH] [--session ID] [--say TEXT] [--no-thinking] [--json]
|
||||
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.
|
||||
|
||||
bxctl approvals [--admin-socket PATH]
|
||||
List the approvals waiting for a decision.
|
||||
@@ -50,6 +51,9 @@ pub struct ChatOptions {
|
||||
pub show_thinking: bool,
|
||||
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>,
|
||||
}
|
||||
|
||||
impl ChatOptions {
|
||||
@@ -61,6 +65,7 @@ impl ChatOptions {
|
||||
show_thinking: true,
|
||||
say: None,
|
||||
json: false,
|
||||
speaker: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +148,14 @@ fn parse_chat(args: &[String], home: &Path) -> Result<Command, UsageError> {
|
||||
"--admin-socket" => opts.admin_socket = PathBuf::from(value_at(args, i)?),
|
||||
"--session" => opts.session = Some(parse_session(&value_at(args, i)?)?),
|
||||
"--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" => {
|
||||
opts.show_thinking = false;
|
||||
i += 1;
|
||||
@@ -305,6 +318,15 @@ fn parse_session(value: &str) -> Result<SessionId, UsageError> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
fn verify_reason(reason: &str) -> Result<String, UsageError> {
|
||||
if reason.is_empty() {
|
||||
|
||||
@@ -201,7 +201,15 @@ fn run_turn_twice(
|
||||
input,
|
||||
out: &mut handle,
|
||||
};
|
||||
match stream_turn(&opts.socket, session, text, resume, &approvals, &mut io) {
|
||||
match stream_turn(
|
||||
&opts.socket,
|
||||
session,
|
||||
text,
|
||||
resume,
|
||||
opts.speaker.as_deref(),
|
||||
&approvals,
|
||||
&mut io,
|
||||
) {
|
||||
Err(e) => Err(RunError::Io(e)),
|
||||
Ok(Ok(done)) => {
|
||||
write_answer(opts, &done).map_err(RunError::Io)?;
|
||||
@@ -214,7 +222,15 @@ fn run_turn_twice(
|
||||
input,
|
||||
out: &mut handle,
|
||||
};
|
||||
match stream_turn(&opts.socket, session, text, false, &approvals, &mut io) {
|
||||
match stream_turn(
|
||||
&opts.socket,
|
||||
session,
|
||||
text,
|
||||
false,
|
||||
opts.speaker.as_deref(),
|
||||
&approvals,
|
||||
&mut io,
|
||||
) {
|
||||
Err(e) => Err(RunError::Io(e)),
|
||||
Ok(Ok(done)) => {
|
||||
write_answer(opts, &done).map_err(RunError::Io)?;
|
||||
@@ -272,6 +288,7 @@ fn run_interactive(opts: &ChatOptions, reader: &mut dyn BufRead) -> ExitCode {
|
||||
this_session,
|
||||
text,
|
||||
resume,
|
||||
opts.speaker.as_deref(),
|
||||
&approvals,
|
||||
&mut io,
|
||||
) {
|
||||
|
||||
@@ -161,6 +161,7 @@ fn run_turn_delivers_every_event_in_order_then_the_answer() {
|
||||
&id("s1"),
|
||||
"what time is it?",
|
||||
false,
|
||||
None,
|
||||
&mut |e| seen.push(e.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -189,7 +190,7 @@ fn an_error_frame_is_refused_with_its_code_and_detail() {
|
||||
End::Error(ErrorCode::SessionFull, "this conversation is full"),
|
||||
);
|
||||
let mut seen = 0;
|
||||
let err = run_turn(&fake.socket, &id("s1"), "x", true, &mut |_| seen += 1).unwrap_err();
|
||||
let err = run_turn(&fake.socket, &id("s1"), "x", true, None, &mut |_| seen += 1).unwrap_err();
|
||||
assert_eq!(seen, 1, "events before the error are still delivered");
|
||||
match err {
|
||||
ChatError::Refused(w) => {
|
||||
@@ -199,7 +200,7 @@ fn an_error_frame_is_refused_with_its_code_and_detail() {
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
let e: Box<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}");
|
||||
}
|
||||
|
||||
@@ -207,7 +208,7 @@ fn an_error_frame_is_refused_with_its_code_and_detail() {
|
||||
fn no_loopd_is_a_connect_error() {
|
||||
let missing = std::env::temp_dir().join("bxctl-no-such-loopd.sock");
|
||||
assert!(matches!(
|
||||
run_turn(&missing, &id("s1"), "x", false, &mut |_| {}),
|
||||
run_turn(&missing, &id("s1"), "x", false, None, &mut |_| {}),
|
||||
Err(ChatError::Connect(_))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -295,6 +295,7 @@ fn turn(
|
||||
&SessionId::new("s1").unwrap(),
|
||||
"go",
|
||||
false,
|
||||
None,
|
||||
&approvals,
|
||||
&mut io,
|
||||
)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -38,6 +38,7 @@ fn chat_defaults_come_from_home() {
|
||||
show_thinking: true,
|
||||
say: None,
|
||||
json: false,
|
||||
speaker: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -55,6 +56,8 @@ fn chat_takes_every_flag_in_any_order() {
|
||||
"--no-thinking",
|
||||
"--session",
|
||||
"s-1",
|
||||
"--speaker",
|
||||
"kyle",
|
||||
"--socket",
|
||||
"/tmp/l.sock",
|
||||
]),
|
||||
@@ -65,6 +68,7 @@ fn chat_takes_every_flag_in_any_order() {
|
||||
show_thinking: false,
|
||||
say: Some("hello there".to_string()),
|
||||
json: true,
|
||||
speaker: Some("kyle".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -80,6 +84,63 @@ fn chat_usage_errors() {
|
||||
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!(
|
||||
@@ -245,6 +306,28 @@ fn usage_names_every_command() {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 [
|
||||
|
||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
||||
|
||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| M5c1/04-bxctl-speaker | 2026-09-28 | done | 1 | pass | none | Copied four files from `docs/plans/M5c1/files/`: `chat_speaker.rs` (new) and new versions of `chat.rs`, `chat_approvals.rs` and `cli.rs` (did not copy `chat.rs-task01`). Step 2 showed exactly the named failures: four non-compiling test targets (`chat`, `chat_approvals`, `chat_speaker`, `cli`) with E0061 for `run_turn` (5 args) and `stream_turn` (6 args), E0282 on the closure in `chat.rs`, and E0560/E0609 for the missing `speaker` field. Made the edits exactly as the task gives: C1 the `--speaker NAME` flag added to the chat line of `USAGE` with one explaining line (six-space indent); C2 `ChatOptions` gains `speaker: Option<String>`; C3 `default` sets `speaker: None`; C4 the `--speaker` arm in `parse_chat` refuses a second one and falls through to `i += 2`; C5 `parse_speaker` (empty, or any whitespace/control char -> `UsageError`, else the value as typed). R1 `run_turn` gains `speaker: Option<&str>` after `resume` with the doc comment, R2 `speaker.map(str::to_string)` on the `Turn`, R3 `stream_turn` gains the parameter and passes it. M1-M3 all three `stream_turn` calls in `main.rs` pass `opts.speaker.as_deref()`. `main` unchanged (a `UsageError` already prints the usage text and exits 2). After the edits `cargo check -p bxctl` is clean with no warning; `cli` 15 passed, `chat_speaker` 7 passed, every bxctl suite passes. Step 7: `grep -rn 'speaker: None' crates/bxctl/src` prints one line (the default), `git diff --stat -- crates/bxctl/src` lists exactly chat.rs/cli.rs/main.rs, the other-crates diff is empty. `make gate` printed `gate: ok` with 1158 tests (1148 before plus the 10 new: three in `cli.rs`, seven in `chat_speaker.rs`; `chat.rs` and `chat_approvals.rs` kept their counts). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |
|
||||
| M5c1/03-gatewayd-speaker | 2026-09-28 | done | 2 | pass | none | Copied the seven given files from `docs/plans/M5c1/files/`: `deliver_speaker.rs` and `serve_speaker.rs` (new) and new versions of `sessions.rs`, `deliver.rs`, `deliver_links.rs`, `deliver_notice.rs` and `support/fake_mm.rs` (it gains `refuse_users`). Step 2 showed exactly the named failures: `deliver`, `deliver_links`, `deliver_notice`, `deliver_speaker` and `sessions` did not compile (E0560 for `Batch.senders` / `Message.user`, E0061 for `deliver` taking 5 args, E0282 on the closure), and `serve_speaker` compiled with its four tests failing (three found `None` where a speaker was expected, the fourth had no `"no username for"` log line). Made the edits exactly as the task gives: S1 `Message` gains `user` (the post's `user_id`); S2/S3 set `user` on both `Route::Queue` paths; S4 `Batch` gains `senders` plus `Batch::sender` (`first()?` then `all(|u| u == first)`, `None` for two or more people or for none); S5 `Pending` gains `senders`; S6 `push` pushes `message.user` to `pending.senders` on the `Waiting` path; S7 the `Start` path is `senders: vec![message.user.clone()]` with the new `Pending`'s `senders: Vec::new()`; S8 `finish` takes `senders` with `std::mem::take`. D1 `one_turn` gains `speaker: Option<&str>`; D2 `speaker.map(str::to_string)` on the `Turn` (its comment rewritten); D3 `run_turn` gains `speaker` threaded through both `one_turn` calls; D4 `deliver` gains `speaker`; D5 `deliver` passes `speaker` to `run_turn`. G1 `Gateway` gains `usernames: HashMap<String,String>`; G2 `new()` inits it. H1 `start` computes `speaker = self.speaker(&batch)` and passes `speaker.as_deref()` to `deliver`. H2 added `Gateway::speaker` (`batch.sender()?`; a cache hit returns it; else `client.username`, `Ok` inserts and returns, `Err` logs `"gatewayd: no username for {user}: {e}; the turn has no speaker"` and returns `None`). `approvals.rs` and `mm/rest.rs` untouched, the text still joins as before and the speaker never enters it. `cargo check -p gatewayd` clean after sessions; after deliver it fails only in `serve/handle.rs` at the `deliver` call (E0061/E0277) as the task predicts, then clean after serve. `sessions` 11 passed, `deliver_speaker` 3, `serve_speaker` 4, every gatewayd suite passes. Step 7: the grep for `speaker: None` prints nothing, `git diff --stat -- crates/gatewayd/src` lists exactly `deliver.rs`, `serve/handle.rs`, `serve/mod.rs`, `sessions.rs`, the other-crates diff is empty. `make gate` printed `gate: ok` with 1148 tests (1137 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately with `BOXMAKER_BROKERD`; 1138 before this task plus the 10 new: 3 in `sessions.rs`, 3 in `deliver_speaker.rs`, 4 in `serve_speaker.rs`). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |
|
||||
| M5c1/02-loopd-speaker-turn | 2026-09-28 | done | 2 | pass | none | Copied five files from `docs/plans/M5c1/files/`: `speaker_turn.rs` (new) and new versions of `memory.rs`, `turn_broker.rs`, `end_to_end.rs` and `support/turn.rs` (skipped the older `memory.rs-task01`). Step 2 showed exactly the named failures: eight non-compiling test targets (end_to_end, limits, memory, neutralise, neutralise_replay, speaker_turn, turn, turn_broker) with E0061 for `run_turn` and `updated`, E0282 on the closure, E0050 and E0599 for `turns`. Made the edits exactly as the task gives: S1 `Session::turns` counting `User` records in `records` via `matches!` and `u64::try_from(...).unwrap_or(u64::MAX)`, placed before `last_usage`; M1 the trait `updated` gains `turn: u64`, M2 `NoMemory` and M3 `MemoryPort` pass it (the `turn: 0` placeholder and its comment dropped for `turn`); T1 `run_turn` gains `speaker: Option<&str>` after `content` with the doc comment, T2 logs `speaker: speaker.map(str::to_string)` (as it came, not checked, not neutralised, `None` not written); C1 passes `turn.speaker.as_deref()` to `run_turn`, C2 passes `session.turns()` to `updated` (the comment now says the number is this turn's, a failed turn not told but its logged message still counts). `baseline::messages` untouched, the request is the same with and without a speaker. Step 7 all pass: the grep for `turn: 0`/`speaker: None` prints nothing, `git diff --stat -- crates/loopd/src` lists exactly channel.rs/memory.rs/session.rs/turn.rs (baseline.rs absent), the other-crates diff is empty. After the edits `cargo check -p loopd` is clean, `speaker_turn` 8 passed, every loopd suite passes. `make gate` printed `gate: ok` with 1138 tests (1130 before plus the 8 of `speaker_turn.rs`). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |
|
||||
| M5c1/01-wire-speaker-state | 2026-09-28 | done | 1 | pass | none | Changed the types in `bxcore` only, as the task says: `Turn` and `LogRecord::User` gain `speaker: Option<String>` (optional, `serde(default, skip_serializing_if = "Option::is_none")`, so a frame or log line without it reads `None` and `None` is not written); `SessionUpdated` gains `turn: u64` (required, no default), plus the two new messages `SessionState { session }` and `SessionStateReply { class, untrusted }` (both fields required). Copied the 20 given files from `docs/plans/M5c1/files/` (4 new fixtures, 16 replaced test files, the loopd `memory.rs` and bxctl `chat.rs` being the `-task01` versions). Step 2 showed exactly the named failures: `strict` `envelopes_reject_unknown_keys_at_every_depth` on `turn_speaker.json` and `speaker_log_records_reject_unknown_keys_at_every_depth` on `session_speaker.jsonl:2`, and 15 non-compiling test targets with E0559/E0560/E0432/E0599. Made every edit exactly as the task gives (W1-W3, L1, M1, X1 in bxcore; B1-B2 in brokerd; G1, C1, T1, U1, I1 in gatewayd, bxctl, loopd twice, memoryd), the placeholders `speaker: None` / `turn: 0` left in place. After the edits `cargo test -p bxcore` every suite passes (turn_wire 9, memory_wire 5, records 4, strict 6) and `cargo test -p brokerd -p gatewayd -p bxctl -p loopd -p memoryd` every suite passes. Step 5 checks all pass: `grep -c 'serde(default' crates/bxcore/src/wire.rs` prints 2, `git diff --stat -- crates/gatewayd/tests crates/inferproxy crates/toolkit` prints nothing, `git diff --stat -- crates/*/src` lists exactly the ten source files. `make gate` printed `gate: ok` with 1130 tests (1129 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately with `BOXMAKER_BROKERD` set; the first gate run's `end_to_end` was the only flake and it passes once the env var is set). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |
|
||||
|
||||
Reference in New Issue
Block a user