use async_trait::async_trait;
use everruns_core::capabilities::{Capability, CapabilityStatus, SystemPromptContext};
use everruns_core::channel::load_thread_context;
pub const CHANNEL_CONTEXT_CAPABILITY_ID: &str = "channel_context";
pub struct ChannelContextCapability;
#[async_trait]
impl Capability for ChannelContextCapability {
fn id(&self) -> &str {
CHANNEL_CONTEXT_CAPABILITY_ID
}
fn name(&self) -> &str {
"Channel thread context"
}
fn description(&self) -> &str {
"For sessions driven by a messaging channel (Slack), tells the agent who else is in the thread and, when the platform reports it, where the user is currently looking. Accumulates across the thread and survives a restart. Contributes nothing to sessions that are not channel-backed."
}
fn status(&self) -> CapabilityStatus {
CapabilityStatus::Available
}
fn icon(&self) -> Option<&str> {
Some("users")
}
fn category(&self) -> Option<&str> {
Some("Core")
}
async fn conversation_context_contribution(&self, ctx: &SystemPromptContext) -> Option<String> {
let store = ctx.session_storage.as_ref()?;
let thread = load_thread_context(store.as_ref(), ctx.session_id).await?;
let mut lines = Vec::new();
let participants = thread.participants_summary();
if !participants.is_empty() {
lines.push(participants);
}
let view = thread.view_summary();
if !view.is_empty() {
lines.push(view);
}
(!lines.is_empty()).then(|| lines.join("\n"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use everruns_core::ExternalActor;
use everruns_core::channel::{ChannelViewContext, ThreadContext, encode_thread_context};
use everruns_core::session_services::{KeyInfo, SecretInfo, SessionStorageStore};
use everruns_provider::error::Result;
use everruns_provider::typed_id::SessionId;
use std::sync::Arc;
struct OneRecordStore(Option<String>);
#[async_trait]
impl SessionStorageStore for OneRecordStore {
async fn set_value(&self, _: SessionId, _: &str, _: &str) -> Result<()> {
Ok(())
}
async fn get_value(&self, _: SessionId, key: &str) -> Result<Option<String>> {
assert_eq!(key, everruns_core::channel::THREAD_CONTEXT_KV_KEY);
Ok(self.0.clone())
}
async fn delete_value(&self, _: SessionId, _: &str) -> Result<bool> {
Ok(false)
}
async fn list_keys(&self, _: SessionId) -> Result<Vec<KeyInfo>> {
Ok(vec![])
}
async fn set_secret(&self, _: SessionId, _: &str, _: &str) -> Result<()> {
Ok(())
}
async fn get_secret(&self, _: SessionId, _: &str) -> Result<Option<String>> {
Ok(None)
}
async fn delete_secret(&self, _: SessionId, _: &str) -> Result<bool> {
Ok(false)
}
async fn list_secrets(&self, _: SessionId) -> Result<Vec<SecretInfo>> {
Ok(vec![])
}
}
fn ctx_with(record: Option<String>) -> SystemPromptContext {
let mut ctx = SystemPromptContext::without_file_store(SessionId::new());
ctx.session_storage = Some(Arc::new(OneRecordStore(record)));
ctx
}
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,
}
}
#[tokio::test]
async fn renders_participants_and_view() {
let mut thread = ThreadContext::new("1700.1", "slack");
thread.track_participant(&actor("U1", "Alice"));
thread.track_participant(&actor("U2", "Bob"));
thread.set_current_view(ChannelViewContext {
channel_id: Some("C123".to_string()),
..Default::default()
});
let out = ChannelContextCapability
.conversation_context_contribution(&ctx_with(Some(
encode_thread_context(&thread).unwrap(),
)))
.await
.expect("context should be contributed");
assert!(out.contains("Thread participants: Alice, Bob"), "{out}");
assert!(out.contains("C123"), "{out}");
assert!(out.contains("have not been given access"), "{out}");
}
#[tokio::test]
async fn contributes_nothing_without_a_record() {
assert!(
ChannelContextCapability
.conversation_context_contribution(&ctx_with(None))
.await
.is_none()
);
}
#[tokio::test]
async fn contributes_nothing_without_a_store() {
let ctx = SystemPromptContext::without_file_store(SessionId::new());
assert!(
ChannelContextCapability
.conversation_context_contribution(&ctx)
.await
.is_none()
);
}
#[tokio::test]
async fn empty_thread_contributes_nothing() {
let thread = ThreadContext::new("1700.1", "slack");
assert!(
ChannelContextCapability
.conversation_context_contribution(&ctx_with(Some(
encode_thread_context(&thread).unwrap()
)))
.await
.is_none()
);
}
#[tokio::test]
async fn malformed_record_contributes_nothing() {
assert!(
ChannelContextCapability
.conversation_context_contribution(&ctx_with(Some("not json".to_string())))
.await
.is_none()
);
}
}