Skip to main content

everruns_core/
traits.rs

1// Core traits for pluggable backends
2//
3// These traits allow the agent loop to be used with different backends:
4// - In-memory implementations for examples and testing
5// - Database implementations for production
6// - Channel-based implementations for streaming
7
8use crate::agent::Agent;
9use crate::harness::Harness;
10use crate::provider::DriverId;
11use crate::session_file::{FileInfo, FileStat, GrepMatch, InitialFile, SessionFile};
12use crate::tool_types::{ToolCall, ToolDefinition, ToolResult};
13use crate::typed_id::{AgentId, HarnessId, ImageId, ModelId, SessionId, WorkspaceId};
14use async_trait::async_trait;
15use chrono::{DateTime, Utc};
16use std::any::{Any, TypeId};
17use std::collections::{HashMap, HashSet};
18use std::sync::Arc;
19use uuid::Uuid;
20
21/// Build a map of tool names to definitions for efficient lookup
22fn build_tool_map(tool_defs: &[ToolDefinition]) -> HashMap<&str, &ToolDefinition> {
23    tool_defs.iter().map(|def| (def.name(), def)).collect()
24}
25
26use crate::error::Result;
27
28// ============================================================================
29// AgentStore - For retrieving agent configurations
30// ============================================================================
31
32/// Trait for retrieving agent configurations
33///
34/// Implementations can:
35/// - Load agents from a database
36/// - Keep agents in memory for testing
37/// - Load agents from a configuration file
38#[async_trait]
39pub trait AgentStore: Send + Sync {
40    /// Get an agent by ID
41    async fn get_agent(&self, agent_id: AgentId) -> Result<Option<Agent>>;
42}
43
44#[async_trait]
45impl<T: AgentStore + ?Sized> AgentStore for std::sync::Arc<T> {
46    async fn get_agent(&self, agent_id: AgentId) -> Result<Option<Agent>> {
47        (**self).get_agent(agent_id).await
48    }
49}
50
51// ============================================================================
52// HarnessStore - For retrieving harness configurations
53// ============================================================================
54
55/// Trait for retrieving harness configurations
56///
57/// Implementations can:
58/// - Load harnesses from a database
59/// - Keep harnesses in memory for testing
60///
61/// Returns the harness inheritance chain (root-to-leaf) so the caller
62/// can fold each harness as an `AgentConfigOverlay`. DB-backed stores
63/// return the raw chain; gRPC-backed stores may return a single
64/// pre-merged harness (functionally equivalent when folded).
65#[async_trait]
66pub trait HarnessStore: Send + Sync {
67    /// Get the harness inheritance chain, root-to-leaf.
68    ///
69    /// Returns `Ok(vec![])` if the harness does not exist.
70    /// A harness with no parent returns a single-element vec.
71    async fn get_harness_chain(&self, harness_id: HarnessId) -> Result<Vec<Harness>>;
72}
73
74#[async_trait]
75impl<T: HarnessStore + ?Sized> HarnessStore for std::sync::Arc<T> {
76    async fn get_harness_chain(&self, harness_id: HarnessId) -> Result<Vec<Harness>> {
77        (**self).get_harness_chain(harness_id).await
78    }
79}
80
81// ============================================================================
82// SessionStore - For retrieving session information
83// ============================================================================
84
85use crate::leased_resource::{LeasedResource, UpsertLeasedResource};
86use crate::session::Session;
87
88/// Trait for retrieving session configurations
89///
90/// Implementations can:
91/// - Load sessions from a database
92/// - Keep sessions in memory for testing
93#[async_trait]
94pub trait SessionStore: Send + Sync {
95    /// Get a session by ID
96    async fn get_session(&self, session_id: SessionId) -> Result<Option<Session>>;
97}
98
99#[async_trait]
100impl<T: SessionStore + ?Sized> SessionStore for std::sync::Arc<T> {
101    async fn get_session(&self, session_id: SessionId) -> Result<Option<Session>> {
102        (**self).get_session(session_id).await
103    }
104}
105
106/// Trait for updating mutable session metadata.
107#[async_trait]
108pub trait SessionMutator: Send + Sync {
109    /// Update a session's human-readable title.
110    async fn update_session_title(&self, session_id: SessionId, title: String) -> Result<Session>;
111}
112
113#[async_trait]
114impl<T: SessionMutator + ?Sized> SessionMutator for std::sync::Arc<T> {
115    async fn update_session_title(&self, session_id: SessionId, title: String) -> Result<Session> {
116        (**self).update_session_title(session_id, title).await
117    }
118}
119
120// ============================================================================
121// ProviderStore - For retrieving LLM provider configurations
122// ============================================================================
123
124/// Model information with provider details needed for LLM calls
125#[derive(Debug, Clone)]
126pub struct ResolvedModel {
127    /// The model ID string to pass to the LLM API (e.g., "gpt-4o", "claude-3-opus")
128    pub model: String,
129    /// Provider type for factory selection
130    pub provider_type: DriverId,
131    /// Decrypted API key (if configured)
132    pub api_key: Option<String>,
133    /// Optional base URL override
134    pub base_url: Option<String>,
135    /// Extra provider-specific metadata (OAuth tokens, account ids, etc.).
136    /// Used by embedder-defined providers that authenticate without an API key.
137    pub provider_metadata: Option<crate::llm_driver_registry::ProviderMetadata>,
138}
139
140/// Trait for retrieving LLM provider and model configurations
141///
142/// This trait abstracts the database lookup and API key decryption needed
143/// to create LLM providers at runtime.
144///
145/// Implementations can:
146/// - Load from a database with encrypted API keys
147/// - Use in-memory configurations for testing
148/// - Load from environment variables for development
149#[async_trait]
150pub trait ProviderStore: Send + Sync {
151    /// Get model with provider info by model ID
152    ///
153    /// Returns the model string ID, provider type, decrypted API key, and base URL
154    /// needed to create an LLM provider via the factory.
155    async fn get_resolved_model(&self, model_id: ModelId) -> Result<Option<ResolvedModel>>;
156
157    /// Get the default model with provider info
158    ///
159    /// Returns the system default model when an agent has no default_model_id set.
160    async fn get_default_model(&self) -> Result<Option<ResolvedModel>>;
161}
162
163#[async_trait]
164impl<T: ProviderStore + ?Sized> ProviderStore for std::sync::Arc<T> {
165    async fn get_resolved_model(&self, model_id: ModelId) -> Result<Option<ResolvedModel>> {
166        (**self).get_resolved_model(model_id).await
167    }
168
169    async fn get_default_model(&self) -> Result<Option<ResolvedModel>> {
170        (**self).get_default_model().await
171    }
172}
173
174// ============================================================================
175// ImageArtifactStore - For durable image persistence from tools
176// ============================================================================
177
178/// Metadata for a stored image artifact.
179#[derive(Debug, Clone)]
180pub struct StoredImageInfo {
181    pub id: ImageId,
182    pub filename: String,
183    pub content_type: String,
184    pub size_bytes: i64,
185    pub metadata: serde_json::Value,
186    pub created_at: DateTime<Utc>,
187}
188
189/// Stored image artifact with binary data.
190#[derive(Debug, Clone)]
191pub struct StoredImage {
192    pub info: StoredImageInfo,
193    pub data: Vec<u8>,
194}
195
196/// Input for creating a stored image artifact.
197#[derive(Debug, Clone)]
198pub struct CreateStoredImage {
199    pub filename: String,
200    pub content_type: String,
201    pub data: Vec<u8>,
202    pub metadata: serde_json::Value,
203}
204
205#[async_trait]
206pub trait ImageArtifactStore: Send + Sync {
207    /// Persist an image artifact and return its durable metadata.
208    async fn create_image(&self, input: CreateStoredImage) -> Result<StoredImageInfo>;
209
210    /// Load a stored image artifact including bytes.
211    async fn get_image(&self, image_id: ImageId) -> Result<Option<StoredImage>>;
212
213    /// Load stored image metadata without binary data.
214    async fn get_image_info(&self, image_id: ImageId) -> Result<Option<StoredImageInfo>>;
215}
216
217// ============================================================================
218// ProviderCredentialStore - For tool-side provider credential resolution
219// ============================================================================
220
221/// Provider credentials resolved for tool-side API clients.
222#[derive(Debug, Clone)]
223pub struct ProviderCredentials {
224    pub api_key: String,
225    pub base_url: Option<String>,
226}
227
228#[async_trait]
229pub trait ProviderCredentialStore: Send + Sync {
230    /// Resolve default credentials for a provider type (for example `openai`).
231    ///
232    /// Implementations may apply environment fallbacks internally, but tools
233    /// should never read provider env vars directly.
234    async fn get_default_provider_credentials(
235        &self,
236        provider_type: &str,
237    ) -> Result<Option<ProviderCredentials>>;
238}
239
240// ============================================================================
241// ToolExecutor - For executing tool calls
242// ============================================================================
243
244/// Trait for executing tool calls
245///
246/// Implementations handle the actual tool execution:
247/// - Webhook calls
248/// - Built-in function execution
249/// - Mock execution for testing
250#[async_trait]
251pub trait ToolExecutor: Send + Sync {
252    /// Execute a single tool call (without context)
253    ///
254    /// This is the legacy method that doesn't provide context to tools.
255    /// Use `execute_with_context` when context is available.
256    async fn execute(&self, tool_call: &ToolCall, tool_def: &ToolDefinition) -> Result<ToolResult>;
257
258    /// Execute a single tool call with context
259    ///
260    /// This method provides runtime context to tools that need it (like filesystem tools).
261    /// The default implementation delegates to `execute()`.
262    async fn execute_with_context(
263        &self,
264        tool_call: &ToolCall,
265        tool_def: &ToolDefinition,
266        _context: &ToolContext,
267    ) -> Result<ToolResult> {
268        // Default: delegate to execute(), ignoring context
269        self.execute(tool_call, tool_def).await
270    }
271
272    /// Execute multiple tool calls (default: sequential)
273    async fn execute_batch(
274        &self,
275        tool_calls: &[ToolCall],
276        tool_defs: &[ToolDefinition],
277    ) -> Result<Vec<ToolResult>> {
278        let mut results = Vec::with_capacity(tool_calls.len());
279
280        let tool_map = build_tool_map(tool_defs);
281
282        for tool_call in tool_calls {
283            let tool_def = tool_map.get(tool_call.name.as_str()).ok_or_else(|| {
284                crate::error::AgentLoopError::tool(format!(
285                    "Tool definition not found: {}",
286                    tool_call.name
287                ))
288            })?;
289
290            results.push(self.execute(tool_call, tool_def).await?);
291        }
292
293        Ok(results)
294    }
295
296    /// Execute multiple tool calls in parallel
297    async fn execute_parallel(
298        &self,
299        tool_calls: &[ToolCall],
300        tool_defs: &[ToolDefinition],
301    ) -> Result<Vec<ToolResult>>
302    where
303        Self: Sized,
304    {
305        use futures::future::join_all;
306
307        let tool_map = build_tool_map(tool_defs);
308
309        let futures: Vec<_> = tool_calls
310            .iter()
311            .map(|tool_call| async {
312                let tool_def = tool_map.get(tool_call.name.as_str()).ok_or_else(|| {
313                    crate::error::AgentLoopError::tool(format!(
314                        "Tool definition not found: {}",
315                        tool_call.name
316                    ))
317                })?;
318                self.execute(tool_call, tool_def).await
319            })
320            .collect();
321
322        let results = join_all(futures).await;
323        results.into_iter().collect()
324    }
325}
326
327/// Delegating impl so callers can hold a `ToolExecutor` as a trait object
328/// (e.g. to choose between a plain registry and an MCP-routing composite at
329/// runtime without monomorphizing the consumer).
330#[async_trait]
331impl ToolExecutor for std::sync::Arc<dyn ToolExecutor> {
332    async fn execute(&self, tool_call: &ToolCall, tool_def: &ToolDefinition) -> Result<ToolResult> {
333        (**self).execute(tool_call, tool_def).await
334    }
335
336    async fn execute_with_context(
337        &self,
338        tool_call: &ToolCall,
339        tool_def: &ToolDefinition,
340        context: &ToolContext,
341    ) -> Result<ToolResult> {
342        (**self)
343            .execute_with_context(tool_call, tool_def, context)
344            .await
345    }
346
347    async fn execute_batch(
348        &self,
349        tool_calls: &[ToolCall],
350        tool_defs: &[ToolDefinition],
351    ) -> Result<Vec<ToolResult>> {
352        (**self).execute_batch(tool_calls, tool_defs).await
353    }
354}
355
356// ============================================================================
357// SessionFileSystem - For session filesystem operations
358// ============================================================================
359
360/// Trait for session filesystem operations
361///
362/// This trait abstracts the session filesystem contract for tools and hosts.
363/// Implementations can:
364/// - Store files in a database (production)
365/// - Use an in-memory filesystem for testing
366/// - Project files onto real disk or object storage
367#[async_trait]
368pub trait SessionFileSystem: Send + Sync {
369    /// Read a file by path
370    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>>;
371
372    /// Write/create a file
373    async fn write_file(
374        &self,
375        session_id: SessionId,
376        path: &str,
377        content: &str,
378        encoding: &str,
379    ) -> Result<SessionFile>;
380
381    /// Write a file only if its current content snapshot still matches.
382    ///
383    /// Implementations backed by transactional storage should override this
384    /// with an atomic compare-and-set update.
385    async fn write_file_if_content_matches(
386        &self,
387        session_id: SessionId,
388        path: &str,
389        expected_content: &str,
390        expected_encoding: &str,
391        content: &str,
392        encoding: &str,
393    ) -> Result<Option<SessionFile>> {
394        let Some(existing) = self.read_file(session_id, path).await? else {
395            return Ok(None);
396        };
397
398        if existing.is_directory {
399            return Ok(None);
400        }
401
402        let current_content = existing.content.unwrap_or_default();
403        if current_content != expected_content || existing.encoding != expected_encoding {
404            return Ok(None);
405        }
406
407        self.write_file(session_id, path, content, encoding)
408            .await
409            .map(Some)
410    }
411
412    /// Delete a file or directory
413    async fn delete_file(&self, session_id: SessionId, path: &str, recursive: bool)
414    -> Result<bool>;
415
416    /// List files in a directory
417    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>>;
418
419    /// Get file metadata
420    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>>;
421
422    /// Search files by pattern (grep)
423    async fn grep_files(
424        &self,
425        session_id: SessionId,
426        pattern: &str,
427        path_pattern: Option<&str>,
428    ) -> Result<Vec<GrepMatch>>;
429
430    /// Create a directory
431    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo>;
432
433    /// Seed a starter file into a session workspace.
434    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
435        if file.is_readonly {
436            return Err(crate::error::AgentLoopError::store(
437                "read-only initial files require a SessionFileSystem-specific seed implementation",
438            ));
439        }
440        self.write_file(session_id, &file.path, &file.content, &file.encoding)
441            .await?;
442        Ok(())
443    }
444}
445
446/// A [`SessionFileSystem`] decorator that pins every operation to a fixed
447/// workspace key, ignoring the per-call `session_id`.
448///
449/// Used to re-key file I/O for a session attached to a shared workspace (where
450/// `workspace.id != session.id`): wrap the session's file store once with the
451/// session's `workspace_id`, and all downstream capability/tool access then
452/// addresses the attached workspace rather than the session's own keyspace. For
453/// the default 1:1 session the key equals the session id, so the wrapper is a
454/// transparent pass-through. See `specs/workspace.md`.
455pub struct WorkspaceScopedFileSystem {
456    inner: Arc<dyn SessionFileSystem>,
457    key: SessionId,
458}
459
460impl WorkspaceScopedFileSystem {
461    /// Wrap `inner`, pinning all operations to `workspace_id`'s key.
462    pub fn wrap(
463        inner: Arc<dyn SessionFileSystem>,
464        workspace_id: WorkspaceId,
465    ) -> Arc<dyn SessionFileSystem> {
466        Arc::new(Self {
467            inner,
468            key: SessionId::from_uuid(workspace_id.uuid()),
469        })
470    }
471}
472
473#[async_trait]
474impl SessionFileSystem for WorkspaceScopedFileSystem {
475    async fn read_file(&self, _session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
476        self.inner.read_file(self.key, path).await
477    }
478    async fn write_file(
479        &self,
480        _session_id: SessionId,
481        path: &str,
482        content: &str,
483        encoding: &str,
484    ) -> Result<SessionFile> {
485        self.inner
486            .write_file(self.key, path, content, encoding)
487            .await
488    }
489    async fn write_file_if_content_matches(
490        &self,
491        _session_id: SessionId,
492        path: &str,
493        expected_content: &str,
494        expected_encoding: &str,
495        content: &str,
496        encoding: &str,
497    ) -> Result<Option<SessionFile>> {
498        self.inner
499            .write_file_if_content_matches(
500                self.key,
501                path,
502                expected_content,
503                expected_encoding,
504                content,
505                encoding,
506            )
507            .await
508    }
509    async fn delete_file(
510        &self,
511        _session_id: SessionId,
512        path: &str,
513        recursive: bool,
514    ) -> Result<bool> {
515        self.inner.delete_file(self.key, path, recursive).await
516    }
517    async fn list_directory(&self, _session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
518        self.inner.list_directory(self.key, path).await
519    }
520    async fn stat_file(&self, _session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
521        self.inner.stat_file(self.key, path).await
522    }
523    async fn grep_files(
524        &self,
525        _session_id: SessionId,
526        pattern: &str,
527        path_pattern: Option<&str>,
528    ) -> Result<Vec<GrepMatch>> {
529        self.inner.grep_files(self.key, pattern, path_pattern).await
530    }
531    async fn create_directory(&self, _session_id: SessionId, path: &str) -> Result<FileInfo> {
532        self.inner.create_directory(self.key, path).await
533    }
534    async fn seed_initial_file(&self, _session_id: SessionId, file: &InitialFile) -> Result<()> {
535        self.inner.seed_initial_file(self.key, file).await
536    }
537}
538
539#[async_trait]
540impl<T: SessionFileSystem + ?Sized> SessionFileSystem for std::sync::Arc<T> {
541    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
542        (**self).read_file(session_id, path).await
543    }
544
545    async fn write_file(
546        &self,
547        session_id: SessionId,
548        path: &str,
549        content: &str,
550        encoding: &str,
551    ) -> Result<SessionFile> {
552        (**self)
553            .write_file(session_id, path, content, encoding)
554            .await
555    }
556
557    async fn write_file_if_content_matches(
558        &self,
559        session_id: SessionId,
560        path: &str,
561        expected_content: &str,
562        expected_encoding: &str,
563        content: &str,
564        encoding: &str,
565    ) -> Result<Option<SessionFile>> {
566        (**self)
567            .write_file_if_content_matches(
568                session_id,
569                path,
570                expected_content,
571                expected_encoding,
572                content,
573                encoding,
574            )
575            .await
576    }
577
578    async fn delete_file(
579        &self,
580        session_id: SessionId,
581        path: &str,
582        recursive: bool,
583    ) -> Result<bool> {
584        (**self).delete_file(session_id, path, recursive).await
585    }
586
587    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
588        (**self).list_directory(session_id, path).await
589    }
590
591    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
592        (**self).stat_file(session_id, path).await
593    }
594
595    async fn grep_files(
596        &self,
597        session_id: SessionId,
598        pattern: &str,
599        path_pattern: Option<&str>,
600    ) -> Result<Vec<GrepMatch>> {
601        (**self).grep_files(session_id, pattern, path_pattern).await
602    }
603
604    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
605        (**self).create_directory(session_id, path).await
606    }
607
608    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
609        (**self).seed_initial_file(session_id, file).await
610    }
611}
612
613/// Backward-compatible alias for the old session filesystem trait name.
614pub use SessionFileSystem as SessionFileStore;
615
616/// Host-supplied values used by platform file-system factories.
617///
618/// The context is intentionally type-erased so `everruns-core` can own the
619/// platform contract without depending on server-only types such as
620/// `StorageBackend` or future object-storage clients.
621#[derive(Clone, Default)]
622pub struct SessionFileSystemFactoryContext {
623    values: Arc<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>,
624}
625
626impl SessionFileSystemFactoryContext {
627    pub fn new() -> Self {
628        Self::default()
629    }
630
631    pub fn with<T: Any + Send + Sync>(mut self, value: Arc<T>) -> Self {
632        let values = Arc::make_mut(&mut self.values);
633        values.insert(TypeId::of::<T>(), value);
634        self
635    }
636
637    pub fn get<T: Any + Send + Sync>(&self) -> Option<Arc<T>> {
638        self.values
639            .get(&TypeId::of::<T>())
640            .and_then(|value| value.clone().downcast::<T>().ok())
641    }
642}
643
644/// Factory for deployment-selected session filesystem implementations.
645#[async_trait]
646pub trait SessionFileSystemFactory: Send + Sync {
647    /// Human-readable factory name for diagnostics.
648    fn name(&self) -> &'static str {
649        "SessionFileSystemFactory"
650    }
651
652    /// Whether this factory intentionally leaves filesystem selection to the
653    /// runtime default.
654    fn is_disabled(&self) -> bool {
655        false
656    }
657
658    /// Resolve a live filesystem from host-provided dependencies.
659    async fn create_session_file_system(
660        &self,
661        context: SessionFileSystemFactoryContext,
662    ) -> Result<Arc<dyn SessionFileSystem>>;
663}
664
665/// Default factory used when a platform does not configure session files.
666#[derive(Debug, Clone, Default)]
667pub struct DisabledSessionFileSystemFactory;
668
669#[async_trait]
670impl SessionFileSystemFactory for DisabledSessionFileSystemFactory {
671    fn name(&self) -> &'static str {
672        "DisabledSessionFileSystemFactory"
673    }
674
675    fn is_disabled(&self) -> bool {
676        true
677    }
678
679    async fn create_session_file_system(
680        &self,
681        _context: SessionFileSystemFactoryContext,
682    ) -> Result<Arc<dyn SessionFileSystem>> {
683        Err(crate::error::AgentLoopError::config(
684            "session filesystem is disabled",
685        ))
686    }
687}
688
689// ============================================================================
690// SessionStorageStore - For session key/value and secret storage
691// ============================================================================
692
693/// Info about a stored key (without its value)
694#[derive(Debug, Clone)]
695pub struct KeyInfo {
696    pub key: String,
697    pub created_at: chrono::DateTime<chrono::Utc>,
698    pub updated_at: chrono::DateTime<chrono::Utc>,
699}
700
701/// Info about a stored secret (without its value)
702#[derive(Debug, Clone)]
703pub struct SecretInfo {
704    pub name: String,
705    pub created_at: chrono::DateTime<chrono::Utc>,
706    pub updated_at: chrono::DateTime<chrono::Utc>,
707}
708
709/// Trait for session key/value and secret storage operations
710///
711/// This trait abstracts storage operations for tools that need to persist
712/// data within a session. Implementations can:
713/// - Store data in a database (production)
714/// - Use in-memory storage for testing
715///
716/// Key/value storage is for general data that doesn't need encryption.
717/// Secret storage is for sensitive data that is encrypted at rest.
718#[async_trait]
719pub trait SessionStorageStore: Send + Sync {
720    // Key/Value operations (plain text)
721
722    /// Set a key/value pair (creates or updates)
723    async fn set_value(&self, session_id: SessionId, key: &str, value: &str) -> Result<()>;
724
725    /// Get a value by key
726    async fn get_value(&self, session_id: SessionId, key: &str) -> Result<Option<String>>;
727
728    /// Delete a key/value pair
729    async fn delete_value(&self, session_id: SessionId, key: &str) -> Result<bool>;
730
731    /// List all keys in a session
732    async fn list_keys(&self, session_id: SessionId) -> Result<Vec<KeyInfo>>;
733
734    // Secret operations (encrypted)
735
736    /// Set a secret (creates or updates, value is encrypted before storage)
737    async fn set_secret(&self, session_id: SessionId, name: &str, value: &str) -> Result<()>;
738
739    /// Get a secret by name (value is decrypted before returning)
740    async fn get_secret(&self, session_id: SessionId, name: &str) -> Result<Option<String>>;
741
742    /// Delete a secret
743    async fn delete_secret(&self, session_id: SessionId, name: &str) -> Result<bool>;
744
745    /// List all secret names in a session (without values)
746    async fn list_secrets(&self, session_id: SessionId) -> Result<Vec<SecretInfo>>;
747}
748
749// ============================================================================
750// SessionScheduleStore - For session-scoped schedule operations
751// ============================================================================
752
753use crate::session_schedule::SessionSchedule;
754use crate::typed_id::ScheduleId;
755
756/// Trait for session schedule CRUD operations.
757///
758/// Used by scheduling tools to create, cancel, and list schedules.
759#[async_trait]
760pub trait SessionScheduleStore: Send + Sync {
761    /// Create a new schedule for a session.
762    async fn create_schedule(
763        &self,
764        session_id: SessionId,
765        description: String,
766        cron_expression: Option<String>,
767        scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
768        timezone: String,
769    ) -> Result<SessionSchedule>;
770
771    /// Cancel (disable) a schedule.
772    async fn cancel_schedule(
773        &self,
774        session_id: SessionId,
775        schedule_id: ScheduleId,
776    ) -> Result<SessionSchedule>;
777
778    /// List schedules for a session.
779    async fn list_schedules(&self, session_id: SessionId) -> Result<Vec<SessionSchedule>>;
780
781    /// Count active (enabled) schedules for a session.
782    async fn count_active_schedules(&self, session_id: SessionId) -> Result<u32>;
783}
784
785// ============================================================================
786// SessionResourceRegistry - Generic session-scoped resource registry
787// ============================================================================
788
789/// Generic registry of resources active alongside a session.
790///
791/// Capabilities register resources here (sandboxes, subagents, browser sessions).
792/// Agents query it ("what's running?"), infrastructure scans it for cleanup.
793/// See `specs/session-resources.md`.
794#[async_trait]
795pub trait SessionResourceRegistry: Send + Sync {
796    /// Register a resource (or update if resource_id already exists for this session).
797    async fn register(
798        &self,
799        entry: crate::session_resource::RegisterSessionResource,
800    ) -> Result<crate::session_resource::SessionResourceEntry>;
801
802    /// Update the status of a registered resource.
803    async fn update_status(
804        &self,
805        session_id: SessionId,
806        resource_id: &str,
807        status: crate::session_resource::SessionResourceStatus,
808    ) -> Result<Option<crate::session_resource::SessionResourceEntry>>;
809
810    /// Get a specific resource by ID.
811    async fn get(
812        &self,
813        session_id: SessionId,
814        resource_id: &str,
815    ) -> Result<Option<crate::session_resource::SessionResourceEntry>>;
816
817    /// List resources for a session, optionally filtered.
818    async fn list(
819        &self,
820        session_id: SessionId,
821        filter: Option<&crate::session_resource::SessionResourceFilter>,
822    ) -> Result<Vec<crate::session_resource::SessionResourceEntry>>;
823
824    /// Remove a resource from the registry.
825    async fn deregister(&self, session_id: SessionId, resource_id: &str) -> Result<bool>;
826}
827
828// ============================================================================
829// LeasedResourceStore - For lifecycle-managed external resources
830// ============================================================================
831
832/// Trait for session-scoped leased resource operations.
833///
834/// Tools use this store to register or refresh leases when they create or use
835/// external provider resources. Cleanup workers operate through control-plane
836/// storage APIs directly so they can claim work across organizations.
837#[async_trait]
838pub trait LeasedResourceStore: Send + Sync {
839    /// Create or refresh a leased resource for a session.
840    ///
841    /// Implementations must treat this as an idempotent upsert keyed by the
842    /// provider-specific resource identity so repeated tool usage extends the
843    /// same lease instead of creating duplicate rows.
844    async fn upsert_resource(&self, input: UpsertLeasedResource) -> Result<LeasedResource>;
845
846    /// Mark a leased resource as explicitly released.
847    ///
848    /// This is the fast path for explicit user intent such as "close browser"
849    /// or "delete sandbox". It should transition the resource to `released`
850    /// without waiting for the durable cleanup worker to observe lease expiry.
851    async fn release_resource(
852        &self,
853        session_id: SessionId,
854        provider: &str,
855        resource_type: &str,
856        external_id: &str,
857    ) -> Result<Option<LeasedResource>>;
858
859    /// List leased resources currently associated with a session.
860    ///
861    /// Session surfaces use this for visibility. Released resources remain
862    /// visible so operators can inspect cleanup outcomes and failure history.
863    async fn list_resources(&self, session_id: SessionId) -> Result<Vec<LeasedResource>>;
864}
865
866// ============================================================================
867// ToolContext - Runtime context for tool execution
868// ============================================================================
869
870/// Type alias for the session SQL DB store trait object.
871pub type SessionSqlDbStoreRef = Arc<dyn crate::session_sqldb::SessionSqlDbStore>;
872
873/// Resolves user connection tokens (e.g. GitHub) lazily at tool execution time.
874///
875/// Instead of eagerly injecting tokens at session creation, tools call this
876/// resolver when they need a token. If the user hasn't connected, returns None.
877#[async_trait]
878pub trait UserConnectionResolver: Send + Sync {
879    /// Get a decrypted connection token for the given provider.
880    /// Returns None if the user has no connection for this provider.
881    async fn get_connection_token(
882        &self,
883        session_id: SessionId,
884        provider: &str,
885    ) -> Result<Option<String>>;
886
887    /// Resolve the user ID of the connection used for a session/provider pair.
888    ///
889    /// This is used by leased resources to bind cleanup to the same provider
890    /// identity that created the remote resource.
891    async fn get_connection_user(
892        &self,
893        _session_id: SessionId,
894        _provider: &str,
895    ) -> Result<Option<Uuid>> {
896        Ok(None)
897    }
898
899    /// Resolve a provider token for a specific user.
900    ///
901    /// Cleanup workers use this to avoid "first org member wins" behavior when
902    /// cleaning resources created by a specific provider connection owner.
903    async fn get_connection_token_for_user(
904        &self,
905        _user_id: Uuid,
906        _provider: &str,
907    ) -> Result<Option<String>> {
908        Ok(None)
909    }
910
911    /// Get provider-specific metadata stored alongside the connection.
912    /// Returns None if no metadata is stored or no connection exists.
913    async fn get_connection_metadata(
914        &self,
915        _session_id: SessionId,
916        _provider: &str,
917    ) -> Result<Option<serde_json::Value>> {
918        Ok(None)
919    }
920}
921
922// ============================================================================
923// BudgetChecker - For querying budget status from tools
924// ============================================================================
925
926/// Trait for checking budget status from within tool execution.
927///
928/// Implemented by gRPC adapters (worker → server) and direct adapters (in-process).
929/// Used by the `check_budget` tool to return real budget data to agents.
930/// The org_id is captured at construction time by the implementing adapter.
931#[async_trait]
932pub trait BudgetChecker: Send + Sync {
933    /// Check all budgets for a session and return a tool-friendly response.
934    async fn check_budgets(&self, session_id: &str) -> Result<crate::budget::BudgetToolResponse>;
935}
936
937// ============================================================================
938// PaymentAuthority - For capability-internal machine payments
939// ============================================================================
940
941/// Internal authority for paid capability operations.
942///
943/// Capabilities call this with fixed, typed requests. The model never receives a
944/// generic paid HTTP tool, wallet credentials, or payment payloads.
945#[async_trait]
946pub trait PaymentAuthority: Send + Sync {
947    async fn execute_machine_payment(
948        &self,
949        session_id: SessionId,
950        request: crate::payment::MachinePaymentRequest,
951    ) -> Result<crate::payment::MachinePaymentResponse>;
952}
953
954// OutboundToolRateLimiter - Per-org outbound tool-call rate limiting (TM-TOOL-009)
955// ============================================================================
956
957/// Per-org gate on outbound tool execution.
958///
959/// Returns `true` if the call is within the per-org budget, `false` if the
960/// org has exceeded its outbound tool rate limit for this window.
961/// Implementations must be fail-open: Valkey/backend errors should return `true`
962/// rather than blocking legitimate tool calls.
963#[async_trait]
964pub trait OutboundToolRateLimiter: Send + Sync {
965    /// Key by the public org UUID (keyed string representation).
966    async fn check_org(&self, org_id: &crate::typed_id::OrgId) -> bool;
967}
968
969// ============================================================================
970// DurableToolResultStore — per-tool-call idempotency (EVE-530)
971// ============================================================================
972
973/// Result of a claim attempt on the per-tool-call idempotency store.
974#[derive(Debug)]
975pub enum ToolCallClaimResult {
976    /// First claim for this (turn_id, tool_call_id); caller should execute the tool.
977    /// `claim_token` must be passed to `settle_tool_call` to verify ownership.
978    Claimed { claim_token: uuid::Uuid },
979    /// A prior run already settled this call; replay the stored result.
980    AlreadySettled {
981        result_json: serde_json::Value,
982        args_fingerprint: String,
983    },
984    /// A prior run started but never settled. For `AtMostOnce` tools the
985    /// caller should NOT re-execute; for `Pure`/`Idempotent` tools the caller
986    /// may re-execute and then try to settle (the settle CAS will be a no-op if
987    /// a different claimer wins first).
988    AlreadyRunning { args_fingerprint: String },
989    /// A settled row exists but its `args_fingerprint` does not match the
990    /// current call — this is a determinism violation (workflow replay with
991    /// different inputs). The workflow should be failed loudly.
992    DeterminismViolation {
993        stored_fingerprint: String,
994        current_fingerprint: String,
995    },
996}
997
998/// Read-only status of a tool call in durable storage (EVE-533).
999#[derive(Debug, Clone)]
1000pub enum DurableToolCallStatus {
1001    /// Tool completed successfully or with an error; result is stored.
1002    Settled { result_json: serde_json::Value },
1003    /// Tool was settled with `interrupted` status; result may contain error details.
1004    Interrupted {
1005        result_json: Option<serde_json::Value>,
1006    },
1007    /// A claim exists but the tool never finished.
1008    Running,
1009}
1010
1011/// Durable per-tool-call idempotency store (EVE-530).
1012///
1013/// Implements the claim/settle CAS that prevents double-execution of
1014/// `AtMostOnce` tools on worker reclaim/replay.
1015#[async_trait]
1016pub trait DurableToolResultStore: Send + Sync + 'static {
1017    /// Atomically claim `(turn_id, tool_call_id)` before tool dispatch.
1018    ///
1019    /// - Inserts a `running` row if none exists → `Claimed`.
1020    /// - Finds an existing `settled` row → `AlreadySettled`.
1021    /// - Finds an existing `running` row → `AlreadyRunning`.
1022    /// - Finds a `settled` row with a mismatched `args_fingerprint`
1023    ///   (determinism violation) → `DeterminismViolation`.
1024    async fn try_claim_tool_call(
1025        &self,
1026        turn_id: &str,
1027        tool_call_id: &str,
1028        tool_name: &str,
1029        args_fingerprint: &str,
1030    ) -> Result<ToolCallClaimResult>;
1031
1032    /// Settle a previously claimed tool call with its result.
1033    ///
1034    /// `claim_token` must match the token returned by `try_claim_tool_call`.
1035    /// Returns `Ok(true)` if the row was updated, `Ok(false)` if the claim
1036    /// token no longer matches (ownership lost — treat as a warning).
1037    async fn settle_tool_call(
1038        &self,
1039        turn_id: &str,
1040        tool_call_id: &str,
1041        result_json: serde_json::Value,
1042        status: &str,
1043        claim_token: uuid::Uuid,
1044    ) -> Result<bool>;
1045
1046    /// Read-only lookup of a tool call's current status in durable storage (EVE-533).
1047    ///
1048    /// Used by transcript repair to decide whether to replay a stored result or
1049    /// synthesize an interrupted placeholder. Returns `None` if no row exists.
1050    async fn get_tool_call_status(
1051        &self,
1052        turn_id: &str,
1053        tool_call_id: &str,
1054    ) -> Result<Option<DurableToolCallStatus>>;
1055}
1056
1057/// No-op implementation — used when no durable store is configured (dev/test).
1058/// Every call is treated as a fresh first execution; no replay or ownership checks.
1059pub struct NoopDurableToolResultStore;
1060
1061#[async_trait]
1062impl DurableToolResultStore for NoopDurableToolResultStore {
1063    async fn try_claim_tool_call(
1064        &self,
1065        _turn_id: &str,
1066        _tool_call_id: &str,
1067        _tool_name: &str,
1068        _args_fingerprint: &str,
1069    ) -> Result<ToolCallClaimResult> {
1070        Ok(ToolCallClaimResult::Claimed {
1071            claim_token: uuid::Uuid::new_v4(),
1072        })
1073    }
1074
1075    async fn settle_tool_call(
1076        &self,
1077        _turn_id: &str,
1078        _tool_call_id: &str,
1079        _result_json: serde_json::Value,
1080        _status: &str,
1081        _claim_token: uuid::Uuid,
1082    ) -> Result<bool> {
1083        Ok(true)
1084    }
1085
1086    async fn get_tool_call_status(
1087        &self,
1088        _turn_id: &str,
1089        _tool_call_id: &str,
1090    ) -> Result<Option<DurableToolCallStatus>> {
1091        Ok(None)
1092    }
1093}
1094
1095// ============================================================================
1096// StreamHeartbeater — per-stream liveness signal for Reason activity (EVE-531)
1097// ============================================================================
1098
1099/// Progress snapshot carried in each stream heartbeat.
1100#[derive(Debug, Clone)]
1101pub struct StreamProgress {
1102    /// Accumulated text + thinking length (characters) at the time of heartbeat.
1103    pub accumulated_len: usize,
1104    /// Wall-clock time of the most recent received token (Unix seconds).
1105    pub last_delta_at: u64,
1106}
1107
1108/// Heartbeater the Reason streaming loop calls on delta batches and a keepalive
1109/// timer, signalling that the provider connection is alive.
1110///
1111/// Implementations bridge to the durable-execution layer (e.g. gRPC).
1112/// The no-op is used in dev/test where no durable store is present.
1113#[async_trait]
1114pub trait StreamHeartbeater: Send + Sync {
1115    /// Signal stream liveness with current progress.
1116    ///
1117    /// Must be best-effort: errors must not propagate to the caller.
1118    /// Cancel-safety is critical — if the worker dies the heartbeat stops
1119    /// and the existing task-level reclaim takes over.
1120    async fn heartbeat(&self, progress: StreamProgress);
1121}
1122
1123/// No-op heartbeater — treats every stream as perpetually alive (dev/test).
1124pub struct NoopStreamHeartbeater;
1125
1126#[async_trait]
1127impl StreamHeartbeater for NoopStreamHeartbeater {
1128    async fn heartbeat(&self, _progress: StreamProgress) {}
1129}
1130
1131// ============================================================================
1132// PartialStreamStore — partial-stream recovery for Reason activity (EVE-532)
1133// ============================================================================
1134
1135/// State of a partially-streamed assistant message detected in the event log.
1136#[derive(Debug, Clone)]
1137pub struct PartialStreamState {
1138    /// Accumulated text from the last `output.message.delta` for the turn.
1139    /// Empty when `output.message.started` was emitted but no delta arrived.
1140    pub accumulated: String,
1141}
1142
1143/// Consults the persisted event log to detect whether a `reason` activity
1144/// was interrupted after `output.message.started` but before
1145/// `output.message.completed` or `output.message.replaced`.
1146///
1147/// Used by `ReasonAtom` on re-entry to apply the ContinuePartial recovery
1148/// policy (EVE-532): finalize the partial text without a second provider call,
1149/// or restart clean if the partial is unusable.
1150#[async_trait]
1151pub trait PartialStreamStore: Send + Sync {
1152    /// Return the partial-stream state for `(session_id, turn_id)` if an
1153    /// in-flight assistant message exists (started but not completed).
1154    async fn get_partial_stream(
1155        &self,
1156        session_id: SessionId,
1157        turn_id: &str,
1158    ) -> Result<Option<PartialStreamState>>;
1159}
1160
1161/// No-op — always reports no partial stream (dev/test / in-memory mode).
1162pub struct NoopPartialStreamStore;
1163
1164#[async_trait]
1165impl PartialStreamStore for NoopPartialStreamStore {
1166    async fn get_partial_stream(
1167        &self,
1168        _session_id: SessionId,
1169        _turn_id: &str,
1170    ) -> Result<Option<PartialStreamState>> {
1171        Ok(None)
1172    }
1173}
1174
1175/// Runtime context provided to tools during execution.
1176///
1177/// This context contains:
1178/// - Session ID for scoping operations
1179/// - Optional stores for tools that need external access
1180///
1181/// Tools that need context-aware execution (like filesystem tools) can use
1182/// the `execute_with_context` method on the Tool trait.
1183#[derive(Clone)]
1184pub struct ToolContext {
1185    /// The session ID for the current execution
1186    pub session_id: SessionId,
1187    /// The workspace this session is attached to — the key for the virtual
1188    /// file store. For the default 1:1 session this equals
1189    /// `WorkspaceId::from_uuid(session_id.uuid())`; for a shared workspace it
1190    /// differs. File-system tools MUST key by this (via `workspace_fs_key`)
1191    /// rather than `session_id` so shared-workspace sessions read/write the
1192    /// attached workspace's files. See specs/workspace.md.
1193    pub workspace_id: WorkspaceId,
1194
1195    /// Optional file store for filesystem operations
1196    pub file_store: Option<Arc<dyn SessionFileSystem>>,
1197
1198    /// Optional storage store for key/value and secret storage
1199    pub storage_store: Option<Arc<dyn SessionStorageStore>>,
1200
1201    /// Optional durable image artifact store for tool-side media persistence.
1202    pub image_store: Option<Arc<dyn ImageArtifactStore>>,
1203
1204    /// Optional provider credential store for tool-side API clients.
1205    pub provider_credential_store: Option<Arc<dyn ProviderCredentialStore>>,
1206
1207    /// Optional system utility LLM service for capability internals.
1208    pub utility_llm_service: Option<Arc<dyn crate::UtilityLlmService>>,
1209
1210    /// Optional outbound egress service for HTTP/API traffic.
1211    pub egress_service: Option<Arc<dyn crate::EgressService>>,
1212
1213    /// Optional session SQL database store
1214    pub sqldb_store: Option<SessionSqlDbStoreRef>,
1215
1216    /// Optional message retriever for tools that need conversation history access
1217    pub message_retriever: Option<Arc<dyn crate::message_retriever::MessageRetriever>>,
1218
1219    /// Optional session store for tools that need session metadata access.
1220    pub session_store: Option<Arc<dyn SessionStore>>,
1221
1222    /// Optional session mutator for tools that need to update session metadata.
1223    pub session_mutator: Option<Arc<dyn SessionMutator>>,
1224
1225    /// Optional agent store for tools that need agent metadata access.
1226    pub agent_store: Option<Arc<dyn AgentStore>>,
1227
1228    /// Optional resolver for user connection tokens (lazy GitHub token lookup, etc.)
1229    pub connection_resolver: Option<Arc<dyn UserConnectionResolver>>,
1230
1231    /// Optional session schedule store for scheduling tools.
1232    pub schedule_store: Option<Arc<dyn SessionScheduleStore>>,
1233
1234    /// Optional platform store for org-level management tools.
1235    pub platform_store: Option<Arc<dyn crate::platform_store::PlatformStore>>,
1236    /// Optional leased resource store for lifecycle-managed provider resources.
1237    pub leased_resource_store: Option<Arc<dyn LeasedResourceStore>>,
1238
1239    /// Optional session resource registry — generic registry of active resources.
1240    pub session_resource_registry: Option<Arc<dyn SessionResourceRegistry>>,
1241
1242    /// Optional session task registry — background work owned by the session
1243    /// (specs/session-tasks.md).
1244    pub session_task_registry: Option<Arc<dyn crate::session_task::SessionTaskRegistry>>,
1245
1246    /// Optional event emitter for tools that need to stream progress updates.
1247    /// When set, tools can emit `tool.progress` events during execution.
1248    pub event_emitter: Option<Arc<dyn EventEmitter>>,
1249
1250    /// Event context for correlating progress events with the current tool call.
1251    /// Set by ActAtom when constructing the ToolContext.
1252    pub event_context: Option<crate::events::EventContext>,
1253
1254    /// The tool call ID for the current execution (set by ActAtom).
1255    /// Used by tools to emit correlated progress events.
1256    pub tool_call_id: Option<String>,
1257    /// Optional capability registry for blueprint lookups.
1258    pub capability_registry: Option<crate::capabilities::CapabilityRegistry>,
1259
1260    /// Optional registry of active built-in tools for meta-tools such as
1261    /// `spawn_background` that need to inspect or delegate to sibling tools.
1262    pub tool_registry: Option<Arc<crate::tools::ToolRegistry>>,
1263
1264    /// Optional allowlist of tools visible to the model for this turn.
1265    /// Registry-introspecting tools must filter through this before returning
1266    /// sibling tool metadata, because the execution registry can be a superset.
1267    pub visible_tool_names: Option<Arc<HashSet<String>>>,
1268
1269    /// Optional org ID for org-scoped operations.
1270    pub org_id: Option<crate::typed_id::OrgId>,
1271
1272    /// Merged network access list (harness ∩ agent ∩ session).
1273    /// When set, tools that make HTTP requests must check URLs against this list.
1274    pub network_access: Option<crate::network_access::NetworkAccessList>,
1275
1276    /// Resolved locale for localized tool behavior (BCP 47, e.g. `uk-UA`).
1277    /// When set, tools that support localization use this to produce
1278    /// locale-appropriate descriptions, error messages, and prompts.
1279    pub locale: Option<String>,
1280
1281    /// Optional budget checker for the check_budget tool.
1282    pub budget_checker: Option<Arc<dyn BudgetChecker>>,
1283
1284    /// Optional internal payment authority for paid capability tools.
1285    pub payment_authority: Option<Arc<dyn PaymentAuthority>>,
1286
1287    /// Optional durable spawn handle store for subagent reattach (EVE-535).
1288    /// When set, `spawn_subagent` uses claim/settle to prevent duplicate spawning
1289    /// on parent worker reclaim.
1290    pub subagent_spawn_store: Option<Arc<dyn SubagentSpawnStore>>,
1291}
1292
1293impl ToolContext {
1294    /// The virtual-file-store key for this execution, derived from the attached
1295    /// workspace. Carried through the `SessionFileSystem` trait's `SessionId`
1296    /// parameter (the store keys by `.uuid()`), so a shared-workspace session
1297    /// addresses the workspace's files rather than its own session-id keyspace.
1298    pub fn workspace_fs_key(&self) -> SessionId {
1299        SessionId::from_uuid(self.workspace_id.uuid())
1300    }
1301
1302    /// Override the attached workspace (default is the 1:1 session-derived id).
1303    pub fn with_workspace_id(mut self, workspace_id: WorkspaceId) -> Self {
1304        self.workspace_id = workspace_id;
1305        self
1306    }
1307
1308    /// Create a new tool context with just a session ID
1309    pub fn new(session_id: SessionId) -> Self {
1310        Self {
1311            session_id,
1312            workspace_id: WorkspaceId::from_uuid(session_id.uuid()),
1313            file_store: None,
1314            storage_store: None,
1315            image_store: None,
1316            provider_credential_store: None,
1317            utility_llm_service: None,
1318            egress_service: None,
1319            sqldb_store: None,
1320            message_retriever: None,
1321            session_store: None,
1322            session_mutator: None,
1323            agent_store: None,
1324            connection_resolver: None,
1325            schedule_store: None,
1326            platform_store: None,
1327            leased_resource_store: None,
1328            session_resource_registry: None,
1329            session_task_registry: None,
1330            event_emitter: None,
1331            event_context: None,
1332            tool_call_id: None,
1333            capability_registry: None,
1334            tool_registry: None,
1335            visible_tool_names: None,
1336            org_id: None,
1337            network_access: None,
1338            locale: None,
1339            budget_checker: None,
1340            payment_authority: None,
1341            subagent_spawn_store: None,
1342        }
1343    }
1344
1345    /// Create a context with a file store
1346    pub fn with_file_store(session_id: SessionId, file_store: Arc<dyn SessionFileSystem>) -> Self {
1347        Self {
1348            session_id,
1349            workspace_id: WorkspaceId::from_uuid(session_id.uuid()),
1350            file_store: Some(file_store),
1351            storage_store: None,
1352            image_store: None,
1353            provider_credential_store: None,
1354            utility_llm_service: None,
1355            egress_service: None,
1356            sqldb_store: None,
1357            message_retriever: None,
1358            session_store: None,
1359            session_mutator: None,
1360            agent_store: None,
1361            connection_resolver: None,
1362            schedule_store: None,
1363            platform_store: None,
1364            leased_resource_store: None,
1365            session_resource_registry: None,
1366            session_task_registry: None,
1367            event_emitter: None,
1368            event_context: None,
1369            tool_call_id: None,
1370            capability_registry: None,
1371            tool_registry: None,
1372            visible_tool_names: None,
1373            org_id: None,
1374            network_access: None,
1375            locale: None,
1376            budget_checker: None,
1377            payment_authority: None,
1378            subagent_spawn_store: None,
1379        }
1380    }
1381
1382    /// Create a context with a storage store
1383    pub fn with_storage_store(
1384        session_id: SessionId,
1385        storage_store: Arc<dyn SessionStorageStore>,
1386    ) -> Self {
1387        Self {
1388            session_id,
1389            workspace_id: WorkspaceId::from_uuid(session_id.uuid()),
1390            file_store: None,
1391            storage_store: Some(storage_store),
1392            image_store: None,
1393            provider_credential_store: None,
1394            utility_llm_service: None,
1395            egress_service: None,
1396            sqldb_store: None,
1397            message_retriever: None,
1398            session_store: None,
1399            session_mutator: None,
1400            agent_store: None,
1401            connection_resolver: None,
1402            schedule_store: None,
1403            platform_store: None,
1404            leased_resource_store: None,
1405            session_resource_registry: None,
1406            session_task_registry: None,
1407            event_emitter: None,
1408            event_context: None,
1409            tool_call_id: None,
1410            capability_registry: None,
1411            tool_registry: None,
1412            visible_tool_names: None,
1413            org_id: None,
1414            network_access: None,
1415            locale: None,
1416            budget_checker: None,
1417            payment_authority: None,
1418            subagent_spawn_store: None,
1419        }
1420    }
1421
1422    /// Create a context with both file store and storage store
1423    pub fn with_stores(
1424        session_id: SessionId,
1425        file_store: Arc<dyn SessionFileSystem>,
1426        storage_store: Arc<dyn SessionStorageStore>,
1427    ) -> Self {
1428        Self {
1429            session_id,
1430            workspace_id: WorkspaceId::from_uuid(session_id.uuid()),
1431            file_store: Some(file_store),
1432            storage_store: Some(storage_store),
1433            sqldb_store: None,
1434            image_store: None,
1435            provider_credential_store: None,
1436            utility_llm_service: None,
1437            egress_service: None,
1438            message_retriever: None,
1439            session_store: None,
1440            session_mutator: None,
1441            agent_store: None,
1442            connection_resolver: None,
1443            schedule_store: None,
1444            platform_store: None,
1445            leased_resource_store: None,
1446            session_resource_registry: None,
1447            session_task_registry: None,
1448            event_emitter: None,
1449            event_context: None,
1450            tool_call_id: None,
1451            capability_registry: None,
1452            tool_registry: None,
1453            visible_tool_names: None,
1454            org_id: None,
1455            network_access: None,
1456            locale: None,
1457            budget_checker: None,
1458            payment_authority: None,
1459            subagent_spawn_store: None,
1460        }
1461    }
1462
1463    /// Add a SQL database store to this context
1464    pub fn with_sqldb_store(mut self, sqldb_store: SessionSqlDbStoreRef) -> Self {
1465        self.sqldb_store = Some(sqldb_store);
1466        self
1467    }
1468
1469    /// Add a message retriever to this context
1470    pub fn with_message_retriever(
1471        mut self,
1472        retriever: Arc<dyn crate::message_retriever::MessageRetriever>,
1473    ) -> Self {
1474        self.message_retriever = Some(retriever);
1475        self
1476    }
1477
1478    /// Add a session store to this context.
1479    pub fn with_session_store(mut self, store: Arc<dyn SessionStore>) -> Self {
1480        self.session_store = Some(store);
1481        self
1482    }
1483
1484    /// Add a session mutator to this context.
1485    pub fn with_session_mutator(mut self, mutator: Arc<dyn SessionMutator>) -> Self {
1486        self.session_mutator = Some(mutator);
1487        self
1488    }
1489
1490    /// Add an agent store to this context.
1491    pub fn with_agent_store(mut self, store: Arc<dyn AgentStore>) -> Self {
1492        self.agent_store = Some(store);
1493        self
1494    }
1495
1496    /// Add a connection resolver to this context
1497    pub fn with_connection_resolver(mut self, resolver: Arc<dyn UserConnectionResolver>) -> Self {
1498        self.connection_resolver = Some(resolver);
1499        self
1500    }
1501
1502    /// Create a context with an image artifact store.
1503    pub fn with_image_store(
1504        session_id: SessionId,
1505        image_store: Arc<dyn ImageArtifactStore>,
1506    ) -> Self {
1507        Self {
1508            session_id,
1509            workspace_id: WorkspaceId::from_uuid(session_id.uuid()),
1510            file_store: None,
1511            storage_store: None,
1512            image_store: Some(image_store),
1513            provider_credential_store: None,
1514            utility_llm_service: None,
1515            egress_service: None,
1516            sqldb_store: None,
1517            message_retriever: None,
1518            session_store: None,
1519            session_mutator: None,
1520            agent_store: None,
1521            connection_resolver: None,
1522            schedule_store: None,
1523            platform_store: None,
1524            leased_resource_store: None,
1525            session_resource_registry: None,
1526            session_task_registry: None,
1527            event_emitter: None,
1528            event_context: None,
1529            tool_call_id: None,
1530            capability_registry: None,
1531            tool_registry: None,
1532            visible_tool_names: None,
1533            org_id: None,
1534            network_access: None,
1535            locale: None,
1536            budget_checker: None,
1537            payment_authority: None,
1538            subagent_spawn_store: None,
1539        }
1540    }
1541
1542    /// Set the provider credential store on this context.
1543    pub fn with_provider_credential_store(
1544        mut self,
1545        store: Arc<dyn ProviderCredentialStore>,
1546    ) -> Self {
1547        self.provider_credential_store = Some(store);
1548        self
1549    }
1550
1551    /// Set the utility LLM service on this context.
1552    pub fn with_utility_llm_service(mut self, service: Arc<dyn crate::UtilityLlmService>) -> Self {
1553        self.utility_llm_service = Some(service);
1554        self
1555    }
1556
1557    /// Set the outbound egress service on this context.
1558    pub fn with_egress_service(mut self, service: Arc<dyn crate::EgressService>) -> Self {
1559        self.egress_service = Some(service);
1560        self
1561    }
1562
1563    /// Set the outbound egress service on this context when available.
1564    /// Preserves any already-set service when `service` is `None`.
1565    pub fn with_egress_service_opt(
1566        mut self,
1567        service: Option<Arc<dyn crate::EgressService>>,
1568    ) -> Self {
1569        if let Some(service) = service {
1570            self.egress_service = Some(service);
1571        }
1572        self
1573    }
1574
1575    /// Set the session storage store on this context (builder method).
1576    pub fn with_storage_store_arc(mut self, store: Arc<dyn SessionStorageStore>) -> Self {
1577        self.storage_store = Some(store);
1578        self
1579    }
1580
1581    /// Add a session schedule store to this context.
1582    pub fn with_schedule_store(mut self, store: Arc<dyn SessionScheduleStore>) -> Self {
1583        self.schedule_store = Some(store);
1584        self
1585    }
1586
1587    /// Add a platform store to this context.
1588    pub fn with_platform_store(
1589        mut self,
1590        store: Arc<dyn crate::platform_store::PlatformStore>,
1591    ) -> Self {
1592        self.platform_store = Some(store);
1593        self
1594    }
1595
1596    /// Add a leased resource store to this context.
1597    pub fn with_leased_resource_store(mut self, store: Arc<dyn LeasedResourceStore>) -> Self {
1598        self.leased_resource_store = Some(store);
1599        self
1600    }
1601
1602    /// Add a session resource registry to this context.
1603    pub fn with_session_resource_registry(
1604        mut self,
1605        registry: Arc<dyn SessionResourceRegistry>,
1606    ) -> Self {
1607        self.session_resource_registry = Some(registry);
1608        self
1609    }
1610
1611    /// Add a session task registry to this context.
1612    pub fn with_session_task_registry(
1613        mut self,
1614        registry: Arc<dyn crate::session_task::SessionTaskRegistry>,
1615    ) -> Self {
1616        self.session_task_registry = Some(registry);
1617        self
1618    }
1619
1620    /// Set org ID for org-scoped operations.
1621    pub fn with_org_id(mut self, org_id: crate::typed_id::OrgId) -> Self {
1622        self.org_id = Some(org_id);
1623        self
1624    }
1625
1626    /// Set the active built-in tool registry on this context.
1627    pub fn with_tool_registry(mut self, registry: Arc<crate::tools::ToolRegistry>) -> Self {
1628        self.tool_registry = Some(registry);
1629        self
1630    }
1631
1632    /// Set the tool names visible to the model in this turn.
1633    pub fn with_visible_tool_names(mut self, names: Arc<HashSet<String>>) -> Self {
1634        self.visible_tool_names = Some(names);
1635        self
1636    }
1637
1638    /// Set the merged network access list for URL filtering.
1639    pub fn with_network_access(
1640        mut self,
1641        network_access: Option<crate::network_access::NetworkAccessList>,
1642    ) -> Self {
1643        self.network_access = network_access;
1644        self
1645    }
1646
1647    /// Set the internal payment authority for paid capability operations.
1648    pub fn with_payment_authority(mut self, authority: Arc<dyn PaymentAuthority>) -> Self {
1649        self.payment_authority = Some(authority);
1650        self
1651    }
1652
1653    /// Set the durable subagent spawn handle store (EVE-535).
1654    pub fn with_subagent_spawn_store(mut self, store: Arc<dyn SubagentSpawnStore>) -> Self {
1655        self.subagent_spawn_store = Some(store);
1656        self
1657    }
1658
1659    /// Emit a `tool.progress` event if an event emitter and context are available.
1660    ///
1661    /// This is a best-effort helper: failures are logged but not propagated,
1662    /// so tools never fail just because a progress event couldn't be sent.
1663    pub async fn emit_progress(&self, tool_name: &str, message: &str) {
1664        let (Some(emitter), Some(ctx), Some(call_id)) =
1665            (&self.event_emitter, &self.event_context, &self.tool_call_id)
1666        else {
1667            return;
1668        };
1669        if let Err(e) = emitter
1670            .emit(EventRequest::new(
1671                self.session_id,
1672                ctx.clone(),
1673                crate::events::ToolProgressData {
1674                    tool_call_id: call_id.clone(),
1675                    tool_name: tool_name.to_string(),
1676                    message: message.to_string(),
1677                    display_name: None,
1678                },
1679            ))
1680            .await
1681        {
1682            tracing::debug!(
1683                tool_call_id = call_id,
1684                tool_name,
1685                error = %e,
1686                "Failed to emit tool.progress event"
1687            );
1688        }
1689    }
1690
1691    /// Emit a `tool.output.delta` event if an event emitter and context are available.
1692    ///
1693    /// Streams incremental output chunks (e.g., stdout/stderr lines) for live
1694    /// rendering in UI and CLI. Best-effort: failures are logged, not propagated.
1695    pub async fn emit_tool_output(&self, tool_name: &str, delta: &str, stream: &str) {
1696        let (Some(emitter), Some(ctx), Some(call_id)) =
1697            (&self.event_emitter, &self.event_context, &self.tool_call_id)
1698        else {
1699            return;
1700        };
1701        if let Err(e) = emitter
1702            .emit(EventRequest::new(
1703                self.session_id,
1704                ctx.clone(),
1705                crate::events::ToolOutputDeltaData {
1706                    tool_call_id: call_id.clone(),
1707                    tool_name: tool_name.to_string(),
1708                    delta: delta.to_string(),
1709                    stream: stream.to_string(),
1710                },
1711            ))
1712            .await
1713        {
1714            tracing::debug!(
1715                tool_call_id = call_id,
1716                tool_name,
1717                error = %e,
1718                "Failed to emit tool.output.delta event"
1719            );
1720        }
1721    }
1722}
1723
1724impl std::fmt::Debug for ToolContext {
1725    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1726        f.debug_struct("ToolContext")
1727            .field("session_id", &self.session_id)
1728            .field("file_store", &self.file_store.is_some())
1729            .field("storage_store", &self.storage_store.is_some())
1730            .field("image_store", &self.image_store.is_some())
1731            .field(
1732                "provider_credential_store",
1733                &self.provider_credential_store.is_some(),
1734            )
1735            .field("utility_llm_service", &self.utility_llm_service.is_some())
1736            .field("egress_service", &self.egress_service.is_some())
1737            .field("sqldb_store", &self.sqldb_store.is_some())
1738            .field("message_retriever", &self.message_retriever.is_some())
1739            .field("session_store", &self.session_store.is_some())
1740            .field("session_mutator", &self.session_mutator.is_some())
1741            .field("agent_store", &self.agent_store.is_some())
1742            .field("connection_resolver", &self.connection_resolver.is_some())
1743            .field("schedule_store", &self.schedule_store.is_some())
1744            .field("platform_store", &self.platform_store.is_some())
1745            .field(
1746                "leased_resource_store",
1747                &self.leased_resource_store.is_some(),
1748            )
1749            .field("event_emitter", &self.event_emitter.is_some())
1750            .field("tool_registry", &self.tool_registry.is_some())
1751            .field("payment_authority", &self.payment_authority.is_some())
1752            .field("subagent_spawn_store", &self.subagent_spawn_store.is_some())
1753            .field("org_id", &self.org_id)
1754            .finish()
1755    }
1756}
1757
1758// ============================================================================
1759// EventEmitter - For emitting events
1760// ============================================================================
1761
1762use crate::events::{Event, EventRequest};
1763
1764/// Trait for emitting events following the standard event protocol
1765///
1766/// Implementations can:
1767/// - Store events in a database
1768/// - Keep events in memory for testing
1769/// - Stream events via SSE/WebSocket
1770/// - Log events for debugging
1771///
1772/// Events follow a consistent schema: id, type, ts, context, data.
1773/// See specs/events.md for the full event protocol specification.
1774#[async_trait]
1775pub trait EventEmitter: Send + Sync {
1776    /// Emit an event request
1777    ///
1778    /// Takes an EventRequest (without id/sequence) and returns the stored Event
1779    /// with id and sequence assigned by the storage layer.
1780    async fn emit(&self, request: EventRequest) -> Result<Event>;
1781}
1782
1783/// Blanket impl: `Arc<E>` delegates to the inner emitter.
1784#[async_trait]
1785impl<E: EventEmitter + ?Sized> EventEmitter for Arc<E> {
1786    async fn emit(&self, request: EventRequest) -> Result<Event> {
1787        (**self).emit(request).await
1788    }
1789}
1790
1791/// No-op event emitter for when event emission is not needed
1792///
1793/// This is useful for testing or when event observability is disabled.
1794#[derive(Debug, Clone, Default)]
1795pub struct NoopEventEmitter;
1796
1797#[async_trait]
1798impl EventEmitter for NoopEventEmitter {
1799    async fn emit(&self, request: EventRequest) -> Result<Event> {
1800        // Return a dummy event with sequence 0
1801        Ok(request.into_event(crate::typed_id::EventId::new(), 0))
1802    }
1803}
1804
1805// Note: EventListener trait has been moved to event_listeners.rs module.
1806// Use `everruns_core::EventListener` or `everruns_core::event_listeners::EventListener`.
1807
1808// ============================================================================
1809// ImageResolver - For resolving image_file content to actual image data
1810// ============================================================================
1811
1812/// Resolved image data for LLM consumption
1813///
1814/// This struct contains the actual image data in a format suitable for
1815/// sending to LLM providers. Both OpenAI and Anthropic accept base64-encoded
1816/// images with media type information.
1817#[derive(Debug, Clone)]
1818pub struct ResolvedImage {
1819    /// Base64-encoded image data (without data URL prefix)
1820    pub base64: String,
1821    /// MIME type (e.g., "image/png", "image/jpeg")
1822    pub media_type: String,
1823}
1824
1825impl ResolvedImage {
1826    /// Create a new resolved image
1827    pub fn new(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
1828        Self {
1829            base64: base64.into(),
1830            media_type: media_type.into(),
1831        }
1832    }
1833
1834    /// Convert to a data URL suitable for OpenAI Vision API
1835    ///
1836    /// Format: `data:{media_type};base64,{base64_data}`
1837    pub fn to_data_url(&self) -> String {
1838        format!("data:{};base64,{}", self.media_type, self.base64)
1839    }
1840}
1841
1842/// Trait for resolving image_file content parts to actual image data
1843///
1844/// When building LLM messages, `image_file` content parts contain only
1845/// a reference (UUID) to an uploaded image. This trait allows resolving
1846/// those references to actual image data.
1847///
1848/// # Provider-specific formatting
1849///
1850/// The resolved image data is then converted to provider-specific formats:
1851///
1852/// **OpenAI Vision:**
1853/// ```json
1854/// {
1855///   "type": "image_url",
1856///   "image_url": { "url": "data:image/png;base64,..." }
1857/// }
1858/// ```
1859///
1860/// **Anthropic Vision:**
1861/// ```json
1862/// {
1863///   "type": "image",
1864///   "source": { "type": "base64", "media_type": "image/png", "data": "..." }
1865/// }
1866/// ```
1867///
1868/// # Implementation notes
1869///
1870/// Implementations should:
1871/// - Fetch image data from storage (database, S3, etc.)
1872/// - Return base64-encoded data with media type
1873/// - Handle missing images gracefully (return None)
1874#[async_trait]
1875pub trait ImageResolver: Send + Sync {
1876    /// Resolve an image_file reference to actual image data
1877    ///
1878    /// Returns `None` if the image is not found.
1879    async fn resolve_image(&self, image_id: Uuid) -> Result<Option<ResolvedImage>>;
1880}
1881
1882// ============================================================================
1883// SubagentSpawnStore — durable spawn handles for subagent reattach (EVE-535)
1884// ============================================================================
1885
1886/// Result of attempting to claim a subagent spawn slot.
1887#[derive(Debug)]
1888pub enum SpawnClaimResult {
1889    /// First claim — child session does not yet exist.
1890    /// Proceed to create the child, then call `register_child_session`.
1891    Claimed {
1892        spawn_handle_id: uuid::Uuid,
1893        claim_token: uuid::Uuid,
1894    },
1895    /// Row exists but `child_session_id` was never registered (crash between
1896    /// claim and `register_child_session`). Re-create the child and call
1897    /// `register_child_session` — same flow as `Claimed`.
1898    ClaimedPendingChild {
1899        spawn_handle_id: uuid::Uuid,
1900        claim_token: uuid::Uuid,
1901    },
1902    /// Child session was created and is still running.
1903    /// Reattach: wait for the existing child and settle with the stored claim_token.
1904    AlreadyRunning {
1905        child_session_id: crate::typed_id::SessionId,
1906        /// Stored claim token — must be used for `settle_spawn` on this replay.
1907        claim_token: uuid::Uuid,
1908    },
1909    /// Child already finished on a previous execution.
1910    /// Fast-path: return the stored result immediately without waiting.
1911    AlreadySettled {
1912        child_session_id: crate::typed_id::SessionId,
1913        /// The `wait_for_idle` return value from the original execution.
1914        terminal_status: String,
1915        terminal_result: String,
1916    },
1917}
1918
1919/// Durable spawn handle store for subagent idempotency (EVE-535).
1920///
1921/// Maps `(parent_session_id, tool_call_id) → child_session_id` so that when
1922/// a parent's `act` is reclaimed mid-`wait_for_idle`, the tool can reattach
1923/// to the existing child instead of spawning a duplicate.
1924///
1925/// Lifecycle: claim → register_child_session → settle_spawn.
1926#[async_trait]
1927pub trait SubagentSpawnStore: Send + Sync + 'static {
1928    /// Attempt to claim a spawn slot for `(parent_session_id, tool_call_id)`.
1929    ///
1930    /// Does NOT accept `child_session_id` — the child session does not exist yet.
1931    /// Call `register_child_session` with the actual child ID after creating it.
1932    async fn try_claim_spawn(
1933        &self,
1934        parent_session_id: crate::typed_id::SessionId,
1935        tool_call_id: &str,
1936        claim_token: uuid::Uuid,
1937    ) -> Result<SpawnClaimResult>;
1938
1939    /// Register the actual child session ID after it has been created.
1940    ///
1941    /// Must be called after `try_claim_spawn` returns `Claimed` or
1942    /// `ClaimedPendingChild`, before waiting for the child to complete.
1943    async fn register_child_session(
1944        &self,
1945        spawn_handle_id: uuid::Uuid,
1946        claim_token: uuid::Uuid,
1947        child_session_id: crate::typed_id::SessionId,
1948    ) -> Result<()>;
1949
1950    /// Record the terminal result once the child has completed.
1951    ///
1952    /// `claim_token` must match the stored token. `terminal_status` is the
1953    /// `wait_for_idle` return value ("idle", "error", "timeout", etc.) and
1954    /// `terminal_result` is the last agent message.
1955    async fn settle_spawn(
1956        &self,
1957        parent_session_id: crate::typed_id::SessionId,
1958        tool_call_id: &str,
1959        claim_token: uuid::Uuid,
1960        terminal_status: &str,
1961        terminal_result: &str,
1962    ) -> Result<()>;
1963}
1964
1965/// Blanket impl: `Arc<S>` delegates to the inner store.
1966#[async_trait]
1967impl<S: SubagentSpawnStore + ?Sized> SubagentSpawnStore for Arc<S> {
1968    async fn try_claim_spawn(
1969        &self,
1970        parent_session_id: crate::typed_id::SessionId,
1971        tool_call_id: &str,
1972        claim_token: uuid::Uuid,
1973    ) -> Result<SpawnClaimResult> {
1974        (**self)
1975            .try_claim_spawn(parent_session_id, tool_call_id, claim_token)
1976            .await
1977    }
1978
1979    async fn register_child_session(
1980        &self,
1981        spawn_handle_id: uuid::Uuid,
1982        claim_token: uuid::Uuid,
1983        child_session_id: crate::typed_id::SessionId,
1984    ) -> Result<()> {
1985        (**self)
1986            .register_child_session(spawn_handle_id, claim_token, child_session_id)
1987            .await
1988    }
1989
1990    async fn settle_spawn(
1991        &self,
1992        parent_session_id: crate::typed_id::SessionId,
1993        tool_call_id: &str,
1994        claim_token: uuid::Uuid,
1995        terminal_status: &str,
1996        terminal_result: &str,
1997    ) -> Result<()> {
1998        (**self)
1999            .settle_spawn(
2000                parent_session_id,
2001                tool_call_id,
2002                claim_token,
2003                terminal_status,
2004                terminal_result,
2005            )
2006            .await
2007    }
2008}
2009
2010/// No-op spawn store — used when no durable store is configured (dev/test).
2011///
2012/// Always claims (no dedup); settle and register are no-ops.
2013pub struct NoopSubagentSpawnStore;
2014
2015#[async_trait]
2016impl SubagentSpawnStore for NoopSubagentSpawnStore {
2017    async fn try_claim_spawn(
2018        &self,
2019        _parent_session_id: crate::typed_id::SessionId,
2020        _tool_call_id: &str,
2021        claim_token: uuid::Uuid,
2022    ) -> Result<SpawnClaimResult> {
2023        Ok(SpawnClaimResult::Claimed {
2024            spawn_handle_id: uuid::Uuid::new_v4(),
2025            claim_token,
2026        })
2027    }
2028
2029    async fn register_child_session(
2030        &self,
2031        _spawn_handle_id: uuid::Uuid,
2032        _claim_token: uuid::Uuid,
2033        _child_session_id: crate::typed_id::SessionId,
2034    ) -> Result<()> {
2035        Ok(())
2036    }
2037
2038    async fn settle_spawn(
2039        &self,
2040        _parent_session_id: crate::typed_id::SessionId,
2041        _tool_call_id: &str,
2042        _claim_token: uuid::Uuid,
2043        _terminal_status: &str,
2044        _terminal_result: &str,
2045    ) -> Result<()> {
2046        Ok(())
2047    }
2048}
2049
2050// ============================================================================
2051// Tests
2052// ============================================================================
2053
2054#[cfg(test)]
2055mod tests {
2056    use super::*;
2057
2058    #[test]
2059    fn test_resolved_image_new() {
2060        let image = ResolvedImage::new("SGVsbG8=", "image/png");
2061        assert_eq!(image.base64, "SGVsbG8=");
2062        assert_eq!(image.media_type, "image/png");
2063    }
2064
2065    #[test]
2066    fn test_resolved_image_to_data_url() {
2067        let image = ResolvedImage::new("SGVsbG8=", "image/png");
2068        let data_url = image.to_data_url();
2069        assert_eq!(data_url, "data:image/png;base64,SGVsbG8=");
2070    }
2071
2072    #[test]
2073    fn test_resolved_image_jpeg() {
2074        let image = ResolvedImage::new("base64data", "image/jpeg");
2075        let data_url = image.to_data_url();
2076        assert!(data_url.starts_with("data:image/jpeg;base64,"));
2077    }
2078}