From 0f2652d7faa8612e394991a731e4eb60099ee322 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 12:19:49 +0000 Subject: [PATCH] mind-body: chat_pending.v3 tail_gaps, a defaulted fourth field; a named room is unknown in the first line, never quiet Bifrost b2e0bb2 sends [persona, deliveries, tail, tail_gaps]; a three-field decode failed LengthMismatch. The field defaults empty, so three-field answers still decode, and render_tail splits unknown (named) from quiet (unnamed, no line). Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/chat_client.rs | 4 ++ crates/mind-body/src/chat_client/tests.rs | 23 ++++++-- crates/mind-body/src/heartbeat/tests.rs | 2 +- crates/mind-body/src/persona.rs | 37 ++++++++---- crates/mind-body/src/persona/tests.rs | 53 ++++++++++++++---- crates/mind-body/tests/chat_rudp.rs | 25 +++++++++ ....persona.chat_pending.v3.tail-gaps.msgpack | Bin 0 -> 127 bytes 7 files changed, 118 insertions(+), 26 deletions(-) create mode 100644 crates/mind-body/tests/fixtures/persona-chat-v7/gamecult.persona.chat_pending.v3.tail-gaps.msgpack diff --git a/crates/mind-body/src/chat_client.rs b/crates/mind-body/src/chat_client.rs index b9e334d..a5cbe07 100644 --- a/crates/mind-body/src/chat_client.rs +++ b/crates/mind-body/src/chat_client.rs @@ -173,6 +173,10 @@ pub struct ChatPending { /// none; a mouth with nothing to show sends an empty list, and that is a different fact. #[serde(default, skip_serializing_if = "Option::is_none")] pub tail: Option>, + /// The rooms whose lines are not all in `tail`, tuple index 3: a room the mouth could not read in time, or whose lines it trimmed. A + /// reader renders such a room as unknown, never quiet. An answer that ends before the field, or an empty list, names none. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tail_gaps: Vec, } /// One line of the room tail: `[room, event_id, at_unix_ms, sender, form, basis, body]`. `room` is the configured alias (the room id diff --git a/crates/mind-body/src/chat_client/tests.rs b/crates/mind-body/src/chat_client/tests.rs index f985df2..828c68c 100644 --- a/crates/mind-body/src/chat_client/tests.rs +++ b/crates/mind-body/src/chat_client/tests.rs @@ -19,6 +19,8 @@ const PENDING_UNPROVEN_NOTICE_BYTES: &[u8] = include_bytes!("../../tests/fixture const PENDING_TAG_TAILED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.tag.msgpack"); const PENDING_UNPROVEN_NOTICE_TAILED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.unproven-notice.msgpack"); const PENDING_TAIL_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.tail.msgpack"); +// The four-field `chat_pending.v3` with `tail_gaps`: Bifrost b2e0bb2's own golden bytes, copied unchanged. +const PENDING_GAPS_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v7/gamecult.persona.chat_pending.v3.tail-gaps.msgpack"); const SENT_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v2/gamecult.persona.chat_send_receipt.v2.sent.msgpack"); const ROOM_MOVED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v2/gamecult.persona.chat_send_receipt.v2.room-moved.msgpack"); const NIL_BASIS_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v2/gamecult.persona.chat_send_request.v2.nil-basis.msgpack"); @@ -71,13 +73,13 @@ fn image() -> ChatImage { #[test] fn a_v1_pending_answer_still_decodes_with_no_image() { let pending: ChatPending = decode(PENDING_V1_BYTES).unwrap(); - assert_eq!(pending, ChatPending { persona: "eureka".into(), deliveries: vec![delivery(None)], tail: None }); + assert_eq!(pending, ChatPending { persona: "eureka".into(), deliveries: vec![delivery(None)], tail: None, tail_gaps: vec![] }); assert_eq!(PENDING_V1_BYTES[0] & 0xf0, 0x90, "a positional tuple"); } #[test] fn a_v2_pending_answer_decodes_with_the_image_at_tuple_index_11_and_no_addressing_facts() { - let expected = |image| ChatPending { persona: "eureka".into(), deliveries: vec![delivery(image)], tail: None }; + let expected = |image| ChatPending { persona: "eureka".into(), deliveries: vec![delivery(image)], tail: None, tail_gaps: vec![] }; assert_eq!(decode::(PENDING_NO_IMAGE_BYTES).unwrap(), expected(None)); assert_eq!(decode::(PENDING_IMAGE_BYTES).unwrap(), expected(Some(image()))); // Index 11 of the delivery tuple, by position: 12 elements, the last an array of [path, mime, size, sha256]. @@ -93,7 +95,7 @@ fn a_v3_pending_answer_is_the_golden_bytes_with_basis_and_form_at_tuple_indices_ let one = |is_direct, mentions: &[&str], basis, form| { let mut delivery = delivery(None); (delivery.is_direct, delivery.mentions, delivery.basis, delivery.form) = (is_direct, mentions.iter().map(|id| (*id).to_string()).collect(), basis, form); - ChatPending { persona: "eureka".into(), deliveries: vec![delivery], tail: None } + ChatPending { persona: "eureka".into(), deliveries: vec![delivery], tail: None, tail_gaps: vec![] } }; golden(PENDING_TAG_BYTES, &one(false, &["@eureka:gamecult.org", "@nova:gamecult.org"], Some(Basis::Tag), Form::Message)); golden(PENDING_UNPROVEN_NOTICE_BYTES, &one(false, &[], None, Form::Notice)); @@ -112,7 +114,7 @@ fn the_pending_answer_decodes_with_the_tail_field_and_without_it() { let one = |is_direct, mentions: &[&str], basis, form, tail| { let mut delivery = delivery(None); (delivery.is_direct, delivery.mentions, delivery.basis, delivery.form) = (is_direct, mentions.iter().map(|id| (*id).to_string()).collect(), basis, form); - ChatPending { persona: "eureka".into(), deliveries: vec![delivery], tail } + ChatPending { persona: "eureka".into(), deliveries: vec![delivery], tail, tail_gaps: vec![] } }; for old in [PENDING_TAG_BYTES, PENDING_UNPROVEN_NOTICE_BYTES, PENDING_V1_BYTES, PENDING_NO_IMAGE_BYTES] { assert_eq!(decode::(old).unwrap().tail, None, "a mouth with no tail sends none"); @@ -226,3 +228,16 @@ fn a_receipt_error_never_echoes_what_it_read() { assert!(!error.contains(canary), "{error}"); } } + +/// `tail_gaps` is a defaulted fourth element: named rooms round-trip as the mouth's bytes, and an empty list encodes as the three-field answer. +#[test] +fn the_pending_answer_decodes_tail_gaps_and_defaults_them_to_none() { + let gapped: ChatPending = decode(PENDING_GAPS_BYTES).unwrap(); + assert_eq!(gapped.tail_gaps, vec!["#swarm:gamecult.org".to_string(), "!dm1:gamecult.org".to_string()]); + golden(PENDING_GAPS_BYTES, &gapped); + let tailed: ChatPending = decode(PENDING_TAIL_BYTES).unwrap(); + assert!(tailed.tail_gaps.is_empty()); + let mut empty_fourth: serde_json::Value = rmp_serde::from_slice(PENDING_TAIL_BYTES).unwrap(); + empty_fourth.as_array_mut().unwrap().push(serde_json::json!([])); + assert_eq!(decode::(&rmp_serde::to_vec(&empty_fourth).unwrap()).unwrap(), tailed, "an empty fourth element is the same answer"); +} diff --git a/crates/mind-body/src/heartbeat/tests.rs b/crates/mind-body/src/heartbeat/tests.rs index 18cd04d..3b22e6b 100644 --- a/crates/mind-body/src/heartbeat/tests.rs +++ b/crates/mind-body/src/heartbeat/tests.rs @@ -90,7 +90,7 @@ struct FakeChat { impl Chat for FakeChat { fn pending(&self, after_seq: u64) -> Result { self.pending_calls.set(self.pending_calls.get() + 1); - Ok(crate::chat_client::ChatPending { persona: "eureka".into(), deliveries: self.deliveries.borrow().iter().filter(|delivery| delivery.seq > after_seq).cloned().collect(), tail: None }) + Ok(crate::chat_client::ChatPending { persona: "eureka".into(), deliveries: self.deliveries.borrow().iter().filter(|delivery| delivery.seq > after_seq).cloned().collect(), tail: None, tail_gaps: vec![] }) } fn send(&self, _: &crate::chat_client::ChatSendRequest) -> Result { diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index b3261c2..66563dd 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -882,7 +882,7 @@ pub fn carry(organs: &Organs, control: &impl ControlSource, swarm: &impl Control progress.failure = Some(Failure::Chat); break; }; - let (mut pending, tail) = (polled.deliveries, polled.tail); + let (mut pending, tail, tail_gaps) = (polled.deliveries, polled.tail, polled.tail_gaps); // A mind that cannot be asked for her Self's replies still serves her chat. let routes = reply_routes(organs.mind, &BTreeSet::new()).unwrap_or_default(); let addressing = addressing_of(config, organs.heartbeat, &own_addressee(config)); @@ -906,6 +906,7 @@ pub fn carry(organs: &Organs, control: &impl ControlSource, swarm: &impl Control break; } session.tail = tail; + session.tail_gaps = tail_gaps; // A line a delivery still waiting will tell is that delivery's to show. session.waiting_ids = pending_ids; let turns = progress.turn; @@ -1126,6 +1127,8 @@ struct Session { first: Vec, /// The mouth's tail of her rooms at the poll this session was made from (None: the mouth sent none); its first line shows it. tail: Option>, + /// The rooms the mouth named at that poll as not fully read: shown as unknown, never quiet. + tail_gaps: Vec, /// The event ids of the deliveries still waiting at that poll: they will be told as messages, which show their lines. waiting_ids: Vec, } @@ -1139,14 +1142,14 @@ fn next_session(config: &Config, addressing: &[Addressee], pending: Vec { opened_alone.insert(route.id.clone()); - Some(Session { conversation: route.conversation.clone(), origin: route.origin.clone().unwrap_or(MemoryOrigin::Wake), first: Vec::new(), tail: None, waiting_ids: Vec::new() }) + Some(Session { conversation: route.conversation.clone(), origin: route.origin.clone().unwrap_or(MemoryOrigin::Wake), first: Vec::new(), tail: None, tail_gaps: Vec::new(), waiting_ids: Vec::new() }) } - None => (process == 0).then(|| Session { conversation: Conversation::Public, origin: MemoryOrigin::Wake, first: Vec::new(), tail: None, waiting_ids: Vec::new() }), + None => (process == 0).then(|| Session { conversation: Conversation::Public, origin: MemoryOrigin::Wake, first: Vec::new(), tail: None, tail_gaps: Vec::new(), waiting_ids: Vec::new() }), } } @@ -1269,6 +1272,8 @@ struct PersonaInbox<'a, C: ControlSource, S: ControlSource> { start: Vec, /// The mouth's tail of her rooms, shown once by the first line; none when the mouth sent none. tail: Option>, + /// The rooms the mouth named as not fully read, shown once with the tail. + tail_gaps: Vec, /// The event ids of the deliveries waiting when the tail was polled: their messages show those lines. waiting_ids: Vec, /// The deliveries derived at the last poll that no line has carried yet. @@ -1336,7 +1341,7 @@ impl<'a, C: ControlSource, S: ControlSource> PersonaInbox<'a, C, S> { /// One process's session inbox over its first batch. The caller has read the gate and started the child. #[allow(clippy::too_many_arguments, reason = "one process's state is these few things, and a struct for them would be a second owner")] fn new(organs: &'a Organs<'a>, control: &'a C, swarm: &'a S, config: &'a Config, run: &'a PipelineRef, progress: &'a mut Progress, session: Session, started_ms: u64, plan: String) -> Self { - let Session { conversation, origin, first, tail, waiting_ids } = session; + let Session { conversation, origin, first, tail, tail_gaps, waiting_ids } = session; let rooms = reply_rooms(config, &conversation); Self { organs, @@ -1352,6 +1357,7 @@ impl<'a, C: ControlSource, S: ControlSource> PersonaInbox<'a, C, S> { plan, started_ms, tail, + tail_gaps, waiting_ids, told: BTreeSet::new(), start: first, @@ -1417,7 +1423,7 @@ impl<'a, C: ControlSource, S: ControlSource> PersonaInbox<'a, C, S> { let (heard, replies) = self.take_reply_lines(); let (attached, images) = attachment_lines(self.config, deliveries); let mut told = lines_told(deliveries, &earlier); - let tail = render_tail(self.config, &self.addressing, std::mem::take(&mut self.tail), &tail_rooms(self.config, &self.conversation), &told, &self.waiting_ids); + let tail = render_tail(self.config, &self.addressing, std::mem::take(&mut self.tail), &std::mem::take(&mut self.tail_gaps), &tail_rooms(self.config, &self.conversation), &told, &self.waiting_ids); told.extend(tail.keys); let input = render_input(self.config, started_self_today, &tail.text, &format!("{heard}{}{attached}", message_lines(self.config, &self.addressee, &self.addressing, deliveries, &earlier)), note, &history, &goals.text, &wear, &recall::render(&recalled), &format!("{}\n\n{tempo}", self.plan), &people); Ok(Rendered { input, shown: Shown { people, open: goals.open }, told, replies, images }) @@ -3499,8 +3505,8 @@ fn tail_line(config: &Config, addressing: &[Addressee], line: &TailLine) -> Stri /// The tail a process opens with, from the mouth's `tail` (None: it sent none): the lines of `rooms` (`tail_rooms`) that the `seen` lines /// of this same line do not already show, the newest `TAIL_PER_ROOM` of each and `TAIL_TOTAL` over all, then the newest that fit /// `TAIL_MAX_BYTES` counted as rendered. A marker says how many were dropped, and a room with no line says so: she is told the room was -/// quiet, or unread by the mouth, and is never left to assume. -fn render_tail(config: &Config, addressing: &[Addressee], tail: Option>, rooms: &[String], seen: &[LineKey], waiting_ids: &[String]) -> Tail { +/// quiet, or that the mouth named it a gap (`gaps`: unknown), and is never left to assume. +fn render_tail(config: &Config, addressing: &[Addressee], tail: Option>, gaps: &[String], rooms: &[String], seen: &[LineKey], waiting_ids: &[String]) -> Tail { let Some(mut lines) = tail else { return Tail { text: format!("{TAIL_UNAVAILABLE}\n\n"), keys: Vec::new() } }; lines.retain(|line| rooms.contains(&line.room)); // A line the first line shows already, as a message, a delivery's context, or a waiting delivery's message, is not repeated; its room @@ -3518,8 +3524,16 @@ fn render_tail(config: &Config, addressing: &[Addressee], tail: Option = rooms.iter().filter(|room| !within.iter().chain(&shown).any(|line| &line.room == *room)).map(|room| quoted(room)).collect(); - let quiet_note = if quiet.is_empty() { String::new() } else { format!("No tail line for: {}. They were quiet, or the mouth could not read them.\n", quiet.join(", ")) }; + // A room the mouth names is unknown, with lines or without: older lines may exist that she cannot see. Only an unnamed room with no line is quiet. + let unknown: Vec = rooms.iter().filter(|room| gaps.contains(room)).map(|room| quoted(room)).collect(); + let quiet: Vec = rooms.iter().filter(|room| !gaps.contains(room) && !within.iter().chain(&shown).any(|line| &line.room == *room)).map(|room| quoted(room)).collect(); + let mut quiet_note = String::new(); + if !unknown.is_empty() { + quiet_note.push_str(&format!("Unknown, not quiet: {}. The mouth could not show all of their lines, so older ones may exist; do not assume nothing was said.\n", unknown.join(", "))); + } + if !quiet.is_empty() { + quiet_note.push_str(&format!("No tail line for: {}. The mouth names no gap there, so they were quiet.\n", quiet.join(", "))); + } let mut budget = TAIL_MAX_BYTES.saturating_sub(TAIL_HEADER.len() + quiet_note.len() + 160); let mut kept: Vec<&TailLine> = Vec::new(); for line in within.iter().rev() { @@ -3532,7 +3546,8 @@ fn render_tail(config: &Config, addressing: &[Addressee], tail: Option>>, /// Rooms that are her DMs, which she may speak in without configuring them. dms: RefCell>, + /// The rooms the fake mouth names as not fully read. + gaps: RefCell>, /// The mouth cannot be asked for its pending. fail_pending: Cell, } @@ -146,7 +148,7 @@ impl Chat for FakeChat { if self.fail_pending.get() { return Err(anyhow!("the mouth is down")); } - Ok(ChatPending { persona: "eureka".into(), deliveries: self.deliveries.borrow().iter().filter(|delivery| delivery.seq > after_seq).cloned().collect(), tail: self.tail_reply() }) + Ok(ChatPending { persona: "eureka".into(), deliveries: self.deliveries.borrow().iter().filter(|delivery| delivery.seq > after_seq).cloned().collect(), tail: self.tail_reply(), tail_gaps: self.gaps.borrow().clone() }) } fn send(&self, request: &ChatSendRequest) -> Result { @@ -499,6 +501,7 @@ impl Rig { open_unserved: Cell::new(false), homeserver: RefCell::new(None), dms: RefCell::default(), + gaps: RefCell::new(Vec::new()), fail_pending: Cell::new(false), }, model: FakeSession { @@ -1090,7 +1093,7 @@ fn the_tail_size_budget_measures_a_mind_line_as_the_line_it_prints() { let lines = |pad: usize| -> Vec { (1..=TAIL_PER_ROOM as u64).map(|n| tl("swarm", n, LIBBY, &format!("{n:02} {}", "t".repeat(pad)))).collect() }; let budget = TAIL_MAX_BYTES - (TAIL_HEADER.len() + 160); let pad = (1..2000).rev().find(|&pad| lines(pad).iter().map(|line| tail_line(&c, &table, line).len()).sum::() <= budget).expect("a pad fits"); - let tail = render_tail(&c, &table, Some(lines(pad)), &rooms, &[], &[]); + let tail = render_tail(&c, &table, Some(lines(pad)), &[], &rooms, &[], &[]); assert_eq!(tail.text.matches("tail room=").count(), TAIL_PER_ROOM, "every line fits when measured as printed"); assert_eq!(tail.text.matches("role=mind").count(), TAIL_PER_ROOM); } @@ -6796,7 +6799,7 @@ fn a_flush_written_beside_a_revision_line_still_makes_the_reply_final() { /// The session a start would open over `first` with no reply waiting: its conversation and origin are the batch's. fn session_of(config: &Config, first: Vec) -> Session { - Session { conversation: first.first().map_or(Conversation::Public, Conversation::of), origin: origin_of(config, &first, &MemoryOrigin::Wake).0, first, tail: None, waiting_ids: Vec::new() } + Session { conversation: first.first().map_or(Conversation::Public, Conversation::of), origin: origin_of(config, &first, &MemoryOrigin::Wake).0, first, tail: None, tail_gaps: Vec::new(), waiting_ids: Vec::new() } } // ---- a Self reply is told only in its brief's conversation ---- @@ -8354,9 +8357,9 @@ fn rooms_she_may_not_speak_in_contribute_nothing_and_a_dm_session_reads_its_own_ let input = first_input(&rig); assert!(input.contains("in-her-room") && !input.contains("in-a-room-not-hers")); // The consumer holds the rule too: a mouth that sent a foreign room's line is not believed. - let foreign = render_tail(&config(), &[], Some(vec![tl("!stranger:chat", 1, "@m:chat", "leak"), tl("!dm:chat", 2, "@m:chat", "dm-line")]), &tail_rooms(&config(), &Conversation::Public), &[], &[]); + let foreign = render_tail(&config(), &[], Some(vec![tl("!stranger:chat", 1, "@m:chat", "leak"), tl("!dm:chat", 2, "@m:chat", "dm-line")]), &[], &tail_rooms(&config(), &Conversation::Public), &[], &[]); assert!(!foreign.text.contains("leak") && !foreign.text.contains("dm-line")); - let dm = render_tail(&config(), &[], Some(vec![tl("swarm", 1, "@m:chat", "public-line"), tl("!dm:chat", 2, "@m:chat", "dm-line")]), &tail_rooms(&config(), &Conversation::Direct("!dm:chat".into())), &[], &[]); + let dm = render_tail(&config(), &[], Some(vec![tl("swarm", 1, "@m:chat", "public-line"), tl("!dm:chat", 2, "@m:chat", "dm-line")]), &[], &tail_rooms(&config(), &Conversation::Direct("!dm:chat".into())), &[], &[]); assert!(dm.text.contains("dm-line") && !dm.text.contains("public-line")); } @@ -8387,7 +8390,7 @@ fn an_instruction_in_a_tail_line_stays_inside_the_data_frame() { fn the_tail_holds_its_byte_bound_counting_its_framing_and_marks_the_drop() { let big = "x".repeat(1000); let lines: Vec = (1..=60u64).map(|n| tl(["swarm", "usage", "operator"][(n % 3) as usize], n, "@m:chat", &format!("{n:02}{}", &big[2..]))).collect(); - let tail = render_tail(&config(), &[], Some(lines), &config().rooms, &[], &[]); + let tail = render_tail(&config(), &[], Some(lines), &[], &config().rooms, &[], &[]); assert!(tail.text.len() <= TAIL_MAX_BYTES, "{} bytes, framing included", tail.text.len()); assert!(tail.text.contains("[tail truncated:") && tail.text.contains("\"60xxx"), "the newest kept, the drop marked"); } @@ -8405,17 +8408,17 @@ fn a_first_poll_the_mouth_cannot_answer_still_fails_the_start_as_chat() { fn the_consumer_holds_the_caps_when_the_mouth_over_sends() { // 200 lines of one room: only the room's newest TAIL_PER_ROOM show. let one_room: Vec = (1..=200u64).map(|n| tl("swarm", n, "@m:chat", &format!("p-{n:04}"))).collect(); - let tail = render_tail(&config(), &[], Some(one_room), &config().rooms, &[], &[]); + let tail = render_tail(&config(), &[], Some(one_room), &[], &config().rooms, &[], &[]); assert_eq!(tail.text.lines().filter(|l| l.starts_with("tail room=")).count(), TAIL_PER_ROOM); assert!(tail.text.contains("p-0200") && !tail.text.contains("p-0180\""), "the newest of the room"); // 300 lines over three rooms: TAIL_TOTAL, with the rest counted in the marker. let three: Vec = (1..=300u64).map(|n| tl(["swarm", "usage", "operator"][(n % 3) as usize], n, "@m:chat", &format!("p-{n:04}"))).collect(); - let tail = render_tail(&config(), &[], Some(three), &config().rooms, &[], &[]); + let tail = render_tail(&config(), &[], Some(three), &[], &config().rooms, &[], &[]); assert_eq!(tail.text.lines().filter(|l| l.starts_with("tail room=")).count(), TAIL_TOTAL); assert!(tail.text.contains("[tail truncated: 240 lines are not shown, the newest 60 are]"), "{}", tail.text); // Lines of a room that is not speakable are not believed, and do not count against the caps of the rooms that are. let mixed: Vec = (1..=100u64).map(|n| tl("!stranger:chat", n, "@m:chat", "foreign")).chain(std::iter::once(tl("swarm", 101, "@m:chat", "hers"))).collect(); - let tail = render_tail(&config(), &[], Some(mixed), &config().rooms, &[], &[]); + let tail = render_tail(&config(), &[], Some(mixed), &[], &config().rooms, &[], &[]); assert!(tail.text.contains("hers") && !tail.text.contains("foreign") && !tail.text.contains("truncated"), "{}", tail.text); } @@ -8426,7 +8429,7 @@ fn a_hostile_sender_room_and_event_id_are_quoted_as_the_body_is() { cfg.rooms = vec![evil.into()]; let mut line = tl(evil, 1, evil, evil); line.event_id = evil.into(); - let tail = render_tail(&cfg, &[], Some(vec![line]), &cfg.rooms, &[], &[]); + let tail = render_tail(&cfg, &[], Some(vec![line]), &[], &cfg.rooms, &[], &[]); assert!(!tail.text.lines().any(|l| l.starts_with("message ")), "{}", tail.text); assert!(!tail.text.contains('\u{2028}'), "a Unicode line separator cannot end the line"); assert_eq!(tail.text.lines().filter(|l| l.starts_with("tail ")).count(), 1, "one tail line, whatever its fields hold"); @@ -8457,3 +8460,33 @@ fn when_every_tail_line_is_shown_below_the_line_does_not_say_the_rooms_were_quie assert!(input.contains("are shown below as messages and are not repeated here"), "{input}"); assert!(!input.contains("Recent tail of your rooms: empty") && !input.contains("No tail line for: \"swarm\""), "swarm was not quiet"); } + +/// A room the mouth names as a gap reads as unknown, whether it has lines or none; a room it does not name and that has no line is quiet. +/// The same bytes a four-field mouth sends decode into the gaps the first line renders. +#[test] +fn a_room_the_mouth_names_as_a_gap_is_unknown_in_the_first_line_and_an_unnamed_empty_one_is_quiet() { + let rig = Rig::new(vec![], &[says(&[])]); + *rig.chat.homeserver.borrow_mut() = Some(vec![tl("operator", 1, "@m:chat", "a-line")]); + *rig.chat.gaps.borrow_mut() = vec!["swarm".into(), "operator".into(), "!stranger:chat".into()]; + let input = first_input(&rig); + assert!(input.contains("Unknown, not quiet: \"swarm\", \"operator\"."), "{input}"); + assert!(!input.contains("!stranger:chat"), "a gap named for a room she may not speak in shows nothing"); + assert!(input.contains("No tail line for: \"usage\".") && !input.contains("Unknown, not quiet: \"swarm\", \"operator\", \"usage\""), "the unnamed empty rooms are quiet, and only they: {input}"); + let rooms = config().rooms; + let quiet_alone = render_tail(&config(), &[], Some(vec![]), &[], &rooms, &[], &[]); + assert!(!quiet_alone.text.contains("Unknown"), "no gap named: nothing is unknown: {}", quiet_alone.text); + let decoded: crate::chat_client::ChatPending = crate::chat_client::decode(include_bytes!("../../tests/fixtures/persona-chat-v7/gamecult.persona.chat_pending.v3.tail-gaps.msgpack")).unwrap(); + let bytes_rendered = render_tail(&config(), &[], decoded.tail, &decoded.tail_gaps, &["#swarm:gamecult.org".to_string(), "#quiet:gamecult.org".to_string()], &[], &[]); + assert!(bytes_rendered.text.contains("Unknown, not quiet: \"#swarm:gamecult.org\"."), "{}", bytes_rendered.text); + assert!(bytes_rendered.text.contains("No tail line for: \"#quiet:gamecult.org\"."), "{}", bytes_rendered.text); +} + +/// No line offered and a room named: the section is the unknown note alone, never "empty"; with no room named, "empty" stays. +#[test] +fn a_named_gap_with_no_lines_reads_unknown_and_never_empty() { + let rooms = config().rooms; + let gapped = render_tail(&config(), &[], Some(vec![]), &["swarm".to_string()], &rooms, &[], &[]); + assert!(gapped.text.contains("Unknown, not quiet: \"swarm\".") && !gapped.text.contains("empty"), "{}", gapped.text); + let none = render_tail(&config(), &[], Some(vec![]), &[], &rooms, &[], &[]); + assert!(none.text.contains("Recent tail of your rooms: empty."), "{}", none.text); +} diff --git a/crates/mind-body/tests/chat_rudp.rs b/crates/mind-body/tests/chat_rudp.rs index 09e2b84..05036fd 100644 --- a/crates/mind-body/tests/chat_rudp.rs +++ b/crates/mind-body/tests/chat_rudp.rs @@ -21,6 +21,8 @@ const PENDING_V1_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecul const PENDING_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack"); const PENDING_TAILED_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.tag.msgpack"); const PENDING_TAIL_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.tail.msgpack"); +// The four-field `chat_pending.v3` of a mouth with `tail_gaps`: Bifrost b2e0bb2's own golden bytes, copied unchanged. +const PENDING_GAPS_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v7/gamecult.persona.chat_pending.v3.tail-gaps.msgpack"); const PENDING_V2_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v3/gamecult.persona.chat_pending.v2.image.msgpack"); const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v5/gamecult.persona.chat_pending_request.v2.msgpack"); const PENDING_REQUEST_V1_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack"); @@ -323,3 +325,26 @@ fn the_three_field_v3_crosses_real_rudp_with_its_tail_and_the_two_field_one_with let old = client(&mouth(KEY), KEY, "eureka", Duration::from_secs(5)).pending(0).unwrap(); assert_eq!((old.deliveries.len(), old.tail), (1, None), "the older mouth's two-field answer carries no tail"); } + +/// The three-field answer with a fourth element appended, as a four-field mouth would send it. +fn four_field(three: &[u8], gaps: &[&str]) -> &'static [u8] { + let mut value: serde_json::Value = rmp_serde::from_slice(three).unwrap(); + value.as_array_mut().unwrap().push(serde_json::json!(gaps)); + Box::leak(rmp_serde::to_vec(&value).unwrap().into_boxed_slice()) +} + +/// Deploy safety for `tail_gaps` (Bifrost b2e0bb2): the three-field answer, the four-field one with gaps and the four-field one with +/// none all cross real RUDP and decode. Only a named room is a gap; a quiet room is never in the list. +#[test] +fn the_four_field_v3_crosses_real_rudp_with_its_gaps_and_the_three_field_one_with_none() { + let fetch = |bytes: &'static [u8]| client(&serve(KEY, "127.0.0.1:0", PENDING, bytes, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES)), KEY, "eureka", Duration::from_secs(5)).pending(0).unwrap(); + let three = fetch(PENDING_TAIL_BYTES); + assert_eq!((three.tail.as_ref().map(Vec::len), three.tail_gaps), (Some(2), Vec::::new()), "a three-field answer names no gap"); + let gapped = fetch(PENDING_GAPS_BYTES); + assert_eq!((gapped.deliveries.len(), gapped.tail.as_ref().map(Vec::len)), (0, Some(1))); + assert_eq!(gapped.tail_gaps, vec!["#swarm:gamecult.org".to_string(), "!dm1:gamecult.org".to_string()], "the mouth's own bytes name two rooms"); + let empty = fetch(four_field(PENDING_TAIL_BYTES, &[])); + assert_eq!((empty.tail.as_ref().map(Vec::len), empty.tail_gaps), (Some(2), Vec::::new()), "an empty fourth element is every room read fully"); + let quiet = fetch(four_field(PENDING_TAIL_BYTES, &["#swarm:gamecult.org"])); + assert!(!quiet.tail_gaps.contains(&"#operator:gamecult.org".to_string()), "a room the mouth did not name is not a gap, however few lines it has"); +} diff --git a/crates/mind-body/tests/fixtures/persona-chat-v7/gamecult.persona.chat_pending.v3.tail-gaps.msgpack b/crates/mind-body/tests/fixtures/persona-chat-v7/gamecult.persona.chat_pending.v3.tail-gaps.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..e622eb78b35d1733c9f33160308054fa3a0284ba GIT binary patch literal 127 zcmbQTEVZ;KH9K*_#Od3V^9xdo5=-)ntkM&6QKTPM#Yp|LlhAJoFp|Q literal 0 HcmV?d00001