Skip to main content

everruns_core/
platform_store.rs

1// Platform Store trait for org-scoped management operations
2//
3// Decision: Single trait covers harness, agent, session CRUD + messaging
4// Decision: Trait lives in core; implementations in server/worker crates
5// Decision: Tool results include UI links via base_url()
6// Decision: PlatformMessage is a simplified view (role + text + timestamp)
7
8use crate::SessionContextReport;
9use crate::agent::Agent;
10use crate::app::{App, AppChannel, ChannelType};
11use crate::capability_dto::CapabilityInfo;
12use crate::error::Result;
13use crate::harness::Harness;
14use crate::session::Session;
15use crate::typed_id::{AgentId, AgentIdentityId, AppChannelId, AppId, HarnessId, SessionId};
16use async_trait::async_trait;
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19
20/// Simplified message representation for platform management tools.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct PlatformMessage {
23    pub role: String,
24    pub content: String,
25    pub created_at: DateTime<Utc>,
26}
27
28/// Trait for platform-level management operations.
29///
30/// Provides org-scoped CRUD for harnesses, agents, and sessions,
31/// plus session messaging and turn management. Used by the
32/// `platform_management` capability tools.
33#[async_trait]
34pub trait PlatformStore: Send + Sync {
35    // =========================================================================
36    // Harness Operations
37    // =========================================================================
38
39    /// List all harnesses in the organization.
40    async fn list_harnesses(&self) -> Result<Vec<Harness>>;
41
42    /// Get a harness by ID.
43    async fn get_harness(&self, id: HarnessId) -> Result<Option<Harness>>;
44
45    /// Create a new harness.
46    async fn create_harness(
47        &self,
48        name: &str,
49        display_name: Option<&str>,
50        description: Option<&str>,
51        system_prompt: &str,
52        parent_harness_id: Option<HarnessId>,
53        capabilities: &[String],
54    ) -> Result<Harness>;
55
56    /// Update a harness (only provided fields are changed).
57    async fn update_harness(
58        &self,
59        id: HarnessId,
60        name: Option<&str>,
61        display_name: Option<&str>,
62        description: Option<&str>,
63        system_prompt: Option<&str>,
64        parent_harness_id: Option<Option<HarnessId>>,
65    ) -> Result<Harness>;
66
67    /// Delete (archive) a harness.
68    async fn delete_harness(&self, id: HarnessId) -> Result<()>;
69
70    /// Copy a harness, optionally with a new name.
71    async fn copy_harness(&self, id: HarnessId, new_name: Option<&str>) -> Result<Harness>;
72
73    // =========================================================================
74    // Agent Operations
75    // =========================================================================
76
77    /// List all agents in the organization.
78    async fn list_agents(&self) -> Result<Vec<Agent>>;
79
80    /// Get an agent by public ID.
81    async fn get_agent_by_id(&self, id: AgentId) -> Result<Option<Agent>>;
82
83    /// Create a new agent.
84    async fn create_agent(
85        &self,
86        name: &str,
87        display_name: Option<&str>,
88        description: Option<&str>,
89        system_prompt: &str,
90        capabilities: &[String],
91    ) -> Result<Agent>;
92
93    /// Update an agent (only provided fields are changed).
94    async fn update_agent(
95        &self,
96        id: AgentId,
97        name: Option<&str>,
98        display_name: Option<&str>,
99        description: Option<&str>,
100        system_prompt: Option<&str>,
101    ) -> Result<Agent>;
102
103    /// Delete (archive) an agent.
104    async fn delete_agent(&self, id: AgentId) -> Result<()>;
105
106    // =========================================================================
107    // App Operations
108    // =========================================================================
109
110    /// List all apps in the organization.
111    async fn list_apps(&self, search: Option<&str>, include_archived: bool) -> Result<Vec<App>>;
112
113    /// Get an app by ID.
114    async fn get_app(&self, id: AppId) -> Result<Option<App>>;
115
116    /// Create a new app.
117    #[allow(clippy::too_many_arguments)]
118    async fn create_app(
119        &self,
120        name: &str,
121        description: Option<&str>,
122        harness_id: HarnessId,
123        agent_id: Option<AgentId>,
124        agent_identity_id: Option<AgentIdentityId>,
125        channel_type: Option<ChannelType>,
126        channel_config: Option<&serde_json::Value>,
127    ) -> Result<App>;
128
129    /// Update an app (only provided fields are changed).
130    #[allow(clippy::too_many_arguments)]
131    async fn update_app(
132        &self,
133        id: AppId,
134        name: Option<&str>,
135        description: Option<&str>,
136        harness_id: Option<HarnessId>,
137        agent_id: Option<AgentId>,
138        agent_identity_id: Option<Option<AgentIdentityId>>,
139    ) -> Result<App>;
140
141    /// Archive an app.
142    async fn delete_app(&self, id: AppId) -> Result<()>;
143
144    /// Permanently destroy an archived app.
145    async fn destroy_app(&self, id: AppId) -> Result<()>;
146
147    /// Publish an app.
148    async fn publish_app(&self, id: AppId) -> Result<App>;
149
150    /// Unpublish an app back to draft.
151    async fn unpublish_app(&self, id: AppId) -> Result<App>;
152
153    /// Add a channel to an app.
154    async fn add_app_channel(
155        &self,
156        app_id: AppId,
157        channel_type: ChannelType,
158        channel_config: Option<&serde_json::Value>,
159        enabled: Option<bool>,
160    ) -> Result<AppChannel>;
161
162    /// Update a channel on an app.
163    async fn update_app_channel(
164        &self,
165        app_id: AppId,
166        channel_id: AppChannelId,
167        channel_type: Option<ChannelType>,
168        channel_config: Option<&serde_json::Value>,
169        enabled: Option<bool>,
170    ) -> Result<AppChannel>;
171
172    /// Delete a channel from an app.
173    async fn delete_app_channel(&self, app_id: AppId, channel_id: AppChannelId) -> Result<()>;
174
175    // =========================================================================
176    // Session Operations
177    // =========================================================================
178
179    /// List sessions, optionally filtered by agent.
180    async fn list_sessions(
181        &self,
182        limit: Option<usize>,
183        agent_id: Option<AgentId>,
184    ) -> Result<Vec<Session>>;
185
186    /// Create a new session.
187    ///
188    /// When `blueprint_id` is set, the session runs a blueprint agent instead
189    /// of inheriting from `harness_id`/`agent_id`. `blueprint_config` is
190    /// validated config for the blueprint (JSON, optional).
191    #[allow(clippy::too_many_arguments)]
192    async fn create_session(
193        &self,
194        harness_id: HarnessId,
195        agent_id: Option<AgentId>,
196        title: Option<&str>,
197        locale: Option<&str>,
198        blueprint_id: Option<&str>,
199        blueprint_config: Option<&serde_json::Value>,
200    ) -> Result<Session>;
201
202    /// Attach subagent metadata to an existing session.
203    ///
204    /// Used by subagent orchestration to persist parent/child linkage and
205    /// support nesting guards plus child-session lookups.
206    async fn set_subagent_metadata(
207        &self,
208        session_id: SessionId,
209        parent_session_id: SessionId,
210        subagent_name: &str,
211        subagent_task: &str,
212        subagent_status: crate::session::SubagentStatus,
213    ) -> Result<Session>;
214
215    /// Get a session by ID.
216    async fn get_session_by_id(&self, id: SessionId) -> Result<Option<Session>>;
217
218    /// Get the latest estimated context breakdown for a session.
219    async fn get_session_context_report(&self, id: SessionId) -> Result<SessionContextReport>;
220
221    /// Delete (archive) a session.
222    async fn delete_session(&self, id: SessionId) -> Result<()>;
223
224    // =========================================================================
225    // Messaging
226    // =========================================================================
227
228    /// Send a user message to a session, triggering a turn.
229    async fn send_message(&self, session_id: SessionId, content: &str) -> Result<()>;
230
231    /// Get messages from a session (most recent first).
232    /// Default limit is 10.
233    async fn get_messages(
234        &self,
235        session_id: SessionId,
236        limit: Option<usize>,
237    ) -> Result<Vec<PlatformMessage>>;
238
239    // =========================================================================
240    // Turn Management
241    // =========================================================================
242
243    /// Wait for a session to become idle (turn completed).
244    /// Returns the final session status as a string.
245    /// Default timeout is 120 seconds.
246    async fn wait_for_idle(
247        &self,
248        session_id: SessionId,
249        timeout_secs: Option<u64>,
250    ) -> Result<String>;
251
252    // =========================================================================
253    // Capabilities
254    // =========================================================================
255
256    /// List all available capabilities (built-in + MCP servers + skills).
257    ///
258    /// Optionally filter by a search query (case-insensitive match against
259    /// name, description, category, and capability ID).
260    async fn list_capabilities(&self, search: Option<&str>) -> Result<Vec<CapabilityInfo>>;
261
262    // =========================================================================
263    // UI Links
264    // =========================================================================
265
266    /// Base URL for constructing UI links (e.g., "http://localhost:9300").
267    fn base_url(&self) -> &str;
268}
269
270#[cfg(test)]
271pub mod tests {
272    use super::*;
273    use crate::AgentCapabilityConfig;
274    use crate::agent::{Agent, AgentStatus};
275    use crate::app::{App, AppChannel, AppStatus, ChannelType};
276    use crate::harness::{Harness, HarnessStatus};
277    use crate::session::{Session, SessionStatus};
278
279    /// Mock PlatformStore for unit tests.
280    ///
281    /// Shared across test modules so that any test exercising
282    /// platform management tools (directly or via ActAtom) uses
283    /// the same mock. This prevents wiring bugs where a tool is
284    /// registered but the store is not passed through.
285    pub struct MockPlatformStore {
286        pub harness: Harness,
287        pub agent: Agent,
288        pub app: App,
289        pub app_channel: AppChannel,
290        pub session: Session,
291        /// Records the `harness_id` argument of every `create_session`
292        /// call so tests can assert which harness a child session was
293        /// created against. See `start_handoff_uses_target_harness_not_parent`.
294        pub created_session_harness_ids: std::sync::Mutex<Vec<HarnessId>>,
295    }
296
297    impl Default for MockPlatformStore {
298        fn default() -> Self {
299            Self::new()
300        }
301    }
302
303    impl MockPlatformStore {
304        pub fn new() -> Self {
305            Self {
306                harness: Harness {
307                    id: HarnessId::new(),
308                    name: "test-harness".to_string(),
309                    display_name: Some("Test Harness".to_string()),
310                    description: Some("test harness".to_string()),
311                    system_prompt: "You are helpful.".to_string(),
312                    parent_harness_id: None,
313                    default_model_id: None,
314                    tags: vec![],
315                    capabilities: vec![AgentCapabilityConfig::new("session")],
316                    initial_files: vec![],
317                    network_access: None,
318                    mcp_servers: Default::default(),
319                    is_built_in: false,
320                    status: HarnessStatus::Active,
321                    created_at: chrono::Utc::now(),
322                    updated_at: chrono::Utc::now(),
323                    archived_at: None,
324                    deleted_at: None,
325                },
326                agent: Agent {
327                    public_id: crate::typed_id::AgentId::new(),
328                    internal_id: uuid::Uuid::now_v7(),
329                    name: "test-agent".to_string(),
330                    display_name: Some("Test Agent".to_string()),
331                    description: Some("test agent".to_string()),
332                    system_prompt: "You are helpful.".to_string(),
333                    default_model_id: None,
334                    default_version_id: None,
335                    forked_from_agent_id: None,
336                    forked_from_version_id: None,
337                    root_agent_id: None,
338                    tags: vec![],
339                    capabilities: vec![],
340                    initial_files: vec![],
341                    network_access: None,
342                    max_iterations: None,
343                    tools: vec![],
344                    mcp_servers: Default::default(),
345                    status: AgentStatus::Active,
346                    created_at: chrono::Utc::now(),
347                    updated_at: chrono::Utc::now(),
348                    archived_at: None,
349                    deleted_at: None,
350                    usage: None,
351                },
352                app_channel: AppChannel {
353                    public_id: AppChannelId::new(),
354                    internal_id: uuid::Uuid::now_v7(),
355                    channel_type: ChannelType::Webhook,
356                    channel_config: serde_json::json!({
357                        "token": "secret-1",
358                        "session_mode": "shared_session",
359                        "message": "Run checks for {{payload.repo.name}}"
360                    }),
361                    enabled: true,
362                    created_at: chrono::Utc::now(),
363                    updated_at: chrono::Utc::now(),
364                },
365                app: App {
366                    public_id: AppId::new(),
367                    internal_id: uuid::Uuid::now_v7(),
368                    org_id: 1,
369                    name: "test-app".to_string(),
370                    description: Some("test app".to_string()),
371                    harness_id: HarnessId::new(),
372                    agent_id: Some(crate::typed_id::AgentId::new()),
373                    agent_version_policy: crate::app::AgentVersionPolicy::Default,
374                    agent_version_id: None,
375                    agent_identity_id: Some(crate::typed_id::AgentIdentityId::new()),
376                    owner_principal_id: crate::PrincipalId::from_seed(1),
377                    resolved_owner_user_id: None,
378                    owner: None,
379                    effective_owner: None,
380                    channels: vec![],
381                    status: AppStatus::Draft,
382                    published_at: None,
383                    created_at: chrono::Utc::now(),
384                    updated_at: chrono::Utc::now(),
385                    archived_at: None,
386                    deleted_at: None,
387                },
388                session: Session {
389                    id: SessionId::new(),
390                    organization_id: "org_00000000000000000000000000000001".to_string(),
391                    harness_id: HarnessId::new(),
392                    agent_id: None,
393                    agent_version_id: None,
394                    agent_identity_id: None,
395                    owner_principal_id: crate::PrincipalId::from_seed(1),
396                    resolved_owner_user_id: None,
397                    owner: None,
398                    effective_owner: None,
399                    title: Some("Test Session".to_string()),
400                    locale: None,
401                    preview: None,
402                    output_preview: None,
403                    tags: vec![],
404                    model_id: None,
405                    capabilities: vec![],
406                    tools: vec![],
407                    mcp_servers: Default::default(),
408                    system_prompt: None,
409                    initial_files: vec![],
410                    hints: None,
411                    network_access: None,
412                    max_iterations: None,
413                    status: SessionStatus::Idle,
414                    created_at: chrono::Utc::now(),
415                    updated_at: chrono::Utc::now(),
416                    started_at: None,
417                    finished_at: None,
418                    usage: None,
419                    is_pinned: None,
420                    active_schedule_count: None,
421                    features: vec![],
422                    parent_session_id: None,
423                    subagent_name: None,
424                    subagent_task: None,
425                    subagent_status: None,
426                    blueprint_id: None,
427                    blueprint_config: None,
428                },
429                created_session_harness_ids: std::sync::Mutex::new(Vec::new()),
430            }
431        }
432    }
433
434    #[async_trait]
435    impl PlatformStore for MockPlatformStore {
436        async fn list_harnesses(&self) -> Result<Vec<Harness>> {
437            Ok(vec![self.harness.clone()])
438        }
439        async fn get_harness(&self, _id: HarnessId) -> Result<Option<Harness>> {
440            Ok(Some(self.harness.clone()))
441        }
442        async fn create_harness(
443            &self,
444            name: &str,
445            display_name: Option<&str>,
446            _desc: Option<&str>,
447            _prompt: &str,
448            parent_harness_id: Option<HarnessId>,
449            _caps: &[String],
450        ) -> Result<Harness> {
451            let mut h = self.harness.clone();
452            h.name = name.to_string();
453            h.display_name = display_name.map(|s| s.to_string());
454            h.parent_harness_id = parent_harness_id;
455            Ok(h)
456        }
457        async fn update_harness(
458            &self,
459            _id: HarnessId,
460            name: Option<&str>,
461            display_name: Option<&str>,
462            _desc: Option<&str>,
463            _prompt: Option<&str>,
464            parent_harness_id: Option<Option<HarnessId>>,
465        ) -> Result<Harness> {
466            let mut h = self.harness.clone();
467            if let Some(n) = name {
468                h.name = n.to_string();
469            }
470            if let Some(dn) = display_name {
471                h.display_name = Some(dn.to_string());
472            }
473            if let Some(parent_harness_id) = parent_harness_id {
474                h.parent_harness_id = parent_harness_id;
475            }
476            Ok(h)
477        }
478        async fn delete_harness(&self, _id: HarnessId) -> Result<()> {
479            Ok(())
480        }
481        async fn copy_harness(&self, _id: HarnessId, new_name: Option<&str>) -> Result<Harness> {
482            let mut h = self.harness.clone();
483            h.id = HarnessId::new();
484            h.name = new_name.unwrap_or("copy").to_string();
485            Ok(h)
486        }
487        async fn list_agents(&self) -> Result<Vec<Agent>> {
488            Ok(vec![self.agent.clone()])
489        }
490        async fn get_agent_by_id(&self, _id: crate::typed_id::AgentId) -> Result<Option<Agent>> {
491            Ok(Some(self.agent.clone()))
492        }
493        async fn create_agent(
494            &self,
495            name: &str,
496            display_name: Option<&str>,
497            _desc: Option<&str>,
498            _prompt: &str,
499            _caps: &[String],
500        ) -> Result<Agent> {
501            let mut a = self.agent.clone();
502            a.name = name.to_string();
503            a.display_name = display_name.map(|s| s.to_string());
504            Ok(a)
505        }
506        async fn update_agent(
507            &self,
508            _id: crate::typed_id::AgentId,
509            name: Option<&str>,
510            display_name: Option<&str>,
511            _desc: Option<&str>,
512            _prompt: Option<&str>,
513        ) -> Result<Agent> {
514            let mut a = self.agent.clone();
515            if let Some(n) = name {
516                a.name = n.to_string();
517            }
518            if let Some(dn) = display_name {
519                a.display_name = Some(dn.to_string());
520            }
521            Ok(a)
522        }
523        async fn delete_agent(&self, _id: crate::typed_id::AgentId) -> Result<()> {
524            Ok(())
525        }
526        async fn list_apps(
527            &self,
528            _search: Option<&str>,
529            _include_archived: bool,
530        ) -> Result<Vec<App>> {
531            let mut app = self.app.clone();
532            app.channels = vec![self.app_channel.clone()];
533            Ok(vec![app])
534        }
535        async fn get_app(&self, _id: AppId) -> Result<Option<App>> {
536            let mut app = self.app.clone();
537            app.channels = vec![self.app_channel.clone()];
538            Ok(Some(app))
539        }
540        async fn create_app(
541            &self,
542            name: &str,
543            description: Option<&str>,
544            harness_id: HarnessId,
545            agent_id: Option<AgentId>,
546            agent_identity_id: Option<AgentIdentityId>,
547            channel_type: Option<ChannelType>,
548            channel_config: Option<&serde_json::Value>,
549        ) -> Result<App> {
550            let mut app = self.app.clone();
551            app.name = name.to_string();
552            app.description = description.map(|value| value.to_string());
553            app.harness_id = harness_id;
554            app.agent_id = agent_id;
555            app.agent_identity_id = agent_identity_id;
556            app.channels = channel_type
557                .map(|channel_type| {
558                    let mut channel = self.app_channel.clone();
559                    channel.channel_type = channel_type;
560                    if let Some(channel_config) = channel_config {
561                        channel.channel_config = channel_config.clone();
562                    }
563                    vec![channel]
564                })
565                .unwrap_or_default();
566            Ok(app)
567        }
568        async fn update_app(
569            &self,
570            _id: AppId,
571            name: Option<&str>,
572            description: Option<&str>,
573            harness_id: Option<HarnessId>,
574            agent_id: Option<AgentId>,
575            agent_identity_id: Option<Option<AgentIdentityId>>,
576        ) -> Result<App> {
577            let mut app = self.app.clone();
578            app.channels = vec![self.app_channel.clone()];
579            if let Some(name) = name {
580                app.name = name.to_string();
581            }
582            if let Some(description) = description {
583                app.description = Some(description.to_string());
584            }
585            if let Some(harness_id) = harness_id {
586                app.harness_id = harness_id;
587            }
588            if let Some(agent_id) = agent_id {
589                app.agent_id = Some(agent_id);
590            }
591            if let Some(agent_identity_id) = agent_identity_id {
592                app.agent_identity_id = agent_identity_id;
593            }
594            Ok(app)
595        }
596        async fn delete_app(&self, _id: AppId) -> Result<()> {
597            Ok(())
598        }
599        async fn destroy_app(&self, _id: AppId) -> Result<()> {
600            Ok(())
601        }
602        async fn publish_app(&self, _id: AppId) -> Result<App> {
603            let mut app = self.app.clone();
604            app.channels = vec![self.app_channel.clone()];
605            app.status = AppStatus::Published;
606            app.published_at = Some(chrono::Utc::now());
607            Ok(app)
608        }
609        async fn unpublish_app(&self, _id: AppId) -> Result<App> {
610            let mut app = self.app.clone();
611            app.channels = vec![self.app_channel.clone()];
612            app.status = AppStatus::Draft;
613            app.published_at = None;
614            Ok(app)
615        }
616        async fn add_app_channel(
617            &self,
618            _app_id: AppId,
619            channel_type: ChannelType,
620            channel_config: Option<&serde_json::Value>,
621            enabled: Option<bool>,
622        ) -> Result<AppChannel> {
623            let mut channel = self.app_channel.clone();
624            channel.channel_type = channel_type;
625            if let Some(channel_config) = channel_config {
626                channel.channel_config = channel_config.clone();
627            }
628            if let Some(enabled) = enabled {
629                channel.enabled = enabled;
630            }
631            Ok(channel)
632        }
633        async fn update_app_channel(
634            &self,
635            _app_id: AppId,
636            _channel_id: AppChannelId,
637            channel_type: Option<ChannelType>,
638            channel_config: Option<&serde_json::Value>,
639            enabled: Option<bool>,
640        ) -> Result<AppChannel> {
641            let mut channel = self.app_channel.clone();
642            if let Some(channel_type) = channel_type {
643                channel.channel_type = channel_type;
644            }
645            if let Some(channel_config) = channel_config {
646                channel.channel_config = channel_config.clone();
647            }
648            if let Some(enabled) = enabled {
649                channel.enabled = enabled;
650            }
651            Ok(channel)
652        }
653        async fn delete_app_channel(
654            &self,
655            _app_id: AppId,
656            _channel_id: AppChannelId,
657        ) -> Result<()> {
658            Ok(())
659        }
660        async fn list_sessions(
661            &self,
662            _limit: Option<usize>,
663            _agent_id: Option<crate::typed_id::AgentId>,
664        ) -> Result<Vec<Session>> {
665            Ok(vec![self.session.clone()])
666        }
667        async fn create_session(
668            &self,
669            hid: HarnessId,
670            aid: Option<crate::typed_id::AgentId>,
671            title: Option<&str>,
672            locale: Option<&str>,
673            blueprint_id: Option<&str>,
674            blueprint_config: Option<&serde_json::Value>,
675        ) -> Result<Session> {
676            if let Ok(mut recorder) = self.created_session_harness_ids.lock() {
677                recorder.push(hid);
678            }
679            let mut s = self.session.clone();
680            s.id = SessionId::new();
681            s.harness_id = hid;
682            s.agent_id = aid;
683            s.title = title.map(|t| t.to_string());
684            s.locale = locale.map(|value| value.to_string());
685            s.blueprint_id = blueprint_id.map(|b| b.to_string());
686            s.blueprint_config = blueprint_config.cloned();
687            Ok(s)
688        }
689        async fn get_session_by_id(&self, _id: SessionId) -> Result<Option<Session>> {
690            Ok(Some(self.session.clone()))
691        }
692        async fn get_session_context_report(&self, id: SessionId) -> Result<SessionContextReport> {
693            Ok(SessionContextReport {
694                session_id: id.to_string(),
695                model: "llmsim".to_string(),
696                context_window_tokens: Some(128_000),
697                estimated_input_tokens: 42,
698                sections: vec![crate::ContextReportSection {
699                    key: "conversation".to_string(),
700                    label: "Conversation".to_string(),
701                    tokens: 42,
702                    items: 1,
703                }],
704                contributions: vec![],
705                cumulative_usage: None,
706            })
707        }
708        async fn set_subagent_metadata(
709            &self,
710            session_id: SessionId,
711            parent_session_id: SessionId,
712            subagent_name: &str,
713            subagent_task: &str,
714            subagent_status: crate::session::SubagentStatus,
715        ) -> Result<Session> {
716            let mut s = self.session.clone();
717            s.id = session_id;
718            s.parent_session_id = Some(parent_session_id);
719            s.subagent_name = Some(subagent_name.to_string());
720            s.subagent_task = Some(subagent_task.to_string());
721            s.subagent_status = Some(subagent_status);
722            Ok(s)
723        }
724        async fn delete_session(&self, _id: SessionId) -> Result<()> {
725            Ok(())
726        }
727        async fn send_message(&self, _id: SessionId, _content: &str) -> Result<()> {
728            Ok(())
729        }
730        async fn get_messages(
731            &self,
732            _id: SessionId,
733            _limit: Option<usize>,
734        ) -> Result<Vec<PlatformMessage>> {
735            Ok(vec![
736                PlatformMessage {
737                    role: "user".into(),
738                    content: "Hello".into(),
739                    created_at: chrono::Utc::now(),
740                },
741                PlatformMessage {
742                    role: "agent".into(),
743                    content: "Hi!".into(),
744                    created_at: chrono::Utc::now(),
745                },
746            ])
747        }
748        async fn wait_for_idle(&self, _id: SessionId, _t: Option<u64>) -> Result<String> {
749            Ok("idle".to_string())
750        }
751        async fn list_capabilities(&self, search: Option<&str>) -> Result<Vec<CapabilityInfo>> {
752            let registry = crate::capabilities::CapabilityRegistry::with_builtins();
753            let mut caps: Vec<CapabilityInfo> = registry
754                .list()
755                .iter()
756                .map(|c| CapabilityInfo::from_core(c.as_ref()))
757                .collect();
758            if let Some(q) = search {
759                caps.retain(|c| c.matches_search(q));
760            }
761            caps.sort_by(|a, b| a.name.cmp(&b.name));
762            Ok(caps)
763        }
764        fn base_url(&self) -> &str {
765            "http://localhost:9300"
766        }
767    }
768}