//! Conversation (spec §10, §13.1): who may post where, threads, mentions //! and direct messages into the inbox, duplicates, limits, redaction, and //! search limited to what the reader may see. mod common; use common::World; use spectra_core::conversation::{self as conv, Post, Posted}; use spectra_core::ids::now_ms; fn post(body: &str) -> Post { Post { body: body.into(), ..Default::default() } } fn id(p: Posted) -> String { match p { Posted::Message { id, .. } => id, Posted::Duplicate { id } => panic!("dropped as a duplicate of {id}"), } } #[tokio::test] async fn agents_post_where_they_belong_and_nowhere_else() { let mut w = World::new(&["oxygen", "oxygenhub"]).await; let claude = w.agent("claude-oxygen", "oxygen").await; let a = w.author(&claude).await; let at = conv::post(&w.ledger, &a, post("default goes to my project"), now_ms()).await.unwrap(); assert!(matches!(&at, Posted::Message { channel, .. } if channel == "#oxygen"), "{at:?}"); for c in ["general", "humans", "#oxygen"] { conv::post(&w.ledger, &a, Post { channel: Some(c.into()), ..post(&format!("hello {c}")) }, now_ms()).await.unwrap(); } for (c, why) in [("oxygenhub", "another project"), ("system", "Spectra's")] { let err = conv::post(&w.ledger, &a, Post { channel: Some(c.into()), ..post("nope") }, now_ms()).await.unwrap_err(); assert!(err.to_string().contains(why), "{c}: {err}"); } let owner_pair = w.owner.clone(); let owner = w.author(&owner_pair).await; conv::post(&w.ledger, &owner, Post { channel: Some("oxygenhub".into()), ..post("the owner posts anywhere but #system") }, now_ms()) .await .unwrap(); w.verify_clean().await; } #[tokio::test] async fn threads_mentions_and_direct_messages_reach_the_inbox() { let mut w = World::new(&["oxygen", "oxygenhub"]).await; let claude = w.agent("claude-oxygen", "oxygen").await; let kimi = w.agent("kimi-hub", "oxygenhub").await; let (c, k) = (w.author(&claude).await, w.author(&kimi).await); let root = id(conv::post( &w.ledger, &c, Post { channel: Some("general".into()), ..post("@kimi-hub the wire protocol grew a field; see oxy:change:abcdef12") }, now_ms(), ) .await .unwrap()); let items = conv::inbox(&w.ledger, "agent:kimi-hub", Some(&k)).await.unwrap(); assert_eq!(items.len(), 1); assert_eq!((items[0].kind.as_str(), items[0].from.as_str(), items[0].channel.as_str()), ("mention", "agent:claude-oxygen", "#general")); let reply = id(conv::post(&w.ledger, &k, Post { reply_to: Some(root.clone()), ..post("@andy should I bump the pin?") }, now_ms()) .await .unwrap()); let nested = id(conv::post(&w.ledger, &c, Post { reply_to: Some(reply.clone()), ..post("yes, after the release") }, now_ms()).await.unwrap()); let (_, thread) = conv::read(&w.ledger, &c.principal, None, Some(&nested), None, 50).await.unwrap(); assert_eq!( thread.iter().map(|m| m.id.clone()).collect::>(), [root.clone(), reply.clone(), nested], "a reply to a reply joins the root's thread" ); let (_, roots) = conv::read(&w.ledger, &c.principal, Some("general"), None, None, 50).await.unwrap(); assert_eq!(roots.len(), 1); assert_eq!(roots[0].replies, 2); assert_eq!(conv::unread(&w.ledger, "human").await.unwrap(), 1, "@andy is the owner"); assert_eq!(conv::ack(&w.ledger, &k, std::slice::from_ref(&root), Some("ack")).await.unwrap(), 1); assert!(conv::inbox(&w.ledger, "agent:kimi-hub", None).await.unwrap().is_empty()); let dm = conv::ensure_direct(&w.ledger, &c.principal, "kimi-hub").await.unwrap(); assert_eq!(dm.display(), "@claude-oxygen+kimi-hub"); conv::post(&w.ledger, &c, Post { channel: Some(dm.name.clone()), ..post("just between us") }, now_ms()).await.unwrap(); assert_eq!(conv::inbox(&w.ledger, "agent:kimi-hub", None).await.unwrap()[0].kind, "dm"); let third = w.agent("third", "oxygen").await; let t = w.author(&third).await; assert!(conv::read(&w.ledger, &t.principal, Some(&dm.name), None, None, 10).await.is_err(), "not a member"); assert!(conv::post(&w.ledger, &t, Post { channel: Some(dm.name.clone()), ..post("let me in") }, now_ms()).await.is_err()); w.verify_clean().await; } #[tokio::test] async fn duplicates_limits_and_redaction() { let mut w = World::new(&["oxygen"]).await; let claude = w.agent("claude-oxygen", "oxygen").await; let c = w.author(&claude).await; let first = id(conv::post(&w.ledger, &c, post("build failed on main"), now_ms()).await.unwrap()); assert!( matches!(conv::post(&w.ledger, &c, post("build failed on main"), now_ms()).await.unwrap(), Posted::Duplicate { id } if id == first) ); assert!(conv::post(&w.ledger, &c, post(&"x".repeat(conv::MAX_BODY + 1)), now_ms()).await.is_err()); assert!(conv::post(&w.ledger, &c, post(" "), now_ms()).await.is_err()); assert!(conv::post(&w.ledger, &c, Post { kind: Some("shout".into()), ..post("x") }, now_ms()).await.is_err()); let secret = id(conv::post(&w.ledger, &c, post("the token is hunter2"), now_ms()).await.unwrap()); assert_eq!(conv::search(&w.ledger, &c.principal, "hunter2", None, None, None, 10).await.unwrap().len(), 1); let kimi = w.agent("kimi", "oxygen").await; let k = w.author(&kimi).await; assert!(conv::redact(&w.ledger, &k, &secret, "not mine").await.is_err(), "only its author or the owner"); conv::redact(&w.ledger, &c, &secret, "a secret pasted by mistake").await.unwrap(); let (_, msgs) = conv::read(&w.ledger, &c.principal, None, None, None, 10).await.unwrap(); let shown = msgs.iter().find(|m| m.id == secret).unwrap(); assert_eq!(shown.body, "[redacted: a secret pasted by mistake]"); assert!(conv::search(&w.ledger, &c.principal, "hunter2", None, None, None, 10).await.unwrap().is_empty(), "gone from search"); w.verify_clean().await; } #[tokio::test] async fn search_finds_stemmed_words_and_respects_who_may_read() { let mut w = World::new(&["oxygen"]).await; let claude = w.agent("claude-oxygen", "oxygen").await; let kimi = w.agent("kimi", "oxygen").await; let (c, k) = (w.author(&claude).await, w.author(&kimi).await); conv::post( &w.ledger, &c, Post { channel: Some("general".into()), ..post("We decided to bump the oxygen pin after releasing 0.11") }, now_ms(), ) .await .unwrap(); conv::post(&w.ledger, &c, post("Unrelated chatter about lunch"), now_ms()).await.unwrap(); let dm = conv::ensure_direct(&w.ledger, &c.principal, "human").await.unwrap(); conv::post(&w.ledger, &c, Post { channel: Some(dm.name.clone()), ..post("privately: the pin bump worries me") }, now_ms()) .await .unwrap(); let hits = conv::search(&w.ledger, &k.principal, "bumping pins", None, None, None, 20).await.unwrap(); assert_eq!(hits.len(), 1, "stemmed, and the direct message is not kimi's to see: {hits:?}"); assert!(hits[0].snippet.contains("[bump]"), "{}", hits[0].snippet); assert_eq!(hits[0].channel, "#general"); let owner_pair = w.owner.clone(); let owner = w.author(&owner_pair).await; assert_eq!( conv::search(&w.ledger, &owner.principal, "pin", None, None, None, 20).await.unwrap().len(), 2, "the owner reads everything" ); assert_eq!( conv::search(&w.ledger, &k.principal, "\"oxygen pin\"", Some("general"), Some("claude-oxygen"), None, 20).await.unwrap().len(), 1 ); assert!(conv::search(&w.ledger, &k.principal, "lunch", Some("general"), None, None, 20).await.unwrap().is_empty()); let err = conv::search(&w.ledger, &k.principal, "AND OR (", None, None, None, 20).await.unwrap_err(); assert!(err.to_string().contains("does not parse"), "{err}"); w.verify_clean().await; }