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 = "is_false")]
654 pub reasoning_summary_visible: bool,
655 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub auth: Option<AppEndpointAuthConfig>,
659}
660
661fn default_session_expiration_seconds() -> u32 {
662 DEFAULT_SESSION_EXPIRATION_SECONDS
663}
664
665fn default_ag_ui_generic_tool_text() -> String {
666 DEFAULT_AG_UI_GENERIC_TOOL_TEXT.to_string()
667}
668
669fn is_default_ag_ui_generic_tool_text(value: &str) -> bool {
670 value == DEFAULT_AG_UI_GENERIC_TOOL_TEXT
671}
672
673fn is_false(value: &bool) -> bool {
674 !*value
675}
676
677pub 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.";
681
682pub const DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS: u32 = 120;
684
685#[derive(Debug, Clone, Serialize, Deserialize)]
701#[cfg_attr(feature = "openapi", derive(ToSchema))]
702pub struct FcpChannelConfig {
703 #[serde(default = "default_true")]
706 pub anonymous: bool,
707 #[serde(default, skip_serializing_if = "Option::is_none")]
712 pub token: Option<String>,
713 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub handshake: Option<String>,
718 #[serde(default = "default_session_expiration_seconds")]
722 pub session_expiration_seconds: u32,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub rate_limit_per_minute: Option<u32>,
729 #[serde(default = "default_fcp_response_timeout_seconds")]
732 pub response_timeout_seconds: u32,
733}
734
735fn default_fcp_response_timeout_seconds() -> u32 {
736 DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS
737}
738
739#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
741#[cfg_attr(feature = "openapi", derive(ToSchema))]
742#[cfg_attr(feature = "openapi", schema(example = "shared_session"))]
743#[serde(rename_all = "snake_case")]
744pub enum InvocationSessionMode {
745 #[default]
747 SharedSession,
748 SessionPerInvocation,
750}
751
752#[derive(Debug, Clone, Serialize, Deserialize)]
757#[cfg_attr(feature = "openapi", derive(ToSchema))]
758pub struct ScheduleChannelConfig {
759 pub cron_expression: String,
761 #[serde(default = "default_timezone")]
763 pub timezone: String,
764 #[serde(default)]
766 pub session_mode: InvocationSessionMode,
767 pub message: String,
769}
770
771#[derive(Debug, Clone, Serialize, Deserialize)]
776#[cfg_attr(feature = "openapi", derive(ToSchema))]
777pub struct WebhookChannelConfig {
778 pub token: String,
780 #[serde(default)]
782 pub session_mode: InvocationSessionMode,
783 pub message: String,
785 #[serde(default, skip_serializing_if = "Option::is_none")]
790 pub rate_limit_per_minute: Option<u32>,
791}
792
793fn default_timezone() -> String {
794 "UTC".to_string()
795}
796
797#[derive(Debug, Clone, Serialize, Deserialize)]
807#[cfg_attr(feature = "openapi", derive(ToSchema))]
808pub struct A2aChannelConfig {
809 pub api_key_hash: String,
811 pub api_key_prefix: String,
813 #[serde(default)]
815 pub session_mode: InvocationSessionMode,
816 pub message: String,
818 #[serde(default, skip_serializing_if = "Option::is_none")]
820 pub agent_card_name: Option<String>,
821 #[serde(default, skip_serializing_if = "Option::is_none")]
823 pub agent_card_description: Option<String>,
824 #[serde(default, skip_serializing_if = "Option::is_none")]
830 pub rate_limit_per_minute: Option<u32>,
831 #[serde(default, skip_serializing_if = "Option::is_none")]
834 pub auth: Option<AppEndpointAuthConfig>,
835 #[serde(default, skip_serializing_if = "Option::is_none")]
846 pub signing_secret: Option<String>,
847}
848
849#[derive(Debug, Clone, Serialize, Deserialize)]
859#[cfg_attr(feature = "openapi", derive(ToSchema))]
860pub struct ApiEndpointChannelConfig {
861 pub api_key_hash: String,
863 pub api_key_prefix: String,
865 #[serde(default)]
867 pub session_mode: InvocationSessionMode,
868 #[serde(default, skip_serializing_if = "Option::is_none")]
873 pub rate_limit_per_minute: Option<u32>,
874 #[serde(default, skip_serializing_if = "Option::is_none")]
877 pub auth: Option<AppEndpointAuthConfig>,
878}
879
880#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
884#[cfg_attr(feature = "openapi", derive(ToSchema))]
885pub struct PublicChatBranding {
886 #[serde(default, skip_serializing_if = "Option::is_none")]
888 pub display_name: Option<String>,
889 #[serde(default, skip_serializing_if = "Option::is_none")]
891 pub logo_url: Option<String>,
892 #[serde(default, skip_serializing_if = "Option::is_none")]
895 pub primary_color: Option<String>,
896 #[serde(default, skip_serializing_if = "Option::is_none")]
898 pub welcome_message: Option<String>,
899}
900
901#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
903#[cfg_attr(feature = "openapi", derive(ToSchema))]
904#[serde(rename_all = "snake_case")]
905pub enum CaptchaProvider {
906 #[default]
908 Turnstile,
909}
910
911#[derive(Debug, Clone, Serialize, Deserialize)]
920#[cfg_attr(feature = "openapi", derive(ToSchema))]
921pub struct PublicChatCaptchaConfig {
922 #[serde(default)]
924 pub provider: CaptchaProvider,
925 #[serde(default = "default_true")]
928 pub enabled: bool,
929 pub site_key: String,
931 #[serde(default, skip_serializing_if = "Option::is_none")]
933 pub secret_key: Option<String>,
934}
935
936#[derive(Debug, Clone, Serialize, Deserialize)]
943#[cfg_attr(feature = "openapi", derive(ToSchema))]
944pub struct PublicChatChannelConfig {
945 #[serde(default = "default_true")]
948 pub anonymous: bool,
949 #[serde(default, skip_serializing_if = "Option::is_none")]
952 pub token: Option<String>,
953 #[serde(default = "default_session_expiration_seconds")]
956 pub session_expiration_seconds: u32,
957 #[serde(default, skip_serializing_if = "Option::is_none")]
960 pub rate_limit_per_minute: Option<u32>,
961 #[serde(default)]
964 pub tool_visibility: AgUiToolVisibility,
965 #[serde(
967 default = "default_ag_ui_generic_tool_text",
968 skip_serializing_if = "is_default_ag_ui_generic_tool_text"
969 )]
970 pub generic_tool_text: String,
971 #[serde(default, skip_serializing_if = "Option::is_none")]
974 pub auth: Option<AppEndpointAuthConfig>,
975 #[serde(default, skip_serializing_if = "PublicChatBranding::is_empty")]
977 pub branding: PublicChatBranding,
978 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub captcha: Option<PublicChatCaptchaConfig>,
981}
982
983impl PublicChatBranding {
984 pub fn is_empty(&self) -> bool {
987 self.display_name.is_none()
988 && self.logo_url.is_none()
989 && self.primary_color.is_none()
990 && self.welcome_message.is_none()
991 }
992}
993
994impl PublicChatChannelConfig {
995 pub fn ag_ui_stream_config(&self) -> AgUiChannelConfig {
1000 AgUiChannelConfig {
1001 anonymous: self.anonymous,
1002 token: self.token.clone(),
1003 session_expiration_seconds: self.session_expiration_seconds,
1004 rate_limit_per_minute: self.rate_limit_per_minute,
1005 tool_visibility: self.tool_visibility,
1006 generic_tool_text: self.generic_tool_text.clone(),
1007 reasoning_summary_visible: false,
1008 auth: self.auth.clone(),
1009 }
1010 }
1011
1012 pub fn captcha_enforced(&self) -> bool {
1017 self.captcha.as_ref().is_some_and(|c| c.enabled)
1018 }
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023 use super::*;
1024
1025 #[test]
1026 fn test_app_status_display() {
1027 assert_eq!(AppStatus::Draft.to_string(), "draft");
1028 assert_eq!(AppStatus::Published.to_string(), "published");
1029 assert_eq!(AppStatus::Archived.to_string(), "archived");
1030 assert_eq!(AppStatus::Deleted.to_string(), "deleted");
1031 }
1032
1033 #[test]
1034 fn test_app_status_from_str() {
1035 assert_eq!(AppStatus::from("draft"), AppStatus::Draft);
1036 assert_eq!(AppStatus::from("published"), AppStatus::Published);
1037 assert_eq!(AppStatus::from("archived"), AppStatus::Archived);
1038 assert_eq!(AppStatus::from("deleted"), AppStatus::Deleted);
1039 assert_eq!(AppStatus::from("unknown"), AppStatus::Draft);
1040 assert_eq!(AppStatus::from(""), AppStatus::Draft);
1041 }
1042
1043 #[test]
1044 fn test_app_status_serde_roundtrip() {
1045 let json = serde_json::to_string(&AppStatus::Published).unwrap();
1046 assert_eq!(json, r#""published""#);
1047 let parsed: AppStatus = serde_json::from_str(&json).unwrap();
1048 assert_eq!(parsed, AppStatus::Published);
1049 }
1050
1051 #[test]
1052 fn test_channel_type_display() {
1053 assert_eq!(ChannelType::Slack.to_string(), "slack");
1054 assert_eq!(ChannelType::AgUi.to_string(), "ag_ui");
1055 assert_eq!(ChannelType::Schedule.to_string(), "schedule");
1056 assert_eq!(ChannelType::Webhook.to_string(), "webhook");
1057 assert_eq!(ChannelType::A2a.to_string(), "a2a");
1058 assert_eq!(ChannelType::Fcp.to_string(), "fcp");
1059 assert_eq!(ChannelType::PublicChat.to_string(), "public_chat");
1060 }
1061
1062 #[test]
1063 fn test_channel_type_from_str_opt() {
1064 assert_eq!(ChannelType::from_str_opt("slack"), Some(ChannelType::Slack));
1065 assert_eq!(ChannelType::from_str_opt("ag_ui"), Some(ChannelType::AgUi));
1066 assert_eq!(
1067 ChannelType::from_str_opt("schedule"),
1068 Some(ChannelType::Schedule)
1069 );
1070 assert_eq!(
1071 ChannelType::from_str_opt("webhook"),
1072 Some(ChannelType::Webhook)
1073 );
1074 assert_eq!(ChannelType::from_str_opt("a2a"), Some(ChannelType::A2a));
1075 assert_eq!(ChannelType::from_str_opt("fcp"), Some(ChannelType::Fcp));
1076 assert_eq!(
1077 ChannelType::from_str_opt("public_chat"),
1078 Some(ChannelType::PublicChat)
1079 );
1080 assert_eq!(ChannelType::from_str_opt("unknown"), None);
1081 assert_eq!(ChannelType::from_str_opt(""), None);
1082 }
1083
1084 #[test]
1085 fn test_channel_type_serde_roundtrip() {
1086 let json = serde_json::to_string(&ChannelType::Slack).unwrap();
1087 assert_eq!(json, r#""slack""#);
1088 let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1089 assert_eq!(parsed, ChannelType::Slack);
1090
1091 let json = serde_json::to_string(&ChannelType::AgUi).unwrap();
1092 assert_eq!(json, r#""ag_ui""#);
1093 let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1094 assert_eq!(parsed, ChannelType::AgUi);
1095
1096 let json = serde_json::to_string(&ChannelType::Schedule).unwrap();
1097 assert_eq!(json, r#""schedule""#);
1098 let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1099 assert_eq!(parsed, ChannelType::Schedule);
1100
1101 let json = serde_json::to_string(&ChannelType::Webhook).unwrap();
1102 assert_eq!(json, r#""webhook""#);
1103 let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1104 assert_eq!(parsed, ChannelType::Webhook);
1105 }
1106
1107 #[test]
1108 fn test_session_strategy_default() {
1109 assert_eq!(SessionStrategy::default(), SessionStrategy::PerThread);
1110 }
1111
1112 #[test]
1113 fn test_session_strategy_serde() {
1114 let json = serde_json::to_string(&SessionStrategy::PerChannel).unwrap();
1115 assert_eq!(json, r#""per_channel""#);
1116 let parsed: SessionStrategy = serde_json::from_str(&json).unwrap();
1117 assert_eq!(parsed, SessionStrategy::PerChannel);
1118
1119 let json = serde_json::to_string(&SessionStrategy::PerUser).unwrap();
1120 assert_eq!(json, r#""per_user""#);
1121 }
1122
1123 #[test]
1124 fn test_slack_channel_config_full() {
1125 let json = r#"{
1126 "signing_secret": "sec123",
1127 "bot_token": "xoxb-tok",
1128 "channel_id": "C123",
1129 "team_id": "T123",
1130 "session_strategy": "per_channel",
1131 "reply_mode": "report_progress_only"
1132 }"#;
1133 let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
1134 assert_eq!(config.signing_secret, "sec123");
1135 assert_eq!(config.bot_token, "xoxb-tok");
1136 assert_eq!(config.channel_id.as_deref(), Some("C123"));
1137 assert_eq!(config.team_id.as_deref(), Some("T123"));
1138 assert_eq!(config.session_strategy, SessionStrategy::PerChannel);
1139 assert_eq!(config.reply_mode, SlackReplyMode::ReportProgressOnly);
1140 }
1141
1142 #[test]
1143 fn test_slack_channel_config_minimal() {
1144 let json = r#"{"signing_secret": "s", "bot_token": "t"}"#;
1145 let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
1146 assert!(config.channel_id.is_none());
1147 assert!(config.team_id.is_none());
1148 assert_eq!(config.session_strategy, SessionStrategy::PerThread);
1149 assert_eq!(config.reply_mode, SlackReplyMode::AllMessages);
1150 assert!(config.webhook_verified_at.is_none());
1151 assert!(config.first_message_received_at.is_none());
1152 }
1153
1154 #[test]
1155 fn test_slack_channel_config_with_verification_timestamps() {
1156 let json = r#"{
1157 "signing_secret": "s",
1158 "bot_token": "t",
1159 "webhook_verified_at": "2025-01-01T00:00:00Z",
1160 "first_message_received_at": "2025-01-01T01:00:00Z"
1161 }"#;
1162 let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
1163 assert!(config.webhook_verified_at.is_some());
1164 assert!(config.first_message_received_at.is_some());
1165
1166 let serialized = serde_json::to_value(&config).unwrap();
1168 assert!(serialized.get("webhook_verified_at").is_some());
1169 assert!(serialized.get("first_message_received_at").is_some());
1170 }
1171
1172 #[test]
1173 fn test_slack_channel_config_timestamps_skipped_when_none() {
1174 let config = SlackChannelConfig {
1175 signing_secret: "s".into(),
1176 bot_token: "t".into(),
1177 channel_id: None,
1178 team_id: None,
1179 session_strategy: SessionStrategy::PerThread,
1180 reply_mode: SlackReplyMode::AllMessages,
1181 webhook_verified_at: None,
1182 first_message_received_at: None,
1183 };
1184 let json = serde_json::to_value(&config).unwrap();
1185 assert!(json.get("webhook_verified_at").is_none());
1186 assert!(json.get("first_message_received_at").is_none());
1187 }
1188
1189 #[test]
1190 fn test_slack_reply_mode_serde_roundtrip() {
1191 let json = serde_json::to_string(&SlackReplyMode::ReportProgressOnly).unwrap();
1192 assert_eq!(json, r#""report_progress_only""#);
1193 let parsed: SlackReplyMode = serde_json::from_str(&json).unwrap();
1194 assert_eq!(parsed, SlackReplyMode::ReportProgressOnly);
1195 }
1196
1197 #[test]
1198 fn test_slack_channel_config_missing_required_field() {
1199 let json = r#"{"signing_secret": "s"}"#;
1200 assert!(serde_json::from_str::<SlackChannelConfig>(json).is_err());
1201 }
1202
1203 #[test]
1204 fn test_ag_ui_channel_config_defaults_to_anonymous() {
1205 let config: AgUiChannelConfig = serde_json::from_str("{}").unwrap();
1206 assert!(config.anonymous);
1207 assert_eq!(
1208 config.session_expiration_seconds,
1209 DEFAULT_SESSION_EXPIRATION_SECONDS
1210 );
1211 assert!(config.rate_limit_per_minute.is_none());
1212 assert!(config.token.is_none());
1213 assert!(config.auth.is_none());
1214 assert_eq!(config.tool_visibility, AgUiToolVisibility::Generic);
1215 assert_eq!(config.generic_tool_text, DEFAULT_AG_UI_GENERIC_TOOL_TEXT);
1216 assert!(!config.reasoning_summary_visible);
1217 }
1218
1219 #[test]
1220 fn test_ag_ui_channel_config_roundtrip() {
1221 let config = AgUiChannelConfig {
1222 anonymous: true,
1223 token: Some("agui-token".to_string()),
1224 session_expiration_seconds: 3600,
1225 rate_limit_per_minute: Some(120),
1226 tool_visibility: AgUiToolVisibility::None,
1227 generic_tool_text: "Please wait".to_string(),
1228 reasoning_summary_visible: true,
1229 auth: None,
1230 };
1231 let json = serde_json::to_string(&config).unwrap();
1232 let parsed: AgUiChannelConfig = serde_json::from_str(&json).unwrap();
1233 assert!(parsed.anonymous);
1234 assert_eq!(parsed.token.as_deref(), Some("agui-token"));
1235 assert_eq!(parsed.session_expiration_seconds, 3600);
1236 assert_eq!(parsed.rate_limit_per_minute, Some(120));
1237 assert_eq!(parsed.tool_visibility, AgUiToolVisibility::None);
1238 assert_eq!(parsed.generic_tool_text, "Please wait");
1239 assert!(parsed.reasoning_summary_visible);
1240 }
1241
1242 #[test]
1243 fn test_ag_ui_channel_config_zero_disables_expiration() {
1244 let config: AgUiChannelConfig =
1245 serde_json::from_str(r#"{"session_expiration_seconds": 0}"#).unwrap();
1246 assert_eq!(config.session_expiration_seconds, 0);
1247 }
1248
1249 #[test]
1250 fn test_ag_ui_channel_config_omits_rate_limit_when_unset() {
1251 let config = AgUiChannelConfig {
1252 anonymous: true,
1253 token: None,
1254 session_expiration_seconds: DEFAULT_SESSION_EXPIRATION_SECONDS,
1255 rate_limit_per_minute: None,
1256 tool_visibility: AgUiToolVisibility::Generic,
1257 generic_tool_text: DEFAULT_AG_UI_GENERIC_TOOL_TEXT.to_string(),
1258 reasoning_summary_visible: false,
1259 auth: None,
1260 };
1261 let json = serde_json::to_value(&config).unwrap();
1262 assert!(json.get("rate_limit_per_minute").is_none());
1263 assert!(json.get("generic_tool_text").is_none());
1264 }
1265
1266 #[test]
1267 fn test_invocation_session_mode_defaults_to_shared_session() {
1268 assert_eq!(
1269 InvocationSessionMode::default(),
1270 InvocationSessionMode::SharedSession
1271 );
1272 }
1273
1274 #[test]
1275 fn test_schedule_channel_config_defaults() {
1276 let config: ScheduleChannelConfig =
1277 serde_json::from_str(r#"{"cron_expression":"0 * * * * * *","message":"Run checks"}"#)
1278 .unwrap();
1279 assert_eq!(config.timezone, "UTC");
1280 assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1281 }
1282
1283 #[test]
1284 fn test_webhook_channel_config_defaults() {
1285 let config: WebhookChannelConfig =
1286 serde_json::from_str(r#"{"token":"top-secret","message":"{{payload.action}}"}"#)
1287 .unwrap();
1288 assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1289 assert!(config.rate_limit_per_minute.is_none());
1291 }
1292
1293 #[test]
1294 fn test_webhook_channel_config_rate_limit_roundtrip() {
1295 let config: WebhookChannelConfig =
1297 serde_json::from_str(r#"{"token":"t","message":"m","rate_limit_per_minute":120}"#)
1298 .unwrap();
1299 assert_eq!(config.rate_limit_per_minute, Some(120));
1300 let json = serde_json::to_value(&config).unwrap();
1301 assert_eq!(
1302 json.get("rate_limit_per_minute").and_then(|v| v.as_u64()),
1303 Some(120)
1304 );
1305
1306 let none_cfg: WebhookChannelConfig =
1308 serde_json::from_str(r#"{"token":"t","message":"m"}"#).unwrap();
1309 let none_json = serde_json::to_value(&none_cfg).unwrap();
1310 assert!(none_json.get("rate_limit_per_minute").is_none());
1311 }
1312
1313 fn test_app(channels: Vec<AppChannel>) -> App {
1314 App {
1315 public_id: AppId::from_uuid(Uuid::nil()),
1316 internal_id: Uuid::nil(),
1317 org_id: 1,
1318 name: "test".into(),
1319 description: None,
1320 harness_id: HarnessId::from_uuid(Uuid::nil()),
1321 agent_id: Some(AgentId::from_uuid(Uuid::nil())),
1322 agent_version_policy: AgentVersionPolicy::Default,
1323 agent_version_id: None,
1324 agent_identity_id: None,
1325 owner_principal_id: PrincipalId::from_seed(1),
1326 resolved_owner_user_id: None,
1327 owner: None,
1328 effective_owner: None,
1329 channels,
1330 status: AppStatus::Draft,
1331 published_at: None,
1332 created_at: Utc::now(),
1333 updated_at: Utc::now(),
1334 archived_at: None,
1335 deleted_at: None,
1336 }
1337 }
1338
1339 fn test_channel(channel_type: ChannelType, config: serde_json::Value) -> AppChannel {
1340 AppChannel {
1341 public_id: AppChannelId::from_uuid(Uuid::nil()),
1342 internal_id: Uuid::nil(),
1343 channel_type,
1344 channel_config: config,
1345 enabled: true,
1346 created_at: Utc::now(),
1347 updated_at: Utc::now(),
1348 }
1349 }
1350
1351 #[test]
1352 fn test_app_channel_slack_config_valid() {
1353 let ch = test_channel(
1354 ChannelType::Slack,
1355 serde_json::json!({"signing_secret": "sec", "bot_token": "tok"}),
1356 );
1357 let config = ch.slack_config().unwrap();
1358 assert_eq!(config.signing_secret, "sec");
1359 }
1360
1361 #[test]
1362 fn test_app_channel_slack_config_invalid_json() {
1363 let ch = test_channel(ChannelType::Slack, serde_json::json!({"bad": "data"}));
1364 assert!(ch.slack_config().is_none());
1365 }
1366
1367 #[test]
1368 fn test_app_slack_channel_lookup() {
1369 let ch = test_channel(
1370 ChannelType::Slack,
1371 serde_json::json!({"signing_secret": "s", "bot_token": "t"}),
1372 );
1373 let app = test_app(vec![ch]);
1374 assert!(app.slack_channel().is_some());
1375 }
1376
1377 #[test]
1378 fn test_app_slack_channel_none_when_empty() {
1379 let app = test_app(vec![]);
1380 assert!(app.slack_channel().is_none());
1381 }
1382
1383 #[test]
1384 fn test_app_channel_ag_ui_config_valid() {
1385 let ch = test_channel(ChannelType::AgUi, serde_json::json!({"anonymous": true}));
1386 let config = ch.ag_ui_config().unwrap();
1387 assert!(config.anonymous);
1388 }
1389
1390 #[test]
1391 fn test_app_ag_ui_channel_lookup() {
1392 let ch = test_channel(ChannelType::AgUi, serde_json::json!({"anonymous": true}));
1393 let app = test_app(vec![ch]);
1394 assert!(app.ag_ui_channel().is_some());
1395 }
1396
1397 #[test]
1398 fn test_app_channel_fcp_config_defaults() {
1399 let ch = test_channel(ChannelType::Fcp, serde_json::json!({}));
1400 let config = ch.fcp_config().unwrap();
1401 assert!(config.anonymous);
1402 assert!(config.token.is_none());
1403 assert!(config.handshake.is_none());
1404 assert_eq!(
1405 config.session_expiration_seconds,
1406 DEFAULT_SESSION_EXPIRATION_SECONDS
1407 );
1408 assert_eq!(
1409 config.response_timeout_seconds,
1410 DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS
1411 );
1412 }
1413
1414 #[test]
1415 fn test_app_fcp_channel_lookup() {
1416 let ch = test_channel(ChannelType::Fcp, serde_json::json!({}));
1417 let app = test_app(vec![ch]);
1418 assert!(app.fcp_channel().is_some());
1419 }
1420
1421 #[test]
1422 fn test_app_channel_schedule_config_valid() {
1423 let ch = test_channel(
1424 ChannelType::Schedule,
1425 serde_json::json!({
1426 "cron_expression": "0 * * * * * *",
1427 "message": "Run checks"
1428 }),
1429 );
1430 let config = ch.schedule_config().unwrap();
1431 assert_eq!(config.message, "Run checks");
1432 }
1433
1434 #[test]
1435 fn test_app_schedule_channel_lookup() {
1436 let ch = test_channel(
1437 ChannelType::Schedule,
1438 serde_json::json!({
1439 "cron_expression": "0 * * * * * *",
1440 "message": "Run checks"
1441 }),
1442 );
1443 let app = test_app(vec![ch]);
1444 assert!(app.schedule_channel().is_some());
1445 }
1446
1447 #[test]
1448 fn test_app_channel_webhook_config_valid() {
1449 let ch = test_channel(
1450 ChannelType::Webhook,
1451 serde_json::json!({
1452 "token": "secret",
1453 "message": "{{payload.ref}}"
1454 }),
1455 );
1456 let config = ch.webhook_config().unwrap();
1457 assert_eq!(config.token, "secret");
1458 }
1459
1460 #[test]
1461 fn test_app_webhook_channel_lookup() {
1462 let ch = test_channel(
1463 ChannelType::Webhook,
1464 serde_json::json!({
1465 "token": "secret",
1466 "message": "{{payload.ref}}"
1467 }),
1468 );
1469 let app = test_app(vec![ch]);
1470 assert!(app.webhook_channel().is_some());
1471 }
1472
1473 #[test]
1474 fn test_a2a_channel_config_defaults() {
1475 let config: A2aChannelConfig = serde_json::from_str(
1476 r#"{"api_key_hash":"abc","api_key_prefix":"evra2a_abc1...","message":"{{a2a.text}}"}"#,
1477 )
1478 .unwrap();
1479 assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1480 assert!(config.agent_card_name.is_none());
1481 assert!(config.agent_card_description.is_none());
1482 assert!(config.rate_limit_per_minute.is_none());
1483 assert!(config.auth.is_none());
1484 assert!(config.signing_secret.is_none());
1485 }
1486
1487 #[test]
1488 fn test_a2a_channel_config_roundtrip() {
1489 let config = A2aChannelConfig {
1490 api_key_hash: "deadbeef".into(),
1491 api_key_prefix: "evra2a_dead...".into(),
1492 session_mode: InvocationSessionMode::SessionPerInvocation,
1493 message: "{{a2a.text}}".into(),
1494 agent_card_name: Some("Inbox triage".into()),
1495 agent_card_description: Some("Triages github events".into()),
1496 rate_limit_per_minute: Some(120),
1497 auth: None,
1498 signing_secret: None,
1499 };
1500 let json = serde_json::to_string(&config).unwrap();
1501 let parsed: A2aChannelConfig = serde_json::from_str(&json).unwrap();
1502 assert_eq!(parsed.api_key_hash, "deadbeef");
1503 assert_eq!(
1504 parsed.session_mode,
1505 InvocationSessionMode::SessionPerInvocation
1506 );
1507 assert_eq!(parsed.agent_card_name.as_deref(), Some("Inbox triage"));
1508 assert_eq!(parsed.rate_limit_per_minute, Some(120));
1509 }
1510
1511 #[test]
1512 fn test_a2a_channel_config_omits_optional_fields() {
1513 let config = A2aChannelConfig {
1514 api_key_hash: "h".into(),
1515 api_key_prefix: "evra2a_h...".into(),
1516 session_mode: InvocationSessionMode::SharedSession,
1517 message: "m".into(),
1518 agent_card_name: None,
1519 agent_card_description: None,
1520 rate_limit_per_minute: None,
1521 auth: None,
1522 signing_secret: None,
1523 };
1524 let json = serde_json::to_value(&config).unwrap();
1525 assert!(json.get("agent_card_name").is_none());
1526 assert!(json.get("agent_card_description").is_none());
1527 assert!(json.get("rate_limit_per_minute").is_none());
1528 assert!(json.get("signing_secret").is_none());
1529 }
1530
1531 #[test]
1532 fn test_app_channel_a2a_config_valid() {
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 config = ch.a2a_config().unwrap();
1542 assert_eq!(config.api_key_prefix, "evra2a_h...");
1543 }
1544
1545 #[test]
1546 fn test_app_a2a_channel_lookup() {
1547 let ch = test_channel(
1548 ChannelType::A2a,
1549 serde_json::json!({
1550 "api_key_hash": "h",
1551 "api_key_prefix": "evra2a_h...",
1552 "message": "{{a2a.text}}"
1553 }),
1554 );
1555 let app = test_app(vec![ch]);
1556 assert!(app.a2a_channel().is_some());
1557 }
1558
1559 #[test]
1560 fn test_channel_type_public_chat_serde_roundtrip() {
1561 let json = serde_json::to_string(&ChannelType::PublicChat).unwrap();
1562 assert_eq!(json, r#""public_chat""#);
1563 let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1564 assert_eq!(parsed, ChannelType::PublicChat);
1565 }
1566
1567 #[test]
1568 fn test_public_chat_channel_config_defaults() {
1569 let config: PublicChatChannelConfig = serde_json::from_str("{}").unwrap();
1570 assert!(config.anonymous);
1571 assert!(config.token.is_none());
1572 assert_eq!(
1573 config.session_expiration_seconds,
1574 DEFAULT_SESSION_EXPIRATION_SECONDS
1575 );
1576 assert!(config.rate_limit_per_minute.is_none());
1577 assert_eq!(config.tool_visibility, AgUiToolVisibility::Generic);
1578 assert_eq!(config.generic_tool_text, DEFAULT_AG_UI_GENERIC_TOOL_TEXT);
1579 assert!(config.auth.is_none());
1580 assert!(config.branding.is_empty());
1581 assert!(config.captcha.is_none());
1582 }
1583
1584 #[test]
1585 fn test_public_chat_channel_config_omits_empty_branding_and_defaults() {
1586 let config: PublicChatChannelConfig = serde_json::from_str("{}").unwrap();
1587 let json = serde_json::to_value(&config).unwrap();
1588 assert!(json.get("branding").is_none());
1589 assert!(json.get("captcha").is_none());
1590 assert!(json.get("generic_tool_text").is_none());
1591 assert!(json.get("rate_limit_per_minute").is_none());
1592 }
1593
1594 #[test]
1595 fn test_public_chat_channel_config_full_roundtrip() {
1596 let json = r##"{
1597 "anonymous": false,
1598 "token": "shared-secret",
1599 "session_expiration_seconds": 3600,
1600 "rate_limit_per_minute": 30,
1601 "tool_visibility": "narrated",
1602 "generic_tool_text": "Thinking...",
1603 "branding": {
1604 "display_name": "Support",
1605 "logo_url": "https://example.com/logo.png",
1606 "primary_color": "#0A1636",
1607 "welcome_message": "How can I help?"
1608 },
1609 "captcha": {
1610 "provider": "turnstile",
1611 "enabled": true,
1612 "site_key": "1x00000000000000000000AA",
1613 "secret_key": "1x0000000000000000000000000000000AA"
1614 }
1615 }"##;
1616 let config: PublicChatChannelConfig = serde_json::from_str(json).unwrap();
1617 assert!(!config.anonymous);
1618 assert_eq!(config.token.as_deref(), Some("shared-secret"));
1619 assert_eq!(config.session_expiration_seconds, 3600);
1620 assert_eq!(config.rate_limit_per_minute, Some(30));
1621 assert_eq!(config.tool_visibility, AgUiToolVisibility::Narrated);
1622 let branding = &config.branding;
1623 assert_eq!(branding.display_name.as_deref(), Some("Support"));
1624 assert_eq!(branding.primary_color.as_deref(), Some("#0A1636"));
1625 let captcha = config.captcha.unwrap();
1626 assert_eq!(captcha.provider, CaptchaProvider::Turnstile);
1627 assert!(captcha.enabled);
1628 assert_eq!(captcha.site_key, "1x00000000000000000000AA");
1629 assert!(captcha.secret_key.is_some());
1630 }
1631
1632 #[test]
1633 fn test_app_channel_public_chat_config_valid() {
1634 let ch = test_channel(
1635 ChannelType::PublicChat,
1636 serde_json::json!({"anonymous": true}),
1637 );
1638 let config = ch.public_chat_config().unwrap();
1639 assert!(config.anonymous);
1640 }
1641
1642 #[test]
1643 fn test_app_public_chat_channel_lookup() {
1644 let ch = test_channel(
1645 ChannelType::PublicChat,
1646 serde_json::json!({"branding": {"display_name": "Helpdesk"}}),
1647 );
1648 let app = test_app(vec![ch]);
1649 assert!(app.public_chat_channel().is_some());
1650 }
1651
1652 #[test]
1653 fn test_app_serde_skips_internal_fields() {
1654 let app = test_app(vec![]);
1655 let json = serde_json::to_value(&app).unwrap();
1656 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()); }
1661}