diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index c7b09e4..88d3e79 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -31,6 +31,7 @@ //! The organ admits only a follow-up, a run's closing resolution and, through //! `open_and_launch`, a Self run. It never writes the brake or the dial. +use std::collections::BTreeMap; use std::fmt; use std::path::PathBuf; @@ -912,12 +913,26 @@ pub(crate) fn quoted(text: &str) -> String { .collect() } +/// The addressees of a message, from the mentions its delivery carries: "you" +/// for this Persona, a display name where this turn's senders showed one, else +/// the Matrix id. Empty when the message names no one. +fn addressees(config: &Config, mentions: &[String], names: &BTreeMap<&str, &str>) -> String { + mentions + .iter() + .map(|id| if *id == config.own_sender { "you" } else { names.get(id.as_str()).copied().unwrap_or(id.as_str()) }) + .collect::>() + .join(", ") +} + /// One message line: the sender id and the role the organ derived from it stand /// beside the member's room, display name and text, which are quoted. Only the /// `role` field says who is the operator, and no quoted text can write one. -fn message_line(config: &Config, label: &str, room: &str, sender: &str, display: Option<&str>, text: &str) -> String { +/// `to` is the addressees, shown only when the message names some; it is quoted. +fn message_line(config: &Config, label: &str, room: &str, sender: &str, display: Option<&str>, to: &str, text: &str) -> String { let name = display.map_or_else(String::new, |display| format!(" name={}", quoted(display))); - format!("{label} room={} sender={} role={}{name} text={}\n", quoted(room), quoted(sender), config.sender(sender).tag(), quoted(text)) + let to = if to.is_empty() { String::new() } else { format!(" to={}", quoted(to)) }; + format!("{label} room={} sender={} role={}{name}{to} text={} +", quoted(room), quoted(sender), config.sender(sender).tag(), quoted(text)) } /// The turn's input: the rooms, the burn line, her latest runs, and the @@ -937,11 +952,13 @@ fn render_input(config: &Config, deliveries: &[ChatDelivery], history: &[String] if deliveries.is_empty() { out.push_str("No new messages. This is a scheduled wake: decide whether to speak or to start work.\n"); } + let names: BTreeMap<&str, &str> = deliveries.iter().map(|d| (d.sender.as_str(), d.sender_display.as_str())).filter(|(_, name)| !name.is_empty()).collect(); for delivery in deliveries { for earlier in &delivery.context { - out.push_str(&message_line(config, "earlier", &delivery.room_alias, &earlier.sender, None, &earlier.text)); + out.push_str(&message_line(config, "earlier", &delivery.room_alias, &earlier.sender, None, "", &earlier.text)); } - out.push_str(&message_line(config, "message", &delivery.room_alias, &delivery.sender, Some(&delivery.sender_display), &delivery.text)); + let to = addressees(config, &delivery.mentions, &names); + out.push_str(&message_line(config, "message", &delivery.room_alias, &delivery.sender, Some(&delivery.sender_display), &to, &delivery.text)); } out } diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index cc45e26..d713091 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -376,7 +376,7 @@ fn recent_runs_are_the_newest_five_newest_first() { fn line_separators_in_member_text_cannot_end_its_line() { let seps = [char::from_u32(0x2028).unwrap(), char::from_u32(0x2029).unwrap(), char::from_u32(0x85).unwrap()]; let text = format!("a{}b{}c{}d", seps[0], seps[1], seps[2]); - let line = message_line(&config(), "message", "swarm", "@m:chat", Some("M"), &text); + let line = message_line(&config(), "message", "swarm", "@m:chat", Some("M"), "", &text); assert_eq!(line.matches('\n').count(), 1, "{line:?}"); assert!(seps.iter().all(|sep| !line.contains(*sep)), "{line:?}"); } @@ -1117,3 +1117,39 @@ fn with_no_campaign_in_force_the_instructions_say_so_and_the_schema_allows_no_st let schema: serde_json::Value = serde_json::from_str(&requests[0].output_schema_json).unwrap(); assert_eq!(schema["properties"]["start_self_run"], json!({ "type": "null" })); } + +fn input_for(mentions: &[&str]) -> String { + let mut aqua = delivery(1, "swarm", "@aqua:chat", "hello"); + aqua.sender_display = "Aqua".into(); + let mut said = delivery(2, "swarm", "@operator:chat", "a reply"); + said.mentions = mentions.iter().map(|m| (*m).to_string()).collect(); + render_input(&config(), &[aqua, said], &[], "burn") +} + +fn message_two(input: &str) -> &str { + input.lines().find(|line| line.contains("a reply")).unwrap() +} + +#[test] +fn a_message_mentioning_another_persona_renders_its_name() { + let input = input_for(&["@aqua:chat"]); + assert!(message_two(&input).contains(" to=\"Aqua\" "), "{input}"); +} + +#[test] +fn a_message_mentioning_this_persona_renders_you() { + let own = config().own_sender; + let input = input_for(&[own.as_str(), "@kiko:chat"]); + assert!(message_two(&input).contains(&format!(" to=\"you, @kiko:chat\" ")), "{input}"); +} + +#[test] +fn a_message_without_mentions_renders_no_addressee() { + let input = input_for(&[]); + assert!(!input.lines().filter(|line| line.starts_with("message ")).any(|line| line.contains(" to=")), "{input}"); +} + +#[test] +fn the_turn_input_says_how_to_read_an_addressee() { + assert!(input_for(&[]).contains("A message addressed to another Mind is theirs to answer. Answer it only if you hold something they need, and say so.")); +}