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, SessionParticipant, SessionSeedMode};
15use crate::typed_id::{AgentId, AgentIdentityId, AppChannelId, AppId, HarnessId, SessionId};
16use async_trait::async_trait;
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use std::collections::HashSet;
20
21/// Simplified message representation for platform management tools.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PlatformMessage {
24    pub role: String,
25    pub content: String,
26    pub created_at: DateTime<Utc>,
27}
28
29/// Options for platform-backed session creation from model-facing tools.
30#[derive(Debug, Clone)]
31pub struct PlatformCreateSessionRequest {
32    pub harness_id: HarnessId,
33    pub agent_id: Option<AgentId>,
34    pub title: Option<String>,
35    pub goal: Option<String>,
36    pub locale: Option<String>,
37    pub blueprint_id: Option<String>,
38    pub blueprint_config: Option<serde_json::Value>,
39    pub parent_session_id: Option<SessionId>,
40    pub forked_from_session_id: Option<SessionId>,
41    /// Internal-only override for the budget/delegation root. Detached spawns
42    /// set this explicitly; ordinary forks must leave it unset.
43    pub budget_root_session_id: Option<SessionId>,
44    pub seed: SessionSeedMode,
45}
46
47/// Trait for platform-level management operations.
48///
49/// Provides org-scoped CRUD for harnesses, agents, and sessions,
50/// plus session messaging and turn management. Used by the
51/// `platform_management` capability tools.
52#[async_trait]
53pub trait PlatformStore: Send + Sync {
54    // =========================================================================
55    // Harness Operations
56    // =========================================================================
57
58    /// List all harnesses in the organization.
59    async fn list_harnesses(&self) -> Result<Vec<Harness>>;
60
61    /// Get a harness by ID.
62    async fn get_harness(&self, id: HarnessId) -> Result<Option<Harness>>;
63
64    /// Get the effective harness chain from root to the requested harness.
65    ///
66    /// Platform management lookups return the raw harness row. Runtime assembly
67    /// applies inherited parent harnesses first, so security-sensitive
68    /// compatibility checks must use this chain rather than a single raw row.
69    async fn get_harness_chain(&self, id: HarnessId) -> Result<Vec<Harness>> {
70        let mut chain = Vec::new();
71        let mut current_id = Some(id);
72        let mut seen = HashSet::new();
73
74        while let Some(harness_id) = current_id {
75            if !seen.insert(harness_id) {
76                return Err(crate::error::AgentLoopError::tool(format!(
77                    "Harness inheritance cycle detected at {harness_id}"
78                )));
79            }
80            let Some(harness) = self.get_harness(harness_id).await? else {
81                return Ok(Vec::new());
82            };
83            current_id = harness.parent_harness_id;
84            chain.push(harness);
85        }
86
87        chain.reverse();
88        Ok(chain)
89    }
90
91    /// Create a new harness.
92    ///
93    /// `system_prompt` is optional: `None` creates a harness with no base
94    /// prompt of its own (it inherits from the parent harness, if any, and
95    /// composes with agent/session/capability layers at runtime).
96    async fn create_harness(
97        &self,
98        name: &str,
99        display_name: Option<&str>,
100        description: Option<&str>,
101        system_prompt: Option<&str>,
102        parent_harness_id: Option<HarnessId>,
103        capabilities: &[String],
104    ) -> Result<Harness>;
105
106    /// Update a harness (only provided fields are changed).
107    async fn update_harness(
108        &self,
109        id: HarnessId,
110        name: Option<&str>,
111        display_name: Option<&str>,
112        description: Option<&str>,
113        system_prompt: Option<&str>,
114        parent_harness_id: Option<Option<HarnessId>>,
115    ) -> Result<Harness>;
116
117    /// Delete (archive) a harness.
118    async fn delete_harness(&self, id: HarnessId) -> Result<()>;
119
120    /// Copy a harness, optionally with a new name.
121    async fn copy_harness(&self, id: HarnessId, new_name: Option<&str>) -> Result<Harness>;
122
123    // =========================================================================
124    // Agent Operations
125    // =========================================================================
126
127    /// List all agents in the organization.
128    async fn list_agents(&self) -> Result<Vec<Agent>>;
129
130    /// Get an agent by public ID.
131    async fn get_agent_by_id(&self, id: AgentId) -> Result<Option<Agent>>;
132
133    /// Create a new agent.
134    async fn create_agent(
135        &self,
136        name: &str,
137        display_name: Option<&str>,
138        description: Option<&str>,
139        system_prompt: &str,
140        capabilities: &[String],
141    ) -> Result<Agent>;
142
143    /// Update an agent (only provided fields are changed).
144    async fn update_agent(
145        &self,
146        id: AgentId,
147        name: Option<&str>,
148        display_name: Option<&str>,
149        description: Option<&str>,
150        system_prompt: Option<&str>,
151    ) -> Result<Agent>;
152
153    /// Delete (archive) an agent.
154    async fn delete_agent(&self, id: AgentId) -> Result<()>;
155
156    // =========================================================================
157    // App Operations
158    // =========================================================================
159
160    /// List all apps in the organization.
161    async fn list_apps(&self, search: Option<&str>, include_archived: bool) -> Result<Vec<App>>;
162
163    /// Get an app by ID.
164    async fn get_app(&self, id: AppId) -> Result<Option<App>>;
165
166    /// Create a new app.
167    #[allow(clippy::too_many_arguments)]
168    async fn create_app(
169        &self,
170        name: &str,
171        description: Option<&str>,
172        harness_id: HarnessId,
173        agent_id: Option<AgentId>,
174        agent_identity_id: Option<AgentIdentityId>,
175        channel_type: Option<ChannelType>,
176        channel_config: Option<&serde_json::Value>,
177    ) -> Result<App>;
178
179    /// Update an app (only provided fields are changed).
180    #[allow(clippy::too_many_arguments)]
181    async fn update_app(
182        &self,
183        id: AppId,
184        name: Option<&str>,
185        description: Option<&str>,
186        harness_id: Option<HarnessId>,
187        agent_id: Option<AgentId>,
188        agent_identity_id: Option<Option<AgentIdentityId>>,
189    ) -> Result<App>;
190
191    /// Archive an app.
192    async fn delete_app(&self, id: AppId) -> Result<()>;
193
194    /// Permanently destroy an archived app.
195    async fn destroy_app(&self, id: AppId) -> Result<()>;
196
197    /// Publish an app.
198    async fn publish_app(&self, id: AppId) -> Result<App>;
199
200    /// Unpublish an app back to draft.
201    async fn unpublish_app(&self, id: AppId) -> Result<App>;
202
203    /// Add a channel to an app.
204    async fn add_app_channel(
205        &self,
206        app_id: AppId,
207        channel_type: ChannelType,
208        channel_config: Option<&serde_json::Value>,
209        enabled: Option<bool>,
210    ) -> Result<AppChannel>;
211
212    /// Update a channel on an app.
213    async fn update_app_channel(
214        &self,
215        app_id: AppId,
216        channel_id: AppChannelId,
217        channel_type: Option<ChannelType>,
218        channel_config: Option<&serde_json::Value>,
219        enabled: Option<bool>,
220    ) -> Result<AppChannel>;
221
222    /// Delete a channel from an app.
223    async fn delete_app_channel(&self, app_id: AppId, channel_id: AppChannelId) -> Result<()>;
224
225    // =========================================================================
226    // Session Operations
227    // =========================================================================
228
229    /// List sessions, optionally filtered by agent.
230    async fn list_sessions(
231        &self,
232        limit: Option<usize>,
233        agent_id: Option<AgentId>,
234    ) -> Result<Vec<Session>>;
235
236    /// Create a new session.
237    ///
238    /// When `blueprint_id` is set, the session runs a blueprint agent instead
239    /// of inheriting from `harness_id`/`agent_id`. `blueprint_config` is
240    /// validated config for the blueprint (JSON, optional).
241    /// `parent_session_id` links child subagent/handoff sessions to their parent
242    /// (used to compute governed subagent delegation depth).
243    #[allow(clippy::too_many_arguments)]
244    async fn create_session(
245        &self,
246        harness_id: HarnessId,
247        agent_id: Option<AgentId>,
248        title: Option<&str>,
249        locale: Option<&str>,
250        blueprint_id: Option<&str>,
251        blueprint_config: Option<&serde_json::Value>,
252        parent_session_id: Option<SessionId>,
253    ) -> Result<Session>;
254
255    async fn create_session_with_options(
256        &self,
257        request: PlatformCreateSessionRequest,
258    ) -> Result<Session> {
259        if request.goal.is_some()
260            || request.forked_from_session_id.is_some()
261            || request.budget_root_session_id.is_some()
262            || request.seed != SessionSeedMode::Fresh
263        {
264            return Err(crate::error::AgentLoopError::tool(
265                "platform store does not support goal, lineage, budget-root override, or seeded session creation",
266            ));
267        }
268        self.create_session(
269            request.harness_id,
270            request.agent_id,
271            request.title.as_deref(),
272            request.locale.as_deref(),
273            request.blueprint_id.as_deref(),
274            request.blueprint_config.as_ref(),
275            request.parent_session_id,
276        )
277        .await
278    }
279
280    /// Get a session by ID.
281    async fn get_session_by_id(&self, id: SessionId) -> Result<Option<Session>>;
282
283    /// Add an agent as a member participant in an existing session.
284    async fn add_agent_session_participant(
285        &self,
286        session_id: SessionId,
287        agent_id: AgentId,
288    ) -> Result<SessionParticipant>;
289
290    /// Get the latest estimated context breakdown for a session.
291    async fn get_session_context_report(&self, id: SessionId) -> Result<SessionContextReport>;
292
293    /// Delete (archive) a session.
294    async fn delete_session(&self, id: SessionId) -> Result<()>;
295
296    // =========================================================================
297    // Messaging
298    // =========================================================================
299
300    /// Send a user message to a session, triggering a turn.
301    async fn send_message(&self, session_id: SessionId, content: &str) -> Result<()>;
302
303    /// Get messages from a session (most recent first).
304    /// Default limit is 10.
305    async fn get_messages(
306        &self,
307        session_id: SessionId,
308        limit: Option<usize>,
309    ) -> Result<Vec<PlatformMessage>>;
310
311    // =========================================================================
312    // Turn Management
313    // =========================================================================
314
315    /// Wait for a session to become idle (turn completed).
316    /// Returns the final session status as a string.
317    /// Default timeout is 120 seconds.
318    async fn wait_for_idle(
319        &self,
320        session_id: SessionId,
321        timeout_secs: Option<u64>,
322    ) -> Result<String>;
323
324    // =========================================================================
325    // Capabilities
326    // =========================================================================
327
328    /// List all available capabilities (built-in + MCP servers + skills).
329    ///
330    /// Optionally filter by a search query (case-insensitive match against
331    /// name, description, category, and capability ID).
332    async fn list_capabilities(&self, search: Option<&str>) -> Result<Vec<CapabilityInfo>>;
333
334    // =========================================================================
335    // UI Links
336    // =========================================================================
337
338    /// Base URL for constructing UI links (e.g., "http://localhost:9300").
339    fn base_url(&self) -> &str;
340}
341
342#[cfg(test)]
343pub mod tests {
344    use super::*;
345    use crate::AgentCapabilityConfig;
346    use crate::agent::{Agent, AgentStatus};
347    use crate::app::{App, AppChannel, AppStatus, ChannelType};
348    use crate::harness::{Harness, HarnessStatus};
349    use crate::session::{Session, SessionStatus};
350
351    /// Mock PlatformStore for unit tests.
352    ///
353    /// Shared across test modules so that any test exercising
354    /// platform management tools (directly or via ActAtom) uses
355    /// the same mock. This prevents wiring bugs where a tool is
356    /// registered but the store is not passed through.
357    pub struct MockPlatformStore {
358        pub harness: Harness,
359        pub extra_harnesses: std::sync::Mutex<std::collections::HashMap<HarnessId, Harness>>,
360        pub agent: Agent,
361        pub app: App,
362        pub app_channel: AppChannel,
363        pub session: Session,
364        pub extra_sessions: std::sync::Mutex<std::collections::HashMap<SessionId, Session>>,
365        pub joined_participants: std::sync::Mutex<Vec<SessionParticipant>>,
366        /// Records the `harness_id` argument of every `create_session`
367        /// call so tests can assert which harness a child session was
368        /// created against. See `start_handoff_uses_target_harness_not_parent`.
369        pub created_session_harness_ids: std::sync::Mutex<Vec<HarnessId>>,
370        /// Records internal budget-root overrides supplied to session creation.
371        pub created_session_budget_roots: std::sync::Mutex<Vec<Option<SessionId>>>,
372        /// Status returned by `wait_for_idle` ("idle" by default). Tests set
373        /// a terminal turn status (e.g. "completed") to exercise settle paths.
374        pub wait_for_idle_status: std::sync::Mutex<String>,
375        /// Records every `send_message` call as `(session_id, content)` so
376        /// tests can assert which session was signaled (e.g. a detached peer
377        /// receiving a cooperative-cancel message).
378        pub sent_messages: std::sync::Mutex<Vec<(SessionId, String)>>,
379    }
380
381    impl Default for MockPlatformStore {
382        fn default() -> Self {
383            Self::new()
384        }
385    }
386
387    impl MockPlatformStore {
388        pub fn new() -> Self {
389            Self {
390                harness: Harness {
391                    id: HarnessId::new(),
392                    name: "test-harness".to_string(),
393                    display_name: Some("Test Harness".to_string()),
394                    description: Some("test harness".to_string()),
395                    system_prompt: Some("You are helpful.".to_string()),
396                    parent_harness_id: None,
397                    default_model_id: None,
398                    tags: vec![],
399                    capabilities: vec![AgentCapabilityConfig::new("session")],
400                    initial_files: vec![],
401                    network_access: None,
402                    parallel_tool_calls: None,
403                    mcp_servers: Default::default(),
404                    embedder_metadata: Default::default(),
405                    is_built_in: false,
406                    status: HarnessStatus::Active,
407                    created_at: chrono::Utc::now(),
408                    updated_at: chrono::Utc::now(),
409                    archived_at: None,
410                    deleted_at: None,
411                },
412                extra_harnesses: std::sync::Mutex::new(std::collections::HashMap::new()),
413                agent: Agent {
414                    public_id: crate::typed_id::AgentId::new(),
415                    internal_id: uuid::Uuid::now_v7(),
416                    name: "test-agent".to_string(),
417                    display_name: Some("Test Agent".to_string()),
418                    description: Some("test agent".to_string()),
419                    system_prompt: "You are helpful.".to_string(),
420                    default_model_id: None,
421
422                    harness_id: crate::typed_id::HarnessId::from_uuid(uuid::Uuid::nil()),
423                    default_version_id: None,
424                    forked_from_agent_id: None,
425                    forked_from_version_id: None,
426                    root_agent_id: None,
427                    tags: vec![],
428                    capabilities: vec![],
429                    initial_files: vec![],
430                    network_access: None,
431                    max_iterations: None,
432                    parallel_tool_calls: None,
433                    tools: vec![],
434                    mcp_servers: Default::default(),
435                    status: AgentStatus::Active,
436                    created_at: chrono::Utc::now(),
437                    updated_at: chrono::Utc::now(),
438                    archived_at: None,
439                    deleted_at: None,
440                    usage: None,
441                },
442                app_channel: AppChannel {
443                    public_id: AppChannelId::new(),
444                    internal_id: uuid::Uuid::now_v7(),
445                    channel_type: ChannelType::Webhook,
446                    channel_config: serde_json::json!({
447                        "token": "secret-1",
448                        "session_mode": "shared_session",
449                        "message": "Run checks for {{payload.repo.name}}"
450                    }),
451                    enabled: true,
452                    created_at: chrono::Utc::now(),
453                    updated_at: chrono::Utc::now(),
454                },
455                app: App {
456                    public_id: AppId::new(),
457                    internal_id: uuid::Uuid::now_v7(),
458                    org_id: 1,
459                    name: "test-app".to_string(),
460                    description: Some("test app".to_string()),
461                    harness_id: HarnessId::new(),
462                    agent_id: Some(crate::typed_id::AgentId::new()),
463                    agent_version_policy: crate::app::AgentVersionPolicy::Default,
464                    agent_version_id: None,
465                    agent_identity_id: Some(crate::typed_id::AgentIdentityId::new()),
466                    owner_principal_id: crate::PrincipalId::from_seed(1),
467                    resolved_owner_user_id: None,
468                    owner: None,
469                    effective_owner: None,
470                    channels: vec![],
471                    status: AppStatus::Draft,
472                    published_at: None,
473                    created_at: chrono::Utc::now(),
474                    updated_at: chrono::Utc::now(),
475                    archived_at: None,
476                    deleted_at: None,
477                },
478                session: {
479                    let session_id = SessionId::new();
480                    Session {
481                        // Default 1:1 session<->workspace: workspace.id mirrors the session id.
482                        id: session_id,
483                        workspace_id: crate::WorkspaceId::from_uuid(session_id.uuid()),
484                        organization_id: "org_00000000000000000000000000000001".to_string(),
485                        harness_id: HarnessId::new(),
486                        agent_id: None,
487                        agent_version_id: None,
488                        agent_identity_id: None,
489                        owner_principal_id: crate::PrincipalId::from_seed(1),
490                        resolved_owner_user_id: None,
491                        owner: None,
492                        effective_owner: None,
493                        title: Some("Test Session".to_string()),
494                        goal: None,
495                        locale: None,
496                        preview: None,
497                        output_preview: None,
498                        tags: vec![],
499                        model_id: None,
500                        capabilities: vec![],
501                        tools: vec![],
502                        mcp_servers: Default::default(),
503                        system_prompt: None,
504                        initial_files: vec![],
505                        hints: None,
506                        network_access: None,
507                        max_iterations: None,
508                        parallel_tool_calls: None,
509                        status: SessionStatus::Idle,
510                        created_at: chrono::Utc::now(),
511                        updated_at: chrono::Utc::now(),
512                        started_at: None,
513                        finished_at: None,
514                        usage: None,
515                        is_pinned: None,
516                        active_schedule_count: None,
517                        features: vec![],
518                        parent_session_id: None,
519                        forked_from_session_id: None,
520                        forked_from_sequence: None,
521                        blueprint_id: None,
522                        blueprint_config: None,
523                    }
524                },
525                extra_sessions: std::sync::Mutex::new(std::collections::HashMap::new()),
526                joined_participants: std::sync::Mutex::new(Vec::new()),
527                created_session_harness_ids: std::sync::Mutex::new(Vec::new()),
528                created_session_budget_roots: std::sync::Mutex::new(Vec::new()),
529                wait_for_idle_status: std::sync::Mutex::new("idle".to_string()),
530                sent_messages: std::sync::Mutex::new(Vec::new()),
531            }
532        }
533    }
534
535    #[async_trait]
536    impl PlatformStore for MockPlatformStore {
537        async fn list_harnesses(&self) -> Result<Vec<Harness>> {
538            Ok(vec![self.harness.clone()])
539        }
540        async fn get_harness(&self, id: HarnessId) -> Result<Option<Harness>> {
541            if let Some(harness) = self.extra_harnesses.lock().unwrap().get(&id).cloned() {
542                return Ok(Some(harness));
543            }
544            Ok(Some(self.harness.clone()))
545        }
546        async fn create_harness(
547            &self,
548            name: &str,
549            display_name: Option<&str>,
550            _desc: Option<&str>,
551            _prompt: Option<&str>,
552            parent_harness_id: Option<HarnessId>,
553            _caps: &[String],
554        ) -> Result<Harness> {
555            let mut h = self.harness.clone();
556            h.name = name.to_string();
557            h.display_name = display_name.map(|s| s.to_string());
558            h.parent_harness_id = parent_harness_id;
559            Ok(h)
560        }
561        async fn update_harness(
562            &self,
563            _id: HarnessId,
564            name: Option<&str>,
565            display_name: Option<&str>,
566            _desc: Option<&str>,
567            _prompt: Option<&str>,
568            parent_harness_id: Option<Option<HarnessId>>,
569        ) -> Result<Harness> {
570            let mut h = self.harness.clone();
571            if let Some(n) = name {
572                h.name = n.to_string();
573            }
574            if let Some(dn) = display_name {
575                h.display_name = Some(dn.to_string());
576            }
577            if let Some(parent_harness_id) = parent_harness_id {
578                h.parent_harness_id = parent_harness_id;
579            }
580            Ok(h)
581        }
582        async fn delete_harness(&self, _id: HarnessId) -> Result<()> {
583            Ok(())
584        }
585        async fn copy_harness(&self, _id: HarnessId, new_name: Option<&str>) -> Result<Harness> {
586            let mut h = self.harness.clone();
587            h.id = HarnessId::new();
588            h.name = new_name.unwrap_or("copy").to_string();
589            Ok(h)
590        }
591        async fn list_agents(&self) -> Result<Vec<Agent>> {
592            Ok(vec![self.agent.clone()])
593        }
594        async fn get_agent_by_id(&self, _id: crate::typed_id::AgentId) -> Result<Option<Agent>> {
595            Ok(Some(self.agent.clone()))
596        }
597        async fn create_agent(
598            &self,
599            name: &str,
600            display_name: Option<&str>,
601            _desc: Option<&str>,
602            _prompt: &str,
603            _caps: &[String],
604        ) -> Result<Agent> {
605            let mut a = self.agent.clone();
606            a.name = name.to_string();
607            a.display_name = display_name.map(|s| s.to_string());
608            Ok(a)
609        }
610        async fn update_agent(
611            &self,
612            _id: crate::typed_id::AgentId,
613            name: Option<&str>,
614            display_name: Option<&str>,
615            _desc: Option<&str>,
616            _prompt: Option<&str>,
617        ) -> Result<Agent> {
618            let mut a = self.agent.clone();
619            if let Some(n) = name {
620                a.name = n.to_string();
621            }
622            if let Some(dn) = display_name {
623                a.display_name = Some(dn.to_string());
624            }
625            Ok(a)
626        }
627        async fn delete_agent(&self, _id: crate::typed_id::AgentId) -> Result<()> {
628            Ok(())
629        }
630        async fn list_apps(
631            &self,
632            _search: Option<&str>,
633            _include_archived: bool,
634        ) -> Result<Vec<App>> {
635            let mut app = self.app.clone();
636            app.channels = vec![self.app_channel.clone()];
637            Ok(vec![app])
638        }
639        async fn get_app(&self, _id: AppId) -> Result<Option<App>> {
640            let mut app = self.app.clone();
641            app.channels = vec![self.app_channel.clone()];
642            Ok(Some(app))
643        }
644        async fn create_app(
645            &self,
646            name: &str,
647            description: Option<&str>,
648            harness_id: HarnessId,
649            agent_id: Option<AgentId>,
650            agent_identity_id: Option<AgentIdentityId>,
651            channel_type: Option<ChannelType>,
652            channel_config: Option<&serde_json::Value>,
653        ) -> Result<App> {
654            let mut app = self.app.clone();
655            app.name = name.to_string();
656            app.description = description.map(|value| value.to_string());
657            app.harness_id = harness_id;
658            app.agent_id = agent_id;
659            app.agent_identity_id = agent_identity_id;
660            app.channels = channel_type
661                .map(|channel_type| {
662                    let mut channel = self.app_channel.clone();
663                    channel.channel_type = channel_type;
664                    if let Some(channel_config) = channel_config {
665                        channel.channel_config = channel_config.clone();
666                    }
667                    vec![channel]
668                })
669                .unwrap_or_default();
670            Ok(app)
671        }
672        async fn update_app(
673            &self,
674            _id: AppId,
675            name: Option<&str>,
676            description: Option<&str>,
677            harness_id: Option<HarnessId>,
678            agent_id: Option<AgentId>,
679            agent_identity_id: Option<Option<AgentIdentityId>>,
680        ) -> Result<App> {
681            let mut app = self.app.clone();
682            app.channels = vec![self.app_channel.clone()];
683            if let Some(name) = name {
684                app.name = name.to_string();
685            }
686            if let Some(description) = description {
687                app.description = Some(description.to_string());
688            }
689            if let Some(harness_id) = harness_id {
690                app.harness_id = harness_id;
691            }
692            if let Some(agent_id) = agent_id {
693                app.agent_id = Some(agent_id);
694            }
695            if let Some(agent_identity_id) = agent_identity_id {
696                app.agent_identity_id = agent_identity_id;
697            }
698            Ok(app)
699        }
700        async fn delete_app(&self, _id: AppId) -> Result<()> {
701            Ok(())
702        }
703        async fn destroy_app(&self, _id: AppId) -> Result<()> {
704            Ok(())
705        }
706        async fn publish_app(&self, _id: AppId) -> Result<App> {
707            let mut app = self.app.clone();
708            app.channels = vec![self.app_channel.clone()];
709            app.status = AppStatus::Published;
710            app.published_at = Some(chrono::Utc::now());
711            Ok(app)
712        }
713        async fn unpublish_app(&self, _id: AppId) -> Result<App> {
714            let mut app = self.app.clone();
715            app.channels = vec![self.app_channel.clone()];
716            app.status = AppStatus::Draft;
717            app.published_at = None;
718            Ok(app)
719        }
720        async fn add_app_channel(
721            &self,
722            _app_id: AppId,
723            channel_type: ChannelType,
724            channel_config: Option<&serde_json::Value>,
725            enabled: Option<bool>,
726        ) -> Result<AppChannel> {
727            let mut channel = self.app_channel.clone();
728            channel.channel_type = channel_type;
729            if let Some(channel_config) = channel_config {
730                channel.channel_config = channel_config.clone();
731            }
732            if let Some(enabled) = enabled {
733                channel.enabled = enabled;
734            }
735            Ok(channel)
736        }
737        async fn update_app_channel(
738            &self,
739            _app_id: AppId,
740            _channel_id: AppChannelId,
741            channel_type: Option<ChannelType>,
742            channel_config: Option<&serde_json::Value>,
743            enabled: Option<bool>,
744        ) -> Result<AppChannel> {
745            let mut channel = self.app_channel.clone();
746            if let Some(channel_type) = channel_type {
747                channel.channel_type = channel_type;
748            }
749            if let Some(channel_config) = channel_config {
750                channel.channel_config = channel_config.clone();
751            }
752            if let Some(enabled) = enabled {
753                channel.enabled = enabled;
754            }
755            Ok(channel)
756        }
757        async fn delete_app_channel(
758            &self,
759            _app_id: AppId,
760            _channel_id: AppChannelId,
761        ) -> Result<()> {
762            Ok(())
763        }
764        async fn list_sessions(
765            &self,
766            _limit: Option<usize>,
767            _agent_id: Option<crate::typed_id::AgentId>,
768        ) -> Result<Vec<Session>> {
769            Ok(vec![self.session.clone()])
770        }
771        async fn create_session(
772            &self,
773            hid: HarnessId,
774            aid: Option<crate::typed_id::AgentId>,
775            title: Option<&str>,
776            locale: Option<&str>,
777            blueprint_id: Option<&str>,
778            blueprint_config: Option<&serde_json::Value>,
779            parent_session_id: Option<SessionId>,
780        ) -> Result<Session> {
781            if let Ok(mut recorder) = self.created_session_harness_ids.lock() {
782                recorder.push(hid);
783            }
784            let mut s = self.session.clone();
785            s.id = SessionId::new();
786            s.harness_id = hid;
787            s.agent_id = aid;
788            s.title = title.map(|t| t.to_string());
789            s.locale = locale.map(|value| value.to_string());
790            s.blueprint_id = blueprint_id.map(|b| b.to_string());
791            s.blueprint_config = blueprint_config.cloned();
792            s.parent_session_id = parent_session_id;
793            if let Ok(mut sessions) = self.extra_sessions.lock() {
794                sessions.insert(s.id, s.clone());
795            }
796            Ok(s)
797        }
798        async fn create_session_with_options(
799            &self,
800            request: PlatformCreateSessionRequest,
801        ) -> Result<Session> {
802            self.created_session_budget_roots
803                .lock()
804                .expect("budget root recorder")
805                .push(request.budget_root_session_id);
806            let mut session = self
807                .create_session(
808                    request.harness_id,
809                    request.agent_id,
810                    request.title.as_deref(),
811                    request.locale.as_deref(),
812                    request.blueprint_id.as_deref(),
813                    request.blueprint_config.as_ref(),
814                    request.parent_session_id,
815                )
816                .await?;
817            session.goal = request.goal;
818            session.forked_from_session_id = request.forked_from_session_id;
819            if let Ok(mut sessions) = self.extra_sessions.lock() {
820                sessions.insert(session.id, session.clone());
821            }
822            Ok(session)
823        }
824        async fn get_session_by_id(&self, id: SessionId) -> Result<Option<Session>> {
825            if id == self.session.id {
826                return Ok(Some(self.session.clone()));
827            }
828            if let Some(session) = self
829                .extra_sessions
830                .lock()
831                .ok()
832                .and_then(|sessions| sessions.get(&id).cloned())
833            {
834                return Ok(Some(session));
835            }
836            Ok(Some(self.session.clone()))
837        }
838        async fn add_agent_session_participant(
839            &self,
840            session_id: SessionId,
841            agent_id: AgentId,
842        ) -> Result<SessionParticipant> {
843            let participant = SessionParticipant {
844                id: crate::typed_id::SessionParticipantId::new(),
845                session_id,
846                kind: crate::session::SessionParticipantKind::Agent,
847                agent_id: Some(agent_id),
848                agent_version_id: self.agent.default_version_id,
849                principal_id: self.session.owner_principal_id,
850                role: crate::session::SessionParticipantRole::Member,
851                joined_at: chrono::Utc::now(),
852                left_at: None,
853            };
854            if let Ok(mut participants) = self.joined_participants.lock() {
855                participants.push(participant.clone());
856            }
857            Ok(participant)
858        }
859        async fn get_session_context_report(&self, id: SessionId) -> Result<SessionContextReport> {
860            Ok(SessionContextReport {
861                session_id: id.to_string(),
862                model: "llmsim".to_string(),
863                context_window_tokens: Some(128_000),
864                estimated_input_tokens: 42,
865                sections: vec![crate::ContextReportSection {
866                    key: "conversation".to_string(),
867                    label: "Conversation".to_string(),
868                    tokens: 42,
869                    items: 1,
870                }],
871                contributions: vec![],
872                cumulative_usage: None,
873            })
874        }
875        async fn delete_session(&self, _id: SessionId) -> Result<()> {
876            Ok(())
877        }
878        async fn send_message(&self, id: SessionId, content: &str) -> Result<()> {
879            self.sent_messages
880                .lock()
881                .unwrap()
882                .push((id, content.to_string()));
883            Ok(())
884        }
885        async fn get_messages(
886            &self,
887            _id: SessionId,
888            _limit: Option<usize>,
889        ) -> Result<Vec<PlatformMessage>> {
890            Ok(vec![
891                PlatformMessage {
892                    role: "user".into(),
893                    content: "Hello".into(),
894                    created_at: chrono::Utc::now(),
895                },
896                PlatformMessage {
897                    role: "agent".into(),
898                    content: "Hi!".into(),
899                    created_at: chrono::Utc::now(),
900                },
901            ])
902        }
903        async fn wait_for_idle(&self, _id: SessionId, _t: Option<u64>) -> Result<String> {
904            Ok(self.wait_for_idle_status.lock().unwrap().clone())
905        }
906        async fn list_capabilities(&self, search: Option<&str>) -> Result<Vec<CapabilityInfo>> {
907            let registry = crate::capabilities::CapabilityRegistry::with_builtins();
908            let mut caps: Vec<CapabilityInfo> = registry
909                .list()
910                .iter()
911                .map(|c| CapabilityInfo::from_core(c.as_ref()))
912                .collect();
913            if let Some(q) = search {
914                caps.retain(|c| c.matches_search(q));
915            }
916            caps.sort_by(|a, b| a.name.cmp(&b.name));
917            Ok(caps)
918        }
919        fn base_url(&self) -> &str {
920            "http://localhost:9300"
921        }
922    }
923}