use crate::message::ExternalActor;
use crate::typed_id::SessionId;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Participant {
pub actor: ExternalActor,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_seen_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ThreadContext {
pub thread_ref: String,
pub platform: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub platform_metadata: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub participants: HashMap<String, Participant>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_view: Option<ChannelViewContext>,
}
pub const THREAD_CONTEXT_KV_KEY: &str = "channel:thread_context";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelViewContext {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub team_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl ChannelViewContext {
pub fn is_empty(&self) -> bool {
self.channel_id.is_none() && self.team_id.is_none()
}
}
impl ThreadContext {
pub fn new(thread_ref: impl Into<String>, platform: impl Into<String>) -> Self {
Self {
thread_ref: thread_ref.into(),
platform: platform.into(),
platform_metadata: HashMap::new(),
participants: HashMap::new(),
current_view: None,
}
}
pub fn track_participant(&mut self, actor: &ExternalActor) -> bool {
use std::collections::hash_map::Entry;
match self.participants.entry(actor.actor_id.clone()) {
Entry::Vacant(entry) => {
entry.insert(Participant {
actor: actor.clone(),
first_seen_at: Some(chrono::Utc::now()),
role: None,
});
true
}
Entry::Occupied(mut entry) => {
if actor.actor_name != entry.get().actor.actor_name {
entry.get_mut().actor.actor_name = actor.actor_name.clone();
}
false
}
}
}
pub fn participant_count(&self) -> usize {
self.participants.len()
}
pub fn participants_summary(&self) -> String {
if self.participants.is_empty() {
return String::new();
}
let mut names: Vec<String> = self
.participants
.values()
.map(|p| p.actor.display_label().to_string())
.collect();
names.sort();
format!("Thread participants: {}", names.join(", "))
}
pub fn set_current_view(&mut self, view: ChannelViewContext) -> bool {
let view = (!view.is_empty()).then_some(view);
if self.current_view == view {
return false;
}
self.current_view = view;
true
}
pub fn view_summary(&self) -> String {
let Some(view) = self.current_view.as_ref() else {
return String::new();
};
let Some(channel_id) = view.channel_id.as_deref() else {
return String::new();
};
format!(
"The user is currently viewing {} channel {}. You have not been given \
access to it — ask before assuming you can read it.",
self.platform, channel_id
)
}
}
pub fn decode_thread_context(raw: &str) -> Option<ThreadContext> {
match serde_json::from_str(raw) {
Ok(ctx) => Some(ctx),
Err(error) => {
tracing::warn!(%error, "Discarding malformed thread context record");
None
}
}
}
pub fn encode_thread_context(context: &ThreadContext) -> crate::error::Result<String> {
serde_json::to_string(context).map_err(|e| crate::error::AgentLoopError::store(e.to_string()))
}
pub async fn load_thread_context(
store: &dyn crate::session_services::SessionStorageStore,
session_id: SessionId,
) -> Option<ThreadContext> {
match store.get_value(session_id, THREAD_CONTEXT_KV_KEY).await {
Ok(Some(raw)) => decode_thread_context(&raw),
Ok(None) => None,
Err(error) => {
tracing::warn!(%session_id, %error, "Failed to read persisted thread context");
None
}
}
}
pub async fn save_thread_context(
store: &dyn crate::session_services::SessionStorageStore,
session_id: SessionId,
context: &ThreadContext,
) -> crate::error::Result<()> {
let encoded = encode_thread_context(context)?;
store
.set_value(session_id, THREAD_CONTEXT_KV_KEY, &encoded)
.await
}
#[derive(Debug, Clone)]
pub struct InboundChannelEvent {
pub actor: ExternalActor,
pub text: String,
pub attachments: Vec<InboundAttachment>,
pub dedup_key: String,
pub thread_ref: Option<String>,
pub routing_metadata: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub enum InboundAttachment {
Image {
url: String,
alt_text: Option<String>,
},
FileDescription {
name: String,
mime_type: Option<String>,
},
}
#[derive(Debug, Clone)]
pub struct OutboundChannelMessage {
pub session_id: SessionId,
pub text: String,
pub thread_ref: String,
pub is_progress_report: bool,
pub correlation_id: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ChannelReplyMode {
#[default]
AllMessages,
ReportProgressOnly,
}
#[async_trait]
pub trait ChannelDeliveryAdapter: Send + Sync {
fn platform(&self) -> &str;
async fn deliver(
&self,
message: &OutboundChannelMessage,
context: &DeliveryContext,
) -> DeliveryResult;
async fn send_ack(
&self,
thread_ref: &str,
text: &str,
context: &DeliveryContext,
) -> DeliveryResult;
fn format_progress_report(
&self,
report: &crate::progress_reporting::ProgressReportPayload,
) -> String;
fn streaming(&self) -> Option<&dyn ChannelStreamDelivery> {
None
}
fn agent_surface(&self) -> Option<&dyn ChannelAgentSurface> {
None
}
}
#[async_trait]
pub trait ChannelAgentSurface: Send + Sync {
async fn set_status(&self, status: &str, context: &DeliveryContext) -> DeliveryResult;
async fn set_title(&self, title: &str, context: &DeliveryContext) -> DeliveryResult;
}
#[async_trait]
pub trait ChannelStreamDelivery: Send + Sync {
async fn start(&self, context: &DeliveryContext) -> Result<String, String>;
async fn append(&self, handle: &str, text: &str, context: &DeliveryContext) -> DeliveryResult;
async fn stop(&self, handle: &str, context: &DeliveryContext) -> DeliveryResult;
}
#[derive(Clone)]
pub struct DeliveryContext {
pub auth_token: String,
pub channel_id: String,
pub thread_ref: String,
pub reply_mode: ChannelReplyMode,
pub extra: HashMap<String, String>,
}
impl std::fmt::Debug for DeliveryContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DeliveryContext")
.field("auth_token", &"[REDACTED]")
.field("channel_id", &self.channel_id)
.field("thread_ref", &self.thread_ref)
.field("reply_mode", &self.reply_mode)
.field("extra", &self.extra)
.finish()
}
}
#[derive(Debug)]
pub enum DeliveryResult {
Ok,
TransientError(String),
PermanentError(String),
}
pub fn build_session_routing_tag(
platform: &str,
binding: &SessionBinding,
metadata: &HashMap<String, String>,
) -> Option<String> {
match binding {
SessionBinding::Thread => metadata
.get("thread_ref")
.map(|t| format!("{}:thread:{}", platform, t)),
SessionBinding::Conversation => metadata
.get("channel_id")
.map(|c| format!("{}:channel:{}", platform, c)),
SessionBinding::Requester => metadata
.get("user_id")
.map(|u| format!("{}:user:{}", platform, u)),
SessionBinding::Endpoint | SessionBinding::Ephemeral => None,
}
}
pub fn resolve_session_binding(
declared: SessionBinding,
event_override: Option<SessionBinding>,
) -> SessionBinding {
event_override.unwrap_or(declared)
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(feature = "openapi", schema(example = "per_thread"))]
pub enum SessionBinding {
#[default]
#[serde(rename = "per_thread", alias = "thread")]
Thread,
#[serde(rename = "per_channel", alias = "conversation")]
Conversation,
#[serde(rename = "per_user", alias = "requester")]
Requester,
#[serde(rename = "shared_session", alias = "endpoint")]
Endpoint,
#[serde(rename = "session_per_invocation", alias = "ephemeral")]
Ephemeral,
}
impl SessionBinding {
pub const MESSAGE_KEYED: [SessionBinding; 3] = [
SessionBinding::Thread,
SessionBinding::Conversation,
SessionBinding::Requester,
];
pub const INVOCATION_KEYED: [SessionBinding; 2] =
[SessionBinding::Endpoint, SessionBinding::Ephemeral];
pub fn is_message_keyed(self) -> bool {
Self::MESSAGE_KEYED.contains(&self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_thread_context_track_participant() {
let mut ctx = ThreadContext::new("1234.5678", "slack");
let mut actor = ExternalActor {
actor_id: "U001".into(),
actor_name: Some("Alice".into()),
source: "slack".into(),
metadata: Some(HashMap::from([("team".into(), "T1".into())])),
};
assert!(ctx.track_participant(&actor));
let participant = &ctx.participants["U001"];
assert_eq!(participant.actor, actor);
assert!(participant.first_seen_at.is_some());
assert!(!ctx.track_participant(&actor));
assert_eq!(ctx.participant_count(), 1);
let first_seen = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap();
let participant = ctx.participants.get_mut("U001").unwrap();
participant.first_seen_at = Some(first_seen);
participant.role = Some("owner".into());
actor.actor_name = Some("Alice B.".into());
assert!(!ctx.track_participant(&actor));
assert_eq!(ctx.participant_count(), 1);
assert_eq!(
ctx.participants["U001"],
Participant {
actor,
first_seen_at: Some(first_seen),
role: Some("owner".into()),
}
);
}
#[test]
fn test_thread_context_participants_summary() {
let mut ctx = ThreadContext::new("thread_1", "discord");
assert_eq!(ctx.participants_summary(), "");
for (actor_id, name) in [
("U003", Some("Zoe")),
("U002", None),
("U001", Some("Alice")),
] {
ctx.track_participant(&ExternalActor {
actor_id: actor_id.into(),
actor_name: name.map(str::to_string),
source: "discord".into(),
metadata: None,
});
}
assert_eq!(
ctx.participants_summary(),
"Thread participants: Alice, U002, Zoe"
);
}
#[test]
fn test_build_session_routing_tags() {
let metadata = HashMap::from([
("thread_ref".into(), "1234.5678".into()),
("channel_id".into(), "C0123".into()),
("user_id".into(), "U999".into()),
]);
for (binding, platform, key, expected) in [
(
SessionBinding::Thread,
"slack",
"thread_ref",
"slack:thread:1234.5678",
),
(
SessionBinding::Conversation,
"discord",
"channel_id",
"discord:channel:C0123",
),
(
SessionBinding::Requester,
"teams",
"user_id",
"teams:user:U999",
),
] {
assert_eq!(
build_session_routing_tag(platform, &binding, &metadata).as_deref(),
Some(expected)
);
let mut missing = metadata.clone();
missing.remove(key);
assert_eq!(
build_session_routing_tag(platform, &binding, &missing),
None
);
assert_eq!(
build_session_routing_tag(platform, &binding, &HashMap::new()),
None
);
}
}
#[test]
fn session_binding_tags_keep_their_legacy_segments() {
let metadata = HashMap::from([
("thread_ref".into(), "T1".into()),
("channel_id".into(), "C1".into()),
("user_id".into(), "U1".into()),
]);
for (binding, expected) in [
(SessionBinding::Thread, Some("slack:thread:T1")),
(SessionBinding::Conversation, Some("slack:channel:C1")),
(SessionBinding::Requester, Some("slack:user:U1")),
(SessionBinding::Endpoint, None),
(SessionBinding::Ephemeral, None),
] {
assert_eq!(
build_session_routing_tag("slack", &binding, &metadata).as_deref(),
expected,
"{binding:?}"
);
}
}
#[test]
fn resolve_session_binding_lets_the_event_override_the_declaration() {
for declared in SessionBinding::MESSAGE_KEYED {
assert_eq!(resolve_session_binding(declared, None), declared);
}
assert_eq!(
resolve_session_binding(SessionBinding::Conversation, Some(SessionBinding::Thread)),
SessionBinding::Thread
);
assert_eq!(
resolve_session_binding(SessionBinding::Requester, Some(SessionBinding::Thread)),
SessionBinding::Thread
);
}
#[test]
fn test_channel_reply_mode_wire_contract() {
assert_eq!(ChannelReplyMode::default(), ChannelReplyMode::AllMessages);
for (mode, wire) in [
(ChannelReplyMode::AllMessages, "\"all_messages\""),
(
ChannelReplyMode::ReportProgressOnly,
"\"report_progress_only\"",
),
] {
assert_eq!(serde_json::to_string(&mode).unwrap(), wire);
assert_eq!(
serde_json::from_str::<ChannelReplyMode>(wire).unwrap(),
mode
);
}
}
#[test]
fn test_session_binding_wire_contract() {
assert_eq!(SessionBinding::default(), SessionBinding::Thread);
for (binding, wire) in [
(SessionBinding::Thread, "\"per_thread\""),
(SessionBinding::Conversation, "\"per_channel\""),
(SessionBinding::Requester, "\"per_user\""),
(SessionBinding::Endpoint, "\"shared_session\""),
(SessionBinding::Ephemeral, "\"session_per_invocation\""),
] {
assert_eq!(
serde_json::to_string(&binding).unwrap(),
wire,
"{binding:?} must still serialize to its legacy value"
);
assert_eq!(
serde_json::from_str::<SessionBinding>(wire).unwrap(),
binding,
"{wire} must still deserialize"
);
}
}
#[test]
fn test_session_binding_accepts_new_names_as_aliases() {
for (alias, binding) in [
("\"thread\"", SessionBinding::Thread),
("\"conversation\"", SessionBinding::Conversation),
("\"requester\"", SessionBinding::Requester),
("\"endpoint\"", SessionBinding::Endpoint),
("\"ephemeral\"", SessionBinding::Ephemeral),
] {
assert_eq!(
serde_json::from_str::<SessionBinding>(alias).unwrap(),
binding
);
}
}
fn actor(id: &str, name: &str) -> ExternalActor {
ExternalActor {
actor_id: id.to_string(),
actor_name: Some(name.to_string()),
source: "slack".to_string(),
metadata: None,
}
}
#[test]
fn participants_accumulate_across_a_round_trip() {
let mut ctx = ThreadContext::new("1700.1", "slack");
assert!(ctx.track_participant(&actor("U1", "Alice")));
let encoded = encode_thread_context(&ctx).expect("encode");
let mut restored = decode_thread_context(&encoded).expect("decode");
assert!(restored.track_participant(&actor("U2", "Bob")));
assert!(
!restored.track_participant(&actor("U1", "Alice")),
"re-seen actor is not new"
);
assert_eq!(restored.participant_count(), 2);
assert_eq!(
restored.participants_summary(),
"Thread participants: Alice, Bob"
);
}
#[test]
fn malformed_record_decodes_to_none() {
assert!(decode_thread_context("not json").is_none());
assert!(decode_thread_context("").is_none());
}
#[test]
fn setting_the_same_view_twice_reports_no_change() {
let mut ctx = ThreadContext::new("1700.1", "slack");
let view = ChannelViewContext {
channel_id: Some("C123".to_string()),
team_id: Some("T1".to_string()),
observed_at: None,
};
assert!(
ctx.set_current_view(view.clone()),
"first report is a change"
);
assert!(!ctx.set_current_view(view), "identical report is not");
let moved = ChannelViewContext {
channel_id: Some("C999".to_string()),
team_id: Some("T1".to_string()),
observed_at: None,
};
assert!(ctx.set_current_view(moved), "a real move is a change");
}
#[test]
fn empty_view_clears_the_current_position() {
let mut ctx = ThreadContext::new("1700.1", "slack");
ctx.set_current_view(ChannelViewContext {
channel_id: Some("C123".to_string()),
..Default::default()
});
assert!(ctx.current_view.is_some());
assert!(ctx.set_current_view(ChannelViewContext::default()));
assert!(ctx.current_view.is_none());
assert_eq!(ctx.view_summary(), "");
}
#[test]
fn view_summary_does_not_imply_access() {
let mut ctx = ThreadContext::new("1700.1", "slack");
ctx.set_current_view(ChannelViewContext {
channel_id: Some("C123".to_string()),
team_id: None,
observed_at: None,
});
let summary = ctx.view_summary();
assert!(summary.contains("C123"), "{summary}");
assert!(summary.contains("slack"), "{summary}");
assert!(
summary.contains("have not been given access"),
"must not present the channel as readable: {summary}"
);
assert!(summary.contains("ask before"), "{summary}");
}
#[test]
fn no_view_yields_no_line() {
let ctx = ThreadContext::new("1700.1", "slack");
assert_eq!(ctx.view_summary(), "");
assert_eq!(ctx.participants_summary(), "");
}
#[test]
fn current_view_survives_encoding() {
let mut ctx = ThreadContext::new("1700.1", "slack");
ctx.set_current_view(ChannelViewContext {
channel_id: Some("C123".to_string()),
team_id: Some("T1".to_string()),
observed_at: None,
});
let restored = decode_thread_context(&encode_thread_context(&ctx).unwrap()).unwrap();
assert_eq!(restored.current_view, ctx.current_view);
}
}