Persona wakes on address only: chat_pending_request.v2 / chat_pending.v3 basis and form; unaddressed deliveries are acked in sequence and tell no one

Consumer half of ruling eureka-body:ruling:addressed-delivery-only. A delivery is for a Persona when it is a message and Matrix says
direct/tag/reply, or its mentions or text name her (address::is_for); notices, edits, @room and a nil basis (unproven) wake no one on
their own. The heartbeat's off-beat wake keeps its sender gate. The Persona acks the leading run of what is not hers in one ack and a
live session is told only what is hers. A mouth that does not serve the v2 request is asked the v1 way. Golden bytes pinned from Bifrost
tests/fixtures/persona-chat-v5; the public-surface snapshot is regenerated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Eureka 2026-11-11 06:43:46 +00:00
commit 50d8e60102
14 changed files with 429 additions and 73 deletions

View file

@ -6,7 +6,7 @@
use eureka_pipeline::{OrgRepo, Short};
use crate::chat_client::ChatDelivery;
use crate::chat_client::{Basis, ChatDelivery, Form};
/// A word a message may call a Mind or her project by.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@ -46,9 +46,17 @@ pub(crate) fn localpart(matrix_id: &str) -> &str {
id.split_once(':').map_or(id, |(local, _)| local)
}
/// Whether a delivery is for `who`: tagged, or named by a word of hers.
/// Whether a delivery is for `who` by what it says: only a message can be (a notice or an edit never is, whatever else it says), and
/// then a tag of hers in its mentions or a word of hers in the text. What Matrix says about the Persona that polled (`Basis`) is not
/// here: it is a fact about that one Persona, not about every Mind in the table (`is_for`).
pub(crate) fn addresses(who: &Addressee, delivery: &ChatDelivery) -> bool {
delivery.mentions.contains(&who.matrix_id) || who.names.iter().any(|name| names_whole_word(&delivery.text, &name.text, name.exact))
delivery.form == Form::Message && (delivery.mentions.contains(&who.matrix_id) || who.names.iter().any(|name| names_whole_word(&delivery.text, &name.text, name.exact)))
}
/// Whether a delivery the mouth gave `own` is for her: Matrix's word that it is her DM, a tag of hers or a reply to her, or what it
/// says (`addresses`). The basis `Room` (`@room`) is for no one, and no basis is unproven, not "not for her": the names still decide.
pub(crate) fn is_for(own: &Addressee, delivery: &ChatDelivery) -> bool {
(delivery.form == Form::Message && matches!(delivery.basis, Some(Basis::Direct | Basis::Tag | Basis::Reply))) || addresses(own, delivery)
}
/// The chat names of one stewarded repo. Listed names are exactly those, each matched exactly as the steward wrote it (capitalization

View file

@ -1,6 +1,7 @@
//! The one addressing rule: a tag, or a name as a whole word; a listed name exactly as written, a derived one-word repo name as the repo spells it, a split phrase or a Mind's own in any case.
use super::*;
use crate::chat_client::{Basis, Form};
fn said(text: &str, mentions: &[&str]) -> ChatDelivery {
ChatDelivery {
@ -10,6 +11,8 @@ fn said(text: &str, mentions: &[&str]) -> ChatDelivery {
room_alias: "swarm".into(),
is_direct: false,
image: None,
basis: None,
form: Form::Message,
sender: "@op:chat".into(),
sender_display: "Op".into(),
at_unix_ms: 0,
@ -108,3 +111,20 @@ fn the_localpart_is_the_name_between_the_at_and_the_colon() {
assert_eq!(localpart("@eureka:chat.gamecult.org"), "eureka");
assert_eq!(localpart("eureka"), "eureka");
}
#[test]
fn only_a_message_can_address_and_the_mouths_basis_stands_for_the_polling_mind_alone() {
let nibu = steward("GameCult/Thing", &[]);
let with = |basis, form, text: &str, mentions: &[&str]| ChatDelivery { basis, form, ..said(text, mentions) };
for basis in [Basis::Direct, Basis::Tag, Basis::Reply] {
assert!(is_for(&nibu, &with(Some(basis), Form::Message, "nothing named", &[])), "{basis:?}");
assert!(!addresses(&nibu, &with(Some(basis), Form::Message, "nothing named", &[])), "a basis is a fact about the Persona that polled, not about every Mind: {basis:?}");
assert!(!is_for(&nibu, &with(Some(basis), Form::Notice, "nothing named", &[])), "a notice is for no one: {basis:?}");
assert!(!is_for(&nibu, &with(Some(basis), Form::Edit, "nibu, look", &["@nibu:chat"])), "nor an edit, tagged and named: {basis:?}");
assert!(!addresses(&nibu, &with(None, Form::Edit, "nibu, look", &["@nibu:chat"])), "{basis:?}");
}
assert!(!is_for(&nibu, &with(Some(Basis::Room), Form::Message, "nothing named", &[])), "@room is for no one");
assert!(!is_for(&nibu, &with(None, Form::Message, "nothing named", &[])), "unproven and unnamed");
assert!(is_for(&nibu, &with(None, Form::Message, "nibu, look", &[])), "unproven is not 'not for her': her name decides");
assert!(is_for(&nibu, &with(Some(Basis::Room), Form::Message, "nibu, look", &[])), "an @room that also names her is named");
}

View file

@ -4,8 +4,8 @@
//! The mouth is Bifrost's chat ear and mouth (cut `persona-mouth`). Five CultNet document kinds cross
//! it, each a positional MessagePack tuple (fields in declaration order, `rmp_serde::to_vec`; the mouth refuses maps): the pending request and its answer, the
//! ack, and the send request with its receipt. Their field order is the mouth's, pinned
//! by its golden fixtures (`tests/fixtures/persona-chat-v1/` for the pending, ack and refusal kinds, `persona-chat-v2/` for the send
//! and its receipt) in both directions. `chat_rudp::RudpChat` is
//! by its golden fixtures (`tests/fixtures/persona-chat-v1/` for the ack and refusal kinds, `persona-chat-v2/` for the send
//! and its receipt, `persona-chat-v5/` for the pending request and its answer) in both directions. `chat_rudp::RudpChat` is
//! the RUDP transport that carries them; `Chat` is its seam, and `encode` and
//! `decode` are its codec. Room aliases are the mouth's
//! full aliases (`#operator:gamecult.org`), which the caller's `--rooms` carries.
@ -17,9 +17,15 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
/// Document kinds, as the mouth names them.
pub const PENDING_REQUEST: &str = "gamecult.persona.chat_pending_request.v1";
pub const PENDING: &str = "gamecult.persona.chat_pending.v2";
/// The pending answer of a mouth that has no image field yet: its deliveries decode with `image: None`.
/// The pending request as a mouth without the addressing facts reads it: `[persona, after_seq]`, answered with `chat_pending.v2`.
pub const PENDING_REQUEST_V1: &str = "gamecult.persona.chat_pending_request.v1";
/// The pending request, the same tuple. Asking by this kind is asking for the answer with the addressing facts.
pub const PENDING_REQUEST: &str = "gamecult.persona.chat_pending_request.v2";
/// The answer to it: a delivery with its `basis` and `form` at tuple indices 12 and 13.
pub const PENDING: &str = "gamecult.persona.chat_pending.v3";
/// The answer a mouth gives a v1 request: no addressing facts, decoded as `basis: None, form: Message`.
pub const PENDING_V2: &str = "gamecult.persona.chat_pending.v2";
/// The pending answer of a mouth that has no image field either: its deliveries decode with `image: None`.
pub const PENDING_V1: &str = "gamecult.persona.chat_pending.v1";
pub const ACK: &str = "gamecult.persona.chat_ack.v1";
/// Her read receipt: the newest event of a room that went into her model's input.
@ -58,6 +64,87 @@ pub struct ChatDelivery {
/// The attachment the mouth stored for an image event. Tuple index 11; a v1 record ends at 10 and reads as none.
#[serde(default)]
pub image: Option<ChatImage>,
/// Why Matrix says the message is for this Persona: tuple index 12. None is unproven, never "not for her"; an answer of an
/// older mouth ends before it and reads as none.
#[serde(default)]
pub basis: Option<Basis>,
/// What kind of event it is: tuple index 13. An answer of an older mouth ends before it and reads as a message.
#[serde(default)]
pub form: Form,
}
/// Why the mouth found a delivery to be for the Persona, by what Matrix says (Bifrost `docs/bridge.md` "Addressing facts"). `Tag` and
/// `Room` are the sender's claim in `m.mentions`; `Reply` is checked against the event it targets. Evidence, not authority: the
/// runtime's own rule (`address::addresses`) weighs it. `Room` is reported and wakes no one.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Basis {
Direct,
Tag,
Reply,
Room,
}
impl Basis {
const ALL: [Self; 4] = [Self::Direct, Self::Tag, Self::Reply, Self::Room];
/// The mouth's spelling: a MessagePack string.
pub fn name(self) -> &'static str {
match self {
Self::Direct => "direct",
Self::Tag => "tag",
Self::Reply => "reply",
Self::Room => "room",
}
}
}
impl Serialize for Basis {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.name())
}
}
impl<'de> Deserialize<'de> for Basis {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let name = String::deserialize(deserializer)?;
Self::ALL.into_iter().find(|basis| basis.name() == name).ok_or_else(|| serde::de::Error::custom("the mouth named a basis this side does not know"))
}
}
/// The kind of event a delivery is. Only a `Message` can address anyone: a `Notice` is an automated line and an `Edit` revises an
/// earlier message.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Form {
#[default]
Message,
Notice,
Edit,
}
impl Form {
const ALL: [Self; 3] = [Self::Message, Self::Notice, Self::Edit];
/// The mouth's spelling: a MessagePack string.
pub fn name(self) -> &'static str {
match self {
Self::Message => "message",
Self::Notice => "notice",
Self::Edit => "edit",
}
}
}
impl Serialize for Form {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.name())
}
}
impl<'de> Deserialize<'de> for Form {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let name = String::deserialize(deserializer)?;
Self::ALL.into_iter().find(|form| form.name() == name).ok_or_else(|| serde::de::Error::custom("the mouth named a form this side does not know"))
}
}
/// An image the mouth fetched and stored, by reference: never its bytes. The path is relative to the Persona's image

