From e43a7343f8788650045d08780f074b778d89fcb7 Mon Sep 17 00:00:00 2001 From: Eureka Date: Tue, 10 Nov 2026 23:44:30 +0000 Subject: [PATCH 1/3] Chat RUDP test: an open the mouth refuses as UnknownKind ends the wait and is heard (failing: the refusal is ignored); MouthRefused error type Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/chat_client.rs | 13 +++++++++ crates/mind-body/tests/chat_rudp.rs | 41 +++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/crates/mind-body/src/chat_client.rs b/crates/mind-body/src/chat_client.rs index 5ce4bad..58a4c12 100644 --- a/crates/mind-body/src/chat_client.rs +++ b/crates/mind-body/src/chat_client.rs @@ -151,6 +151,19 @@ impl<'de> Deserialize<'de> for Refusal { } } +/// The mouth refused a request outright with a typed refusal frame, and nothing was done. An error of `open_direct` when the +/// mouth does not serve the kind: told as not sent, unlike a silent mouth, whose open may have gone out. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MouthRefused(pub Refusal); + +impl std::fmt::Display for MouthRefused { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "the mouth refused the request: {}", self.0.name()) + } +} + +impl std::error::Error for MouthRefused {} + /// The mouth's typed answer to a request that names a Persona it does not /// serve: the pending request, the ack and the send alike. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/mind-body/tests/chat_rudp.rs b/crates/mind-body/tests/chat_rudp.rs index 9944139..530e85e 100644 --- a/crates/mind-body/tests/chat_rudp.rs +++ b/crates/mind-body/tests/chat_rudp.rs @@ -13,7 +13,7 @@ use std::thread::JoinHandle; use std::time::Duration; use cultnet_rs::{CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions}; -use mind_body::chat_client::{self, 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, 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_rudp::{RudpChat, connection_id}; const ACK_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_ack.v1.msgpack"); @@ -23,11 +23,15 @@ const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/ga 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"); +const UNKNOWN_KIND_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_refusal.v1.unknown-kind.msgpack"); const OPENED_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_receipt.v1.opened.msgpack"); const KEY: &str = "connection-key-for-test"; const TAG: &str = "cultnet.transport.rudp.v0"; +/// What a fixture mouth answers an open with: the document kind, a record key (none: the request's own), and its bytes. +type OpenReply = (&'static str, Option<&'static str>, &'static [u8]); + type Seen = Arc)>>>; struct Mouth { @@ -70,15 +74,15 @@ fn mouth(key: &str) -> Mouth { /// A mouth that has no image field yet: it answers a pending request in the v1 kind. fn legacy_mouth(key: &str) -> Mouth { - serve(key, "127.0.0.1:0", PENDING_V1, PENDING_V1_BYTES) + serve(key, "127.0.0.1:0", PENDING_V1, PENDING_V1_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) + serve(key, bind, PENDING, PENDING_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES)) } -fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'static [u8]) -> Mouth { +fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'static [u8], open: OpenReply) -> Mouth { let socket = UdpSocket::bind(bind).unwrap(); socket.set_read_timeout(Some(Duration::from_millis(20))).unwrap(); let address = socket.local_addr().unwrap(); @@ -98,7 +102,7 @@ fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'sta let reply = match document.schema_id.as_str() { PENDING_REQUEST if for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(answer(pending_kind, "eureka", pending_bytes)), SEND_REQUEST if for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(answer(SEND_RECEIPT, &document.record_key, SEND_RECEIPT_BYTES)), - OPEN_DIRECT_REQUEST if for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(answer(OPEN_DIRECT_RECEIPT, &document.record_key, OPENED_BYTES)), + OPEN_DIRECT_REQUEST if for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(answer(open.0, open.1.unwrap_or(&document.record_key), open.2)), _ => None, }; if let Some(reply) = reply { @@ -231,3 +235,30 @@ fn an_open_crosses_a_real_rudp_session_in_the_mouths_bytes_and_a_receipt_for_ano let seen = mouth.seen.lock().unwrap().clone(); assert_eq!(seen[0], (OPEN_DIRECT_REQUEST.to_string(), OPEN_REQUEST_BYTES.to_vec())); } + +/// A mouth that answers every open with `reply`. +fn opening_with(reply: OpenReply) -> Mouth { + serve(KEY, "127.0.0.1:0", PENDING, PENDING_BYTES, reply) +} + +#[test] +fn a_mouth_that_does_not_serve_the_open_kind_refuses_it_and_the_open_is_told_not_sent_at_once() { + let mouth = opening_with((REFUSAL, Some("eureka"), UNKNOWN_KIND_BYTES)); + let chat = client(&mouth, KEY, "eureka", Duration::from_secs(20)); + let request = chat_client::decode::(OPEN_REQUEST_BYTES).unwrap(); + let started = std::time::Instant::now(); + let error = chat.open_direct(&request).unwrap_err(); + assert!(started.elapsed() < Duration::from_secs(5), "the refusal ends the wait: {:?}", started.elapsed()); + assert_eq!(error.downcast_ref::(), Some(&MouthRefused(Refusal::UnknownKind)), "{error}"); +} + +#[test] +fn a_refusal_for_another_persona_or_another_kind_does_not_satisfy_the_open() { + let wrong_persona = opening_with((REFUSAL, Some("someone-else"), UNKNOWN_KIND_BYTES)); + let request = chat_client::decode::(OPEN_REQUEST_BYTES).unwrap(); + let error = client(&wrong_persona, KEY, "eureka", Duration::from_millis(800)).open_direct(&request).unwrap_err(); + assert!(error.downcast_ref::().is_none(), "a refusal keyed by another persona is not heard: {error}"); + let wrong_kind = opening_with((SEND_RECEIPT, Some("eureka"), SEND_RECEIPT_BYTES)); + let error = client(&wrong_kind, KEY, "eureka", Duration::from_millis(800)).open_direct(&request).unwrap_err(); + assert!(error.downcast_ref::().is_none(), "another kind is not heard: {error}"); +} From 88a36386a5ab890a9c1bdba443ac5d3b0ac3098a Mon Sep 17 00:00:00 2001 From: Eureka Date: Tue, 10 Nov 2026 23:46:35 +0000 Subject: [PATCH 2/3] open_direct hears the mouth's UnknownKind refusal and is told not sent; one creation-policy text for the schema and the DailyLimit notice; pin InvitePending wording Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/chat_rudp.rs | 32 ++++++++++++++++++--------- crates/mind-body/src/persona.rs | 28 +++++++++++++++++------ crates/mind-body/src/persona/tests.rs | 29 +++++++++++++++++++++++- 3 files changed, 71 insertions(+), 18 deletions(-) diff --git a/crates/mind-body/src/chat_rudp.rs b/crates/mind-body/src/chat_rudp.rs index 0d47f8b..89384f9 100644 --- a/crates/mind-body/src/chat_rudp.rs +++ b/crates/mind-body/src/chat_rudp.rs @@ -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, 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, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, PENDING_V1, READ, REFUSAL, SEND_RECEIPT, SEND_REQUEST, decode, encode, }; @@ -92,14 +92,14 @@ impl RudpChat { Ok(Link { transport, runtime_id }) } - /// Puts one document and, when `answer` names a kind and record key, waits for + /// Puts one document and, when `answer` names (kind, record key) pairs, waits for /// the document the mouth answers it with. Documents of other kinds or keys /// (a late receipt of an earlier send) are skipped. /// /// A failed exchange drops the link: a restarted mouth silently ignores packets from a sender it has not connected, so a held link /// would time out on every call after it. The next call opens a fresh one. Nothing is repeated on it: the caller's request id is /// what makes a repeat of a send the same send. - fn exchange(&self, kind: &str, record_key: &str, document: &T, answer: Option<(&[&str], &str)>) -> Result)>> { + fn exchange(&self, kind: &str, record_key: &str, document: &T, answer: Option<&[(&str, &str)]>) -> Result)>> { let result = self.exchange_on_link(kind, record_key, document, answer); if result.is_err() && let Some(mut link) = self.link.borrow_mut().take() @@ -109,7 +109,7 @@ impl RudpChat { result } - fn exchange_on_link(&self, kind: &str, record_key: &str, document: &T, answer: Option<(&[&str], &str)>) -> Result)>> { + fn exchange_on_link(&self, kind: &str, record_key: &str, document: &T, answer: Option<&[(&str, &str)]>) -> Result)>> { let mut slot = self.link.borrow_mut(); if slot.is_none() { *slot = Some(self.open()?); @@ -131,7 +131,7 @@ impl RudpChat { }; link.transport.send_schema_message(&message)?; let deadline = Instant::now() + self.reply_timeout; - let Some((answer_kinds, answer_key)) = answer else { + let Some(answers) = answer else { // Nothing is answered; the ack is done when the mouth has the packet. let left = deadline.saturating_duration_since(Instant::now()); link.transport.flush_reliable(left)?; @@ -144,7 +144,7 @@ impl RudpChat { link.transport.poll_resends()?; if let Some(CultNetMessage::DocumentPutRaw { document, .. }) = link.transport.receive_schema_message_once()? { let tagged = document.tags.as_deref() == Some(&[TAG.to_string()][..]); - if tagged && answer_kinds.contains(&document.schema_id.as_str()) && document.record_key == answer_key { + if tagged && answers.contains(&(document.schema_id.as_str(), document.record_key.as_str())) { return Ok(Some((document.schema_id, document.payload))); } } @@ -152,7 +152,7 @@ impl RudpChat { } fn call(&self, kind: &str, key: &str, request: &Q, answer: (&str, &str)) -> Result { - let (_, payload) = self.exchange(kind, key, request, Some((&[answer.0][..], answer.1)))?.ok_or_else(|| anyhow!("no answer payload"))?; + let (_, payload) = self.exchange(kind, key, request, Some(&[answer]))?.ok_or_else(|| anyhow!("no answer payload"))?; decode(&payload) } } @@ -160,8 +160,8 @@ impl RudpChat { impl Chat for RudpChat { fn pending(&self, after_seq: u64) -> Result> { let request = ChatPendingRequest { persona: self.persona.clone(), after_seq }; - let kinds: &[&str] = &[PENDING, PENDING_V1, REFUSAL]; - let (kind, payload) = self.exchange(PENDING_REQUEST, &self.persona, &request, Some((kinds, self.persona.as_str())))?.ok_or_else(|| anyhow!("no answer payload"))?; + 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"))?; if kind == REFUSAL { let refusal: ChatRefusal = decode(&payload)?; bail!("the mouth refused the pending request: {}", refusal.refusal.name()); @@ -182,7 +182,19 @@ impl Chat for RudpChat { } fn open_direct(&self, request: &ChatOpenDirectRequest) -> Result { - let receipt: ChatOpenDirectReceipt = self.call(OPEN_DIRECT_REQUEST, &request.request_id, request, (OPEN_DIRECT_RECEIPT, &request.request_id))?; + // A mouth that does not serve the kind answers with a refusal keyed by the persona, not by the request: heard here, + // it ends the wait, and the open is known not to have been sent. + let p = self.persona.as_str(); + let answers = [(OPEN_DIRECT_RECEIPT, request.request_id.as_str()), (REFUSAL, p)]; + let (kind, payload) = self.exchange(OPEN_DIRECT_REQUEST, &request.request_id, request, Some(&answers))?.ok_or_else(|| anyhow!("no answer payload"))?; + if kind == REFUSAL { + let refusal: ChatRefusal = decode(&payload)?; + if refusal.persona != self.persona || refusal.refusal != Refusal::UnknownKind { + bail!("the mouth refused the open: {}", refusal.refusal.name()); + } + return Err(MouthRefused(refusal.refusal).into()); + } + let receipt: ChatOpenDirectReceipt = decode(&payload)?; if receipt.request_id != request.request_id { bail!("the mouth answered another open"); } diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index b139d64..9405664 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -86,7 +86,7 @@ use uuid::Uuid; use crate::address::{Addressee, Name, addresses, localpart, project_names}; use crate::chat_image::{self, Image, MAX_PER_LINE}; -use crate::chat_client::{Chat, ChatDelivery, ChatOpenDirectRequest, ChatRead, DirectOutcome, ChatSendRequest, ContextLine, Refusal, SendBasis}; +use crate::chat_client::{Chat, ChatDelivery, MouthRefused, ChatOpenDirectRequest, ChatRead, DirectOutcome, ChatSendRequest, ContextLine, Refusal, SendBasis}; use crate::connector::render_plan; use crate::control::{ControlSource, Effective, HeldReason, derive}; use crate::heartbeat::{HeartbeatView, PollTrace}; @@ -413,6 +413,12 @@ fn start_in_bounds(ask: &StartSelfRun) -> bool { (1..=BRIEF_MAX_CHARS).contains(&ask.brief.chars().count()) && repo_in_bounds(&ask.repo) && campaign_in_bounds(&ask.campaign) } +/// The `open_direct` field's description: the base text with the mouth's creation policy before the closing line. +fn open_direct_description() -> String { + let (ask, tail) = OPEN_DIRECT_BASE.split_once(" Their replies").expect("the base names the reply channel"); + format!("{ask} {OPEN_DAILY_POLICY} Their replies{tail}") +} + /// The JSON Schema every result of a process is constrained to: one per process, with the room /// enum from the process's conversation and the campaign enum from the mind's campaigns in force /// (none in force: `start_self_run` can only be null). The people she may name and the open items @@ -422,7 +428,11 @@ const REMEMBER_DESCRIPTION: &str = "What you must still know when this conversat const INTENT_DESCRIPTION: &str = "Write this first. What you are going after right now in what you steward, and what this reply does about it. Name the thing: a repo, a run, a commitment, a person, an idea. Waiting is an answer only when you name whose decision you wait on, why it is theirs and not yours, and what this turn does toward it. If you have already asked and nothing more can be done, say so and name the next thing."; const INTENT_MAX_CHARS: usize = 600; const SETTLE_DESCRIPTION: &str = "Close an open item shown under 'You committed to' or 'Asked of you, not yet taken on' in this session: done when you have done it, declined when you will not (a commitment you let go: tell whoever holds the other parts), wrong when it was a mistake. Item is the id shown there. Leave empty when you close none."; -const OPEN_DIRECT_DESCRIPTION: &str = "Ask one person to talk with you in a direct message, or null. Only a public conversation may; `with` is an id copied exactly from the people this session showed you, never a Mind and never yourself. `text` is the invitation: the person sees it as the reason before they accept, and it is the first line of the conversation, so say who you are and why you ask. The invitation is the ask: they accept or decline. A decline stands until they write to you first, so do not ask again. The mouth allows one new conversation with a person a day. Their replies reach you in the direct message, not here."; +/// The mouth's creation policy as this side states it to the Mind, in the schema and in the DailyLimit notice. The mouth (Bifrost +/// `openDirectNow`) is the authority and answers `DailyLimit`; this is a display copy with no channel from the mouth. If the mouth's +/// policy changes, change this text with it. +const OPEN_DAILY_POLICY: &str = "The mouth allows one new conversation with a person a day."; +const OPEN_DIRECT_BASE: &str = "Ask one person to talk with you in a direct message, or null. Only a public conversation may; `with` is an id copied exactly from the people this session showed you, never a Mind and never yourself. `text` is the invitation: the person sees it as the reason before they accept, and it is the first line of the conversation, so say who you are and why you ask. The invitation is the ask: they accept or decline. A decline stands until they write to you first, so do not ask again. Their replies reach you in the direct message, not here."; const START_SELF_RUN_DESCRIPTION: &str = "A brief for your Self: it reaches your live Self within a minute, or starts one. It is also how you answer a `reply` line; your Self's replies to your briefs come back as `reply` lines."; const FOR_ITEM_DESCRIPTION: &str = "The open commitment or ask this Self run answers: null, an open item id shown in this session, or new: for remember[i] (sort Asked or Committed) written in this same reply. An item taken on leaves the open lists."; @@ -491,7 +501,7 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String], may_open: bool) -> S { "type": "object", "additionalProperties": false, - "description": OPEN_DIRECT_DESCRIPTION, + "description": open_direct_description(), "required": ["with", "text"], "properties": { "with": { "type": "string" }, "text": { "type": "string", "minLength": 1 } } } @@ -1921,9 +1931,13 @@ fn act(inbox: &PersonaInbox<'_, C, S>, batch let request_id = if finality.revised { format!("{batch_id}-r{turn}-d") } else { format!("{batch_id}-d") }; let request = ChatOpenDirectRequest { request_id, persona: instance.0.clone(), user_id: ask.with.clone(), text: ask.text.clone() }; // A refusal and a transport fault are told, never a failed step: the memories and settles of this reply still land. - let outcome = organs.chat.open_direct(&request).ok().map(|receipt| receipt.outcome); - outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("open_direct {}: {}", ask.with, outcome.map_or("unanswered", DirectOutcome::name)) }); - notice = Some(organ_notice(unix_ms(organs.clock.now()), &open_told(&ask.with, outcome))); + let answer = organs.chat.open_direct(&request); + // A mouth that refused the kind outright did nothing; a silent one may have. + let unserved = answer.as_ref().err().and_then(|error| error.downcast_ref::()).map(|refused| refused.0.name()); + let outcome = answer.ok().map(|receipt| receipt.outcome); + outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("open_direct {}: {}", ask.with, unserved.or(outcome.map(DirectOutcome::name)).unwrap_or("unanswered")) }); + let told = if unserved.is_some() { format!("Your invitation to {} was not sent: the mouth does not serve invitations yet.", ask.with) } else { open_told(&ask.with, outcome) }; + notice = Some(organ_notice(unix_ms(organs.clock.now()), &told)); } // The one origin of this turn: its memories and its brief record the same conversation. let origin = origin_of(config, batch, &inbox.origin); @@ -2903,7 +2917,7 @@ fn open_told(with: &str, outcome: Option) -> String { Some(D::NoSuchUser) => format!("The mouth knows no such user as {with}."), Some(D::InvitePending) => format!("{with} has not answered your earlier invitation; it stays open. Do not send another."), Some(D::Declined) => format!("{with} declined your invitation or left that conversation. That stands until they write to you first; do not ask again."), - Some(D::DailyLimit) => format!("The mouth allows one new conversation with a person a day, and you opened one with {with} today. Try again tomorrow if it still matters, or wait for them to write."), + Some(D::DailyLimit) => format!("{OPEN_DAILY_POLICY} You opened one with {with} today. Try again tomorrow if it still matters, or wait for them to write."), Some(refused @ (D::PersonaMismatch | D::UnknownPersona | D::EmptyText | D::SendFailed)) => format!("Your invitation to {with} was not sent: the mouth refused it ({}).", refused.name()), None => format!("The mouth did not answer your invitation to {with}, so it may or may not have gone out. Do not repeat it unless they stay silent."), } diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index f2a3554..11d61e3 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -80,6 +80,8 @@ struct FakeChat { opens: RefCell>, /// How the fake mouth answers an open; none is a transport error. open_outcome: Cell>, + /// The mouth refuses the open kind outright (an older mouth): the open errors with the typed refusal. + open_unserved: Cell, } impl FakeChat { @@ -150,6 +152,9 @@ impl Chat for FakeChat { fn open_direct(&self, request: &ChatOpenDirectRequest) -> Result { self.log.borrow_mut().push("open".into()); self.opens.borrow_mut().push(request.clone()); + if self.open_unserved.get() { + return Err(crate::chat_client::MouthRefused(Refusal::UnknownKind).into()); + } let Some(outcome) = self.open_outcome.get() else { return Err(anyhow!("CANARY-open-down {}", request.user_id)) }; let landed = matches!(outcome, DirectOutcome::Opened | DirectOutcome::Existing); Ok(ChatOpenDirectReceipt { request_id: request.request_id.clone(), outcome, room_id: landed.then(|| "!dm:chat".to_string()), event_id: landed.then(|| "$open".to_string()) }) @@ -448,6 +453,7 @@ impl Rig { keep_on_ack: Cell::new(false), opens: RefCell::default(), open_outcome: Cell::new(Some(DirectOutcome::Opened)), + open_unserved: Cell::new(false), }, model: FakeSession { log: log.clone(), @@ -7799,7 +7805,7 @@ fn a_held_brake_stops_the_open() { fn a_refused_or_unanswered_open_is_told_and_the_turn_stands() { let cases: [(Option, &str); 4] = [ (Some(DirectOutcome::Declined), "until they write to you first"), - (Some(DirectOutcome::InvitePending), "has not answered"), + (Some(DirectOutcome::InvitePending), "has not answered your earlier invitation; it stays open. Do not send another."), (None, "did not answer"), (Some(DirectOutcome::DailyLimit), "today"), ]; @@ -7822,6 +7828,27 @@ fn a_refused_or_unanswered_open_is_told_and_the_turn_stands() { } } +#[test] +fn an_open_the_mouth_does_not_serve_is_told_as_not_sent() { + let kept = json!({ "intent": "Ask them over", "say": [{ "room": "swarm", "text": "yes" }], "open_direct": { "with": "@ann:chat", "text": "Hello Ann" }, "start_self_run": null, "remember": [], "settle": [], "urgency_permille": null }).to_string(); + let rig = public_rig_with_a_next_line(&[kept, says(&[])]); + rig.chat.open_unserved.set(true); + let Outcome::Served { failure, .. } = rig.go() else { panic!("served") }; + assert_eq!(failure, None); + let line = input_of(&rig, 1); + assert!(line.contains("was not sent") && !line.contains("may or may not"), "{line}"); + let trace = rig.trace.0.borrow(); + assert!(trace[0].outputs.iter().any(|item| item.text == "open_direct @ann:chat: UnknownKind"), "{trace:?}"); +} + +#[test] +fn the_daily_creation_policy_has_one_text_in_the_schema_and_the_notice() { + let schema = reply_schema(&["swarm".to_string()], &[], true); + let told = open_told("@ann:chat", Some(DirectOutcome::DailyLimit)); + assert!(open_direct_description().contains(OPEN_DAILY_POLICY) && told.contains(OPEN_DAILY_POLICY), "{told}"); + assert!(schema.contains(OPEN_DAILY_POLICY)); +} + #[test] fn a_pending_invitation_is_told_not_to_be_sent_again() { let told = open_told("@ann:chat", Some(DirectOutcome::InvitePending)); From 05a6726738c8fc3fb843964e18ff7f7005938a62 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 00:14:55 +0000 Subject: [PATCH 3/3] Tests kill three open_direct mutants: schema field description, non-UnknownKind refusal, payload-persona mismatch Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/persona/tests.rs | 6 ++++-- crates/mind-body/tests/chat_rudp.rs | 24 +++++++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index 11d61e3..19e42f0 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -7845,8 +7845,10 @@ fn an_open_the_mouth_does_not_serve_is_told_as_not_sent() { fn the_daily_creation_policy_has_one_text_in_the_schema_and_the_notice() { let schema = reply_schema(&["swarm".to_string()], &[], true); let told = open_told("@ann:chat", Some(DirectOutcome::DailyLimit)); - assert!(open_direct_description().contains(OPEN_DAILY_POLICY) && told.contains(OPEN_DAILY_POLICY), "{told}"); - assert!(schema.contains(OPEN_DAILY_POLICY)); + assert!(told.contains(OPEN_DAILY_POLICY), "{told}"); + let schema: Value = serde_json::from_str(&schema).unwrap(); + let described = schema.pointer("/properties/open_direct/anyOf/1/description").and_then(Value::as_str).unwrap_or_default(); + assert!(described.contains(OPEN_DAILY_POLICY), "{described}"); } #[test] diff --git a/crates/mind-body/tests/chat_rudp.rs b/crates/mind-body/tests/chat_rudp.rs index 530e85e..927062f 100644 --- a/crates/mind-body/tests/chat_rudp.rs +++ b/crates/mind-body/tests/chat_rudp.rs @@ -13,7 +13,7 @@ use std::thread::JoinHandle; use std::time::Duration; use cultnet_rs::{CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions}; -use mind_body::chat_client::{self, 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, 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_rudp::{RudpChat, connection_id}; const ACK_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_ack.v1.msgpack"); @@ -24,6 +24,7 @@ const SEND_RECEIPT_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v2/gamec 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"); const UNKNOWN_KIND_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_refusal.v1.unknown-kind.msgpack"); +const REFUSAL_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_refusal.v1.msgpack"); const OPENED_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_receipt.v1.opened.msgpack"); const KEY: &str = "connection-key-for-test"; @@ -262,3 +263,24 @@ fn a_refusal_for_another_persona_or_another_kind_does_not_satisfy_the_open() { let error = client(&wrong_kind, KEY, "eureka", Duration::from_millis(800)).open_direct(&request).unwrap_err(); assert!(error.downcast_ref::().is_none(), "another kind is not heard: {error}"); } + +#[test] +fn a_refusal_of_another_kind_ends_the_wait_but_is_not_told_as_not_sent() { + // The fixture refusal is persona "ghost", UnknownPersona: the mouth refuses this Persona's open for a reason other than the kind. + let mouth = opening_with((REFUSAL, Some("ghost"), REFUSAL_BYTES)); + let request = chat_client::decode::(OPEN_REQUEST_BYTES).unwrap(); + let started = std::time::Instant::now(); + let error = client(&mouth, KEY, "ghost", Duration::from_secs(20)).open_direct(&request).unwrap_err(); + assert!(started.elapsed() < Duration::from_secs(5), "the refusal ends the wait: {:?}", started.elapsed()); + assert!(error.downcast_ref::().is_none(), "only UnknownKind is a known not-sent: {error}"); + assert!(error.to_string().contains("UnknownPersona"), "{error}"); +} + +#[test] +fn an_unknown_kind_refusal_keyed_by_this_persona_but_naming_another_is_not_heard_as_not_sent() { + let other = chat_client::encode(&ChatRefusal { persona: "someone-else".into(), refusal: Refusal::UnknownKind }).unwrap(); + let mouth = opening_with((REFUSAL, Some("eureka"), Box::leak(other.into_boxed_slice()))); + let request = chat_client::decode::(OPEN_REQUEST_BYTES).unwrap(); + let error = client(&mouth, KEY, "eureka", Duration::from_secs(20)).open_direct(&request).unwrap_err(); + assert!(error.downcast_ref::().is_none(), "the payload names another Persona: {error}"); +}