use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::principal::PrincipalSummary;
use crate::typed_id::{
AgentId, AgentIdentityId, AgentVersionId, AppChannelId, AppId, HarnessId, PrincipalId,
};
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(feature = "openapi", schema(example = "published"))]
#[serde(rename_all = "lowercase")]
pub enum AppStatus {
Draft,
Published,
Archived,
Deleted,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(feature = "openapi", schema(example = "pinned"))]
#[serde(rename_all = "lowercase")]
pub enum AgentVersionPolicy {
#[default]
Default,
Latest,
Pinned,
}
impl std::fmt::Display for AgentVersionPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AgentVersionPolicy::Default => write!(f, "default"),
AgentVersionPolicy::Latest => write!(f, "latest"),
AgentVersionPolicy::Pinned => write!(f, "pinned"),
}
}
}
impl From<&str> for AgentVersionPolicy {
fn from(s: &str) -> Self {
match s {
"latest" => AgentVersionPolicy::Latest,
"pinned" => AgentVersionPolicy::Pinned,
_ => AgentVersionPolicy::Default,
}
}
}
impl std::fmt::Display for AppStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppStatus::Draft => write!(f, "draft"),
AppStatus::Published => write!(f, "published"),
AppStatus::Archived => write!(f, "archived"),
AppStatus::Deleted => write!(f, "deleted"),
}
}
}
impl From<&str> for AppStatus {
fn from(s: &str) -> Self {
match s {
"published" => AppStatus::Published,
"archived" => AppStatus::Archived,
"deleted" => AppStatus::Deleted,
_ => AppStatus::Draft,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(feature = "openapi", schema(example = "webhook"))]
#[serde(rename_all = "lowercase")]
pub enum ChannelType {
Slack,
#[serde(rename = "ag_ui")]
AgUi,
Schedule,
Webhook,
A2a,
Fcp,
}
impl std::fmt::Display for ChannelType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ChannelType::Slack => write!(f, "slack"),
ChannelType::AgUi => write!(f, "ag_ui"),
ChannelType::Schedule => write!(f, "schedule"),
ChannelType::Webhook => write!(f, "webhook"),
ChannelType::A2a => write!(f, "a2a"),
ChannelType::Fcp => write!(f, "fcp"),
}
}
}
impl ChannelType {
pub fn from_str_opt(s: &str) -> Option<Self> {
match s {
"slack" => Some(ChannelType::Slack),
"ag_ui" => Some(ChannelType::AgUi),
"schedule" => Some(ChannelType::Schedule),
"webhook" => Some(ChannelType::Webhook),
"a2a" => Some(ChannelType::A2a),
"fcp" => Some(ChannelType::Fcp),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct App {
#[serde(rename = "id")]
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "app_01933b5a000070008000000000000001"))]
pub public_id: AppId,
#[serde(skip, default = "Uuid::nil")]
pub internal_id: Uuid,
#[serde(skip, default)]
pub org_id: i64,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "harness_01933b5a00007000800000000000001"))]
pub harness_id: HarnessId,
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agent_01933b5a00007000800000000000001"))]
pub agent_id: Option<AgentId>,
#[serde(default)]
pub agent_version_policy: AgentVersionPolicy,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agentver_01933b5a00007000800000000000001"))]
pub agent_version_id: Option<AgentVersionId>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "identity_01933b5a00007000800000000000001"))]
pub agent_identity_id: Option<AgentIdentityId>,
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "principal_01933b5a000070008000000000000001"))]
pub owner_principal_id: PrincipalId,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_owner_user_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<PrincipalSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub effective_owner: Option<PrincipalSummary>,
#[serde(default)]
pub channels: Vec<AppChannel>,
pub status: AppStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub archived_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct AppChannel {
#[serde(rename = "id")]
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "appchan_01933b5a000070008000000000000001"))]
pub public_id: AppChannelId,
#[serde(skip, default = "Uuid::nil")]
pub internal_id: Uuid,
pub channel_type: ChannelType,
#[serde(default)]
pub channel_config: serde_json::Value,
#[serde(default = "default_true")]
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
fn default_true() -> bool {
true
}
impl AppChannel {
pub fn slack_config(&self) -> Option<SlackChannelConfig> {
if self.channel_type != ChannelType::Slack {
return None;
}
serde_json::from_value(self.channel_config.clone()).ok()
}
pub fn ag_ui_config(&self) -> Option<AgUiChannelConfig> {
if self.channel_type != ChannelType::AgUi {
return None;
}
serde_json::from_value(self.channel_config.clone()).ok()
}
pub fn schedule_config(&self) -> Option<ScheduleChannelConfig> {
if self.channel_type != ChannelType::Schedule {
return None;
}
serde_json::from_value(self.channel_config.clone()).ok()
}
pub fn webhook_config(&self) -> Option<WebhookChannelConfig> {
if self.channel_type != ChannelType::Webhook {
return None;
}
serde_json::from_value(self.channel_config.clone()).ok()
}
pub fn fcp_config(&self) -> Option<FcpChannelConfig> {
if self.channel_type != ChannelType::Fcp {
return None;
}
serde_json::from_value(self.channel_config.clone()).ok()
}
pub fn a2a_config(&self) -> Option<A2aChannelConfig> {
if self.channel_type != ChannelType::A2a {
return None;
}
serde_json::from_value(self.channel_config.clone()).ok()
}
}
impl App {
pub fn slack_channel(&self) -> Option<&AppChannel> {
self.channels
.iter()
.find(|ch| ch.channel_type == ChannelType::Slack && ch.enabled)
}
pub fn ag_ui_channel(&self) -> Option<&AppChannel> {
self.channels
.iter()
.find(|ch| ch.channel_type == ChannelType::AgUi && ch.enabled)
}
pub fn schedule_channel(&self) -> Option<&AppChannel> {
self.channels
.iter()
.find(|ch| ch.channel_type == ChannelType::Schedule && ch.enabled)
}
pub fn webhook_channel(&self) -> Option<&AppChannel> {
self.channels
.iter()
.find(|ch| ch.channel_type == ChannelType::Webhook && ch.enabled)
}
pub fn fcp_channel(&self) -> Option<&AppChannel> {
self.channels
.iter()
.find(|ch| ch.channel_type == ChannelType::Fcp && ch.enabled)
}
pub fn a2a_channel(&self) -> Option<&AppChannel> {
self.channels
.iter()
.find(|ch| ch.channel_type == ChannelType::A2a && ch.enabled)
}
pub fn channel_by_id(&self, id: &AppChannelId) -> Option<&AppChannel> {
self.channels.iter().find(|ch| ch.public_id == *id)
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum SessionStrategy {
#[default]
PerThread,
PerChannel,
PerUser,
}
impl From<SessionStrategy> for crate::channel::SessionRoutingStrategy {
fn from(s: SessionStrategy) -> Self {
match s {
SessionStrategy::PerThread => Self::PerThread,
SessionStrategy::PerChannel => Self::PerChannel,
SessionStrategy::PerUser => Self::PerUser,
}
}
}
impl From<crate::channel::SessionRoutingStrategy> for SessionStrategy {
fn from(s: crate::channel::SessionRoutingStrategy) -> Self {
match s {
crate::channel::SessionRoutingStrategy::PerThread => Self::PerThread,
crate::channel::SessionRoutingStrategy::PerChannel => Self::PerChannel,
crate::channel::SessionRoutingStrategy::PerUser => Self::PerUser,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum SlackReplyMode {
#[default]
AllMessages,
ReportProgressOnly,
}
impl From<SlackReplyMode> for crate::channel::ChannelReplyMode {
fn from(m: SlackReplyMode) -> Self {
match m {
SlackReplyMode::AllMessages => Self::AllMessages,
SlackReplyMode::ReportProgressOnly => Self::ReportProgressOnly,
}
}
}
impl From<crate::channel::ChannelReplyMode> for SlackReplyMode {
fn from(m: crate::channel::ChannelReplyMode) -> Self {
match m {
crate::channel::ChannelReplyMode::AllMessages => Self::AllMessages,
crate::channel::ChannelReplyMode::ReportProgressOnly => Self::ReportProgressOnly,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct SlackChannelConfig {
pub signing_secret: String,
pub bot_token: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub team_id: Option<String>,
#[serde(default)]
pub session_strategy: SessionStrategy,
#[serde(default)]
pub reply_mode: SlackReplyMode,
#[serde(skip_serializing_if = "Option::is_none")]
pub webhook_verified_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_message_received_at: Option<DateTime<Utc>>,
}
pub const DEFAULT_SESSION_EXPIRATION_SECONDS: u32 = 6 * 60 * 60;
pub const DEFAULT_AG_UI_GENERIC_TOOL_TEXT: &str = "Working...";
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum AgUiToolVisibility {
None,
#[default]
Generic,
Narrated,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum AppEndpointAuthMode {
Anonymous,
SharedSecret,
ApiKey,
GoogleOidc,
Oidc,
OAuth2Introspection,
HttpBasic,
Mtls,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AppEndpointAuthProviderConfig {
GoogleOidc {
client_id: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
allowed_domains: Vec<String>,
},
Oidc {
issuer: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
jwks_url: Option<String>,
},
OAuth2Introspection {
introspection_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
client_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
client_secret: Option<String>,
},
HttpBasic {
username: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
password: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
password_hash: Option<String>,
},
Mtls {
header_name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
allowed_values: Vec<String>,
},
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct AppEndpointAuthRequirements {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub audiences: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<String>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub claims: serde_json::Map<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subjects: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub domains: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(
feature = "openapi",
schema(example = json!({"mode": "api_key", "requirements": {"audiences": ["everruns-api"], "scopes": ["app:invoke"]}}))
)]
pub struct AppEndpointAuthConfig {
pub mode: AppEndpointAuthMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<AppEndpointAuthProviderConfig>,
#[serde(default)]
pub requirements: AppEndpointAuthRequirements,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct AgUiChannelConfig {
#[serde(default = "default_true")]
pub anonymous: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
#[serde(default = "default_session_expiration_seconds")]
pub session_expiration_seconds: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate_limit_per_minute: Option<u32>,
#[serde(default)]
pub tool_visibility: AgUiToolVisibility,
#[serde(
default = "default_ag_ui_generic_tool_text",
skip_serializing_if = "is_default_ag_ui_generic_tool_text"
)]
pub generic_tool_text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<AppEndpointAuthConfig>,
}
fn default_session_expiration_seconds() -> u32 {
DEFAULT_SESSION_EXPIRATION_SECONDS
}
fn default_ag_ui_generic_tool_text() -> String {
DEFAULT_AG_UI_GENERIC_TOOL_TEXT.to_string()
}
fn is_default_ag_ui_generic_tool_text(value: &str) -> bool {
value == DEFAULT_AG_UI_GENERIC_TOOL_TEXT
}
pub const DEFAULT_FCP_HANDSHAKE: &str = "FCP endpoint.\n\nPOST plain text or `application/json` (`{\"message\": \"...\"}`) to\nthis URL to talk to the agent. Replies are returned as `text/markdown`.\n\nSession state, when supported, is carried by the `fcp_session` cookie.";
pub const DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS: u32 = 120;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct FcpChannelConfig {
#[serde(default = "default_true")]
pub anonymous: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handshake: Option<String>,
#[serde(default = "default_session_expiration_seconds")]
pub session_expiration_seconds: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate_limit_per_minute: Option<u32>,
#[serde(default = "default_fcp_response_timeout_seconds")]
pub response_timeout_seconds: u32,
}
fn default_fcp_response_timeout_seconds() -> u32 {
DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(feature = "openapi", schema(example = "shared_session"))]
#[serde(rename_all = "snake_case")]
pub enum InvocationSessionMode {
#[default]
SharedSession,
SessionPerInvocation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ScheduleChannelConfig {
pub cron_expression: String,
#[serde(default = "default_timezone")]
pub timezone: String,
#[serde(default)]
pub session_mode: InvocationSessionMode,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct WebhookChannelConfig {
pub token: String,
#[serde(default)]
pub session_mode: InvocationSessionMode,
pub message: String,
}
fn default_timezone() -> String {
"UTC".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct A2aChannelConfig {
pub api_key_hash: String,
pub api_key_prefix: String,
#[serde(default)]
pub session_mode: InvocationSessionMode,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_card_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_card_description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate_limit_per_minute: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<AppEndpointAuthConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signing_secret: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_app_status_display() {
assert_eq!(AppStatus::Draft.to_string(), "draft");
assert_eq!(AppStatus::Published.to_string(), "published");
assert_eq!(AppStatus::Archived.to_string(), "archived");
assert_eq!(AppStatus::Deleted.to_string(), "deleted");
}
#[test]
fn test_app_status_from_str() {
assert_eq!(AppStatus::from("draft"), AppStatus::Draft);
assert_eq!(AppStatus::from("published"), AppStatus::Published);
assert_eq!(AppStatus::from("archived"), AppStatus::Archived);
assert_eq!(AppStatus::from("deleted"), AppStatus::Deleted);
assert_eq!(AppStatus::from("unknown"), AppStatus::Draft);
assert_eq!(AppStatus::from(""), AppStatus::Draft);
}
#[test]
fn test_app_status_serde_roundtrip() {
let json = serde_json::to_string(&AppStatus::Published).unwrap();
assert_eq!(json, r#""published""#);
let parsed: AppStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, AppStatus::Published);
}
#[test]
fn test_channel_type_display() {
assert_eq!(ChannelType::Slack.to_string(), "slack");
assert_eq!(ChannelType::AgUi.to_string(), "ag_ui");
assert_eq!(ChannelType::Schedule.to_string(), "schedule");
assert_eq!(ChannelType::Webhook.to_string(), "webhook");
assert_eq!(ChannelType::A2a.to_string(), "a2a");
assert_eq!(ChannelType::Fcp.to_string(), "fcp");
}
#[test]
fn test_channel_type_from_str_opt() {
assert_eq!(ChannelType::from_str_opt("slack"), Some(ChannelType::Slack));
assert_eq!(ChannelType::from_str_opt("ag_ui"), Some(ChannelType::AgUi));
assert_eq!(
ChannelType::from_str_opt("schedule"),
Some(ChannelType::Schedule)
);
assert_eq!(
ChannelType::from_str_opt("webhook"),
Some(ChannelType::Webhook)
);
assert_eq!(ChannelType::from_str_opt("a2a"), Some(ChannelType::A2a));
assert_eq!(ChannelType::from_str_opt("fcp"), Some(ChannelType::Fcp));
assert_eq!(ChannelType::from_str_opt("unknown"), None);
assert_eq!(ChannelType::from_str_opt(""), None);
}
#[test]
fn test_channel_type_serde_roundtrip() {
let json = serde_json::to_string(&ChannelType::Slack).unwrap();
assert_eq!(json, r#""slack""#);
let parsed: ChannelType = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, ChannelType::Slack);
let json = serde_json::to_string(&ChannelType::AgUi).unwrap();
assert_eq!(json, r#""ag_ui""#);
let parsed: ChannelType = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, ChannelType::AgUi);
let json = serde_json::to_string(&ChannelType::Schedule).unwrap();
assert_eq!(json, r#""schedule""#);
let parsed: ChannelType = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, ChannelType::Schedule);
let json = serde_json::to_string(&ChannelType::Webhook).unwrap();
assert_eq!(json, r#""webhook""#);
let parsed: ChannelType = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, ChannelType::Webhook);
}
#[test]
fn test_session_strategy_default() {
assert_eq!(SessionStrategy::default(), SessionStrategy::PerThread);
}
#[test]
fn test_session_strategy_serde() {
let json = serde_json::to_string(&SessionStrategy::PerChannel).unwrap();
assert_eq!(json, r#""per_channel""#);
let parsed: SessionStrategy = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, SessionStrategy::PerChannel);
let json = serde_json::to_string(&SessionStrategy::PerUser).unwrap();
assert_eq!(json, r#""per_user""#);
}
#[test]
fn test_slack_channel_config_full() {
let json = r#"{
"signing_secret": "sec123",
"bot_token": "xoxb-tok",
"channel_id": "C123",
"team_id": "T123",
"session_strategy": "per_channel",
"reply_mode": "report_progress_only"
}"#;
let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.signing_secret, "sec123");
assert_eq!(config.bot_token, "xoxb-tok");
assert_eq!(config.channel_id.as_deref(), Some("C123"));
assert_eq!(config.team_id.as_deref(), Some("T123"));
assert_eq!(config.session_strategy, SessionStrategy::PerChannel);
assert_eq!(config.reply_mode, SlackReplyMode::ReportProgressOnly);
}
#[test]
fn test_slack_channel_config_minimal() {
let json = r#"{"signing_secret": "s", "bot_token": "t"}"#;
let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
assert!(config.channel_id.is_none());
assert!(config.team_id.is_none());
assert_eq!(config.session_strategy, SessionStrategy::PerThread);
assert_eq!(config.reply_mode, SlackReplyMode::AllMessages);
assert!(config.webhook_verified_at.is_none());
assert!(config.first_message_received_at.is_none());
}
#[test]
fn test_slack_channel_config_with_verification_timestamps() {
let json = r#"{
"signing_secret": "s",
"bot_token": "t",
"webhook_verified_at": "2025-01-01T00:00:00Z",
"first_message_received_at": "2025-01-01T01:00:00Z"
}"#;
let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
assert!(config.webhook_verified_at.is_some());
assert!(config.first_message_received_at.is_some());
let serialized = serde_json::to_value(&config).unwrap();
assert!(serialized.get("webhook_verified_at").is_some());
assert!(serialized.get("first_message_received_at").is_some());
}
#[test]
fn test_slack_channel_config_timestamps_skipped_when_none() {
let config = SlackChannelConfig {
signing_secret: "s".into(),
bot_token: "t".into(),
channel_id: None,
team_id: None,
session_strategy: SessionStrategy::PerThread,
reply_mode: SlackReplyMode::AllMessages,
webhook_verified_at: None,
first_message_received_at: None,
};
let json = serde_json::to_value(&config).unwrap();
assert!(json.get("webhook_verified_at").is_none());
assert!(json.get("first_message_received_at").is_none());
}
#[test]
fn test_slack_reply_mode_serde_roundtrip() {
let json = serde_json::to_string(&SlackReplyMode::ReportProgressOnly).unwrap();
assert_eq!(json, r#""report_progress_only""#);
let parsed: SlackReplyMode = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, SlackReplyMode::ReportProgressOnly);
}
#[test]
fn test_slack_channel_config_missing_required_field() {
let json = r#"{"signing_secret": "s"}"#;
assert!(serde_json::from_str::<SlackChannelConfig>(json).is_err());
}
#[test]
fn test_ag_ui_channel_config_defaults_to_anonymous() {
let config: AgUiChannelConfig = serde_json::from_str("{}").unwrap();
assert!(config.anonymous);
assert_eq!(
config.session_expiration_seconds,
DEFAULT_SESSION_EXPIRATION_SECONDS
);
assert!(config.rate_limit_per_minute.is_none());
assert!(config.token.is_none());
assert!(config.auth.is_none());
assert_eq!(config.tool_visibility, AgUiToolVisibility::Generic);
assert_eq!(config.generic_tool_text, DEFAULT_AG_UI_GENERIC_TOOL_TEXT);
}
#[test]
fn test_ag_ui_channel_config_roundtrip() {
let config = AgUiChannelConfig {
anonymous: true,
token: Some("agui-token".to_string()),
session_expiration_seconds: 3600,
rate_limit_per_minute: Some(120),
tool_visibility: AgUiToolVisibility::None,
generic_tool_text: "Please wait".to_string(),
auth: None,
};
let json = serde_json::to_string(&config).unwrap();
let parsed: AgUiChannelConfig = serde_json::from_str(&json).unwrap();
assert!(parsed.anonymous);
assert_eq!(parsed.token.as_deref(), Some("agui-token"));
assert_eq!(parsed.session_expiration_seconds, 3600);
assert_eq!(parsed.rate_limit_per_minute, Some(120));
assert_eq!(parsed.tool_visibility, AgUiToolVisibility::None);
assert_eq!(parsed.generic_tool_text, "Please wait");
}
#[test]
fn test_ag_ui_channel_config_zero_disables_expiration() {
let config: AgUiChannelConfig =
serde_json::from_str(r#"{"session_expiration_seconds": 0}"#).unwrap();
assert_eq!(config.session_expiration_seconds, 0);
}
#[test]
fn test_ag_ui_channel_config_omits_rate_limit_when_unset() {
let config = AgUiChannelConfig {
anonymous: true,
token: None,
session_expiration_seconds: DEFAULT_SESSION_EXPIRATION_SECONDS,
rate_limit_per_minute: None,
tool_visibility: AgUiToolVisibility::Generic,
generic_tool_text: DEFAULT_AG_UI_GENERIC_TOOL_TEXT.to_string(),
auth: None,
};
let json = serde_json::to_value(&config).unwrap();
assert!(json.get("rate_limit_per_minute").is_none());
assert!(json.get("generic_tool_text").is_none());
}
#[test]
fn test_invocation_session_mode_defaults_to_shared_session() {
assert_eq!(
InvocationSessionMode::default(),
InvocationSessionMode::SharedSession
);
}
#[test]
fn test_schedule_channel_config_defaults() {
let config: ScheduleChannelConfig =
serde_json::from_str(r#"{"cron_expression":"0 * * * * * *","message":"Run checks"}"#)
.unwrap();
assert_eq!(config.timezone, "UTC");
assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
}
#[test]
fn test_webhook_channel_config_defaults() {
let config: WebhookChannelConfig =
serde_json::from_str(r#"{"token":"top-secret","message":"{{payload.action}}"}"#)
.unwrap();
assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
}
fn test_app(channels: Vec<AppChannel>) -> App {
App {
public_id: AppId::from_uuid(Uuid::nil()),
internal_id: Uuid::nil(),
org_id: 1,
name: "test".into(),
description: None,
harness_id: HarnessId::from_uuid(Uuid::nil()),
agent_id: Some(AgentId::from_uuid(Uuid::nil())),
agent_version_policy: AgentVersionPolicy::Default,
agent_version_id: None,
agent_identity_id: None,
owner_principal_id: PrincipalId::from_seed(1),
resolved_owner_user_id: None,
owner: None,
effective_owner: None,
channels,
status: AppStatus::Draft,
published_at: None,
created_at: Utc::now(),
updated_at: Utc::now(),
archived_at: None,
deleted_at: None,
}
}
fn test_channel(channel_type: ChannelType, config: serde_json::Value) -> AppChannel {
AppChannel {
public_id: AppChannelId::from_uuid(Uuid::nil()),
internal_id: Uuid::nil(),
channel_type,
channel_config: config,
enabled: true,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn test_app_channel_slack_config_valid() {
let ch = test_channel(
ChannelType::Slack,
serde_json::json!({"signing_secret": "sec", "bot_token": "tok"}),
);
let config = ch.slack_config().unwrap();
assert_eq!(config.signing_secret, "sec");
}
#[test]
fn test_app_channel_slack_config_invalid_json() {
let ch = test_channel(ChannelType::Slack, serde_json::json!({"bad": "data"}));
assert!(ch.slack_config().is_none());
}
#[test]
fn test_app_slack_channel_lookup() {
let ch = test_channel(
ChannelType::Slack,
serde_json::json!({"signing_secret": "s", "bot_token": "t"}),
);
let app = test_app(vec![ch]);
assert!(app.slack_channel().is_some());
}
#[test]
fn test_app_slack_channel_none_when_empty() {
let app = test_app(vec![]);
assert!(app.slack_channel().is_none());
}
#[test]
fn test_app_channel_ag_ui_config_valid() {
let ch = test_channel(ChannelType::AgUi, serde_json::json!({"anonymous": true}));
let config = ch.ag_ui_config().unwrap();
assert!(config.anonymous);
}
#[test]
fn test_app_ag_ui_channel_lookup() {
let ch = test_channel(ChannelType::AgUi, serde_json::json!({"anonymous": true}));
let app = test_app(vec![ch]);
assert!(app.ag_ui_channel().is_some());
}
#[test]
fn test_app_channel_fcp_config_defaults() {
let ch = test_channel(ChannelType::Fcp, serde_json::json!({}));
let config = ch.fcp_config().unwrap();
assert!(config.anonymous);
assert!(config.token.is_none());
assert!(config.handshake.is_none());
assert_eq!(
config.session_expiration_seconds,
DEFAULT_SESSION_EXPIRATION_SECONDS
);
assert_eq!(
config.response_timeout_seconds,
DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS
);
}
#[test]
fn test_app_fcp_channel_lookup() {
let ch = test_channel(ChannelType::Fcp, serde_json::json!({}));
let app = test_app(vec![ch]);
assert!(app.fcp_channel().is_some());
}
#[test]
fn test_app_channel_schedule_config_valid() {
let ch = test_channel(
ChannelType::Schedule,
serde_json::json!({
"cron_expression": "0 * * * * * *",
"message": "Run checks"
}),
);
let config = ch.schedule_config().unwrap();
assert_eq!(config.message, "Run checks");
}
#[test]
fn test_app_schedule_channel_lookup() {
let ch = test_channel(
ChannelType::Schedule,
serde_json::json!({
"cron_expression": "0 * * * * * *",
"message": "Run checks"
}),
);
let app = test_app(vec![ch]);
assert!(app.schedule_channel().is_some());
}
#[test]
fn test_app_channel_webhook_config_valid() {
let ch = test_channel(
ChannelType::Webhook,
serde_json::json!({
"token": "secret",
"message": "{{payload.ref}}"
}),
);
let config = ch.webhook_config().unwrap();
assert_eq!(config.token, "secret");
}
#[test]
fn test_app_webhook_channel_lookup() {
let ch = test_channel(
ChannelType::Webhook,
serde_json::json!({
"token": "secret",
"message": "{{payload.ref}}"
}),
);
let app = test_app(vec![ch]);
assert!(app.webhook_channel().is_some());
}
#[test]
fn test_a2a_channel_config_defaults() {
let config: A2aChannelConfig = serde_json::from_str(
r#"{"api_key_hash":"abc","api_key_prefix":"evra2a_abc1...","message":"{{a2a.text}}"}"#,
)
.unwrap();
assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
assert!(config.agent_card_name.is_none());
assert!(config.agent_card_description.is_none());
assert!(config.rate_limit_per_minute.is_none());
assert!(config.auth.is_none());
assert!(config.signing_secret.is_none());
}
#[test]
fn test_a2a_channel_config_roundtrip() {
let config = A2aChannelConfig {
api_key_hash: "deadbeef".into(),
api_key_prefix: "evra2a_dead...".into(),
session_mode: InvocationSessionMode::SessionPerInvocation,
message: "{{a2a.text}}".into(),
agent_card_name: Some("Inbox triage".into()),
agent_card_description: Some("Triages github events".into()),
rate_limit_per_minute: Some(120),
auth: None,
signing_secret: None,
};
let json = serde_json::to_string(&config).unwrap();
let parsed: A2aChannelConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.api_key_hash, "deadbeef");
assert_eq!(
parsed.session_mode,
InvocationSessionMode::SessionPerInvocation
);
assert_eq!(parsed.agent_card_name.as_deref(), Some("Inbox triage"));
assert_eq!(parsed.rate_limit_per_minute, Some(120));
}
#[test]
fn test_a2a_channel_config_omits_optional_fields() {
let config = A2aChannelConfig {
api_key_hash: "h".into(),
api_key_prefix: "evra2a_h...".into(),
session_mode: InvocationSessionMode::SharedSession,
message: "m".into(),
agent_card_name: None,
agent_card_description: None,
rate_limit_per_minute: None,
auth: None,
signing_secret: None,
};
let json = serde_json::to_value(&config).unwrap();
assert!(json.get("agent_card_name").is_none());
assert!(json.get("agent_card_description").is_none());
assert!(json.get("rate_limit_per_minute").is_none());
assert!(json.get("signing_secret").is_none());
}
#[test]
fn test_app_channel_a2a_config_valid() {
let ch = test_channel(
ChannelType::A2a,
serde_json::json!({
"api_key_hash": "h",
"api_key_prefix": "evra2a_h...",
"message": "{{a2a.text}}"
}),
);
let config = ch.a2a_config().unwrap();
assert_eq!(config.api_key_prefix, "evra2a_h...");
}
#[test]
fn test_app_a2a_channel_lookup() {
let ch = test_channel(
ChannelType::A2a,
serde_json::json!({
"api_key_hash": "h",
"api_key_prefix": "evra2a_h...",
"message": "{{a2a.text}}"
}),
);
let app = test_app(vec![ch]);
assert!(app.a2a_channel().is_some());
}
#[test]
fn test_app_serde_skips_internal_fields() {
let app = test_app(vec![]);
let json = serde_json::to_value(&app).unwrap();
assert!(json.get("id").is_some()); assert!(json.get("internal_id").is_none()); assert!(json.get("org_id").is_none()); assert!(json.get("published_at").is_none()); }
}