View file

@ -10,7 +10,10 @@ const ACK_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/ga
const PENDING_V1_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending.v1.msgpack");
const PENDING_NO_IMAGE_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v3/gamecult.persona.chat_pending.v2.no-image.msgpack");
const PENDING_IMAGE_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v3/gamecult.persona.chat_pending.v2.image.msgpack");
const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack");
const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending_request.v2.msgpack");
const PENDING_REQUEST_V1_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack");
const PENDING_TAG_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack");
const PENDING_UNPROVEN_NOTICE_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.unproven-notice.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");
@ -31,6 +34,8 @@ fn golden<T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug>(bytes:
#[test]
fn the_pending_request_and_ack_are_the_mouths_bytes() {
golden(PENDING_REQUEST_BYTES, &ChatPendingRequest { persona: "eureka".into(), after_seq: 3 });
// The v1 kind carries the same tuple: asking by the v2 kind is the only difference.
assert_eq!(PENDING_REQUEST_V1_BYTES, PENDING_REQUEST_BYTES);
golden(ACK_BYTES, &ChatAck { persona: "eureka".into(), through_seq: 7 });
}
@ -48,6 +53,8 @@ fn delivery(image: Option<ChatImage>) -> ChatDelivery {
mentions: vec!["@eureka:gamecult.org".into(), "@nova:gamecult.org".into()],
is_direct: true,
image,
basis: None,
form: Form::Message,
}
}
@ -64,13 +71,44 @@ fn a_v1_pending_answer_still_decodes_with_no_image() {
}
#[test]
fn a_v2_pending_answer_is_the_golden_bytes_with_the_image_at_tuple_index_11() {
golden(PENDING_NO_IMAGE_BYTES, &ChatPending { persona: "eureka".into(), deliveries: vec![delivery(None)] });
golden(PENDING_IMAGE_BYTES, &ChatPending { persona: "eureka".into(), deliveries: vec![delivery(Some(image()))] });
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)] };
assert_eq!(decode::<ChatPending>(PENDING_NO_IMAGE_BYTES).unwrap(), expected(None));
assert_eq!(decode::<ChatPending>(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].
let value = shape::delivery_tuple(PENDING_IMAGE_BYTES);
assert_eq!(value, shape::Probe { delivery_len: 12, image_len: Some(4) });
assert_eq!(shape::delivery_tuple(PENDING_IMAGE_BYTES), shape::Probe { delivery_len: 12, image_len: Some(4) });
assert_eq!(shape::delivery_tuple(PENDING_NO_IMAGE_BYTES), shape::Probe { delivery_len: 12, image_len: None });
// A v1 request is answered with that 12-field tuple: no basis is unproven, and the event is a message.
let old = decode::<ChatPending>(PENDING_IMAGE_BYTES).unwrap().deliveries.remove(0);
assert_eq!((old.basis, old.form), (None, Form::Message));
}
#[test]
fn a_v3_pending_answer_is_the_golden_bytes_with_basis_and_form_at_tuple_indices_12_and_13() {
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] }
};
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));
let fields = |bytes: &[u8]| {
let value: serde_json::Value = rmp_serde::from_slice(bytes).unwrap();
let tuple = value[1][0].as_array().unwrap().clone();
(tuple.len(), tuple[12].clone(), tuple[13].clone())
};
assert_eq!(fields(PENDING_TAG_BYTES), (14, "tag".into(), "message".into()));
assert_eq!(fields(PENDING_UNPROVEN_NOTICE_BYTES), (14, serde_json::Value::Null, "notice".into()));
}
#[test]
fn a_basis_or_form_this_side_does_not_know_does_not_decode() {
let mut value: serde_json::Value = rmp_serde::from_slice(PENDING_TAG_BYTES).unwrap();
value[1][0][12] = "everyone".into();
assert!(decode::<ChatPending>(&rmp_serde::to_vec(&value).unwrap()).is_err());
value[1][0][12] = "tag".into();
value[1][0][13] = "reaction".into();
assert!(decode::<ChatPending>(&rmp_serde::to_vec(&value).unwrap()).is_err());
}
/// Reads the tuple's shape from the raw bytes, independent of the serde types under test.
@ -104,15 +142,6 @@ fn a_send_and_its_receipt_are_the_mouths_bytes() {
golden(ROOM_MOVED_BYTES, &ChatSendReceipt { request_id: "req-1".into(), event_id: None, refusal: Some(Refusal::RoomMoved), newer: vec!["$a".into(), "$b".into()] });
}
/// Writes the v2 fixtures; run once with `WRITE_CHAT_FIXTURES=1` when the wire changes.
#[test]
#[ignore = "regenerates the golden fixtures"]
fn write_the_v2_pending_fixtures() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/persona-chat-v3/");
std::fs::write(format!("{dir}gamecult.persona.chat_pending.v2.no-image.msgpack"), encode(&ChatPending { persona: "eureka".into(), deliveries: vec![delivery(None)] }).unwrap()).unwrap();
std::fs::write(format!("{dir}gamecult.persona.chat_pending.v2.image.msgpack"), encode(&ChatPending { persona: "eureka".into(), deliveries: vec![delivery(Some(image()))] }).unwrap()).unwrap();
}
#[test]
fn open_direct_fixtures_round_trip() {
golden(OPEN_REQUEST_BYTES, &ChatOpenDirectRequest { request_id: "req-1".into(), persona: "eureka".into(), user_id: "@meta:gamecult.org".into(), text: "hello Meta".into() });

View file

@ -29,7 +29,7 @@ use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256};
use crate::chat_client::{
ACK, Chat, ChatAck, ChatDelivery, ChatOpenDirectReceipt, ChatOpenDirectRequest, ChatPending, ChatPendingRequest, ChatRead, ChatRefusal, MouthRefused, Refusal, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, PENDING_V1, READ, REFUSAL, SEND_RECEIPT,
ACK, Chat, ChatAck, ChatDelivery, ChatOpenDirectReceipt, ChatOpenDirectRequest, ChatPending, ChatPendingRequest, ChatRead, ChatRefusal, MouthRefused, Refusal, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_REQUEST_V1, PENDING_V2, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, PENDING_V1, READ, REFUSAL, SEND_RECEIPT,
SEND_REQUEST, decode, encode,
};
@ -161,7 +161,12 @@ impl Chat for RudpChat {
fn pending(&self, after_seq: u64) -> Result<Vec<ChatDelivery>> {
let request = ChatPendingRequest { persona: self.persona.clone(), after_seq };
let p = self.persona.as_str();
let (kind, payload) = self.exchange(PENDING_REQUEST, p, &request, Some(&[(PENDING, p), (PENDING_V1, p), (REFUSAL, p)]))?.ok_or_else(|| anyhow!("no answer payload"))?;
let answers = [(PENDING, p), (PENDING_V2, p), (PENDING_V1, p), (REFUSAL, p)];
let (mut kind, mut payload) = self.exchange(PENDING_REQUEST, p, &request, Some(&answers))?.ok_or_else(|| anyhow!("no answer payload"))?;
// A mouth that does not serve the v2 request answers `UnknownKind`: ask it the v1 way, and its deliveries carry no addressing facts.
if kind == REFUSAL && decode::<ChatRefusal>(&payload)?.refusal == Refusal::UnknownKind {
(kind, payload) = self.exchange(PENDING_REQUEST_V1, p, &request, Some(&answers))?.ok_or_else(|| anyhow!("no answer payload"))?;
}
if kind == REFUSAL {
let refusal: ChatRefusal = decode(&payload)?;
bail!("the mouth refused the pending request: {}", refusal.refusal.name());

View file

@ -39,7 +39,7 @@ use cultnet_rs::Selection;
use eureka_pipeline::{PipelineKind, PipelineRef, ResolutionOutcome, RunOperator, RunTurn, Slug};
use huginn_mind::{PipelineDocumentSummary, PipelineFacts, PipelinePageItems};
use crate::address::{Addressee, Name, addresses, project_names};
use crate::address::{Addressee, Name, is_for, project_names};
use crate::chat_client::{Chat, ChatDelivery};
use crate::control::{ControlSource, Effective, FileSource, HeldReason, SwarmEffective, read_effective, read_envelope, set_mode_world_readable};
use crate::launch::{Clock, LaunchOutcome, LaunchRequest, Launcher, Ports, open_and_launch, slot};
@ -518,7 +518,7 @@ fn project_names_of(mind: &dyn MindPort) -> Result<Vec<Name>> {
fn wakes(config: &Config, who: &Addressee, delivery: &ChatDelivery) -> bool {
let from_operator = config.operators.contains(&delivery.sender);
let from_other_mind = delivery.sender != who.matrix_id() && config.personas.iter().any(|mind| mind.matrix_id == delivery.sender);
(from_operator || from_other_mind) && addresses(who, delivery)
(from_operator || from_other_mind) && is_for(who, delivery)
}
/// The mouth pages a pending answer, so fewer deliveries may come than wait.
@ -633,7 +633,7 @@ impl Row<'_> {
/// A member (not the operator) has a pending message that names or tags her.
fn addressed_by_member(&self, config: &Config) -> bool {
self.pending.iter().any(|delivery| !config.operators.contains(&delivery.sender) && addresses(&self.addressee, delivery))
self.pending.iter().any(|delivery| !config.operators.contains(&delivery.sender) && is_for(&self.addressee, delivery))
}
}

View file

@ -12,6 +12,7 @@ use chrono::{Duration, TimeZone};
use eureka_pipeline::PipelineKind as K;
use huginn_mind::PipelineSelectionPage;
use tempfile::TempDir;
use crate::address::addresses;
use super::*;
use crate::control::{Brake, ControlState, SwarmState};
@ -161,6 +162,8 @@ fn delivery(seq: u64, sender: &str, text: &str, mentions: &[&str]) -> ChatDelive
mentions: mentions.iter().map(|id| (*id).to_string()).collect(),
is_direct: false,
image: None,
basis: None,
form: crate::chat_client::Form::Message,
}
}
@ -1556,3 +1559,34 @@ fn a_sleeping_mind_is_not_eligible_for_the_beat() {
assert_eq!(rig.poll(), Outcome::Polled { launched: vec![Slug("ann".into()), Slug("bob".into())] });
assert_eq!(started_turns(&rig), vec![("ann".to_string(), RunTurn::Sleep), ("bob".to_string(), RunTurn::PersonaTurn)], "the beat went to the Mind who was awake, though ann's urgency was the higher");
}
/// Matrix's word that a message is her DM, a tag or a reply wakes her off the beat from the operator; a notice or an edit never does,
/// whatever its basis, and a stranger's direct message is delivered but wakes no one.
#[test]
fn matrixs_addressing_facts_wake_her_off_the_beat_only_on_a_message_from_the_roster() {
use crate::chat_client::{Basis, Form};
let with = |seq, sender: &str, basis, form| ChatDelivery { basis, form, ..delivery(seq, sender, "no name here", &[]) };
for (basis, form, wakes) in [
(Some(Basis::Direct), Form::Message, true),
(Some(Basis::Tag), Form::Message, true),
(Some(Basis::Reply), Form::Message, true),
(Some(Basis::Room), Form::Message, false),
(None, Form::Message, false),
(Some(Basis::Direct), Form::Notice, false),
(Some(Basis::Tag), Form::Edit, false),
] {
let rig = Rig::new();
rig.turn(ANN, 10);
rig.turn(BOB, 10);
rig.store.save(&HeartbeatState { last_beat_at: rig.now(), handled_mentions: vec![] }).unwrap();
rig.say(BOB, with(1, "@op:chat", basis, form));
let launched = if wakes { vec![Slug("bob".into())] } else { vec![] };
assert_eq!(rig.poll(), Outcome::Polled { launched }, "{basis:?} {form:?}");
}
let rig = Rig::new();
rig.turn(ANN, 10);
rig.turn(BOB, 10);
rig.store.save(&HeartbeatState { last_beat_at: rig.now(), handled_mentions: vec![] }).unwrap();
rig.say(BOB, with(1, "@stranger:chat", Some(Basis::Direct), Form::Message));
assert_eq!(rig.poll(), Outcome::Polled { launched: vec![] }, "the sender gate stays: a stranger's DM is delivered and wakes no one off the beat");
}

View file

@ -84,7 +84,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use uuid::Uuid;
use crate::address::{Addressee, Name, addresses, localpart, project_names};
use crate::address::{Addressee, Name, addresses, is_for, localpart, project_names};
use crate::chat_image::{self, Image, MAX_PER_LINE};
use crate::chat_client::{Chat, ChatDelivery, MouthRefused, ChatOpenDirectRequest, ChatRead, DirectOutcome, ChatSendRequest, ContextLine, Refusal, SendBasis};
use crate::connector::render_plan;
@ -886,13 +886,23 @@ pub fn carry(organs: &Organs, control: &impl ControlSource, swarm: &impl Control
progress.failure = Some(Failure::Held(reason));
break;
}
let Ok(pending) = organs.chat.pending(progress.acked) else {
let Ok(mut pending) = organs.chat.pending(progress.acked) else {
progress.failure = Some(Failure::Chat);
break;
};
// 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));
pending.sort_by_key(|delivery| delivery.seq);
let own = addressing.iter().find(|mind| mind.matrix_id() == config.own_sender).cloned().unwrap_or_else(|| own_addressee(config));
if let Some(through) = quiet_through(&own, &pending, 0) {
if organs.chat.ack(through).is_err() {
progress.failure = Some(Failure::Ack);
break;
}
progress.acked = through;
pending.retain(|delivery| delivery.seq > through);
}
let Some(session) = next_session(config, &addressing, pending, &routes, &mut opened_alone, process, unix_ms(organs.clock.now())) else { break };
let remaining_s = i64::try_from(organs.run_cap_s).unwrap_or(i64::MAX).saturating_sub((organs.clock.now() - started).num_seconds());
if process > 0 && remaining_s <= ROTATE_MARGIN_S {
@ -1593,6 +1603,20 @@ impl<C: ControlSource, S: ControlSource> SessionInbox for PersonaInbox<'_, C, S>
self.note_repo_changes();
let mut pending = self.organs.chat.pending(self.progress.acked)?;
pending.sort_by_key(|delivery| delivery.seq);
// Whom a delivery is for is the heartbeat's table as it stands, her own row first: it knows her projects' names before her
// first line has read them.
if !pending.is_empty() {
self.addressing = addressing_of(self.config, self.organs.heartbeat, &self.addressee);
}
let own = self.addressing.iter().find(|mind| mind.matrix_id() == self.config.own_sender).cloned().unwrap_or_else(|| self.addressee.clone());
if let Some(through) = quiet_through(&own, &pending, self.told_through) {
if self.organs.chat.ack(through).is_err() {
self.progress.failure.get_or_insert(Failure::Ack);
return Ok(Polled { pending: false, other: false, other_first: false, yield_now: true });
}
self.progress.acked = through;
pending.retain(|delivery| delivery.seq > through);
}
if let Some(revision) = &mut self.revision {
revision.polls += 1;
revision.ready |= revision.polls >= REVISION_POLLS || revision.awaiting.iter().all(|id| pending.iter().any(|delivery| delivery.event_id == *id));
@ -1611,10 +1635,7 @@ impl<C: ControlSource, S: ControlSource> SessionInbox for PersonaInbox<'_, C, S>
self.first_seen.entry(delivery.seq).or_insert(now);
}
let any_untold = !untold.is_empty();
if any_untold {
self.addressing = addressing_of(self.config, self.organs.heartbeat, &self.addressee);
}
let due = revision_ready || untold.iter().any(|delivery| now >= self.first_seen[&delivery.seq] + stagger_ms(self.config, &self.addressing, delivery));
let due = revision_ready || untold.iter().any(|delivery| is_for(&own, delivery) && now >= self.first_seen[&delivery.seq] + stagger_ms(self.config, &self.addressing, delivery));
self.waiting = if due { one_line_of(untold, |batch| message_lines(self.config, &self.addressee, &self.addressing, batch, &self.earlier(batch)).len()) } else { Vec::new() };
Ok(self.polled(!self.waiting.is_empty() || self.retell.is_some() || revision_ready || !self.notices.is_empty() || !self.waiting_replies.is_empty(), any_untold))
}
@ -1972,6 +1993,15 @@ fn act<C: ControlSource, S: ControlSource>(inbox: &PersonaInbox<'_, C, S>, batch
Ok(Step::Answered(started, reply.intent, opened, notice))
}
/// The seq an ack may pass because nothing in it is for her: the leading run of `pending` (oldest first) that is not addressed to
/// `own` and lies above `told_through`, so no delivery she was told and has not answered is passed. None when the front is addressed,
/// or already told. Such a delivery wakes and tells no one; it is acked in sequence so the mouth's position moves (it stops reading
/// at 500 unacked) and is not lost: Matrix keeps it, and the `context` of the next addressed delivery shows it.
fn quiet_through(own: &Addressee, pending: &[ChatDelivery], told_through: u64) -> Option<u64> {
let run: Vec<&ChatDelivery> = pending.iter().take_while(|delivery| delivery.seq > told_through && !is_for(own, delivery)).collect();
run.last().map(|last| last.seq)
}
/// How long after a poll first sees `delivery` a live session is told it: none in a direct message, otherwise her rank times the
/// step. The Minds the message is for (tagged, named, or named by a project, by `address::addresses` over the swarm's addressing)
/// rank first, by a hash of the Mind and the event, and the rest follow them, ranked the same way: the first addressed Mind hears
@ -1986,7 +2016,7 @@ fn stagger_ms(config: &Config, addressing: &[Addressee], delivery: &ChatDelivery
let mut minds: Vec<&Addressee> = addressing.iter().filter(|mind| mind.matrix_id() != delivery.sender).collect();
minds.sort_unstable_by_key(|mind| mind.matrix_id());
minds.dedup_by_key(|mind| mind.matrix_id());
let (addressed, others): (Vec<&Addressee>, Vec<&Addressee>) = minds.into_iter().partition(|mind| addresses(mind, delivery));
let (addressed, others): (Vec<&Addressee>, Vec<&Addressee>) = minds.into_iter().partition(|mind| if mind.matrix_id() == config.own_sender { is_for(mind, delivery) } else { addresses(mind, delivery) });
let own_is_addressed = addressed.iter().any(|mind| mind.matrix_id() == config.own_sender);
let (set, before) = if own_is_addressed { (&addressed, 0) } else { (&others, addressed.len()) };
let mine = (stagger_hash(&config.own_sender, &delivery.event_id), config.own_sender.as_str());
@ -2725,7 +2755,7 @@ struct Addressees {
}
fn addressees(own: &Addressee, addressing: &[Addressee], delivery: &ChatDelivery, names: &BTreeMap<&str, &str>) -> Addressees {
let to_you = addresses(own, delivery);
let to_you = is_for(own, delivery);
let mut ids: Vec<&str> = delivery.mentions.iter().map(String::as_str).filter(|id| *id != own.matrix_id()).collect();
for row in addressing {
let id = row.matrix_id();

View file

@ -395,7 +395,8 @@ fn config() -> Config {
fn delivery(seq: u64, room: &str, sender: &str, text: &str) -> ChatDelivery {
let ranked_first = |event_id: &str| stagger(&config(), &own_addressee(&config()), &ChatDelivery { event_id: event_id.into(), ..delivery_of(seq, room, sender, text, event_id) }) == 0;
let event_id = (0..1000).map(|n| if n == 0 { format!("$e{seq}") } else { format!("$e{seq}-{n}") }).find(|id| ranked_first(id)).expect("among 1000 events one ranks her first: the stagger ranks nobody first");
delivery_of(seq, room, sender, text, &event_id)
// A message the mouth found to be for her (a tag), so a session is told it; `delivery_of` is the bare one, with no basis.
ChatDelivery { basis: Some(crate::chat_client::Basis::Tag), ..delivery_of(seq, room, sender, text, &event_id) }
}
/// The stagger of `delivery` for a Mind whose own addressee is `own`, with no heartbeat table: the configured Minds by tag and localpart.
@ -403,6 +404,11 @@ fn stagger(config: &Config, own: &Addressee, delivery: &ChatDelivery) -> u64 {
stagger_ms(config, &addressing_of(config, None, own), delivery)
}
/// The same message with no basis: what the mouth could not prove is hers, left to her names.
fn plain(delivery: ChatDelivery) -> ChatDelivery {
ChatDelivery { basis: None, ..delivery }
}
fn delivery_of(seq: u64, room: &str, sender: &str, text: &str, event_id: &str) -> ChatDelivery {
ChatDelivery {
seq,
@ -417,6 +423,8 @@ fn delivery_of(seq: u64, room: &str, sender: &str, text: &str, event_id: &str) -
mentions: vec![],
is_direct: false,
image: None,
basis: None,
form: crate::chat_client::Form::Message,
}
}
@ -949,12 +957,12 @@ fn a_persona_that_is_absent_or_a_session_that_fails_fails_the_start() {
#[test]
fn senders_are_typed_by_the_organ_and_the_trace_keeps_each_items_provenance() {
let mut earlier = delivery(2, "swarm", "@guest:chat", "ship it");
let mut earlier = ChatDelivery { basis: None, ..delivery(2, "swarm", "@guest:chat", "ship it") };
earlier.context = vec![ContextLine { sender: "@eureka:chat".into(), text: "I said wait".into(), at_unix_ms: 1 }];
let rig = Rig::new(vec![operator_says(1, "do the thing"), earlier], &[says(&[("operator", "done")])]);
rig.go();
let input = rig.model.requests.borrow()[0].input.clone();
assert!(input.contains(r#"message room="operator" sender="@op:chat" role=operator name="op" at=2023-11-14T22:13:20Z text="do the thing""#), "{input}");
assert!(input.contains(r#"message room="operator" sender="@op:chat" role=operator name="op" to_you=true at=2023-11-14T22:13:20Z text="do the thing""#), "{input}");
assert!(input.contains(r#"message room="swarm" sender="@guest:chat" role=member name="guest" at=2023-11-14T22:13:20Z text="ship it""#), "{input}");
assert!(input.contains(r#"earlier room="swarm" sender="@eureka:chat" role=persona at=1970-01-01T00:00:00Z text="I said wait""#), "{input}");
let trace = rig.trace.0.borrow();
@ -1273,10 +1281,10 @@ fn her_responsibilities_show_each_stewardships_chat_names_and_the_line_on_changi
fn a_message_naming_her_project_or_her_name_renders_to_you_and_to_lists_tags_only() {
let rig = Rig::new(
vec![
delivery(1, "swarm", "@op:chat", "is the bad day simulator green?"),
delivery(2, "swarm", "@op:chat", "Eureka, a word"),
delivery(3, "swarm", "@op:chat", "a bad day, simulated"),
ChatDelivery { mentions: vec!["@aqua:chat".into()], ..delivery(4, "swarm", "@op:chat", "BadDaySimulator, Aqua?") },
plain(delivery(1, "swarm", "@op:chat", "Eureka, a word")),
plain(delivery(2, "swarm", "@op:chat", "is the bad day simulator green?")),
plain(delivery(3, "swarm", "@op:chat", "a bad day, simulated")),
ChatDelivery { mentions: vec!["@aqua:chat".into()], ..plain(delivery(4, "swarm", "@op:chat", "BadDaySimulator, Aqua?")) },
],
&[says(&[])],
);
@ -1287,7 +1295,7 @@ fn a_message_naming_her_project_or_her_name_renders_to_you_and_to_lists_tags_onl
assert!(line("a word").contains(" to_you=true "), "{input}");
assert!(!line("simulated").contains("to_you"), "{input}");
assert!(line("Aqua?").contains(" to_you=true to=[\"@aqua:chat\"] "), "the tag stays in `to`, hers is not listed: {input}");
let other = message_lines(&config(), &own_addressee(&config()), &[], &[delivery(1, "swarm", "@op:chat", "is the bad day simulator green?")], &[vec![]]);
let other = message_lines(&config(), &own_addressee(&config()), &[], &[plain(delivery(1, "swarm", "@op:chat", "is the bad day simulator green?"))], &[vec![]]);
assert!(!other.contains("to_you"), "another Mind is not addressed by a project she does not steward: {other}");
}
@ -1347,9 +1355,9 @@ fn the_responsibilities_hold_their_bound_and_mark_what_they_drop() {
}
fn input_for(mentions: &[&str], display: &str) -> String {
let mut aqua = delivery(1, "swarm", "@aqua:chat", "hello");
let mut aqua = ChatDelivery { basis: None, ..delivery(1, "swarm", "@aqua:chat", "hello") };
aqua.sender_display = display.into();
let mut said = delivery(2, "swarm", "@operator:chat", "a reply");
let mut said = ChatDelivery { basis: None, ..delivery(2, "swarm", "@operator:chat", "a reply") };
said.mentions = mentions.iter().map(|m| (*m).to_string()).collect();
let c = config();
let batch = [aqua, said];
@ -2279,7 +2287,7 @@ fn a_dm_memory_names_its_confidant_not_its_subject() {
fn about_can_name_an_absent_mind() {
let (libby, op) = ("@libby:chat", "@op:chat");
let decided = reply(&[("!dm-op:chat", "ok")], json!([keeps("Decided", "Libby does the shaders for the Cinny fork", &[libby])]), json!([]), json!(null));
let dm = ChatDelivery { is_direct: true, room_id: "!dm-op:chat".into(), ..delivery_of(1, "!dm-op:chat", op, "Libby will do the shaders for the Cinny fork", "$dm-libby") };
let dm = ChatDelivery { is_direct: true, basis: Some(crate::chat_client::Basis::Direct), room_id: "!dm-op:chat".into(), ..delivery_of(1, "!dm-op:chat", op, "Libby will do the shaders for the Cinny fork", "$dm-libby") };
let rig = Rig::new(vec![dm], &[decided, says(&[("swarm", "go")])]);
assert_eq!(failure_of(rig.go()), None);
assert_eq!(rig.sent_texts().len(), 1);
@ -3938,7 +3946,7 @@ fn a_first_line_is_the_whole_line_before_this_cut_but_for_what_it_says_it_gained
assert_eq!(TURN_INSTRUCTION.len(), 2382 + GAINED.len(), "the instruction's length, and what it gained");
let rig = a_small_machine();
assert_eq!(failure_of(rig.go()), None);
let expected = header_of_this_cut(&with_chat_names(BASE_SMALL_MACHINE)).replace(" text=", " at=2023-11-14T22:13:20Z text=");
let expected = header_of_this_cut(&with_chat_names(BASE_SMALL_MACHINE)).replace(" text=", " at=2023-11-14T22:13:20Z text=").replace("name=\"libby\" at=", "name=\"libby\" to_you=true at=");
assert_eq!(input_of(&rig, 0).strip_prefix(TURN_INSTRUCTION).unwrap(), expected);
let bare = Rig::new(vec![], &[says(&[])]);
assert_eq!(failure_of(bare.go()), None);
@ -4069,7 +4077,7 @@ fn a_tell_is_only_the_message_at_live_shape() {
println!("TELL-CHARS first={} later={}", whole.chars().count(), tell.chars().count());
assert!(whole.len() > 25 * 1024, "{}", whole.len());
assert!(whole.starts_with(TURN_INSTRUCTION) && whole.contains("MINE") && whole.contains("line 19 "));
assert_eq!(tell, format!("message room=\"swarm\" sender=\"{libby}\" role=member name=\"libby\" at=2023-11-14T22:13:20Z text=\"{body}\"\n\n{WORK_TAIL}\n"), "the message and its envelope, then the question of her work, and nothing else");
assert_eq!(tell, format!("message room=\"swarm\" sender=\"{libby}\" role=member name=\"libby\" to_you=true at=2023-11-14T22:13:20Z text=\"{body}\"\n\n{WORK_TAIL}\n"), "the message and its envelope, then the question of her work, and nothing else");
}
#[test]
@ -4939,7 +4947,7 @@ fn a_burst_is_told_over_lines_within_the_bound() {
let lines = told(&rig);
assert_eq!(lines.len(), 3, "{}", lines.len());
let carried = |line: &str| line.lines().filter(|line| line.starts_with("message ")).count();
assert_eq!((carried(&lines[1]), carried(&lines[2])), (8, 2), "32 KiB of rendered 4000 B messages, the rest next");
assert_eq!((carried(&lines[1]), carried(&lines[2])), (7, 3), "32 KiB of rendered 4000 B messages, the rest next");
assert_eq!(rig.chat.acked.borrow().last(), Some(&11));
}
@ -4951,7 +4959,7 @@ fn a_burst_waiting_at_a_start_is_told_over_lines_within_the_bound() {
assert_eq!(failure_of(rig.go()), None);
let lines = told(&rig);
let carried = |line: &str| line.lines().filter(|line| line.starts_with("message ")).count();
assert_eq!(lines.iter().map(|line| carried(line)).collect::<Vec<_>>(), [8, 2]);
assert_eq!(lines.iter().map(|line| carried(line)).collect::<Vec<_>>(), [7, 3]);
assert_eq!(rig.chat.acked.borrow().last(), Some(&10));
}
@ -6458,16 +6466,17 @@ fn an_addressed_message_is_told_at_once_and_an_unaddressed_one_waits_her_rank()
let config = three_minds("@eureka:chat");
let plain = unaddressed_at_rank(1, 2, "who takes the cellar?");
let addressed = ChatDelivery { mentions: vec!["@eureka:chat".into()], ..plain.clone() };
let in_dm = ChatDelivery { is_direct: true, ..plain.clone() };
let in_dm = ChatDelivery { is_direct: true, basis: Some(crate::chat_client::Basis::Direct), ..plain.clone() };
let second = addressed_at_rank_one(2, "who takes the cellar?");
assert_eq!(STAGGER_STEP_S, 8, "the step is the calibration the ruling names");
for (name, message, wait_ms) in [("addressed", addressed, 0), ("direct", in_dm, 0), ("unaddressed", plain, 16_000)] {
for (name, message, wait_ms) in [("addressed", addressed, 0), ("direct", in_dm, 0), ("addressed second", second, 8_000)] {
let rig = Rig::new(vec![], &[]);
rig.clock.step.set(0);
let t0 = rig.clock.now.get();
arrives(&rig, &message);
with_inbox(&rig, &config, vec![message.clone()], |inbox| {
if wait_ms > 0 {
for waited in [0, 8_000, 15_999] {
for waited in [0, 4_000, 7_999] {
rig.clock.now.set(t0 + waited);
assert!(!inbox.poll().unwrap().pending, "{name}: told after {waited} ms of {wait_ms}");
}
@ -6478,6 +6487,26 @@ fn an_addressed_message_is_told_at_once_and_an_unaddressed_one_waits_her_rank()
assert_eq!(inbox.tell(false).unwrap(), expected, "{name}: the tell is the message and its envelope, unchanged by the wait");
});
}
// The ruling `addressed-delivery-only`: a message that is not hers is never told by waiting.
let rig = Rig::new(vec![], &[]);
rig.clock.step.set(0);
let t0 = rig.clock.now.get();
arrives(&rig, &plain);
with_inbox(&rig, &config, vec![plain.clone()], |inbox| {
for waited in [0, 16_000, 3_600_000] {
rig.clock.now.set(t0 + waited);
assert!(!inbox.poll().unwrap().pending, "told after {waited} ms though it is not hers");
}
});
}
/// An addressed message that two Minds are tagged in, whose event ranks her second: she hears it one step after the first.
fn addressed_at_rank_one(seq: u64, text: &str) -> ChatDelivery {
let config = three_minds("@eureka:chat");
(0..1000)
.map(|n| ChatDelivery { mentions: vec!["@eureka:chat".into(), "@libby:chat".into()], basis: Some(crate::chat_client::Basis::Tag), ..delivery_of(seq, "swarm", "@op:chat", text, &format!("$s{seq}-{n}")) })
.find(|candidate| stagger(&config, &own_addressee(&config), candidate) == STAGGER_STEP_S * 1000)
.expect("among 1000 events one ranks her second")
}
#[test]
@ -6486,22 +6515,22 @@ fn the_wait_is_bounded_under_a_stream_of_messages() {
rig.clock.step.set(0);
let config = three_minds("@eureka:chat");
let t0 = rig.clock.now.get();
arrives(&rig, &unaddressed_at_rank(1, 2, "the first"));
arrives(&rig, &addressed_at_rank_one(1, "the first"));
with_inbox(&rig, &config, vec![], |inbox| {
let mut told_at = None;
for second in 0..=40i64 {
rig.clock.now.set(t0 + second * 1000);
if second > 0 && second % 3 == 0 {
arrives(&rig, &unaddressed_at_rank(u64::try_from(second).unwrap() + 1, 2, &format!("more {second}")));
arrives(&rig, &addressed_at_rank_one(u64::try_from(second).unwrap() + 1, &format!("more {second}")));
}
if inbox.poll().unwrap().pending {
told_at = Some(second);
break;
}
}
assert_eq!(told_at, Some(16), "the first message's clock is its own: newer messages do not move it");
assert_eq!(told_at, Some(8), "the first message's clock is its own: newer messages do not move it");
let line = inbox.tell(false).unwrap();
assert!(line.contains("the first") && line.contains("more 15"), "{line}");
assert!(line.contains("the first") && line.contains("more 6"), "{line}");
});
}
@ -7021,10 +7050,12 @@ fn a_poll_with_untold_messages_reads_the_heartbeats_table_again() {
let mut inbox = PersonaInbox::new(&organs, &control, &swarm, &config, &run, &mut progress, session_of(&config, vec![]), started_ms, String::new());
let message = unaddressed_at_rank(1, 2, "who takes the bad day simulator?");
arrives(&rig, &message);
assert!(!inbox.poll().unwrap().pending, "no table: unaddressed, she waits her rank");
assert!(!inbox.poll().unwrap().pending, "no table: not hers, and nothing makes it hers by waiting");
let mut minds = eight_minds(&[("@eureka:chat", "GameCult/BadDaySimulator", &[])]);
minds.truncate(1);
*view.0.borrow_mut() = Some(crate::heartbeat::Addressing { minds });
// The first was not hers and is acked; the heartbeat now says a project of hers is named, and she is told the next at once.
arrives(&rig, &unaddressed_at_rank(2, 2, "who takes the bad day simulator?"));
assert!(inbox.poll().unwrap().pending, "the heartbeat now says the message names her project: she is told at once");
}
@ -7904,3 +7935,79 @@ fn the_schema_a_session_runs_under_admits_an_open_only_in_a_public_session() {
assert_eq!(failure_of(private.go()), None);
assert_eq!(schema_of(&private)["properties"]["open_direct"], json!({ "type": "null" }));
}
fn heard(seq: u64, text: &str, basis: Option<crate::chat_client::Basis>, form: crate::chat_client::Form) -> ChatDelivery {
ChatDelivery { basis, form, ..delivery_of(seq, "swarm", "@libby:chat", text, &format!("$q{seq}")) }
}
/// The ruling `addressed-delivery-only`: what Matrix and the names do not make hers is acked in sequence and wakes and tells no one.
#[test]
fn a_leading_run_of_what_is_not_hers_is_quiet_and_an_ack_never_passes_what_is_told() {
use crate::chat_client::{Basis, Form};
let own = own_addressee(&config());
let room = |seq| heard(seq, "anyone around", Some(Basis::Room), Form::Message);
let unproven = |seq| heard(seq, "anyone around", None, Form::Message);
assert_eq!(quiet_through(&own, &[unproven(1), room(2), unproven(3)], 0), Some(3), "a nil basis and an @room are not hers");
let hers = heard(4, "anyone around", Some(Basis::Reply), Form::Message);
assert_eq!(quiet_through(&own, &[unproven(1), hers.clone(), unproven(5)], 0), Some(1), "the run stops at the first that is hers");
assert_eq!(quiet_through(&own, &[hers, unproven(5)], 0), None);
assert_eq!(quiet_through(&own, &[unproven(1), unproven(2)], 1), None, "the front was told and is not answered: nothing passes it");
assert_eq!(quiet_through(&own, &[unproven(5), unproven(6)], 5), None);
assert_eq!(quiet_through(&own, &[], 0), None);
}
/// A notice or an edit never addresses her, whatever else it says; a nil basis does not mean "not for her": her name still does.
#[test]
fn a_notice_and_an_edit_are_quiet_whatever_they_say_and_a_nil_basis_with_her_name_is_hers() {
use crate::chat_client::{Basis, Form};
let own = own_addressee(&config());
for (basis, form) in [(Some(Basis::Direct), Form::Notice), (Some(Basis::Tag), Form::Edit), (Some(Basis::Reply), Form::Notice), (None, Form::Edit)] {
let mut line = heard(1, "eureka, look", basis, form);
line.mentions = vec!["@eureka:chat".into()];
assert_eq!(quiet_through(&own, &[line], 0), Some(1), "{basis:?} {form:?}");
}
assert_eq!(quiet_through(&own, &[heard(1, "Eureka, look", None, Form::Message)], 0), None, "an old mouth's delivery, named in the text");
assert_eq!(quiet_through(&own, &[heard(1, "look", Some(Basis::Direct), Form::Message)], 0), None);
}
/// Two unaddressed lines ahead of an addressed one are acked at the start, in one ack, and the turn is told the addressed one alone.
#[test]
fn unaddressed_deliveries_are_acked_in_sequence_ahead_of_the_addressed_one_that_wakes_her() {
use crate::chat_client::{Basis, Form};
let rig = Rig::new(vec![heard(1, "lunch anyone", None, Form::Message), heard(2, "@room ping", Some(Basis::Room), Form::Message), operator_says(3, "three")], &[]);
rig.clock.step.set(0);
let (child, writes) = answering(&rig, reply_saying("ok"));
let (outcome, acked) = carry_children(&rig, vec![child]);
assert_eq!(failure_of(outcome), None);
assert_eq!(acked, [2, 3], "the quiet run first, then the addressed one once answered");
let told = first_line(&writes);
assert!(told.contains("three") && !told.contains("lunch anyone") && !told.contains("@room ping"), "{told}");
}
/// The mouth stops reading at 500 unacked deliveries: 600 that are none of hers cost one ack, and no turn is told any of them.
#[test]
fn five_hundred_unaddressed_deliveries_are_one_ack_and_tell_no_one() {
use crate::chat_client::Form;
let flood: Vec<ChatDelivery> = (1..=600).map(|seq| heard(seq, "chatter", None, Form::Message)).collect();
let rig = Rig::new(flood, &[]);
rig.clock.step.set(0);
let (child, writes) = answering(&rig, empty_reply());
let (_, acked) = carry_children(&rig, vec![child]);
assert_eq!(acked, [600]);
assert!(!first_line(&writes).contains("chatter"));
}
/// An answer of an old mouth (no basis, every event a message) wakes her on her name and on a tag in its mentions, as it did.
#[test]
fn a_v1_mouths_deliveries_still_wake_her_by_name_and_by_tag() {
use crate::chat_client::Form;
let mut tagged = heard(2, "look", None, Form::Message);
tagged.mentions = vec!["@eureka:chat".into()];
let rig = Rig::new(vec![heard(1, "Eureka, look", None, Form::Message), tagged], &[]);
rig.clock.step.set(0);
let (child, writes) = answering(&rig, reply_saying("ok"));
let (outcome, acked) = carry_children(&rig, vec![child]);
assert_eq!(failure_of(outcome), None);
assert_eq!(acked, [2]);
assert!(first_line(&writes).contains("Eureka, look"));
}

View file

@ -13,13 +13,15 @@ use std::thread::JoinHandle;
use std::time::Duration;
use cultnet_rs::{CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions};
use mind_body::chat_client::{self, ChatRefusal, MouthRefused, Refusal, REFUSAL, ACK, Chat, ChatAck, ChatOpenDirectReceipt, ChatOpenDirectRequest, DirectOutcome, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, ChatPending, ChatPendingRequest, ChatRead, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_V1, READ, SEND_RECEIPT, SEND_REQUEST};
use mind_body::chat_client::{self, Basis, Form, ChatRefusal, MouthRefused, Refusal, REFUSAL, ACK, Chat, ChatAck, ChatOpenDirectReceipt, ChatOpenDirectRequest, DirectOutcome, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, ChatPending, ChatPendingRequest, ChatRead, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_REQUEST_V1, PENDING_V1, PENDING_V2, READ, SEND_RECEIPT, SEND_REQUEST};
use mind_body::chat_rudp::{RudpChat, connection_id};
const ACK_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_ack.v1.msgpack");
const PENDING_V1_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_pending.v1.msgpack");
const PENDING_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-v1/gamecult.persona.chat_pending_request.v1.msgpack");
const PENDING_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.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");
const SEND_RECEIPT_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v2/gamecult.persona.chat_send_receipt.v2.sent.msgpack");
const SEND_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v2/gamecult.persona.chat_send_request.v2.nil-basis.msgpack");
const OPEN_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_request.v1.msgpack");
@ -73,11 +75,16 @@ fn mouth(key: &str) -> Mouth {
mouth_at(key, "127.0.0.1:0")
}
/// A mouth that has no image field yet: it answers a pending request in the v1 kind.
/// A mouth that has no image field yet: it answers a pending request in the v1 kind, and does not serve the v2 request.
fn legacy_mouth(key: &str) -> Mouth {
serve(key, "127.0.0.1:0", PENDING_V1, PENDING_V1_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES))
}
/// A mouth that has the image field and not the addressing facts: it answers the v1 request in the v2 kind, and does not serve the v2 request.
fn imaged_mouth(key: &str) -> Mouth {
serve(key, "127.0.0.1:0", PENDING_V2, PENDING_V2_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES))
}
/// The same mouth on a chosen address: a restart binds the address the old one held.
fn mouth_at(key: &str, bind: &str) -> Mouth {
serve(key, bind, PENDING, PENDING_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES))
@ -101,7 +108,10 @@ fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'sta
log.lock().unwrap().push((document.schema_id.clone(), document.payload.clone()));
let for_eureka = |persona: &str| persona == "eureka";
let reply = match document.schema_id.as_str() {
PENDING_REQUEST if for_eureka(&chat_client::decode::<ChatPendingRequest>(&document.payload).unwrap().persona) => Some(answer(pending_kind, "eureka", pending_bytes)),
PENDING_REQUEST if for_eureka(&chat_client::decode::<ChatPendingRequest>(&document.payload).unwrap().persona) => {
Some(if pending_kind == PENDING { answer(pending_kind, "eureka", pending_bytes) } else { answer(REFUSAL, "eureka", UNKNOWN_KIND_BYTES) })
}
PENDING_REQUEST_V1 if pending_kind != PENDING && for_eureka(&chat_client::decode::<ChatPendingRequest>(&document.payload).unwrap().persona) => Some(answer(pending_kind, "eureka", pending_bytes)),
SEND_REQUEST if for_eureka(&chat_client::decode::<ChatSendRequest>(&document.payload).unwrap().persona) => Some(answer(SEND_RECEIPT, &document.record_key, SEND_RECEIPT_BYTES)),
OPEN_DIRECT_REQUEST if for_eureka(&chat_client::decode::<ChatOpenDirectRequest>(&document.payload).unwrap().persona) => Some(answer(open.0, open.1.unwrap_or(&document.record_key), open.2)),
_ => None,
@ -211,15 +221,25 @@ fn a_send_that_timed_out_is_not_repeated_by_the_client() {
}
#[test]
fn a_v1_mouths_pending_answer_still_reads_with_no_image_and_a_v2_one_carries_it() {
let legacy = legacy_mouth(KEY);
let old = client(&legacy, KEY, "eureka", Duration::from_millis(1500)).pending(0).unwrap();
assert_eq!(old.len(), 1);
assert!(old[0].image.is_none());
fn a_mouth_without_the_v2_request_is_asked_the_v1_way_and_its_deliveries_carry_no_addressing_facts() {
for (mouth, text) in [(legacy_mouth(KEY), "v1 answer"), (imaged_mouth(KEY), "v2 answer")] {
let old = client(&mouth, KEY, "eureka", Duration::from_millis(1500)).pending(3).unwrap();
assert_eq!(old.len(), 1, "{text}");
assert_eq!((old[0].basis, old[0].form), (None, Form::Message), "{text}");
let seen: Vec<String> = mouth.seen.lock().unwrap().iter().map(|(kind, _)| kind.clone()).collect();
assert_eq!(seen, [PENDING_REQUEST, PENDING_REQUEST_V1], "{text}: the v2 request first, refused as an unknown kind, then the v1 request");
assert_eq!(mouth.seen.lock().unwrap()[1].1, PENDING_REQUEST_V1_BYTES, "{text}");
}
assert!(client(&imaged_mouth(KEY), KEY, "eureka", Duration::from_millis(1500)).pending(0).unwrap()[0].image.is_some());
}
#[test]
fn a_current_mouth_answers_the_v2_request_with_basis_and_form() {
let current = mouth(KEY);
let new = client(&current, KEY, "eureka", Duration::from_millis(1500)).pending(0).unwrap();
assert_eq!(new[0].image.as_ref().map(|image| (image.mime.as_str(), image.size)), Some(("image/png", 2048)));
assert_eq!(new[0].text, old[0].text, "the same message, with its attachment");
let new = client(&current, KEY, "eureka", Duration::from_millis(1500)).pending(3).unwrap();
assert_eq!((new[0].basis, new[0].form), (Some(Basis::Tag), Form::Message));
let kinds: Vec<String> = current.seen.lock().unwrap().iter().map(|(kind, _)| kind.clone()).collect();
assert_eq!(kinds, [PENDING_REQUEST], "no v1 request follows an answered v2 request");
}
#[test]

View file

@ -14,6 +14,8 @@ mind_body: mod tempo
mind_body::address: impl Addressee
mind_body::address: struct Addressee # [derive (Clone , Debug , PartialEq , Eq , serde :: Serialize , serde :: Deserialize)] pub-fields[]
mind_body::address: struct Name # [derive (Clone , Debug , PartialEq , Eq , serde :: Serialize , serde :: Deserialize)] pub-fields[]
mind_body::chat_client: # [derive (Clone , Copy , Debug , Default , PartialEq , Eq)] pub enum Form { # [default] Message , Notice , Edit , }
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Basis { Direct , Tag , Reply , Room , }
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum DirectOutcome { Opened , Existing , Brake , PersonaMismatch , UnknownPersona , EmptyText , ForeignUser , NotAPerson , NoSuchUser , InvitePending , Declined , DailyLimit , SendFailed , }
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Refusal { Brake , PersonaMismatch , UnknownRoom , EmptyText , SendFailed , UnknownPersona , RoomMoved , UnknownKind , }
mind_body::chat_client: const ACK : & str
@ -21,25 +23,37 @@ mind_body::chat_client: const OPEN_DIRECT_RECEIPT : & str
mind_body::chat_client: const OPEN_DIRECT_REQUEST : & str
mind_body::chat_client: const PENDING : & str
mind_body::chat_client: const PENDING_REQUEST : & str
mind_body::chat_client: const PENDING_REQUEST_V1 : & str
mind_body::chat_client: const PENDING_V1 : & str
mind_body::chat_client: const PENDING_V2 : & str
mind_body::chat_client: const READ : & str
mind_body::chat_client: const REFUSAL : & str
mind_body::chat_client: const SEND_RECEIPT : & str
mind_body::chat_client: const SEND_REQUEST : & str
mind_body::chat_client: fn decode < T : DeserializeOwned > (bytes : & [u8]) -> Result < T >
mind_body::chat_client: fn encode < T : Serialize > (document : & T) -> Result < Vec < u8 > >
mind_body::chat_client: impl Basis
mind_body::chat_client: impl Basis: fn name (self) -> & 'static str
mind_body::chat_client: impl DirectOutcome
mind_body::chat_client: impl DirectOutcome: fn name (self) -> & 'static str
mind_body::chat_client: impl Form
mind_body::chat_client: impl Form: fn name (self) -> & 'static str
mind_body::chat_client: impl Refusal
mind_body::chat_client: impl Refusal: fn name (self) -> & 'static str
mind_body::chat_client: impl Serialize for Basis
mind_body::chat_client: impl Serialize for DirectOutcome
mind_body::chat_client: impl Serialize for Form
mind_body::chat_client: impl Serialize for Refusal
mind_body::chat_client: impl TryFrom < RawOpenReceipt > for ChatOpenDirectReceipt
mind_body::chat_client: impl std :: error :: Error for MouthRefused
mind_body::chat_client: impl std :: fmt :: Display for MouthRefused
mind_body::chat_client: impl< 'de > Deserialize < 'de > for Basis
mind_body::chat_client: impl< 'de > Deserialize < 'de > for DirectOutcome
mind_body::chat_client: impl< 'de > Deserialize < 'de > for Form
mind_body::chat_client: impl< 'de > Deserialize < 'de > for Refusal
mind_body::chat_client: pub trait Chat { fn pending (& self , after_seq : u64) -> Result < Vec < ChatDelivery > > ; fn send (& self , request : & ChatSendRequest) -> Result < ChatSendReceipt > ; fn open_direct (& self , request : & ChatOpenDirectRequest) -> Result < ChatOpenDirectReceipt > ; fn ack (& self , through_seq : u64) -> Result < () > ; fn read (& self , read : & ChatRead) -> Result < () > ; }
mind_body::chat_client: struct ChatAck # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, through_seq: u64]
mind_body::chat_client: struct ChatDelivery # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seq: u64, room_alias: String, room_id: String, event_id: String, sender: String, sender_display: String, text: String, at_unix_ms: u64, context: Vec < ContextLine >, mentions: Vec < String >, is_direct: bool, image: Option < ChatImage >]
mind_body::chat_client: struct ChatDelivery # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seq: u64, room_alias: String, room_id: String, event_id: String, sender: String, sender_display: String, text: String, at_unix_ms: u64, context: Vec < ContextLine >, mentions: Vec < String >, is_direct: bool, image: Option < ChatImage >, basis: Option < Basis >, form: Form]
mind_body::chat_client: struct ChatImage # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[path: String, mime: String, size: u64, sha256: String]
mind_body::chat_client: struct ChatOpenDirectReceipt # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, outcome: DirectOutcome, room_id: Option < String >, event_id: Option < String >]
mind_body::chat_client: struct ChatOpenDirectRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, persona: String, user_id: String, text: String]
@ -50,6 +64,7 @@ mind_body::chat_client: struct ChatRefusal # [derive (Clone , Debug , PartialEq
mind_body::chat_client: struct ChatSendReceipt # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, event_id: Option < String >, refusal: Option < Refusal >, newer: Vec < String >]
mind_body::chat_client: struct ChatSendRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, persona: String, room_alias: String, text: String, basis: Option < SendBasis >]
mind_body::chat_client: struct ContextLine # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[sender: String, text: String, at_unix_ms: u64]
mind_body::chat_client: struct MouthRefused # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub-fields[0: Refusal]
mind_body::chat_client: struct SendBasis # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seen_event_id: Option < String >, since_unix_ms: u64]
mind_body::chat_image: impl Skip
mind_body::chat_rudp: fn connection_id (key_file_text : & str) -> u32