Skip to main content

distri_types/
stores.rs

1use crate::connections::{Connection, ConnectionStatus, ConnectionToken, NewConnection};
2use crate::{ScratchpadEntry, ToolAuthStore, ToolResponse};
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize, de::DeserializeOwned};
7use serde_json::Value;
8use std::{collections::HashMap, sync::Arc};
9use tokio::sync::oneshot;
10use utoipa::ToSchema;
11use uuid::Uuid;
12
13use crate::{
14    AgentEvent, CreateThreadRequest, Message, Task, TaskMessage, TaskStatus, Thread,
15    UpdateThreadRequest,
16};
17
18// Redis and PostgreSQL stores moved to distri-stores crate
19
20/// Filter for listing threads
21#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, JsonSchema)]
22pub struct ThreadListFilter {
23    /// Filter by agent ID
24    pub agent_id: Option<String>,
25    /// Filter by external ID (for integration with external systems)
26    pub external_id: Option<String>,
27    /// Filter by thread attributes (JSON matching)
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub attributes: Option<serde_json::Value>,
30    /// Full-text search across title and last_message
31    pub search: Option<String>,
32    /// Filter threads updated after this time
33    pub from_date: Option<DateTime<Utc>>,
34    /// Filter threads updated before this time
35    pub to_date: Option<DateTime<Utc>>,
36    /// Filter by tags (array of tag strings to match)
37    pub tags: Option<Vec<String>>,
38}
39
40/// Paginated response for thread listing
41#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
42pub struct ThreadListResponse {
43    pub threads: Vec<crate::ThreadSummary>,
44    pub total: i64,
45    pub page: u32,
46    pub page_size: u32,
47}
48
49/// Agent usage information for sorting agents by thread count
50#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
51pub struct AgentUsageInfo {
52    pub agent_id: String,
53    pub agent_name: String,
54    pub thread_count: i64,
55}
56
57/// Initialized store collection
58#[derive(Clone)]
59pub struct InitializedStores {
60    pub session_store: Arc<dyn SessionStore>,
61    pub agent_store: Arc<dyn AgentStore>,
62    pub task_store: Arc<dyn TaskStore>,
63    pub thread_store: Arc<dyn ThreadStore>,
64    pub tool_auth_store: Arc<dyn ToolAuthStore>,
65    pub scratchpad_store: Arc<dyn ScratchpadStore>,
66    pub memory_store: Option<Arc<dyn MemoryStore>>,
67    pub crawl_store: Option<Arc<dyn CrawlStore>>,
68    pub external_tool_calls_store: Arc<dyn ExternalToolCallsStore>,
69    pub prompt_template_store: Option<Arc<dyn PromptTemplateStore>>,
70    pub secret_store: Option<Arc<dyn SecretStore>>,
71    pub skill_store: Option<Arc<dyn SkillStore>>,
72    pub connection_store: Option<Arc<dyn ConnectionStore>>,
73    pub connection_token_store: Option<Arc<dyn ConnectionTokenStore>>,
74    pub provider_registry: Option<Arc<dyn crate::auth::ProviderRegistry>>,
75}
76impl InitializedStores {
77    pub fn set_tool_auth_store(&mut self, tool_auth_store: Arc<dyn ToolAuthStore>) {
78        self.tool_auth_store = tool_auth_store;
79    }
80
81    pub fn set_external_tool_calls_store(mut self, store: Arc<dyn ExternalToolCallsStore>) {
82        self.external_tool_calls_store = store;
83    }
84
85    pub fn set_session_store(&mut self, session_store: Arc<dyn SessionStore>) {
86        self.session_store = session_store;
87    }
88
89    pub fn set_agent_store(&mut self, agent_store: Arc<dyn AgentStore>) {
90        self.agent_store = agent_store;
91    }
92
93    pub fn with_task_store(&mut self, task_store: Arc<dyn TaskStore>) {
94        self.task_store = task_store;
95    }
96
97    pub fn with_thread_store(&mut self, thread_store: Arc<dyn ThreadStore>) {
98        self.thread_store = thread_store;
99    }
100
101    pub fn with_scratchpad_store(&mut self, scratchpad_store: Arc<dyn ScratchpadStore>) {
102        self.scratchpad_store = scratchpad_store;
103    }
104}
105
106impl std::fmt::Debug for InitializedStores {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        f.debug_struct("InitializedStores").finish()
109    }
110}
111
112#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, JsonSchema)]
113pub struct SessionSummary {
114    pub session_id: String,
115    pub keys: Vec<String>,
116    pub key_count: usize,
117    pub updated_at: Option<DateTime<Utc>>,
118}
119
120// SessionStore trait - manages current conversation thread/run
121#[async_trait::async_trait]
122pub trait SessionStore: Send + Sync + std::fmt::Debug {
123    async fn clear_session(&self, namespace: &str) -> anyhow::Result<()>;
124
125    async fn set_value(&self, namespace: &str, key: &str, value: &Value) -> anyhow::Result<()>;
126
127    async fn set_value_with_expiry(
128        &self,
129        namespace: &str,
130        key: &str,
131        value: &Value,
132        expiry: Option<chrono::DateTime<chrono::Utc>>,
133    ) -> anyhow::Result<()>;
134
135    async fn get_value(&self, namespace: &str, key: &str) -> anyhow::Result<Option<Value>>;
136
137    async fn delete_value(&self, namespace: &str, key: &str) -> anyhow::Result<()>;
138
139    async fn get_all_values(&self, namespace: &str) -> anyhow::Result<HashMap<String, Value>>;
140
141    async fn list_sessions(
142        &self,
143        namespace: Option<&str>,
144        limit: Option<usize>,
145        offset: Option<usize>,
146    ) -> anyhow::Result<Vec<SessionSummary>>;
147}
148#[async_trait::async_trait]
149pub trait SessionStoreExt: SessionStore {
150    async fn set<T: Serialize + Sync>(
151        &self,
152        namespace: &str,
153        key: &str,
154        value: &T,
155    ) -> anyhow::Result<()> {
156        self.set_value(namespace, key, &serde_json::to_value(value)?)
157            .await
158    }
159    async fn set_with_expiry<T: Serialize + Sync>(
160        &self,
161        namespace: &str,
162        key: &str,
163        value: &T,
164        expiry: Option<chrono::DateTime<chrono::Utc>>,
165    ) -> anyhow::Result<()> {
166        self.set_value_with_expiry(namespace, key, &serde_json::to_value(value)?, expiry)
167            .await
168    }
169    async fn get<T: DeserializeOwned + Sync>(
170        &self,
171        namespace: &str,
172        key: &str,
173    ) -> anyhow::Result<Option<T>> {
174        match self.get_value(namespace, key).await? {
175            Some(b) => Ok(Some(serde_json::from_value(b)?)),
176            None => Ok(None),
177        }
178    }
179}
180impl<T: SessionStore + ?Sized> SessionStoreExt for T {}
181
182// Higher-level MemoryStore trait - manages cross-session permanent memory using user_id
183#[async_trait::async_trait]
184pub trait MemoryStore: Send + Sync {
185    /// Store permanent memory from a session for cross-session access
186    async fn store_memory(
187        &self,
188        user_id: &str,
189        session_memory: SessionMemory,
190    ) -> anyhow::Result<()>;
191
192    /// Search for relevant memories across sessions for a user
193    async fn search_memories(
194        &self,
195        user_id: &str,
196        query: &str,
197        limit: Option<usize>,
198    ) -> anyhow::Result<Vec<String>>;
199
200    /// Get all permanent memories for a user
201    async fn get_user_memories(&self, user_id: &str) -> anyhow::Result<Vec<String>>;
202
203    /// Clear all memories for a user
204    async fn clear_user_memories(&self, user_id: &str) -> anyhow::Result<()>;
205}
206
207#[derive(Debug, Clone)]
208pub struct SessionMemory {
209    pub agent_id: String,
210    pub thread_id: String,
211    pub session_summary: String,
212    pub key_insights: Vec<String>,
213    pub important_facts: Vec<String>,
214    pub timestamp: chrono::DateTime<chrono::Utc>,
215}
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema, JsonSchema)]
217#[serde(tag = "type", rename_all = "snake_case")]
218pub enum FilterMessageType {
219    Events,
220    Messages,
221    Artifacts,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
225pub struct MessageFilter {
226    pub filter: Option<Vec<FilterMessageType>>,
227    pub limit: Option<usize>,
228    pub offset: Option<usize>,
229}
230
231// Task Store trait for A2A task management
232#[async_trait]
233pub trait TaskStore: Send + Sync {
234    fn init_task(
235        &self,
236        context_id: &str,
237        task_id: Option<&str>,
238        status: Option<TaskStatus>,
239    ) -> Task {
240        let task_id = task_id.unwrap_or(&Uuid::new_v4().to_string()).to_string();
241        Task {
242            id: task_id,
243            status: status.unwrap_or(TaskStatus::Pending),
244            created_at: chrono::Utc::now().timestamp_millis(),
245            updated_at: chrono::Utc::now().timestamp_millis(),
246            thread_id: context_id.to_string(),
247            parent_task_id: None,
248        }
249    }
250    async fn get_or_create_task(
251        &self,
252        thread_id: &str,
253        task_id: &str,
254    ) -> Result<(), anyhow::Error> {
255        match self.get_task(task_id).await? {
256            Some(task) => task,
257            None => {
258                self.create_task(thread_id, Some(task_id), Some(TaskStatus::Running))
259                    .await?
260            }
261        };
262
263        Ok(())
264    }
265    async fn create_task(
266        &self,
267        context_id: &str,
268        task_id: Option<&str>,
269        task_status: Option<TaskStatus>,
270    ) -> anyhow::Result<Task>;
271    async fn get_task(&self, task_id: &str) -> anyhow::Result<Option<Task>>;
272    async fn update_task_status(&self, task_id: &str, status: TaskStatus) -> anyhow::Result<()>;
273    async fn add_event_to_task(&self, task_id: &str, event: AgentEvent) -> anyhow::Result<()>;
274    async fn add_message_to_task(&self, task_id: &str, message: &Message) -> anyhow::Result<()>;
275    async fn cancel_task(&self, task_id: &str) -> anyhow::Result<Task>;
276    async fn list_tasks(&self, thread_id: Option<&str>) -> anyhow::Result<Vec<Task>>;
277
278    async fn get_history(
279        &self,
280        thread_id: &str,
281        filter: Option<MessageFilter>,
282    ) -> anyhow::Result<Vec<(Task, Vec<TaskMessage>)>>;
283
284    async fn update_parent_task(
285        &self,
286        task_id: &str,
287        parent_task_id: Option<&str>,
288    ) -> anyhow::Result<()>;
289}
290
291// Thread Store trait for thread management
292#[async_trait]
293pub trait ThreadStore: Send + Sync {
294    fn as_any(&self) -> &dyn std::any::Any;
295    async fn create_thread(&self, request: CreateThreadRequest) -> anyhow::Result<Thread>;
296    async fn get_thread(&self, thread_id: &str) -> anyhow::Result<Option<Thread>>;
297    async fn update_thread(
298        &self,
299        thread_id: &str,
300        request: UpdateThreadRequest,
301    ) -> anyhow::Result<Thread>;
302    async fn delete_thread(&self, thread_id: &str) -> anyhow::Result<()>;
303
304    /// List threads with pagination and filtering
305    /// Returns a paginated response with total count
306    async fn list_threads(
307        &self,
308        filter: &ThreadListFilter,
309        limit: Option<u32>,
310        offset: Option<u32>,
311    ) -> anyhow::Result<ThreadListResponse>;
312
313    async fn update_thread_with_message(
314        &self,
315        thread_id: &str,
316        message: &str,
317    ) -> anyhow::Result<()>;
318
319    /// Get aggregated home statistics
320    async fn get_home_stats(&self) -> anyhow::Result<HomeStats>;
321
322    /// Get agents sorted by thread count (most active first)
323    /// Includes all registered agents (even those with 0 threads).
324    /// Optionally filters by name using a search string.
325    async fn get_agents_by_usage(
326        &self,
327        search: Option<&str>,
328    ) -> anyhow::Result<Vec<AgentUsageInfo>>;
329
330    /// Get a map of agent name -> stats for all agents with activity
331    async fn get_agent_stats_map(
332        &self,
333    ) -> anyhow::Result<std::collections::HashMap<String, AgentStatsInfo>>;
334
335    // ========== Message Read Status Methods ==========
336
337    /// Mark a message as read by the current user
338    async fn mark_message_read(
339        &self,
340        thread_id: &str,
341        message_id: &str,
342    ) -> anyhow::Result<MessageReadStatus>;
343
344    /// Get read status for a specific message
345    async fn get_message_read_status(
346        &self,
347        thread_id: &str,
348        message_id: &str,
349    ) -> anyhow::Result<Option<MessageReadStatus>>;
350
351    /// Get read status for all messages in a thread for the current user
352    async fn get_thread_read_status(
353        &self,
354        thread_id: &str,
355    ) -> anyhow::Result<Vec<MessageReadStatus>>;
356
357    // ========== Message Voting Methods ==========
358
359    /// Vote on a message (upvote or downvote)
360    /// For downvotes, a comment is required
361    async fn vote_message(&self, request: VoteMessageRequest) -> anyhow::Result<MessageVote>;
362
363    /// Remove a vote from a message
364    async fn remove_vote(&self, thread_id: &str, message_id: &str) -> anyhow::Result<()>;
365
366    /// Get the current user's vote on a message
367    async fn get_user_vote(
368        &self,
369        thread_id: &str,
370        message_id: &str,
371    ) -> anyhow::Result<Option<MessageVote>>;
372
373    /// Get vote summary for a message (counts + current user's vote)
374    async fn get_message_vote_summary(
375        &self,
376        thread_id: &str,
377        message_id: &str,
378    ) -> anyhow::Result<MessageVoteSummary>;
379
380    /// Get all votes for a message (admin/analytics use)
381    async fn get_message_votes(
382        &self,
383        thread_id: &str,
384        message_id: &str,
385    ) -> anyhow::Result<Vec<MessageVote>>;
386}
387
388/// Home statistics for dashboard
389#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema, JsonSchema)]
390pub struct HomeStats {
391    pub total_agents: i64,
392    pub total_threads: i64,
393    pub total_messages: i64,
394    pub avg_run_time_ms: Option<f64>,
395    // Cloud-specific fields (optional)
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub total_owned_agents: Option<i64>,
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub total_accessible_agents: Option<i64>,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub most_active_agent: Option<MostActiveAgent>,
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub latest_threads: Option<Vec<LatestThreadInfo>>,
404    /// Recently used agents (last 10 by most recent thread activity)
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub recently_used_agents: Option<Vec<RecentlyUsedAgent>>,
407    /// Custom metrics that can be displayed in the stats overview
408    /// Key is the metric name (e.g., "usage"), value is the metric data
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub custom_metrics: Option<std::collections::HashMap<String, CustomMetric>>,
411}
412
413/// A custom metric for display in the stats overview
414#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema, JsonSchema)]
415pub struct CustomMetric {
416    /// Display label (e.g., "Monthly Calls")
417    pub label: String,
418    /// Current value as a string (formatted)
419    pub value: String,
420    /// Optional helper text below the value
421    #[serde(skip_serializing_if = "Option::is_none")]
422    pub helper: Option<String>,
423    /// Optional limit (for progress display)
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub limit: Option<String>,
426    /// Optional raw numeric value for calculations
427    #[serde(skip_serializing_if = "Option::is_none")]
428    pub raw_value: Option<i64>,
429    /// Optional raw limit for calculations
430    #[serde(skip_serializing_if = "Option::is_none")]
431    pub raw_limit: Option<i64>,
432}
433
434#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema, JsonSchema)]
435pub struct MostActiveAgent {
436    pub id: String,
437    pub name: String,
438    pub thread_count: i64,
439}
440
441/// Agent that was recently used (based on thread activity)
442#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema, JsonSchema)]
443pub struct RecentlyUsedAgent {
444    pub id: String,
445    pub name: String,
446    pub description: Option<String>,
447    pub last_used_at: chrono::DateTime<chrono::Utc>,
448}
449
450#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema, JsonSchema)]
451pub struct LatestThreadInfo {
452    pub id: String,
453    pub title: String,
454    pub agent_id: String,
455    pub agent_name: String,
456    pub updated_at: chrono::DateTime<chrono::Utc>,
457}
458
459/// Agent statistics for display
460#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, ToSchema, JsonSchema)]
461pub struct AgentStatsInfo {
462    pub thread_count: i64,
463    pub sub_agent_usage_count: i64,
464    pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
465}
466
467#[async_trait]
468pub trait AgentStore: Send + Sync {
469    async fn list(
470        &self,
471        cursor: Option<String>,
472        limit: Option<usize>,
473    ) -> (Vec<crate::configuration::AgentConfig>, Option<String>);
474
475    async fn get(&self, name: &str) -> Option<crate::configuration::AgentConfig>;
476    async fn register(&self, config: crate::configuration::AgentConfig) -> anyhow::Result<()>;
477    /// Update an existing agent with new definition
478    async fn update(&self, config: crate::configuration::AgentConfig) -> anyhow::Result<()>;
479
480    async fn clear(&self) -> anyhow::Result<()>;
481
482    /// Delete an agent by name or ID
483    async fn delete(&self, id: &str) -> anyhow::Result<()>;
484
485    /// Get an agent with cloud-specific metadata (id, published, is_owner, etc.)
486    /// Default impl returns empty metadata — override in cloud stores.
487    async fn get_with_cloud_metadata(
488        &self,
489        name: &str,
490    ) -> Option<(
491        crate::configuration::AgentConfig,
492        crate::configuration::AgentCloudMetadata,
493    )> {
494        self.get(name)
495            .await
496            .map(|c| (c, crate::configuration::AgentCloudMetadata::default()))
497    }
498
499    /// List agents with cloud-specific metadata.
500    /// Default impl returns empty metadata — override in cloud stores.
501    async fn list_with_cloud_metadata(
502        &self,
503        cursor: Option<String>,
504        limit: Option<usize>,
505    ) -> (
506        Vec<(
507            crate::configuration::AgentConfig,
508            crate::configuration::AgentCloudMetadata,
509        )>,
510        Option<String>,
511    ) {
512        let (configs, cursor) = self.list(cursor, limit).await;
513        (
514            configs
515                .into_iter()
516                .map(|c| (c, crate::configuration::AgentCloudMetadata::default()))
517                .collect(),
518            cursor,
519        )
520    }
521}
522
523/// Store for managing scratchpad entries across conversations
524#[async_trait::async_trait]
525pub trait ScratchpadStore: Send + Sync + std::fmt::Debug {
526    /// Add a scratchpad entry for a specific thread
527    async fn add_entry(
528        &self,
529        thread_id: &str,
530        entry: ScratchpadEntry,
531    ) -> Result<(), crate::AgentError>;
532
533    /// Clear all scratchpad entries for a thread
534    async fn clear_entries(&self, thread_id: &str) -> Result<(), crate::AgentError>;
535
536    /// Get entries for a specific task within a thread
537    async fn get_entries(
538        &self,
539        thread_id: &str,
540        task_id: &str,
541        limit: Option<usize>,
542    ) -> Result<Vec<ScratchpadEntry>, crate::AgentError>;
543
544    async fn get_all_entries(
545        &self,
546        thread_id: &str,
547        limit: Option<usize>,
548    ) -> Result<Vec<ScratchpadEntry>, crate::AgentError>;
549}
550
551/// Web crawl result data
552#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
553pub struct CrawlResult {
554    pub id: String,
555    pub url: String,
556    pub title: Option<String>,
557    pub content: String,
558    pub html: Option<String>,
559    pub metadata: serde_json::Value,
560    pub links: Vec<String>,
561    pub images: Vec<String>,
562    pub status_code: Option<u16>,
563    pub crawled_at: chrono::DateTime<chrono::Utc>,
564    pub processing_time_ms: Option<u64>,
565}
566
567/// Store for managing web crawl results
568#[async_trait]
569pub trait CrawlStore: Send + Sync {
570    /// Store a crawl result
571    async fn store_crawl_result(&self, result: CrawlResult) -> anyhow::Result<String>;
572
573    /// Get a crawl result by ID
574    async fn get_crawl_result(&self, id: &str) -> anyhow::Result<Option<CrawlResult>>;
575
576    /// Get crawl results for a specific URL
577    async fn get_crawl_results_by_url(&self, url: &str) -> anyhow::Result<Vec<CrawlResult>>;
578
579    /// Get recent crawl results (within time limit)
580    async fn get_recent_crawl_results(
581        &self,
582        limit: Option<usize>,
583        since: Option<chrono::DateTime<chrono::Utc>>,
584    ) -> anyhow::Result<Vec<CrawlResult>>;
585
586    /// Check if URL was recently crawled (within cache duration)
587    async fn is_url_recently_crawled(
588        &self,
589        url: &str,
590        cache_duration: chrono::Duration,
591    ) -> anyhow::Result<Option<CrawlResult>>;
592
593    /// Delete crawl result
594    async fn delete_crawl_result(&self, id: &str) -> anyhow::Result<()>;
595
596    /// Clear all crawl results older than specified date
597    async fn cleanup_old_results(
598        &self,
599        before: chrono::DateTime<chrono::Utc>,
600    ) -> anyhow::Result<usize>;
601}
602
603// ========== Message Read & Voting Types ==========
604
605/// Vote type for message feedback
606#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, ToSchema, JsonSchema)]
607#[serde(rename_all = "lowercase")]
608pub enum VoteType {
609    Upvote,
610    Downvote,
611}
612
613/// Record of a message being read
614#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
615pub struct MessageReadStatus {
616    pub thread_id: String,
617    pub message_id: String,
618    pub user_id: String,
619    pub read_at: chrono::DateTime<chrono::Utc>,
620}
621
622/// Request to mark a message as read
623#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
624pub struct MarkMessageReadRequest {
625    pub thread_id: String,
626    pub message_id: String,
627}
628
629/// A vote on a message with optional feedback comment
630#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
631pub struct MessageVote {
632    pub id: String,
633    pub thread_id: String,
634    pub message_id: String,
635    pub user_id: String,
636    pub vote_type: VoteType,
637    /// Comment is required for downvotes, optional for upvotes
638    pub comment: Option<String>,
639    pub created_at: chrono::DateTime<chrono::Utc>,
640    pub updated_at: chrono::DateTime<chrono::Utc>,
641}
642
643/// Request to vote on a message
644#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
645#[schema(example = json!({"vote_type": "up"}))]
646pub struct VoteMessageRequest {
647    pub thread_id: String,
648    pub message_id: String,
649    pub vote_type: VoteType,
650    /// Required for downvotes
651    pub comment: Option<String>,
652}
653
654/// Summary of votes for a message
655#[derive(Debug, Clone, Serialize, Deserialize, Default, ToSchema, JsonSchema)]
656pub struct MessageVoteSummary {
657    pub message_id: String,
658    pub upvotes: i64,
659    pub downvotes: i64,
660    /// Current user's vote on this message, if any
661    pub user_vote: Option<VoteType>,
662}
663
664/// Store for managing external tool call completions using oneshot channels
665#[async_trait]
666pub trait ExternalToolCallsStore: Send + Sync + std::fmt::Debug {
667    /// Register a new external tool call session and return a receiver for the response
668    async fn register_external_tool_call(
669        &self,
670        session_id: &str,
671    ) -> anyhow::Result<oneshot::Receiver<ToolResponse>>;
672
673    /// Complete an external tool call by sending the response
674    async fn complete_external_tool_call(
675        &self,
676        session_id: &str,
677        tool_response: ToolResponse,
678    ) -> anyhow::Result<()>;
679
680    /// Remove a session (cleanup)
681    async fn remove_tool_call(&self, session_id: &str) -> anyhow::Result<()>;
682
683    /// List all pending sessions (for debugging)
684    async fn list_pending_tool_calls(&self) -> anyhow::Result<Vec<String>>;
685}
686
687// ========== Prompt Template Store ==========
688
689#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
690pub struct PromptTemplateRecord {
691    pub id: String,
692    pub name: String,
693    pub template: String,
694    pub description: Option<String>,
695    pub version: Option<String>,
696    pub is_system: bool,
697    pub created_at: chrono::DateTime<chrono::Utc>,
698    pub updated_at: chrono::DateTime<chrono::Utc>,
699}
700
701#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
702#[schema(example = json!({"name": "greeting", "content": "Hello {{name}}, welcome to {{service}}!", "description": "A greeting template"}))]
703pub struct NewPromptTemplate {
704    pub name: String,
705    pub template: String,
706    pub description: Option<String>,
707    pub version: Option<String>,
708    #[serde(default)]
709    pub is_system: bool,
710}
711
712#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
713pub struct UpdatePromptTemplate {
714    pub name: String,
715    pub template: String,
716    pub description: Option<String>,
717}
718
719#[async_trait]
720pub trait PromptTemplateStore: Send + Sync {
721    async fn list(&self) -> anyhow::Result<Vec<PromptTemplateRecord>>;
722    async fn get(&self, id: &str) -> anyhow::Result<Option<PromptTemplateRecord>>;
723    /// Fetch multiple templates by name in a single query.
724    async fn get_by_names(&self, names: &[String]) -> anyhow::Result<Vec<PromptTemplateRecord>>;
725    async fn create(&self, template: NewPromptTemplate) -> anyhow::Result<PromptTemplateRecord>;
726    async fn update(
727        &self,
728        id: &str,
729        update: UpdatePromptTemplate,
730    ) -> anyhow::Result<PromptTemplateRecord>;
731    async fn delete(&self, id: &str) -> anyhow::Result<()>;
732    async fn clone_template(&self, id: &str) -> anyhow::Result<PromptTemplateRecord>;
733    async fn sync_system_templates(&self, templates: Vec<NewPromptTemplate>) -> anyhow::Result<()>;
734}
735
736// ========== Secret Store ==========
737
738#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
739pub struct SecretRecord {
740    pub id: String,
741    pub key: String,
742    pub value: String,
743    pub created_at: chrono::DateTime<chrono::Utc>,
744    pub updated_at: chrono::DateTime<chrono::Utc>,
745}
746
747#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
748#[schema(example = json!({"key": "OPENAI_API_KEY", "value": "sk-..."}))]
749pub struct NewSecret {
750    pub key: String,
751    pub value: String,
752}
753
754#[async_trait]
755pub trait SecretStore: Send + Sync {
756    async fn list(&self) -> anyhow::Result<Vec<SecretRecord>>;
757    async fn get(&self, key: &str) -> anyhow::Result<Option<SecretRecord>>;
758    async fn create(&self, secret: NewSecret) -> anyhow::Result<SecretRecord>;
759    async fn update(&self, key: &str, value: &str) -> anyhow::Result<SecretRecord>;
760    async fn delete(&self, key: &str) -> anyhow::Result<()>;
761}
762
763// ========== Provider Store ==========
764
765#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
766pub struct CustomProviderConfig {
767    pub id: String,
768    pub name: String,
769    pub base_url: String,
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    pub project_id: Option<String>,
772}
773
774#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
775pub struct CustomModelEntry {
776    pub provider: String,
777    pub model: String,
778    /// "completion" (default), "tts", or "stt"
779    #[serde(default = "default_completion")]
780    pub capability: String,
781}
782
783fn default_completion() -> String {
784    "completion".to_string()
785}
786
787/// A custom connection provider (OAuth integration) stored in workspace settings.
788#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
789pub struct ConnectionProviderConfig {
790    /// Unique identifier (e.g., "linear", "figma", "custom_crm")
791    pub id: String,
792    /// Display name
793    pub name: String,
794    /// OAuth2 authorization URL
795    pub authorization_url: String,
796    /// OAuth2 token URL
797    pub token_url: String,
798    /// Optional refresh URL (defaults to token_url)
799    #[serde(default, skip_serializing_if = "Option::is_none")]
800    pub refresh_url: Option<String>,
801    /// Scopes the provider supports
802    #[serde(default)]
803    pub scopes_supported: Vec<String>,
804    /// Default scopes to request
805    #[serde(default)]
806    pub default_scopes: Vec<String>,
807    /// Friendly scope name → full scope string mappings
808    #[serde(default)]
809    pub scope_mappings: std::collections::HashMap<String, String>,
810}
811
812/// Request payload for upserting a provider configuration.
813#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
814pub struct UpsertProviderRequest {
815    pub provider_id: String,
816    #[serde(default)]
817    pub secrets: std::collections::HashMap<String, String>,
818    #[serde(default)]
819    pub config: Option<CustomProviderConfig>,
820    #[serde(default)]
821    pub custom_models: Option<Vec<CustomModelEntry>>,
822    /// Default model in "provider/model" format. Empty string or null to clear.
823    #[serde(default)]
824    pub default_model: Option<String>,
825    /// Connection provider config (OAuth integration) to add/update.
826    #[serde(default)]
827    pub connection_provider: Option<ConnectionProviderConfig>,
828}
829
830/// Response after upserting a provider.
831#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
832pub struct UpsertProviderResponse {
833    pub provider_id: String,
834    pub secrets_saved: usize,
835    pub config_saved: bool,
836}
837
838#[async_trait]
839pub trait ProviderStore: Send + Sync {
840    async fn upsert_provider(
841        &self,
842        req: UpsertProviderRequest,
843    ) -> anyhow::Result<UpsertProviderResponse>;
844
845    async fn delete_provider(&self, provider_id: &str) -> anyhow::Result<()>;
846
847    async fn get_default_model(&self) -> anyhow::Result<Option<String>>;
848}
849
850// ========== Skill Store ==========
851
852/// How a skill is executed relative to the calling agent's context.
853/// Mirrors the `context` field in claude-code's prompt command spec.
854#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, ToSchema, JsonSchema)]
855#[serde(rename_all = "lowercase")]
856pub enum ContextExecutionType {
857    /// Inject the full skill content into the current agent's context window.
858    /// The calling agent incorporates it directly — no sub-agent spawned.
859    #[default]
860    Inline,
861    /// Spawn an isolated child agent with the skill as its instruction set.
862    /// The child runs with its own token budget and task record; its result
863    /// is summarised and returned to the parent.
864    Fork,
865}
866
867impl std::fmt::Display for ContextExecutionType {
868    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
869        match self {
870            ContextExecutionType::Inline => write!(f, "inline"),
871            ContextExecutionType::Fork => write!(f, "fork"),
872        }
873    }
874}
875
876impl std::str::FromStr for ContextExecutionType {
877    type Err = ();
878    fn from_str(s: &str) -> Result<Self, Self::Err> {
879        match s {
880            "fork" => Ok(ContextExecutionType::Fork),
881            _ => Ok(ContextExecutionType::Inline),
882        }
883    }
884}
885
886/// Total token budget for all skill listings in the system prompt.
887pub const SKILL_LISTING_BUDGET: usize = 2_000;
888/// Max description chars per skill in system prompt listing.
889pub const SKILL_DESCRIPTION_CAP: usize = 250;
890/// Default max output tokens for a skill when not explicitly set.
891pub const DEFAULT_SKILL_MAX_TOKENS: u32 = 8000;
892
893/// Parsed frontmatter from a skill markdown file.
894#[derive(Debug, Clone, Serialize, Deserialize, Default, ToSchema, JsonSchema)]
895pub struct SkillFrontmatter {
896    pub name: String,
897    #[serde(default)]
898    pub description: Option<String>,
899    #[serde(default)]
900    pub tags: Vec<String>,
901    #[serde(default)]
902    pub model: Option<String>,
903    #[serde(default)]
904    pub max_tokens: Option<u32>,
905    #[serde(default)]
906    pub can_spawn_tasks: bool,
907    #[serde(default)]
908    pub paths: Vec<String>,
909    #[serde(default)]
910    pub is_public: bool,
911}
912
913impl SkillFrontmatter {
914    pub fn effective_max_tokens(&self) -> u32 {
915        self.max_tokens.unwrap_or(DEFAULT_SKILL_MAX_TOKENS)
916    }
917
918    pub fn as_listing_line(&self) -> String {
919        let desc = self.description.as_deref().unwrap_or("No description");
920        let desc_truncated = if desc.len() > SKILL_DESCRIPTION_CAP {
921            format!("{}...", &desc[..SKILL_DESCRIPTION_CAP.min(desc.len())])
922        } else {
923            desc.to_string()
924        };
925        let mut meta = Vec::new();
926        if let Some(model) = &self.model {
927            meta.push(format!("model: {}", model));
928        }
929        if self.can_spawn_tasks {
930            meta.push("tasks: yes".to_string());
931        }
932        if meta.is_empty() {
933            format!("- {}: {}", self.name, desc_truncated)
934        } else {
935            format!("- {}: {} ({})", self.name, desc_truncated, meta.join(", "))
936        }
937    }
938}
939
940/// Format a list of skills for the system prompt, respecting a token budget.
941pub fn format_skill_listing(skills: &[SkillFrontmatter], budget_tokens: usize) -> String {
942    let budget_chars = budget_tokens * 4;
943    let mut result = String::new();
944    let mut remaining_chars = budget_chars;
945    for skill in skills {
946        let line = format!("{}\n", skill.as_listing_line());
947        if line.len() > remaining_chars {
948            let name_line = format!("- {}\n", skill.name);
949            if name_line.len() <= remaining_chars {
950                result.push_str(&name_line);
951                remaining_chars -= name_line.len();
952            } else {
953                break;
954            }
955        } else {
956            result.push_str(&line);
957            remaining_chars -= line.len();
958        }
959    }
960    result.trim_end().to_string()
961}
962
963/// API response wrapper for skill list endpoints.
964#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
965pub struct SkillsListResponse {
966    pub skills: Vec<SkillListItem>,
967}
968
969/// Lighter skill record for list endpoints — no content or scripts.
970/// Used by both distri-server (OSS) and distri-cloud.
971#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
972pub struct SkillListItem {
973    pub id: String,
974    #[serde(default)]
975    pub workspace_slug: String,
976    pub name: String,
977    #[serde(default)]
978    pub full_name: String,
979    #[serde(default)]
980    pub description: Option<String>,
981    #[serde(default)]
982    pub tags: Vec<String>,
983    #[serde(default)]
984    pub is_public: bool,
985    #[serde(default)]
986    pub is_system: bool,
987    #[serde(default)]
988    pub is_owner: bool,
989    /// True when the skill belongs to the current workspace
990    #[serde(default)]
991    pub is_workspace: bool,
992    #[serde(default)]
993    pub star_count: i32,
994    #[serde(default)]
995    pub clone_count: i32,
996    #[serde(default)]
997    pub is_starred: bool,
998    pub created_at: chrono::DateTime<chrono::Utc>,
999    pub updated_at: chrono::DateTime<chrono::Utc>,
1000}
1001
1002#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
1003pub struct SkillRecord {
1004    pub id: String,
1005    /// Workspace slug (cloud: resolved from workspace_id, OSS: "local")
1006    #[serde(default)]
1007    pub workspace_slug: String,
1008    pub name: String,
1009    /// Full qualified name: "{workspace_slug}/{name}"
1010    #[serde(default)]
1011    pub full_name: String,
1012    pub description: Option<String>,
1013    pub content: String,
1014    pub tags: Vec<String>,
1015    pub is_public: bool,
1016    pub is_system: bool,
1017    /// Whether the current user owns this skill
1018    #[serde(default)]
1019    pub is_owner: bool,
1020    /// True when the skill belongs to the current workspace
1021    #[serde(default)]
1022    pub is_workspace: bool,
1023    pub star_count: i32,
1024    pub clone_count: i32,
1025    /// Whether the current user has starred this skill
1026    #[serde(default)]
1027    pub is_starred: bool,
1028    pub created_at: chrono::DateTime<chrono::Utc>,
1029    pub updated_at: chrono::DateTime<chrono::Utc>,
1030    /// Preferred model for skill execution (overrides agent default)
1031    #[serde(default, skip_serializing_if = "Option::is_none")]
1032    pub model: Option<String>,
1033    /// How to deliver skill content: inline (default) or fork (isolated sub-agent)
1034    #[serde(default)]
1035    pub context: ContextExecutionType,
1036}
1037
1038#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
1039#[schema(example = json!({"name": "my-skill", "content": "# My Skill\nA helpful utility skill", "description": "A utility skill", "tags": ["utility"], "is_public": false}))]
1040pub struct NewSkill {
1041    pub name: String,
1042    pub description: Option<String>,
1043    pub content: String,
1044    #[serde(default)]
1045    pub tags: Vec<String>,
1046    #[serde(default)]
1047    pub is_public: bool,
1048    #[serde(default, skip_serializing_if = "Option::is_none")]
1049    pub model: Option<String>,
1050    #[serde(default)]
1051    pub context: ContextExecutionType,
1052}
1053
1054#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
1055pub struct UpdateSkill {
1056    pub name: Option<String>,
1057    pub description: Option<String>,
1058    pub content: Option<String>,
1059    pub tags: Option<Vec<String>>,
1060    pub is_public: Option<bool>,
1061    #[serde(default, skip_serializing_if = "Option::is_none")]
1062    pub model: Option<String>,
1063    #[serde(default, skip_serializing_if = "Option::is_none")]
1064    pub context: Option<ContextExecutionType>,
1065}
1066
1067/// Which slice of skills to return.
1068#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1069#[serde(rename_all = "snake_case")]
1070pub enum SkillScope {
1071    /// Skills belonging to the current workspace (not system)
1072    #[default]
1073    Workspace,
1074    /// Starred skills
1075    Starred,
1076    /// System skills
1077    System,
1078    /// Public skills from other workspaces (excludes own + system)
1079    Discover,
1080    /// Everything the user can see (workspace + public + system)
1081    All,
1082}
1083
1084/// Filters for listing skills — one struct drives list, search, and pagination.
1085#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1086pub struct SkillFilter {
1087    /// Which slice of skills to return
1088    #[serde(default)]
1089    pub scope: SkillScope,
1090    /// Full-text search on name/description (empty = no search filter)
1091    #[serde(default)]
1092    pub search: Option<String>,
1093    /// Page number (1-based, default 1)
1094    #[serde(default = "default_page")]
1095    pub page: i64,
1096    /// Items per page (default 50)
1097    #[serde(default = "default_per_page")]
1098    pub per_page: i64,
1099}
1100
1101fn default_page() -> i64 {
1102    1
1103}
1104fn default_per_page() -> i64 {
1105    50
1106}
1107
1108/// Paginated skill list response.
1109#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
1110pub struct SkillListResponse {
1111    pub skills: Vec<SkillListItem>,
1112    pub total: i64,
1113    pub page: i64,
1114    pub per_page: i64,
1115    pub total_pages: i64,
1116}
1117
1118#[async_trait]
1119pub trait SkillStore: Send + Sync {
1120    /// List skills — scope, search, and pagination all via SkillFilter.
1121    async fn list(&self, filter: SkillFilter) -> anyhow::Result<SkillListResponse>;
1122    async fn get(&self, id: &str) -> anyhow::Result<Option<SkillRecord>>;
1123    async fn create(&self, skill: NewSkill) -> anyhow::Result<SkillRecord>;
1124    async fn update(&self, id: &str, update: UpdateSkill) -> anyhow::Result<SkillRecord>;
1125    async fn delete(&self, id: &str) -> anyhow::Result<()>;
1126    async fn star(&self, skill_id: &str) -> anyhow::Result<()>;
1127    async fn unstar(&self, skill_id: &str) -> anyhow::Result<()>;
1128    async fn clone_skill(&self, skill_id: &str) -> anyhow::Result<SkillRecord>;
1129}
1130
1131// ─── Usage Service ──────────────────────────────────────────────────────────
1132
1133/// Current usage snapshot for a workspace/user.
1134#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, JsonSchema)]
1135pub struct UsageSnapshot {
1136    pub day_tokens: i64,
1137    pub week_tokens: i64,
1138    pub month_tokens: i64,
1139}
1140
1141/// Configured token limits for a workspace.
1142#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, JsonSchema)]
1143pub struct UsageLimits {
1144    pub daily_tokens: Option<i64>,
1145    pub weekly_tokens: Option<i64>,
1146    pub monthly_tokens: Option<i64>,
1147}
1148
1149/// Result of a rate limit check.
1150#[derive(Debug, Clone)]
1151pub enum UsageCheckResult {
1152    Allowed,
1153    Denied { reason: String },
1154}
1155
1156/// Trait for usage tracking, rate limiting, and workspace limit management.
1157///
1158/// OSS: can use a no-op or in-memory implementation.
1159/// Cloud: backed by Redis + Postgres with caching.
1160#[async_trait]
1161pub trait UsageService: Send + Sync {
1162    /// Check whether a request should be allowed based on all rate limits.
1163    /// Called by middleware before processing a request.
1164    /// `is_llm` indicates whether this is an LLM-consuming endpoint.
1165    /// `auth_source` is "jwt" or "api_key" for per-source analytics.
1166    async fn check_request(
1167        &self,
1168        workspace_id: &str,
1169        user_id: &str,
1170        is_llm: bool,
1171        auth_source: &str,
1172    ) -> UsageCheckResult;
1173
1174    /// Record token usage after a completed agent run.
1175    async fn record_usage(
1176        &self,
1177        workspace_id: &str,
1178        user_id: &str,
1179        tokens_used: i64,
1180    ) -> anyhow::Result<()>;
1181
1182    /// Get current usage snapshot for display.
1183    async fn get_usage(&self, workspace_id: &str, user_id: &str) -> anyhow::Result<UsageSnapshot>;
1184
1185    /// Get the configured limits for a workspace.
1186    async fn get_limits(&self, workspace_id: &str) -> anyhow::Result<UsageLimits>;
1187}
1188
1189/// No-op usage service for OSS / development.
1190/// Always allows requests, never records anything.
1191#[derive(Debug, Clone)]
1192pub struct NoOpUsageService;
1193
1194#[async_trait]
1195impl UsageService for NoOpUsageService {
1196    async fn check_request(
1197        &self,
1198        _workspace_id: &str,
1199        _user_id: &str,
1200        _is_llm: bool,
1201        _auth_source: &str,
1202    ) -> UsageCheckResult {
1203        UsageCheckResult::Allowed
1204    }
1205
1206    async fn record_usage(
1207        &self,
1208        _workspace_id: &str,
1209        _user_id: &str,
1210        _tokens_used: i64,
1211    ) -> anyhow::Result<()> {
1212        Ok(())
1213    }
1214
1215    async fn get_usage(
1216        &self,
1217        _workspace_id: &str,
1218        _user_id: &str,
1219    ) -> anyhow::Result<UsageSnapshot> {
1220        Ok(UsageSnapshot::default())
1221    }
1222
1223    async fn get_limits(&self, _workspace_id: &str) -> anyhow::Result<UsageLimits> {
1224        Ok(UsageLimits::default())
1225    }
1226}
1227
1228// ========== Connection Store ==========
1229
1230/// Persistence for connection records (Postgres-backed in cloud).
1231#[async_trait]
1232pub trait ConnectionStore: Send + Sync + 'static {
1233    async fn create(&self, connection: NewConnection) -> anyhow::Result<Connection>;
1234    async fn get_by_id(&self, id: &str) -> anyhow::Result<Option<Connection>>;
1235    async fn list_by_workspace(&self, workspace_id: &str) -> anyhow::Result<Vec<Connection>>;
1236    async fn update_status(&self, id: &str, status: ConnectionStatus) -> anyhow::Result<()>;
1237    async fn update_skill_id(&self, id: &str, skill_id: uuid::Uuid) -> anyhow::Result<()>;
1238    async fn delete(&self, id: &str) -> anyhow::Result<()>;
1239    async fn get_by_provider(
1240        &self,
1241        workspace_id: &str,
1242        provider: &str,
1243    ) -> anyhow::Result<Option<Connection>>;
1244}
1245
1246/// Token storage for OAuth connections (Redis-backed in cloud).
1247#[async_trait]
1248pub trait ConnectionTokenStore: Send + Sync + 'static {
1249    async fn store_token(&self, connection_id: &str, token: ConnectionToken) -> anyhow::Result<()>;
1250    async fn get_token(&self, connection_id: &str) -> anyhow::Result<Option<ConnectionToken>>;
1251    async fn remove_token(&self, connection_id: &str) -> anyhow::Result<()>;
1252
1253    /// Attempt to refresh an expired OAuth token using the stored refresh_token.
1254    /// Returns the new token if refresh succeeds, or None if refresh is not
1255    /// supported or fails. The implementation should store the refreshed token.
1256    ///
1257    /// Cloud implementation uses OAuthHandler.refresh_get_session().
1258    /// Default: no refresh support (returns None).
1259    async fn refresh_token(
1260        &self,
1261        _connection_id: &str,
1262        _connection: &Connection,
1263    ) -> anyhow::Result<Option<ConnectionToken>> {
1264        Ok(None)
1265    }
1266
1267    async fn store_oauth_state(
1268        &self,
1269        state_key: &str,
1270        state: serde_json::Value,
1271    ) -> anyhow::Result<()>;
1272    async fn get_oauth_state(&self, state_key: &str) -> anyhow::Result<Option<serde_json::Value>>;
1273    async fn remove_oauth_state(&self, state_key: &str) -> anyhow::Result<()>;
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278    use super::*;
1279
1280    #[test]
1281    fn test_skills_list_response_deserialize_cloud_format() {
1282        let json = r#"{"skills":[{"id":"abc","workspace_slug":"ws","name":"test","full_name":"ws/test","description":"desc","tags":["t"],"is_public":true,"is_system":false,"is_owner":true,"star_count":0,"clone_count":0,"is_starred":false,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}]}"#;
1283        let resp: SkillsListResponse = serde_json::from_str(json).unwrap();
1284        assert_eq!(resp.skills.len(), 1);
1285        assert_eq!(resp.skills[0].name, "test");
1286        assert_eq!(resp.skills[0].workspace_slug, "ws");
1287        assert_eq!(resp.skills[0].full_name, "ws/test");
1288        assert!(resp.skills[0].is_public);
1289    }
1290
1291    #[test]
1292    fn test_skills_list_response_deserialize_defaults() {
1293        let json = r#"{"skills":[{"id":"abc","name":"test","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}]}"#;
1294        let resp: SkillsListResponse = serde_json::from_str(json).unwrap();
1295        assert_eq!(resp.skills[0].workspace_slug, "");
1296        assert_eq!(resp.skills[0].full_name, "");
1297        assert!(!resp.skills[0].is_public);
1298        assert!(!resp.skills[0].is_owner);
1299    }
1300
1301    #[test]
1302    fn test_skills_list_response_roundtrip() {
1303        let resp = SkillsListResponse {
1304            skills: vec![SkillListItem {
1305                id: "id1".into(),
1306                workspace_slug: "local".into(),
1307                name: "my_skill".into(),
1308                full_name: "local/my_skill".into(),
1309                description: Some("A skill".into()),
1310                tags: vec!["tag1".into()],
1311                is_public: false,
1312                is_system: false,
1313                is_owner: true,
1314                is_workspace: true,
1315                star_count: 5,
1316                clone_count: 2,
1317                is_starred: true,
1318                created_at: chrono::Utc::now(),
1319                updated_at: chrono::Utc::now(),
1320            }],
1321        };
1322        let json = serde_json::to_string(&resp).unwrap();
1323        let decoded: SkillsListResponse = serde_json::from_str(&json).unwrap();
1324        assert_eq!(decoded.skills[0].name, "my_skill");
1325        assert_eq!(decoded.skills[0].star_count, 5);
1326    }
1327}