1use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use uuid::Uuid;
16
17use crate::principal::PrincipalSummary;
18use crate::typed_id::{
19 AgentId, AgentIdentityId, AgentVersionId, AppChannelId, AppId, HarnessId, PrincipalId,
20};
21
22#[cfg(feature = "openapi")]
23use utoipa::ToSchema;
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31#[cfg_attr(feature = "openapi", derive(ToSchema))]
32#[cfg_attr(feature = "openapi", schema(example = "published"))]
33#[serde(rename_all = "lowercase")]
34pub enum AppStatus {
35 Draft,
36 Published,
37 Archived,
38 Deleted,
39}
40
41#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
43#[cfg_attr(feature = "openapi", derive(ToSchema))]
44#[cfg_attr(feature = "openapi", schema(example = "pinned"))]
45#[serde(rename_all = "lowercase")]
46pub enum AgentVersionPolicy {
47 #[default]
49 Default,
50 Latest,
52 Pinned,
54}
55
56impl std::fmt::Display for AgentVersionPolicy {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 AgentVersionPolicy::Default => write!(f, "default"),
60 AgentVersionPolicy::Latest => write!(f, "latest"),
61 AgentVersionPolicy::Pinned => write!(f, "pinned"),
62 }
63 }
64}
65
66impl From<&str> for AgentVersionPolicy {
67 fn from(s: &str) -> Self {
68 match s {
69 "latest" => AgentVersionPolicy::Latest,
70 "pinned" => AgentVersionPolicy::Pinned,
71 _ => AgentVersionPolicy::Default,
72 }
73 }
74}
75
76impl std::fmt::Display for AppStatus {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 match self {
79 AppStatus::Draft => write!(f, "draft"),
80 AppStatus::Published => write!(f, "published"),
81 AppStatus::Archived => write!(f, "archived"),
82 AppStatus::Deleted => write!(f, "deleted"),
83 }
84 }
85}
86
87impl From<&str> for AppStatus {
88 fn from(s: &str) -> Self {
89 match s {
90 "published" => AppStatus::Published,
91 "archived" => AppStatus::Archived,
92 "deleted" => AppStatus::Deleted,
93 _ => AppStatus::Draft,
94 }
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100#[cfg_attr(feature = "openapi", derive(ToSchema))]
101#[cfg_attr(feature = "openapi", schema(example = "webhook"))]
102#[serde(rename_all = "lowercase")]
103pub enum ChannelType {
104 Slack,
105 #[serde(rename = "ag_ui")]
106 AgUi,
107 Schedule,
108 Webhook,
109 A2a,
111 Fcp,
115 #[serde(rename = "api_endpoint")]
118 ApiEndpoint,
119 #[serde(rename = "public_chat")]
124 PublicChat,
125}
126
127impl std::fmt::Display for ChannelType {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 match self {
130 ChannelType::Slack => write!(f, "slack"),
131 ChannelType::AgUi => write!(f, "ag_ui"),
132 ChannelType::Schedule => write!(f, "schedule"),
133 ChannelType::Webhook => write!(f, "webhook"),
134 ChannelType::A2a => write!(f, "a2a"),
135 ChannelType::Fcp => write!(f, "fcp"),
136 ChannelType::ApiEndpoint => write!(f, "api_endpoint"),
137 ChannelType::PublicChat => write!(f, "public_chat"),
138 }
139 }
140}
141
142impl ChannelType {
143 pub fn from_str_opt(s: &str) -> Option<Self> {
144 match s {
145 "slack" => Some(ChannelType::Slack),
146 "ag_ui" => Some(ChannelType::AgUi),
147 "schedule" => Some(ChannelType::Schedule),
148 "webhook" => Some(ChannelType::Webhook),
149 "a2a" => Some(ChannelType::A2a),
150 "fcp" => Some(ChannelType::Fcp),
151 "api_endpoint" => Some(ChannelType::ApiEndpoint),
152 "public_chat" => Some(ChannelType::PublicChat),
153 _ => None,
154 }
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
162#[cfg_attr(feature = "openapi", derive(ToSchema))]
163pub struct App {
164 #[serde(rename = "id")]
166 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "app_01933b5a000070008000000000000001"))]
167 pub public_id: AppId,
168 #[serde(skip, default = "Uuid::nil")]
170 pub internal_id: Uuid,
171 #[serde(skip, default)]
173 pub org_id: i64,
174 pub name: String,
176 #[serde(skip_serializing_if = "Option::is_none")]
178 pub description: Option<String>,
179 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "harness_01933b5a00007000800000000000001"))]
181 pub harness_id: HarnessId,
182 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agent_01933b5a00007000800000000000001"))]
184 pub agent_id: Option<AgentId>,
185 #[serde(default)]
187 pub agent_version_policy: AgentVersionPolicy,
188 #[serde(skip_serializing_if = "Option::is_none")]
190 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agentver_01933b5a00007000800000000000001"))]
191 pub agent_version_id: Option<AgentVersionId>,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "identity_01933b5a00007000800000000000001"))]
195 pub agent_identity_id: Option<AgentIdentityId>,
196 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "principal_01933b5a000070008000000000000001"))]
198 pub owner_principal_id: PrincipalId,
199 #[serde(skip_serializing_if = "Option::is_none")]
201 pub resolved_owner_user_id: Option<Uuid>,
202 #[serde(skip_serializing_if = "Option::is_none")]
204 pub owner: Option<PrincipalSummary>,
205 #[serde(skip_serializing_if = "Option::is_none")]
207 pub effective_owner: Option<PrincipalSummary>,
208 #[serde(default)]
210 pub channels: Vec<AppChannel>,
211 pub status: AppStatus,
213 #[serde(skip_serializing_if = "Option::is_none")]
215 pub published_at: Option<DateTime<Utc>>,
216 pub created_at: DateTime<Utc>,
218 pub updated_at: DateTime<Utc>,
220 #[serde(skip_serializing_if = "Option::is_none")]
222 pub archived_at: Option<DateTime<Utc>>,
223 #[serde(skip_serializing_if = "Option::is_none")]
225 pub deleted_at: Option<DateTime<Utc>>,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
231#[cfg_attr(feature = "openapi", derive(ToSchema))]
232pub struct AppChannel {
233 #[serde(rename = "id")]
235 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "appchan_01933b5a000070008000000000000001"))]
236 pub public_id: AppChannelId,
237 #[serde(skip, default = "Uuid::nil")]
239 pub internal_id: Uuid,
240 pub channel_type: ChannelType,
242 #[serde(default)]
244 pub channel_config: serde_json::Value,
245 #[serde(default = "default_true")]
247 pub enabled: bool,
248 pub created_at: DateTime<Utc>,
250 pub updated_at: DateTime<Utc>,
252}
253
254fn default_true() -> bool {
255 true
256}
257
258impl AppChannel {
259 pub fn slack_config(&self) -> Option<SlackChannelConfig> {
262 if self.channel_type != ChannelType::Slack {
263 return None;
264 }
265 serde_json::from_value(self.channel_config.clone()).ok()
266 }
267
268 pub fn ag_ui_config(&self) -> Option<AgUiChannelConfig> {
271 if self.channel_type != ChannelType::AgUi {
272 return None;
273 }
274 serde_json::from_value(self.channel_config.clone()).ok()
275 }
276
277 pub fn schedule_config(&self) -> Option<ScheduleChannelConfig> {
280 if self.channel_type != ChannelType::Schedule {
281 return None;
282 }
283 serde_json::from_value(self.channel_config.clone()).ok()
284 }
285
286 pub fn webhook_config(&self) -> Option<WebhookChannelConfig> {
289 if self.channel_type != ChannelType::Webhook {
290 return None;
291 }
292 serde_json::from_value(self.channel_config.clone()).ok()
293 }
294
295 pub fn fcp_config(&self) -> Option<FcpChannelConfig> {
298 if self.channel_type != ChannelType::Fcp {
299 return None;
300 }
301 serde_json::from_value(self.channel_config.clone()).ok()
302 }
303
304 pub fn a2a_config(&self) -> Option<A2aChannelConfig> {
307 if self.channel_type != ChannelType::A2a {
308 return None;
309 }
310 serde_json::from_value(self.channel_config.clone()).ok()
311 }
312
313 pub fn api_endpoint_config(&self) -> Option<ApiEndpointChannelConfig> {
316 if self.channel_type != ChannelType::ApiEndpoint {
317 return None;
318 }
319 serde_json::from_value(self.channel_config.clone()).ok()
320 }
321
322 pub fn public_chat_config(&self) -> Option<PublicChatChannelConfig> {
325 if self.channel_type != ChannelType::PublicChat {
326 return None;
327 }
328 serde_json::from_value(self.channel_config.clone()).ok()
329 }
330}
331
332impl App {
333 pub fn slack_channel(&self) -> Option<&AppChannel> {
335 self.channels
336 .iter()
337 .find(|ch| ch.channel_type == ChannelType::Slack && ch.enabled)
338 }
339
340 pub fn ag_ui_channel(&self) -> Option<&AppChannel> {
342 self.channels
343 .iter()
344 .find(|ch| ch.channel_type == ChannelType::AgUi && ch.enabled)
345 }
346
347 pub fn schedule_channel(&self) -> Option<&AppChannel> {
349 self.channels
350 .iter()
351 .find(|ch| ch.channel_type == ChannelType::Schedule && ch.enabled)
352 }
353
354 pub fn webhook_channel(&self) -> Option<&AppChannel> {
356 self.channels
357 .iter()
358 .find(|ch| ch.channel_type == ChannelType::Webhook && ch.enabled)
359 }
360
361 pub fn fcp_channel(&self) -> Option<&AppChannel> {
363 self.channels
364 .iter()
365 .find(|ch| ch.channel_type == ChannelType::Fcp && ch.enabled)
366 }
367
368 pub fn a2a_channel(&self) -> Option<&AppChannel> {
370 self.channels
371 .iter()
372 .find(|ch| ch.channel_type == ChannelType::A2a && ch.enabled)
373 }
374
375 pub fn api_endpoint_channel(&self) -> Option<&AppChannel> {
377 self.channels
378 .iter()
379 .find(|ch| ch.channel_type == ChannelType::ApiEndpoint && ch.enabled)
380 }
381
382 pub fn public_chat_channel(&self) -> Option<&AppChannel> {
384 self.channels
385 .iter()
386 .find(|ch| ch.channel_type == ChannelType::PublicChat && ch.enabled)
387 }
388
389 pub fn channel_by_id(&self, id: &AppChannelId) -> Option<&AppChannel> {
391 self.channels.iter().find(|ch| ch.public_id == *id)
392 }
393}
394
395#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
400#[cfg_attr(feature = "openapi", derive(ToSchema))]
401#[serde(rename_all = "snake_case")]
402pub enum SessionStrategy {
403 #[default]
405 PerThread,
406 PerChannel,
408 PerUser,
410}
411
412impl From<SessionStrategy> for crate::channel::SessionRoutingStrategy {
413 fn from(s: SessionStrategy) -> Self {
414 match s {
415 SessionStrategy::PerThread => Self::PerThread,
416 SessionStrategy::PerChannel => Self::PerChannel,
417 SessionStrategy::PerUser => Self::PerUser,
418 }
419 }
420}
421
422impl From<crate::channel::SessionRoutingStrategy> for SessionStrategy {
423 fn from(s: crate::channel::SessionRoutingStrategy) -> Self {
424 match s {
425 crate::channel::SessionRoutingStrategy::PerThread => Self::PerThread,
426 crate::channel::SessionRoutingStrategy::PerChannel => Self::PerChannel,
427 crate::channel::SessionRoutingStrategy::PerUser => Self::PerUser,
428 }
429 }
430}
431
432#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
437#[cfg_attr(feature = "openapi", derive(ToSchema))]
438#[serde(rename_all = "snake_case")]
439pub enum SlackReplyMode {
440 #[default]
442 AllMessages,
443 ReportProgressOnly,
445}
446
447impl From<SlackReplyMode> for crate::channel::ChannelReplyMode {
448 fn from(m: SlackReplyMode) -> Self {
449 match m {
450 SlackReplyMode::AllMessages => Self::AllMessages,
451 SlackReplyMode::ReportProgressOnly => Self::ReportProgressOnly,
452 }
453 }
454}
455
456impl From<crate::channel::ChannelReplyMode> for SlackReplyMode {
457 fn from(m: crate::channel::ChannelReplyMode) -> Self {
458 match m {
459 crate::channel::ChannelReplyMode::AllMessages => Self::AllMessages,
460 crate::channel::ChannelReplyMode::ReportProgressOnly => Self::ReportProgressOnly,
461 }
462 }
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
468#[cfg_attr(feature = "openapi", derive(ToSchema))]
469pub struct SlackChannelConfig {
470 pub signing_secret: String,
472 pub bot_token: String,
474 #[serde(skip_serializing_if = "Option::is_none")]
476 pub channel_id: Option<String>,
477 #[serde(skip_serializing_if = "Option::is_none")]
479 pub team_id: Option<String>,
480 #[serde(default)]
482 pub session_strategy: SessionStrategy,
483 #[serde(default)]
485 pub reply_mode: SlackReplyMode,
486 #[serde(skip_serializing_if = "Option::is_none")]
488 pub webhook_verified_at: Option<DateTime<Utc>>,
489 #[serde(skip_serializing_if = "Option::is_none")]
491 pub first_message_received_at: Option<DateTime<Utc>>,
492}
493
494pub const DEFAULT_SESSION_EXPIRATION_SECONDS: u32 = 6 * 60 * 60;
496
497pub const DEFAULT_AG_UI_GENERIC_TOOL_TEXT: &str = "Working...";
499
500#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
502#[cfg_attr(feature = "openapi", derive(ToSchema))]
503#[serde(rename_all = "snake_case")]
504pub enum AgUiToolVisibility {
505 None,
507 #[default]
509 Generic,
510 Narrated,
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
519#[cfg_attr(feature = "openapi", derive(ToSchema))]
520#[serde(rename_all = "snake_case")]
521pub enum AppEndpointAuthMode {
522 Anonymous,
523 SharedSecret,
524 ApiKey,
525 GoogleOidc,
526 Oidc,
527 OAuth2Introspection,
528 HttpBasic,
529 Mtls,
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
534#[cfg_attr(feature = "openapi", derive(ToSchema))]
535#[serde(tag = "type", rename_all = "snake_case")]
536pub enum AppEndpointAuthProviderConfig {
537 GoogleOidc {
538 client_id: String,
539 #[serde(default, skip_serializing_if = "Vec::is_empty")]
540 allowed_domains: Vec<String>,
541 },
542 Oidc {
543 issuer: String,
544 #[serde(default, skip_serializing_if = "Option::is_none")]
545 jwks_url: Option<String>,
546 },
547 OAuth2Introspection {
548 introspection_url: String,
549 #[serde(default, skip_serializing_if = "Option::is_none")]
550 client_id: Option<String>,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
552 client_secret: Option<String>,
553 },
554 HttpBasic {
555 username: String,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
557 password: Option<String>,
558 #[serde(default, skip_serializing_if = "Option::is_none")]
559 password_hash: Option<String>,
560 },
561 Mtls {
562 header_name: String,
563 #[serde(default, skip_serializing_if = "Vec::is_empty")]
564 allowed_values: Vec<String>,
565 #[serde(default, skip_serializing_if = "Option::is_none")]
568 proxy_secret_header: Option<String>,
569 #[serde(default, skip_serializing_if = "Option::is_none")]
572 proxy_secret: Option<String>,
573 },
574}
575
576#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
578#[cfg_attr(feature = "openapi", derive(ToSchema))]
579pub struct AppEndpointAuthRequirements {
580 #[serde(default, skip_serializing_if = "Vec::is_empty")]
582 pub audiences: Vec<String>,
583 #[serde(default, skip_serializing_if = "Vec::is_empty")]
585 pub scopes: Vec<String>,
586 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
588 pub claims: serde_json::Map<String, serde_json::Value>,
589 #[serde(default, skip_serializing_if = "Vec::is_empty")]
591 pub subjects: Vec<String>,
592 #[serde(default, skip_serializing_if = "Vec::is_empty")]
594 pub groups: Vec<String>,
595 #[serde(default, skip_serializing_if = "Vec::is_empty")]
597 pub domains: Vec<String>,
598}
599
600#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
602#[cfg_attr(feature = "openapi", derive(ToSchema))]
603#[cfg_attr(
604 feature = "openapi",
605 schema(example = json!({"mode": "api_key", "requirements": {"audiences": ["everruns-api"], "scopes": ["app:invoke"]}}))
606)]
607pub struct AppEndpointAuthConfig {
608 pub mode: AppEndpointAuthMode,
609 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub provider: Option<AppEndpointAuthProviderConfig>,
611 #[serde(default)]
612 pub requirements: AppEndpointAuthRequirements,
613}
614
615#[derive(Debug, Clone, Serialize, Deserialize)]
619#[cfg_attr(feature = "openapi", derive(ToSchema))]
620pub struct AgUiChannelConfig {
621 #[serde(default = "default_true")]
624 pub anonymous: bool,
625 #[serde(default, skip_serializing_if = "Option::is_none")]
628 pub token: Option<String>,
629 #[serde(default = "default_session_expiration_seconds")]
634 pub session_expiration_seconds: u32,
635 #[serde(default, skip_serializing_if = "Option::is_none")]
640 pub rate_limit_per_minute: Option<u32>,
641 #[serde(default)]
643 pub tool_visibility: AgUiToolVisibility,
644 #[serde(
646 default = "default_ag_ui_generic_tool_text",
647 skip_serializing_if = "is_default_ag_ui_generic_tool_text"
648 )]
649 pub generic_tool_text: String,
650 #[serde(default, skip_serializing_if = "Option::is_none")]
653 pub auth: Option<AppEndpointAuthConfig>,
654}
655
656fn default_session_expiration_seconds() -> u32 {
657 DEFAULT_SESSION_EXPIRATION_SECONDS
658}
659
660fn default_ag_ui_generic_tool_text() -> String {
661 DEFAULT_AG_UI_GENERIC_TOOL_TEXT.to_string()
662}
663
664fn is_default_ag_ui_generic_tool_text(value: &str) -> bool {
665 value == DEFAULT_AG_UI_GENERIC_TOOL_TEXT
666}
667
668pub 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.";
672
673pub const DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS: u32 = 120;
675
676#[derive(Debug, Clone, Serialize, Deserialize)]
692#[cfg_attr(feature = "openapi", derive(ToSchema))]
693pub struct FcpChannelConfig {
694 #[serde(default = "default_true")]
697 pub anonymous: bool,
698 #[serde(default, skip_serializing_if = "Option::is_none")]
703 pub token: Option<String>,
704 #[serde(default, skip_serializing_if = "Option::is_none")]
708 pub handshake: Option<String>,
709 #[serde(default = "default_session_expiration_seconds")]
713 pub session_expiration_seconds: u32,
714 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub rate_limit_per_minute: Option<u32>,
720 #[serde(default = "default_fcp_response_timeout_seconds")]
723 pub response_timeout_seconds: u32,
724}
725
726fn default_fcp_response_timeout_seconds() -> u32 {
727 DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS
728}
729
730#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
732#[cfg_attr(feature = "openapi", derive(ToSchema))]
733#[cfg_attr(feature = "openapi", schema(example = "shared_session"))]
734#[serde(rename_all = "snake_case")]
735pub enum InvocationSessionMode {
736 #[default]
738 SharedSession,
739 SessionPerInvocation,
741}
742
743#[derive(Debug, Clone, Serialize, Deserialize)]
748#[cfg_attr(feature = "openapi", derive(ToSchema))]
749pub struct ScheduleChannelConfig {
750 pub cron_expression: String,
752 #[serde(default = "default_timezone")]
754 pub timezone: String,
755 #[serde(default)]
757 pub session_mode: InvocationSessionMode,
758 pub message: String,
760}
761
762#[derive(Debug, Clone, Serialize, Deserialize)]
767#[cfg_attr(feature = "openapi", derive(ToSchema))]
768pub struct WebhookChannelConfig {
769 pub token: String,
771 #[serde(default)]
773 pub session_mode: InvocationSessionMode,
774 pub message: String,
776 #[serde(default, skip_serializing_if = "Option::is_none")]
781 pub rate_limit_per_minute: Option<u32>,
782}
783
784fn default_timezone() -> String {
785 "UTC".to_string()
786}
787
788#[derive(Debug, Clone, Serialize, Deserialize)]
798#[cfg_attr(feature = "openapi", derive(ToSchema))]
799pub struct A2aChannelConfig {
800 pub api_key_hash: String,
802 pub api_key_prefix: String,
804 #[serde(default)]
806 pub session_mode: InvocationSessionMode,
807 pub message: String,
809 #[serde(default, skip_serializing_if = "Option::is_none")]
811 pub agent_card_name: Option<String>,
812 #[serde(default, skip_serializing_if = "Option::is_none")]
814 pub agent_card_description: Option<String>,
815 #[serde(default, skip_serializing_if = "Option::is_none")]
821 pub rate_limit_per_minute: Option<u32>,
822 #[serde(default, skip_serializing_if = "Option::is_none")]
825 pub auth: Option<AppEndpointAuthConfig>,
826 #[serde(default, skip_serializing_if = "Option::is_none")]
837 pub signing_secret: Option<String>,
838}
839
840#[derive(Debug, Clone, Serialize, Deserialize)]
850#[cfg_attr(feature = "openapi", derive(ToSchema))]
851pub struct ApiEndpointChannelConfig {
852 pub api_key_hash: String,
854 pub api_key_prefix: String,
856 #[serde(default)]
858 pub session_mode: InvocationSessionMode,
859 #[serde(default, skip_serializing_if = "Option::is_none")]
864 pub rate_limit_per_minute: Option<u32>,
865 #[serde(default, skip_serializing_if = "Option::is_none")]
868 pub auth: Option<AppEndpointAuthConfig>,
869}
870
871#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
875#[cfg_attr(feature = "openapi", derive(ToSchema))]
876pub struct PublicChatBranding {
877 #[serde(default, skip_serializing_if = "Option::is_none")]
879 pub display_name: Option<String>,
880 #[serde(default, skip_serializing_if = "Option::is_none")]
882 pub logo_url: Option<String>,
883 #[serde(default, skip_serializing_if = "Option::is_none")]
886 pub primary_color: Option<String>,
887 #[serde(default, skip_serializing_if = "Option::is_none")]
889 pub welcome_message: Option<String>,
890}
891
892#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
894#[cfg_attr(feature = "openapi", derive(ToSchema))]
895#[serde(rename_all = "snake_case")]
896pub enum CaptchaProvider {
897 #[default]
899 Turnstile,
900}
901
902#[derive(Debug, Clone, Serialize, Deserialize)]
911#[cfg_attr(feature = "openapi", derive(ToSchema))]
912pub struct PublicChatCaptchaConfig {
913 #[serde(default)]
915 pub provider: CaptchaProvider,
916 #[serde(default = "default_true")]
919 pub enabled: bool,
920 pub site_key: String,
922 #[serde(default, skip_serializing_if = "Option::is_none")]
924 pub secret_key: Option<String>,
925}
926
927#[derive(Debug, Clone, Serialize, Deserialize)]
934#[cfg_attr(feature = "openapi", derive(ToSchema))]
935pub struct PublicChatChannelConfig {
936 #[serde(default = "default_true")]
939 pub anonymous: bool,
940 #[serde(default, skip_serializing_if = "Option::is_none")]
943 pub token: Option<String>,
944 #[serde(default = "default_session_expiration_seconds")]
947 pub session_expiration_seconds: u32,
948 #[serde(default, skip_serializing_if = "Option::is_none")]
951 pub rate_limit_per_minute: Option<u32>,
952 #[serde(default)]
955 pub tool_visibility: AgUiToolVisibility,
956 #[serde(
958 default = "default_ag_ui_generic_tool_text",
959 skip_serializing_if = "is_default_ag_ui_generic_tool_text"
960 )]
961 pub generic_tool_text: String,
962 #[serde(default, skip_serializing_if = "Option::is_none")]
965 pub auth: Option<AppEndpointAuthConfig>,
966 #[serde(default, skip_serializing_if = "PublicChatBranding::is_empty")]
968 pub branding: PublicChatBranding,
969 #[serde(default, skip_serializing_if = "Option::is_none")]
971 pub captcha: Option<PublicChatCaptchaConfig>,
972}
973
974impl PublicChatBranding {
975 pub fn is_empty(&self) -> bool {
978 self.display_name.is_none()
979 && self.logo_url.is_none()
980 && self.primary_color.is_none()
981 && self.welcome_message.is_none()
982 }
983}
984
985impl PublicChatChannelConfig {
986 pub fn ag_ui_stream_config(&self) -> AgUiChannelConfig {
991 AgUiChannelConfig {
992 anonymous: self.anonymous,
993 token: self.token.clone(),
994 session_expiration_seconds: self.session_expiration_seconds,
995 rate_limit_per_minute: self.rate_limit_per_minute,
996 tool_visibility: self.tool_visibility,
997 generic_tool_text: self.generic_tool_text.clone(),
998 auth: self.auth.clone(),
999 }
1000 }
1001
1002 pub fn captcha_enforced(&self) -> bool {
1007 self.captcha.as_ref().is_some_and(|c| c.enabled)
1008 }
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013 use super::*;
1014
1015 #[test]
1016 fn test_app_status_display() {
1017 assert_eq!(AppStatus::Draft.to_string(), "draft");
1018 assert_eq!(AppStatus::Published.to_string(), "published");
1019 assert_eq!(AppStatus::Archived.to_string(), "archived");
1020 assert_eq!(AppStatus::Deleted.to_string(), "deleted");
1021 }
1022
1023 #[test]
1024 fn test_app_status_from_str() {
1025 assert_eq!(AppStatus::from("draft"), AppStatus::Draft);
1026 assert_eq!(AppStatus::from("published"), AppStatus::Published);
1027 assert_eq!(AppStatus::from("archived"), AppStatus::Archived);
1028 assert_eq!(AppStatus::from("deleted"), AppStatus::Deleted);
1029 assert_eq!(AppStatus::from("unknown"), AppStatus::Draft);
1030 assert_eq!(AppStatus::from(""), AppStatus::Draft);
1031 }
1032
1033 #[test]
1034 fn test_app_status_serde_roundtrip() {
1035 let json = serde_json::to_string(&AppStatus::Published).unwrap();
1036 assert_eq!(json, r#""published""#);
1037 let parsed: AppStatus = serde_json::from_str(&json).unwrap();
1038 assert_eq!(parsed, AppStatus::Published);
1039 }
1040
1041 #[test]
1042 fn test_channel_type_display() {
1043 assert_eq!(ChannelType::Slack.to_string(), "slack");
1044 assert_eq!(ChannelType::AgUi.to_string(), "ag_ui");
1045 assert_eq!(ChannelType::Schedule.to_string(), "schedule");
1046 assert_eq!(ChannelType::Webhook.to_string(), "webhook");
1047 assert_eq!(ChannelType::A2a.to_string(), "a2a");
1048 assert_eq!(ChannelType::Fcp.to_string(), "fcp");
1049 assert_eq!(ChannelType::PublicChat.to_string(), "public_chat");
1050 }
1051
1052 #[test]
1053 fn test_channel_type_from_str_opt() {
1054 assert_eq!(ChannelType::from_str_opt("slack"), Some(ChannelType::Slack));
1055 assert_eq!(ChannelType::from_str_opt("ag_ui"), Some(ChannelType::AgUi));
1056 assert_eq!(
1057 ChannelType::from_str_opt("schedule"),
1058 Some(ChannelType::Schedule)
1059 );
1060 assert_eq!(
1061 ChannelType::from_str_opt("webhook"),
1062 Some(ChannelType::Webhook)
1063 );
1064 assert_eq!(ChannelType::from_str_opt("a2a"), Some(ChannelType::A2a));
1065 assert_eq!(ChannelType::from_str_opt("fcp"), Some(ChannelType::Fcp));
1066 assert_eq!(
1067 ChannelType::from_str_opt("public_chat"),
1068 Some(ChannelType::PublicChat)
1069 );
1070 assert_eq!(ChannelType::from_str_opt("unknown"), None);
1071 assert_eq!(ChannelType::from_str_opt(""), None);
1072 }
1073
1074 #[test]
1075 fn test_channel_type_serde_roundtrip() {
1076 let json = serde_json::to_string(&ChannelType::Slack).unwrap();
1077 assert_eq!(json, r#""slack""#);
1078 let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1079 assert_eq!(parsed, ChannelType::Slack);
1080
1081 let json = serde_json::to_string(&ChannelType::AgUi).unwrap();
1082 assert_eq!(json, r#""ag_ui""#);
1083 let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1084 assert_eq!(parsed, ChannelType::AgUi);
1085
1086 let json = serde_json::to_string(&ChannelType::Schedule).unwrap();
1087 assert_eq!(json, r#""schedule""#);
1088 let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1089 assert_eq!(parsed, ChannelType::Schedule);
1090
1091 let json = serde_json::to_string(&ChannelType::Webhook).unwrap();
1092 assert_eq!(json, r#""webhook""#);
1093 let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1094 assert_eq!(parsed, ChannelType::Webhook);
1095 }
1096
1097 #[test]
1098 fn test_session_strategy_default() {
1099 assert_eq!(SessionStrategy::default(), SessionStrategy::PerThread);
1100 }
1101
1102 #[test]
1103 fn test_session_strategy_serde() {
1104 let json = serde_json::to_string(&SessionStrategy::PerChannel).unwrap();
1105 assert_eq!(json, r#""per_channel""#);
1106 let parsed: SessionStrategy = serde_json::from_str(&json).unwrap();
1107 assert_eq!(parsed, SessionStrategy::PerChannel);
1108
1109 let json = serde_json::to_string(&SessionStrategy::PerUser).unwrap();
1110 assert_eq!(json, r#""per_user""#);
1111 }
1112
1113 #[test]
1114 fn test_slack_channel_config_full() {
1115 let json = r#"{
1116 "signing_secret": "sec123",
1117 "bot_token": "xoxb-tok",
1118 "channel_id": "C123",
1119 "team_id": "T123",
1120 "session_strategy": "per_channel",
1121 "reply_mode": "report_progress_only"
1122 }"#;
1123 let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
1124 assert_eq!(config.signing_secret, "sec123");
1125 assert_eq!(config.bot_token, "xoxb-tok");
1126 assert_eq!(config.channel_id.as_deref(), Some("C123"));
1127 assert_eq!(config.team_id.as_deref(), Some("T123"));
1128 assert_eq!(config.session_strategy, SessionStrategy::PerChannel);
1129 assert_eq!(config.reply_mode, SlackReplyMode::ReportProgressOnly);
1130 }
1131
1132 #[test]
1133 fn test_slack_channel_config_minimal() {
1134 let json = r#"{"signing_secret": "s", "bot_token": "t"}"#;
1135 let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
1136 assert!(config.channel_id.is_none());
1137 assert!(config.team_id.is_none());
1138 assert_eq!(config.session_strategy, SessionStrategy::PerThread);
1139 assert_eq!(config.reply_mode, SlackReplyMode::AllMessages);
1140 assert!(config.webhook_verified_at.is_none());
1141 assert!(config.first_message_received_at.is_none());
1142 }
1143
1144 #[test]
1145 fn test_slack_channel_config_with_verification_timestamps() {
1146 let json = r#"{
1147 "signing_secret": "s",
1148 "bot_token": "t",
1149 "webhook_verified_at": "2025-01-01T00:00:00Z",
1150 "first_message_received_at": "2025-01-01T01:00:00Z"
1151 }"#;
1152 let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
1153 assert!(config.webhook_verified_at.is_some());
1154 assert!(config.first_message_received_at.is_some());
1155
1156 let serialized = serde_json::to_value(&config).unwrap();
1158 assert!(serialized.get("webhook_verified_at").is_some());
1159 assert!(serialized.get("first_message_received_at").is_some());
1160 }
1161
1162 #[test]
1163 fn test_slack_channel_config_timestamps_skipped_when_none() {
1164 let config = SlackChannelConfig {
1165 signing_secret: "s".into(),
1166 bot_token: "t".into(),
1167 channel_id: None,
1168 team_id: None,
1169 session_strategy: SessionStrategy::PerThread,
1170 reply_mode: SlackReplyMode::AllMessages,
1171 webhook_verified_at: None,
1172 first_message_received_at: None,
1173 };
1174 let json = serde_json::to_value(&config).unwrap();
1175 assert!(json.get("webhook_verified_at").is_none());
1176 assert!(json.get("first_message_received_at").is_none());
1177 }
1178
1179 #[test]
1180 fn test_slack_reply_mode_serde_roundtrip() {
1181 let json = serde_json::to_string(&SlackReplyMode::ReportProgressOnly).unwrap();
1182 assert_eq!(json, r#""report_progress_only""#);
1183 let parsed: SlackReplyMode = serde_json::from_str(&json).unwrap();
1184 assert_eq!(parsed, SlackReplyMode::ReportProgressOnly);
1185 }
1186
1187 #[test]
1188 fn test_slack_channel_config_missing_required_field() {
1189 let json = r#"{"signing_secret": "s"}"#;
1190 assert!(serde_json::from_str::<SlackChannelConfig>(json).is_err());
1191 }
1192
1193 #[test]
1194 fn test_ag_ui_channel_config_defaults_to_anonymous() {
1195 let config: AgUiChannelConfig = serde_json::from_str("{}").unwrap();
1196 assert!(config.anonymous);
1197 assert_eq!(
1198 config.session_expiration_seconds,
1199 DEFAULT_SESSION_EXPIRATION_SECONDS
1200 );
1201 assert!(config.rate_limit_per_minute.is_none());
1202 assert!(config.token.is_none());
1203 assert!(config.auth.is_none());
1204 assert_eq!(config.tool_visibility, AgUiToolVisibility::Generic);
1205 assert_eq!(config.generic_tool_text, DEFAULT_AG_UI_GENERIC_TOOL_TEXT);
1206 }
1207
1208 #[test]
1209 fn test_ag_ui_channel_config_roundtrip() {
1210 let config = AgUiChannelConfig {
1211 anonymous: true,
1212 token: Some("agui-token".to_string()),
1213 session_expiration_seconds: 3600,
1214 rate_limit_per_minute: Some(120),
1215 tool_visibility: AgUiToolVisibility::None,
1216 generic_tool_text: "Please wait".to_string(),
1217 auth: None,
1218 };
1219 let json = serde_json::to_string(&config).unwrap();
1220 let parsed: AgUiChannelConfig = serde_json::from_str(&json).unwrap();
1221 assert!(parsed.anonymous);
1222 assert_eq!(parsed.token.as_deref(), Some("agui-token"));
1223 assert_eq!(parsed.session_expiration_seconds, 3600);
1224 assert_eq!(parsed.rate_limit_per_minute, Some(120));
1225 assert_eq!(parsed.tool_visibility, AgUiToolVisibility::None);
1226 assert_eq!(parsed.generic_tool_text, "Please wait");
1227 }
1228
1229 #[test]
1230 fn test_ag_ui_channel_config_zero_disables_expiration() {
1231 let config: AgUiChannelConfig =
1232 serde_json::from_str(r#"{"session_expiration_seconds": 0}"#).unwrap();
1233 assert_eq!(config.session_expiration_seconds, 0);
1234 }
1235
1236 #[test]
1237 fn test_ag_ui_channel_config_omits_rate_limit_when_unset() {
1238 let config = AgUiChannelConfig {
1239 anonymous: true,
1240 token: None,
1241 session_expiration_seconds: DEFAULT_SESSION_EXPIRATION_SECONDS,
1242 rate_limit_per_minute: None,
1243 tool_visibility: AgUiToolVisibility::Generic,
1244 generic_tool_text: DEFAULT_AG_UI_GENERIC_TOOL_TEXT.to_string(),
1245 auth: None,
1246 };
1247 let json = serde_json::to_value(&config).unwrap();
1248 assert!(json.get("rate_limit_per_minute").is_none());
1249 assert!(json.get("generic_tool_text").is_none());
1250 }
1251
1252 #[test]
1253 fn test_invocation_session_mode_defaults_to_shared_session() {
1254 assert_eq!(
1255 InvocationSessionMode::default(),
1256 InvocationSessionMode::SharedSession
1257 );
1258 }
1259
1260 #[test]
1261 fn test_schedule_channel_config_defaults() {
1262 let config: ScheduleChannelConfig =
1263 serde_json::from_str(r#"{"cron_expression":"0 * * * * * *","message":"Run checks"}"#)
1264 .unwrap();
1265 assert_eq!(config.timezone, "UTC");
1266 assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1267 }
1268
1269 #[test]
1270 fn test_webhook_channel_config_defaults() {
1271 let config: WebhookChannelConfig =
1272 serde_json::from_str(r#"{"token":"top-secret","message":"{{payload.action}}"}"#)
1273 .unwrap();
1274 assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1275 assert!(config.rate_limit_per_minute.is_none());
1277 }
1278
1279 #[test]
1280 fn test_webhook_channel_config_rate_limit_roundtrip() {
1281 let config: WebhookChannelConfig =
1283 serde_json::from_str(r#"{"token":"t","message":"m","rate_limit_per_minute":120}"#)
1284 .unwrap();
1285 assert_eq!(config.rate_limit_per_minute, Some(120));
1286 let json = serde_json::to_value(&config).unwrap();
1287 assert_eq!(
1288 json.get("rate_limit_per_minute").and_then(|v| v.as_u64()),
1289 Some(120)
1290 );
1291
1292 let none_cfg: WebhookChannelConfig =
1294 serde_json::from_str(r#"{"token":"t","message":"m"}"#).unwrap();
1295 let none_json = serde_json::to_value(&none_cfg).unwrap();
1296 assert!(none_json.get("rate_limit_per_minute").is_none());
1297 }
1298
1299 fn test_app(channels: Vec<AppChannel>) -> App {
1300 App {
1301 public_id: AppId::from_uuid(Uuid::nil()),
1302 internal_id: Uuid::nil(),
1303 org_id: 1,
1304 name: "test".into(),
1305 description: None,
1306 harness_id: HarnessId::from_uuid(Uuid::nil()),
1307 agent_id: Some(AgentId::from_uuid(Uuid::nil())),
1308 agent_version_policy: AgentVersionPolicy::Default,
1309 agent_version_id: None,
1310 agent_identity_id: None,
1311 owner_principal_id: PrincipalId::from_seed(1),
1312 resolved_owner_user_id: None,
1313 owner: None,
1314 effective_owner: None,
1315 channels,
1316 status: AppStatus::Draft,
1317 published_at: None,
1318 created_at: Utc::now(),
1319 updated_at: Utc::now(),
1320 archived_at: None,
1321 deleted_at: None,
1322 }
1323 }
1324
1325 fn test_channel(channel_type: ChannelType, config: serde_json::Value) -> AppChannel {
1326 AppChannel {
1327 public_id: AppChannelId::from_uuid(Uuid::nil()),
1328 internal_id: Uuid::nil(),
1329 channel_type,
1330 channel_config: config,
1331 enabled: true,
1332 created_at: Utc::now(),
1333 updated_at: Utc::now(),
1334 }
1335 }
1336
1337 #[test]
1338 fn test_app_channel_slack_config_valid() {
1339 let ch = test_channel(
1340 ChannelType::Slack,
1341 serde_json::json!({"signing_secret": "sec", "bot_token": "tok"}),
1342 );
1343 let config = ch.slack_config().unwrap();
1344 assert_eq!(config.signing_secret, "sec");
1345 }
1346
1347 #[test]
1348 fn test_app_channel_slack_config_invalid_json() {
1349 let ch = test_channel(ChannelType::Slack, serde_json::json!({"bad": "data"}));
1350 assert!(ch.slack_config().is_none());
1351 }
1352
1353 #[test]
1354 fn test_app_slack_channel_lookup() {
1355 let ch = test_channel(
1356 ChannelType::Slack,
1357 serde_json::json!({"signing_secret": "s", "bot_token": "t"}),
1358 );
1359 let app = test_app(vec![ch]);
1360 assert!(app.slack_channel().is_some());
1361 }
1362
1363 #[test]
1364 fn test_app_slack_channel_none_when_empty() {
1365 let app = test_app(vec![]);
1366 assert!(app.slack_channel().is_none());
1367 }
1368
1369 #[test]
1370 fn test_app_channel_ag_ui_config_valid() {
1371 let ch = test_channel(ChannelType::AgUi, serde_json::json!({"anonymous": true}));
1372 let config = ch.ag_ui_config().unwrap();
1373 assert!(config.anonymous);
1374 }
1375
1376 #[test]
1377 fn test_app_ag_ui_channel_lookup() {
1378 let ch = test_channel(ChannelType::AgUi, serde_json::json!({"anonymous": true}));
1379 let app = test_app(vec![ch]);
1380 assert!(app.ag_ui_channel().is_some());
1381 }
1382
1383 #[test]
1384 fn test_app_channel_fcp_config_defaults() {
1385 let ch = test_channel(ChannelType::Fcp, serde_json::json!({}));
1386 let config = ch.fcp_config().unwrap();
1387 assert!(config.anonymous);
1388 assert!(config.token.is_none());
1389 assert!(config.handshake.is_none());
1390 assert_eq!(
1391 config.session_expiration_seconds,
1392 DEFAULT_SESSION_EXPIRATION_SECONDS
1393 );
1394 assert_eq!(
1395 config.response_timeout_seconds,
1396 DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS
1397 );
1398 }
1399
1400 #[test]
1401 fn test_app_fcp_channel_lookup() {
1402 let ch = test_channel(ChannelType::Fcp, serde_json::json!({}));
1403 let app = test_app(vec![ch]);
1404 assert!(app.fcp_channel().is_some());
1405 }
1406
1407 #[test]
1408 fn test_app_channel_schedule_config_valid() {
1409 let ch = test_channel(
1410 ChannelType::Schedule,
1411 serde_json::json!({
1412 "cron_expression": "0 * * * * * *",
1413 "message": "Run checks"
1414 }),
1415 );
1416 let config = ch.schedule_config().unwrap();
1417 assert_eq!(config.message, "Run checks");
1418 }
1419
1420 #[test]
1421 fn test_app_schedule_channel_lookup() {
1422 let ch = test_channel(
1423 ChannelType::Schedule,
1424 serde_json::json!({
1425 "cron_expression": "0 * * * * * *",
1426 "message": "Run checks"
1427 }),
1428 );
1429 let app = test_app(vec![ch]);
1430 assert!(app.schedule_channel().is_some());
1431 }
1432
1433 #[test]
1434 fn test_app_channel_webhook_config_valid() {
1435 let ch = test_channel(
1436 ChannelType::Webhook,
1437 serde_json::json!({
1438 "token": "secret",
1439 "message": "{{payload.ref}}"
1440 }),
1441 );
1442 let config = ch.webhook_config().unwrap();
1443 assert_eq!(config.token, "secret");
1444 }
1445
1446 #[test]
1447 fn test_app_webhook_channel_lookup() {
1448 let ch = test_channel(
1449 ChannelType::Webhook,
1450 serde_json::json!({
1451 "token": "secret",
1452 "message": "{{payload.ref}}"
1453 }),
1454 );
1455 let app = test_app(vec![ch]);
1456 assert!(app.webhook_channel().is_some());
1457 }
1458
1459 #[test]
1460 fn test_a2a_channel_config_defaults() {
1461 let config: A2aChannelConfig = serde_json::from_str(
1462 r#"{"api_key_hash":"abc","api_key_prefix":"evra2a_abc1...","message":"{{a2a.text}}"}"#,
1463 )
1464 .unwrap();
1465 assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1466 assert!(config.agent_card_name.is_none());
1467 assert!(config.agent_card_description.is_none());
1468 assert!(config.rate_limit_per_minute.is_none());
1469 assert!(config.auth.is_none());
1470 assert!(config.signing_secret.is_none());
1471 }
1472
1473 #[test]
1474 fn test_a2a_channel_config_roundtrip() {
1475 let config = A2aChannelConfig {
1476 api_key_hash: "deadbeef".into(),
1477 api_key_prefix: "evra2a_dead...".into(),
1478 session_mode: InvocationSessionMode::SessionPerInvocation,
1479 message: "{{a2a.text}}".into(),
1480 agent_card_name: Some("Inbox triage".into()),
1481 agent_card_description: Some("Triages github events".into()),
1482 rate_limit_per_minute: Some(120),
1483 auth: None,
1484 signing_secret: None,
1485 };
1486 let json = serde_json::to_string(&config).unwrap();
1487 let parsed: A2aChannelConfig = serde_json::from_str(&json).unwrap();
1488 assert_eq!(parsed.api_key_hash, "deadbeef");
1489 assert_eq!(
1490 parsed.session_mode,
1491 InvocationSessionMode::SessionPerInvocation
1492 );
1493 assert_eq!(parsed.agent_card_name.as_deref(), Some("Inbox triage"));
1494 assert_eq!(parsed.rate_limit_per_minute, Some(120));
1495 }
1496
1497 #[test]
1498 fn test_a2a_channel_config_omits_optional_fields() {
1499 let config = A2aChannelConfig {
1500 api_key_hash: "h".into(),
1501 api_key_prefix: "evra2a_h...".into(),
1502 session_mode: InvocationSessionMode::SharedSession,
1503 message: "m".into(),
1504 agent_card_name: None,
1505 agent_card_description: None,
1506 rate_limit_per_minute: None,
1507 auth: None,
1508 signing_secret: None,
1509 };
1510 let json = serde_json::to_value(&config).unwrap();
1511 assert!(json.get("agent_card_name").is_none());
1512 assert!(json.get("agent_card_description").is_none());
1513 assert!(json.get("rate_limit_per_minute").is_none());
1514 assert!(json.get("signing_secret").is_none());
1515 }
1516
1517 #[test]
1518 fn test_app_channel_a2a_config_valid() {
1519 let ch = test_channel(
1520 ChannelType::A2a,
1521 serde_json::json!({
1522 "api_key_hash": "h",
1523 "api_key_prefix": "evra2a_h...",
1524 "message": "{{a2a.text}}"
1525 }),
1526 );
1527 let config = ch.a2a_config().unwrap();
1528 assert_eq!(config.api_key_prefix, "evra2a_h...");
1529 }
1530
1531 #[test]
1532 fn test_app_a2a_channel_lookup() {
1533 let ch = test_channel(
1534 ChannelType::A2a,
1535 serde_json::json!({
1536 "api_key_hash": "h",
1537 "api_key_prefix": "evra2a_h...",
1538 "message": "{{a2a.text}}"
1539 }),
1540 );
1541 let app = test_app(vec![ch]);
1542 assert!(app.a2a_channel().is_some());
1543 }
1544
1545 #[test]
1546 fn test_channel_type_public_chat_serde_roundtrip() {
1547 let json = serde_json::to_string(&ChannelType::PublicChat).unwrap();
1548 assert_eq!(json, r#""public_chat""#);
1549 let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1550 assert_eq!(parsed, ChannelType::PublicChat);
1551 }
1552
1553 #[test]
1554 fn test_public_chat_channel_config_defaults() {
1555 let config: PublicChatChannelConfig = serde_json::from_str("{}").unwrap();
1556 assert!(config.anonymous);
1557 assert!(config.token.is_none());
1558 assert_eq!(
1559 config.session_expiration_seconds,
1560 DEFAULT_SESSION_EXPIRATION_SECONDS
1561 );
1562 assert!(config.rate_limit_per_minute.is_none());
1563 assert_eq!(config.tool_visibility, AgUiToolVisibility::Generic);
1564 assert_eq!(config.generic_tool_text, DEFAULT_AG_UI_GENERIC_TOOL_TEXT);
1565 assert!(config.auth.is_none());
1566 assert!(config.branding.is_empty());
1567 assert!(config.captcha.is_none());
1568 }
1569
1570 #[test]
1571 fn test_public_chat_channel_config_omits_empty_branding_and_defaults() {
1572 let config: PublicChatChannelConfig = serde_json::from_str("{}").unwrap();
1573 let json = serde_json::to_value(&config).unwrap();
1574 assert!(json.get("branding").is_none());
1575 assert!(json.get("captcha").is_none());
1576 assert!(json.get("generic_tool_text").is_none());
1577 assert!(json.get("rate_limit_per_minute").is_none());
1578 }
1579
1580 #[test]
1581 fn test_public_chat_channel_config_full_roundtrip() {
1582 let json = r##"{
1583 "anonymous": false,
1584 "token": "shared-secret",
1585 "session_expiration_seconds": 3600,
1586 "rate_limit_per_minute": 30,
1587 "tool_visibility": "narrated",
1588 "generic_tool_text": "Thinking...",
1589 "branding": {
1590 "display_name": "Support",
1591 "logo_url": "https://example.com/logo.png",
1592 "primary_color": "#0A1636",
1593 "welcome_message": "How can I help?"
1594 },
1595 "captcha": {
1596 "provider": "turnstile",
1597 "enabled": true,
1598 "site_key": "1x00000000000000000000AA",
1599 "secret_key": "1x0000000000000000000000000000000AA"
1600 }
1601 }"##;
1602 let config: PublicChatChannelConfig = serde_json::from_str(json).unwrap();
1603 assert!(!config.anonymous);
1604 assert_eq!(config.token.as_deref(), Some("shared-secret"));
1605 assert_eq!(config.session_expiration_seconds, 3600);
1606 assert_eq!(config.rate_limit_per_minute, Some(30));
1607 assert_eq!(config.tool_visibility, AgUiToolVisibility::Narrated);
1608 let branding = &config.branding;
1609 assert_eq!(branding.display_name.as_deref(), Some("Support"));
1610 assert_eq!(branding.primary_color.as_deref(), Some("#0A1636"));
1611 let captcha = config.captcha.unwrap();
1612 assert_eq!(captcha.provider, CaptchaProvider::Turnstile);
1613 assert!(captcha.enabled);
1614 assert_eq!(captcha.site_key, "1x00000000000000000000AA");
1615 assert!(captcha.secret_key.is_some());
1616 }
1617
1618 #[test]
1619 fn test_app_channel_public_chat_config_valid() {
1620 let ch = test_channel(
1621 ChannelType::PublicChat,
1622 serde_json::json!({"anonymous": true}),
1623 );
1624 let config = ch.public_chat_config().unwrap();
1625 assert!(config.anonymous);
1626 }
1627
1628 #[test]
1629 fn test_app_public_chat_channel_lookup() {
1630 let ch = test_channel(
1631 ChannelType::PublicChat,
1632 serde_json::json!({"branding": {"display_name": "Helpdesk"}}),
1633 );
1634 let app = test_app(vec![ch]);
1635 assert!(app.public_chat_channel().is_some());
1636 }
1637
1638 #[test]
1639 fn test_app_serde_skips_internal_fields() {
1640 let app = test_app(vec![]);
1641 let json = serde_json::to_value(&app).unwrap();
1642 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()); }
1647}