bxcore: a turn's speaker, SessionUpdated's turn, the session-state messages

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-28 10:27:28 -07:00
co-authored by ornith-1.5-35b-a3b
parent 027146f359
commit c798714af1
31 changed files with 238 additions and 13 deletions
+2 -1
View File
@@ -38,7 +38,8 @@ pub use ids::{CallId, Epoch, Hash32, SessionId, Timestamp, ValueError};
pub use log::{LogRecord, ToolCall, Usage};
pub use memory::{
ConsolidateEntry, EntryAction, MemoryAdmin, MemoryAdminReply, MemoryArgs, MemoryBlockReply,
MemoryCall, MemoryCommand, MemoryFile, RecallArgs, Reindexed, SessionUpdated,
MemoryCall, MemoryCommand, MemoryFile, RecallArgs, Reindexed, SessionState, SessionStateReply,
SessionUpdated,
};
pub use wire::{
ApprovalList, Approve, ApproveResult, DenyReason, Empty, Envelope, ErrorCode, GrantProblem,
+4
View File
@@ -37,6 +37,10 @@ pub enum LogRecord {
User {
time: Timestamp,
content: String,
/// The turn's speaker (M5c spec, section 11). Written only when present, so a log written
/// before M5c reads as it is, and a turn without a speaker is logged as before.
#[serde(default, skip_serializing_if = "Option::is_none")]
speaker: Option<String>,
},
Assistant {
time: Timestamp,
+20 -1
View File
@@ -228,11 +228,30 @@ pub struct MemoryBlockReply {
pub text: String,
}
/// `loopd` → `memoryd`: this session's log has new records.
/// `loopd` → `memoryd`: this session's log has new records, those of a turn that has ended.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionUpdated {
pub session: SessionId,
/// The ended turn's number in the session (M5c spec, section 5). It has no default: a frame
/// without it is refused, never read as turn 0.
pub turn: u64,
}
/// `memoryd` → `brokerd`, on `state.sock`: the session's state now (M5c spec, section 7).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionState {
pub session: SessionId,
}
/// `brokerd` → `memoryd`: the session's class, and whether it has read untrusted content. Neither
/// field has a default: a reply without one is refused, never read as a clean session.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionStateReply {
pub class: DataClass,
pub untrusted: bool,
}
/// `brokerd` → `memoryd`: a decided `recall` or `memory` call, with the session's state when it
+10 -2
View File
@@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize};
use crate::gateway::{ApprovalOutcome, ApprovalPosted, ApprovalReply, ApprovalRequest};
use crate::memory::{
MemoryAdmin, MemoryAdminReply, MemoryBlockReply, MemoryCall, Reindexed, SessionUpdated,
MemoryAdmin, MemoryAdminReply, MemoryBlockReply, MemoryCall, Reindexed, SessionState,
SessionStateReply, SessionUpdated,
};
use crate::{CallId, DataClass, DecisionRecord, SessionId, Timestamp, Usage};
@@ -51,6 +52,8 @@ pub enum Message {
Reindexed(Reindexed),
MemoryAdmin(MemoryAdmin),
MemoryAdminReply(MemoryAdminReply),
SessionState(SessionState),
SessionStateReply(SessionStateReply),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -126,13 +129,18 @@ pub enum DenyReason {
StateUnreadable,
}
// JSON: {"kind":"turn","body":{"session":"…","content":"…","resume":false}}
// JSON: {"kind":"turn","body":{"session":"…","content":"…","resume":false,"speaker":"kyle"}}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Turn {
pub session: SessionId,
pub content: String,
pub resume: bool,
/// Who spoke the turn, when one person did (M5c spec, section 11). Written only when present,
/// and a frame without it reads as `None`, the safe side: a turn without a speaker is never
/// synced to Honcho.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speaker: Option<String>,
}
// JSON: {"event":"content","text":"…"} — the tag sits beside the fields, snake_case
@@ -0,0 +1,5 @@
{"type":"session_start","time":"2026-09-27T09:00:00.000Z","session":"mm-r0000000000000000000000001","epoch":0,"slot":2,"baseline":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"}
{"type":"user","time":"2026-09-27T09:00:01.000Z","content":"When is the launch?","speaker":"kyle"}
{"type":"assistant","time":"2026-09-27T09:00:04.000Z","content":"On Thursday.","reasoning_content":null,"tool_calls":[]}
{"type":"user","time":"2026-09-27T09:01:00.000Z","content":"And the review?"}
{"type":"assistant","time":"2026-09-27T09:01:03.000Z","content":"On Friday.","reasoning_content":null,"tool_calls":[]}
+1
View File
@@ -0,0 +1 @@
{"v":1,"id":1,"final":true,"msg":{"kind":"session_state","body":{"session":"mm-r0000000000000000000000001"}}}
@@ -0,0 +1 @@
{"v":1,"id":1,"final":true,"msg":{"kind":"session_state_reply","body":{"class":"private","untrusted":true}}}
+1 -1
View File
@@ -1 +1 @@
{"v":1,"id":1,"final":true,"msg":{"kind":"session_updated","body":{"session":"mm-r0000000000000000000000001"}}}
{"v":1,"id":1,"final":true,"msg":{"kind":"session_updated","body":{"session":"mm-r0000000000000000000000001","turn":3}}}
+1
View File
@@ -0,0 +1 @@
{"v":1,"id":3,"final":true,"msg":{"kind":"turn","body":{"session":"mm-r0000000000000000000000001","content":"What time is it?","resume":true,"speaker":"kyle"}}}
+58 -3
View File
@@ -1,9 +1,11 @@
//! The eight memory messages against byte-exact fixtures, each refusing an unknown key (M5a spec,
//! section 7). Do not edit these or the fixtures.
//! The memory messages against byte-exact fixtures, each refusing an unknown key: the eight of M5a
//! (M5a spec, section 7) and the session-state question of M5c (M5c spec, section 7). Do not edit
//! these or the fixtures.
use bxcore::{
CallId, DataClass, Empty, Envelope, MemoryAdmin, MemoryAdminReply, MemoryBlockReply,
MemoryCall, MemoryCommand, Message, Reindexed, SessionId, SessionUpdated, ToolRequest,
MemoryCall, MemoryCommand, Message, Reindexed, SessionId, SessionState, SessionStateReply,
SessionUpdated, ToolRequest,
};
const SESSION: &str = "mm-r0000000000000000000000001";
@@ -53,6 +55,7 @@ fn the_eight_messages() {
1,
Message::SessionUpdated(SessionUpdated {
session: SessionId::new(SESSION).unwrap(),
turn: 3,
}),
);
check(
@@ -96,6 +99,58 @@ fn the_eight_messages() {
);
}
/// `turn` has no default: a `session_updated` without it, as a `loopd` from before M5c sent, is
/// refused, never read as turn 0 (M5c spec, section 5).
#[test]
fn a_session_updated_without_turn_is_refused() {
let old = r#"{"v":1,"id":1,"final":true,"msg":{"kind":"session_updated","body":{"session":"mm-r0000000000000000000000001"}}}"#;
let error = serde_json::from_str::<Envelope>(old).unwrap_err();
assert!(
error.to_string().contains("missing field `turn`"),
"{error}"
);
}
/// `memoryd` asks `brokerd` for a session's state on `state.sock` (M5c spec, section 7).
#[test]
fn the_session_state_messages() {
check(
"session_state.json",
1,
Message::SessionState(SessionState {
session: SessionId::new(SESSION).unwrap(),
}),
);
check(
"session_state_reply.json",
1,
Message::SessionStateReply(SessionStateReply {
class: DataClass::Private,
untrusted: true,
}),
);
}
/// Neither field of the reply has a default: a reply without one is refused, never read as a
/// clean or `public` session.
#[test]
fn a_session_state_reply_needs_both_fields() {
let text = fixture("session_state_reply.json");
for (field, without) in [
("class", text.replacen(r#""class":"private","#, "", 1)),
("untrusted", text.replacen(r#","untrusted":true"#, "", 1)),
] {
assert_ne!(without, text, "{field}: the fixture holds the field");
let error = serde_json::from_str::<Envelope>(&without).unwrap_err();
assert!(
error
.to_string()
.contains(&format!("missing field `{field}`")),
"{field}: {error}"
);
}
}
#[test]
fn every_command_has_its_name() {
for (command, name) in [
+40
View File
@@ -235,6 +235,7 @@ fn session_log_records() {
LogRecord::User {
time: ts("2026-09-17T08:05:01.000Z"),
content: "What is in /etc/hosts?".to_string(),
speaker: None,
},
LogRecord::Assistant {
time: ts("2026-09-17T08:05:03.000Z"),
@@ -275,6 +276,45 @@ fn session_log_records() {
check("session.jsonl", &want);
}
/// A user record holds its turn's speaker when the turn had one, written last; a record without
/// one has the bytes it had before M5c, so a log written earlier reads as it is and is never
/// rewritten (M5c spec, section 11).
#[test]
fn user_records_with_and_without_a_speaker() {
let want = [
LogRecord::SessionStart {
time: ts("2026-09-27T09:00:00.000Z"),
session: sid("mm-r0000000000000000000000001"),
epoch: Epoch(0),
slot: 2,
baseline: Hash32::from_hex(SEQ_HEX).unwrap(),
},
LogRecord::User {
time: ts("2026-09-27T09:00:01.000Z"),
content: "When is the launch?".to_string(),
speaker: Some("kyle".to_string()),
},
LogRecord::Assistant {
time: ts("2026-09-27T09:00:04.000Z"),
content: Some("On Thursday.".to_string()),
reasoning_content: None,
tool_calls: vec![],
},
LogRecord::User {
time: ts("2026-09-27T09:01:00.000Z"),
content: "And the review?".to_string(),
speaker: None,
},
LogRecord::Assistant {
time: ts("2026-09-27T09:01:03.000Z"),
content: Some("On Friday.".to_string()),
reasoning_content: None,
tool_calls: vec![],
},
];
check("session_speaker.jsonl", &want);
}
#[test]
fn unknown_fields_and_types_are_rejected() {
let user = r#"{"type":"user","time":"2026-09-17T08:05:01.000Z","content":"hi"}"#;
+14
View File
@@ -92,6 +92,10 @@ fn envelopes_reject_unknown_keys_at_every_depth() {
"error_no_such_approval.json",
"turn_event_approval_pending.json",
"turn_event_tool_denied.json",
"turn_speaker.json",
"session_updated.json",
"session_state.json",
"session_state_reply.json",
] {
// Envelope, msg and body: three objects. Some bodies hold more: turn_done a usage
// object, approval_list one item, approve_result an outcome, grants_report two problems.
@@ -142,6 +146,16 @@ fn usage_log_records_reject_unknown_keys_at_every_depth() {
assert_eq!(tried, 8);
}
#[test]
fn speaker_log_records_reject_unknown_keys_at_every_depth() {
let mut tried = 0;
for (i, line) in fixture("records/session_speaker.jsonl").lines().enumerate() {
tried += check::<LogRecord>(&format!("session_speaker.jsonl:{}", i + 1), line);
}
// Five records, none holding another object.
assert_eq!(tried, 5);
}
#[test]
fn grants_reject_unknown_keys_at_every_depth() {
let grant: Grant = toml::from_str(&fixture("grant/full.toml")).unwrap();
+31
View File
@@ -46,16 +46,46 @@ fn usage() -> Usage {
}
}
/// A turn without a speaker has the bytes it had before M5c, and a frame without `speaker` reads
/// as `None`: a turn with no speaker is never synced (M5c spec, section 4).
#[test]
fn turn() {
let body = Turn {
session: SessionId::new("chat-1789700000-42").unwrap(),
content: "What time is it?".to_string(),
resume: false,
speaker: None,
};
check("turn.json", env(3, true, Message::Turn(body)));
}
/// The speaker is written last, when there is one (M5c spec, section 11).
#[test]
fn turn_with_a_speaker() {
let body = Turn {
session: SessionId::new("mm-r0000000000000000000000001").unwrap(),
content: "What time is it?".to_string(),
resume: true,
speaker: Some("kyle".to_string()),
};
check("turn_speaker.json", env(3, true, Message::Turn(body)));
}
/// `bxcore` does not check a speaker's name: `memoryd` checks it against Honcho's pattern (M5c
/// spec, section 3). A Mattermost username with a `.` is carried as it is.
#[test]
fn a_speaker_is_carried_as_it_is() {
let body = Turn {
session: SessionId::new("mm-r0000000000000000000000001").unwrap(),
content: "hi".to_string(),
resume: false,
speaker: Some("first.last".to_string()),
};
let text = serde_json::to_string(&body).unwrap();
assert!(text.ends_with(r#","speaker":"first.last"}"#), "{text}");
assert_eq!(serde_json::from_str::<Turn>(&text).unwrap(), body);
}
#[test]
fn turn_events() {
check(
@@ -262,6 +292,7 @@ fn usage_records() {
LogRecord::User {
time: Timestamp::parse("2026-09-18T08:05:01.000Z").unwrap(),
content: "What time is it?".to_string(),
speaker: None,
},
LogRecord::Assistant {
time: Timestamp::parse("2026-09-18T08:05:03.000Z").unwrap(),