Skip to main content

a3s_code_core/
agent_api.rs

1//! Agent Facade API
2//!
3//! High-level, ergonomic API for using A3S Code as an embedded library.
4//!
5//! ## Example
6//!
7//! ```rust,no_run
8//! use a3s_code_core::Agent;
9//!
10//! # async fn run() -> anyhow::Result<()> {
11//! let agent = Agent::new("agent.hcl").await?;
12//! let session = agent.session("/my-project", None)?;
13//! let result = session.send("Explain the auth module", None).await?;
14//! println!("{}", result.text);
15//! # Ok(())
16//! # }
17//! ```
18
19use crate::agent::{AgentConfig, AgentEvent, AgentLoop, AgentResult};
20use crate::config::CodeConfig;
21use crate::error::Result;
22use crate::llm::{LlmClient, Message};
23use crate::queue::{
24    ExternalTask, ExternalTaskResult, LaneHandlerConfig, SessionLane, SessionQueueConfig,
25    SessionQueueStats,
26};
27use crate::session_lane_queue::SessionLaneQueue;
28use crate::tools::{ToolContext, ToolExecutor};
29use a3s_lane::{DeadLetter, MetricsSnapshot};
30use anyhow::Context;
31use std::path::{Path, PathBuf};
32use std::sync::{Arc, RwLock};
33use tokio::sync::{broadcast, mpsc};
34use tokio::task::JoinHandle;
35
36// ============================================================================
37// ToolCallResult
38// ============================================================================
39
40/// Result of a direct tool execution (no LLM).
41#[derive(Debug, Clone)]
42pub struct ToolCallResult {
43    pub name: String,
44    pub output: String,
45    pub exit_code: i32,
46}
47
48// ============================================================================
49// SessionOptions
50// ============================================================================
51
52/// Optional per-session overrides.
53#[derive(Clone, Default)]
54pub struct SessionOptions {
55    /// Override the default model. Format: `"provider/model"` (e.g., `"openai/gpt-4o"`).
56    pub model: Option<String>,
57    /// Extra directories to scan for agent files.
58    /// Merged with any global `agent_dirs` from [`CodeConfig`].
59    pub agent_dirs: Vec<PathBuf>,
60    /// Optional queue configuration for lane-based tool execution.
61    ///
62    /// When set, enables priority-based tool scheduling with parallel execution
63    /// of read-only (Query-lane) tools, DLQ, metrics, and external task handling.
64    pub queue_config: Option<SessionQueueConfig>,
65    /// Optional security provider for taint tracking and output sanitization
66    pub security_provider: Option<Arc<dyn crate::security::SecurityProvider>>,
67    /// Optional context providers for RAG
68    pub context_providers: Vec<Arc<dyn crate::context::ContextProvider>>,
69    /// Optional confirmation manager for HITL
70    pub confirmation_manager: Option<Arc<dyn crate::hitl::ConfirmationProvider>>,
71    /// Optional permission checker
72    pub permission_checker: Option<Arc<dyn crate::permissions::PermissionChecker>>,
73    /// Enable planning
74    pub planning_enabled: bool,
75    /// Enable goal tracking
76    pub goal_tracking: bool,
77    /// Optional skill registry for instruction injection
78    pub skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
79    /// Optional memory store for long-term memory persistence
80    pub memory_store: Option<Arc<dyn crate::memory::MemoryStore>>,
81    /// Deferred file memory directory — constructed async in `build_session()`
82    pub(crate) file_memory_dir: Option<PathBuf>,
83    /// Optional session store for persistence
84    pub session_store: Option<Arc<dyn crate::store::SessionStore>>,
85    /// Explicit session ID (auto-generated if not set)
86    pub session_id: Option<String>,
87    /// Auto-save after each `send()` call
88    pub auto_save: bool,
89    /// Max consecutive parse errors before aborting (overrides default of 2).
90    /// `None` uses the `AgentConfig` default.
91    pub max_parse_retries: Option<u32>,
92    /// Per-tool execution timeout in milliseconds.
93    /// `None` = no timeout (default).
94    pub tool_timeout_ms: Option<u64>,
95    /// Circuit-breaker threshold: max consecutive LLM API failures before
96    /// aborting in non-streaming mode (overrides default of 3).
97    /// `None` uses the `AgentConfig` default.
98    pub circuit_breaker_threshold: Option<u32>,
99    /// Optional sandbox configuration.
100    ///
101    /// When set, `bash` tool commands are routed through an A3S Box MicroVM
102    /// sandbox instead of `std::process::Command`. Requires the `sandbox`
103    /// Cargo feature to be enabled.
104    pub sandbox_config: Option<crate::sandbox::SandboxConfig>,
105}
106
107impl std::fmt::Debug for SessionOptions {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.debug_struct("SessionOptions")
110            .field("model", &self.model)
111            .field("agent_dirs", &self.agent_dirs)
112            .field("queue_config", &self.queue_config)
113            .field("security_provider", &self.security_provider.is_some())
114            .field("context_providers", &self.context_providers.len())
115            .field("confirmation_manager", &self.confirmation_manager.is_some())
116            .field("permission_checker", &self.permission_checker.is_some())
117            .field("planning_enabled", &self.planning_enabled)
118            .field("goal_tracking", &self.goal_tracking)
119            .field(
120                "skill_registry",
121                &self
122                    .skill_registry
123                    .as_ref()
124                    .map(|r| format!("{} skills", r.len())),
125            )
126            .field("memory_store", &self.memory_store.is_some())
127            .field("session_store", &self.session_store.is_some())
128            .field("session_id", &self.session_id)
129            .field("auto_save", &self.auto_save)
130            .field("max_parse_retries", &self.max_parse_retries)
131            .field("tool_timeout_ms", &self.tool_timeout_ms)
132            .field("circuit_breaker_threshold", &self.circuit_breaker_threshold)
133            .field("sandbox_config", &self.sandbox_config)
134            .finish()
135    }
136}
137
138impl SessionOptions {
139    pub fn new() -> Self {
140        Self::default()
141    }
142
143    pub fn with_model(mut self, model: impl Into<String>) -> Self {
144        self.model = Some(model.into());
145        self
146    }
147
148    pub fn with_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
149        self.agent_dirs.push(dir.into());
150        self
151    }
152
153    pub fn with_queue_config(mut self, config: SessionQueueConfig) -> Self {
154        self.queue_config = Some(config);
155        self
156    }
157
158    /// Enable default security provider with taint tracking and output sanitization
159    pub fn with_default_security(mut self) -> Self {
160        self.security_provider = Some(Arc::new(crate::security::DefaultSecurityProvider::new()));
161        self
162    }
163
164    /// Set a custom security provider
165    pub fn with_security_provider(
166        mut self,
167        provider: Arc<dyn crate::security::SecurityProvider>,
168    ) -> Self {
169        self.security_provider = Some(provider);
170        self
171    }
172
173    /// Add a file system context provider for simple RAG
174    pub fn with_fs_context(mut self, root_path: impl Into<PathBuf>) -> Self {
175        let config = crate::context::FileSystemContextConfig::new(root_path);
176        self.context_providers
177            .push(Arc::new(crate::context::FileSystemContextProvider::new(
178                config,
179            )));
180        self
181    }
182
183    /// Add a custom context provider
184    pub fn with_context_provider(
185        mut self,
186        provider: Arc<dyn crate::context::ContextProvider>,
187    ) -> Self {
188        self.context_providers.push(provider);
189        self
190    }
191
192    /// Set a confirmation manager for HITL
193    pub fn with_confirmation_manager(
194        mut self,
195        manager: Arc<dyn crate::hitl::ConfirmationProvider>,
196    ) -> Self {
197        self.confirmation_manager = Some(manager);
198        self
199    }
200
201    /// Set a permission checker
202    pub fn with_permission_checker(
203        mut self,
204        checker: Arc<dyn crate::permissions::PermissionChecker>,
205    ) -> Self {
206        self.permission_checker = Some(checker);
207        self
208    }
209
210    /// Allow all tool execution without confirmation (permissive mode).
211    ///
212    /// Use this for automated scripts, demos, and CI environments where
213    /// human-in-the-loop confirmation is not needed. Without this (or a
214    /// custom permission checker), the default is `Ask`, which requires a
215    /// HITL confirmation manager to be configured.
216    pub fn with_permissive_policy(self) -> Self {
217        self.with_permission_checker(Arc::new(crate::permissions::PermissionPolicy::permissive()))
218    }
219
220    /// Enable planning
221    pub fn with_planning(mut self, enabled: bool) -> Self {
222        self.planning_enabled = enabled;
223        self
224    }
225
226    /// Enable goal tracking
227    pub fn with_goal_tracking(mut self, enabled: bool) -> Self {
228        self.goal_tracking = enabled;
229        self
230    }
231
232    /// Add a skill registry with built-in skills
233    pub fn with_builtin_skills(mut self) -> Self {
234        self.skill_registry = Some(Arc::new(crate::skills::SkillRegistry::with_builtins()));
235        self
236    }
237
238    /// Add a custom skill registry
239    pub fn with_skill_registry(mut self, registry: Arc<crate::skills::SkillRegistry>) -> Self {
240        self.skill_registry = Some(registry);
241        self
242    }
243
244    /// Load skills from a directory
245    pub fn with_skills_from_dir(mut self, dir: impl AsRef<std::path::Path>) -> Self {
246        let registry = self
247            .skill_registry
248            .unwrap_or_else(|| Arc::new(crate::skills::SkillRegistry::new()));
249        let _ = registry.load_from_dir(dir);
250        self.skill_registry = Some(registry);
251        self
252    }
253
254    /// Set a custom memory store
255    pub fn with_memory(mut self, store: Arc<dyn crate::memory::MemoryStore>) -> Self {
256        self.memory_store = Some(store);
257        self
258    }
259
260    /// Use a file-based memory store at the given directory.
261    ///
262    /// The store is created lazily when the session is built (requires async).
263    /// This stores the directory path; `FileMemoryStore::new()` is called during
264    /// session construction.
265    pub fn with_file_memory(mut self, dir: impl Into<PathBuf>) -> Self {
266        self.file_memory_dir = Some(dir.into());
267        self
268    }
269
270    /// Set a session store for persistence
271    pub fn with_session_store(mut self, store: Arc<dyn crate::store::SessionStore>) -> Self {
272        self.session_store = Some(store);
273        self
274    }
275
276    /// Use a file-based session store at the given directory
277    pub fn with_file_session_store(mut self, dir: impl Into<PathBuf>) -> Self {
278        let dir = dir.into();
279        match tokio::runtime::Handle::try_current() {
280            Ok(handle) => {
281                match tokio::task::block_in_place(|| {
282                    handle.block_on(crate::store::FileSessionStore::new(dir))
283                }) {
284                    Ok(store) => {
285                        self.session_store =
286                            Some(Arc::new(store) as Arc<dyn crate::store::SessionStore>);
287                    }
288                    Err(e) => {
289                        tracing::warn!("Failed to create file session store: {}", e);
290                    }
291                }
292            }
293            Err(_) => {
294                tracing::warn!(
295                    "No async runtime available for file session store — persistence disabled"
296                );
297            }
298        }
299        self
300    }
301
302    /// Set an explicit session ID (auto-generated UUID if not set)
303    pub fn with_session_id(mut self, id: impl Into<String>) -> Self {
304        self.session_id = Some(id.into());
305        self
306    }
307
308    /// Enable auto-save after each `send()` call
309    pub fn with_auto_save(mut self, enabled: bool) -> Self {
310        self.auto_save = enabled;
311        self
312    }
313
314    /// Set the maximum number of consecutive malformed-tool-args errors before
315    /// the agent loop bails.
316    ///
317    /// Default: 2 (the LLM gets two chances to self-correct before the session
318    /// is aborted).
319    pub fn with_parse_retries(mut self, max: u32) -> Self {
320        self.max_parse_retries = Some(max);
321        self
322    }
323
324    /// Set a per-tool execution timeout.
325    ///
326    /// When set, each tool execution is wrapped in `tokio::time::timeout`.
327    /// A timeout produces an error message that is fed back to the LLM
328    /// (the session continues).
329    pub fn with_tool_timeout(mut self, timeout_ms: u64) -> Self {
330        self.tool_timeout_ms = Some(timeout_ms);
331        self
332    }
333
334    /// Set the circuit-breaker threshold.
335    ///
336    /// In non-streaming mode, the agent retries transient LLM API failures up
337    /// to this many times (with exponential backoff) before aborting.
338    /// Default: 3 attempts.
339    pub fn with_circuit_breaker(mut self, threshold: u32) -> Self {
340        self.circuit_breaker_threshold = Some(threshold);
341        self
342    }
343
344    /// Enable all resilience defaults with sensible values:
345    ///
346    /// - `max_parse_retries = 2`
347    /// - `tool_timeout_ms = 120_000` (2 minutes)
348    /// - `circuit_breaker_threshold = 3`
349    pub fn with_resilience_defaults(self) -> Self {
350        self.with_parse_retries(2)
351            .with_tool_timeout(120_000)
352            .with_circuit_breaker(3)
353    }
354
355    /// Route `bash` tool execution through an A3S Box MicroVM sandbox.
356    ///
357    /// The workspace directory is mounted read-write at `/workspace` inside
358    /// the sandbox. Requires the `sandbox` Cargo feature; without it a warning
359    /// is logged and bash commands continue to run locally.
360    ///
361    /// # Example
362    ///
363    /// ```rust,no_run
364    /// use a3s_code_core::{SessionOptions, SandboxConfig};
365    ///
366    /// SessionOptions::new().with_sandbox(SandboxConfig {
367    ///     image: "ubuntu:22.04".into(),
368    ///     memory_mb: 512,
369    ///     network: false,
370    ///     ..SandboxConfig::default()
371    /// });
372    /// ```
373    pub fn with_sandbox(mut self, config: crate::sandbox::SandboxConfig) -> Self {
374        self.sandbox_config = Some(config);
375        self
376    }
377}
378
379// ============================================================================
380// Agent
381// ============================================================================
382
383/// High-level agent facade.
384///
385/// Holds the LLM client and agent config. Workspace-independent.
386/// Use [`Agent::session()`] to bind to a workspace.
387pub struct Agent {
388    llm_client: Arc<dyn LlmClient>,
389    code_config: CodeConfig,
390    config: AgentConfig,
391}
392
393impl std::fmt::Debug for Agent {
394    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395        f.debug_struct("Agent").finish()
396    }
397}
398
399impl Agent {
400    /// Create from a config file path or inline config string.
401    ///
402    /// Auto-detects: file path (.hcl/.json) vs inline JSON vs inline HCL.
403    pub async fn new(config_source: impl Into<String>) -> Result<Self> {
404        let source = config_source.into();
405        let path = Path::new(&source);
406
407        let config = if path.extension().is_some() && path.exists() {
408            CodeConfig::from_file(path)
409                .with_context(|| format!("Failed to load config: {}", path.display()))?
410        } else {
411            // Try to parse as HCL string
412            CodeConfig::from_hcl(&source).context("Failed to parse config as HCL string")?
413        };
414
415        Self::from_config(config).await
416    }
417
418    /// Create from a config file path or inline config string.
419    ///
420    /// Alias for [`Agent::new()`] — provides a consistent API with
421    /// the Python and Node.js SDKs.
422    pub async fn create(config_source: impl Into<String>) -> Result<Self> {
423        Self::new(config_source).await
424    }
425
426    /// Create from a [`CodeConfig`] struct.
427    pub async fn from_config(config: CodeConfig) -> Result<Self> {
428        let llm_config = config
429            .default_llm_config()
430            .context("default_model must be set in 'provider/model' format with a valid API key")?;
431        let llm_client = crate::llm::create_client_with_config(llm_config);
432
433        let agent_config = AgentConfig {
434            max_tool_rounds: config
435                .max_tool_rounds
436                .unwrap_or(AgentConfig::default().max_tool_rounds),
437            ..AgentConfig::default()
438        };
439
440        Ok(Agent {
441            llm_client,
442            code_config: config,
443            config: agent_config,
444        })
445    }
446
447    /// Bind to a workspace directory, returning an [`AgentSession`].
448    ///
449    /// Pass `None` for defaults, or `Some(SessionOptions)` to override
450    /// the model, agent directories for this session.
451    pub fn session(
452        &self,
453        workspace: impl Into<String>,
454        options: Option<SessionOptions>,
455    ) -> Result<AgentSession> {
456        let opts = options.unwrap_or_default();
457
458        let llm_client = if let Some(ref model) = opts.model {
459            let (provider_name, model_id) = model
460                .split_once('/')
461                .context("model format must be 'provider/model' (e.g., 'openai/gpt-4o')")?;
462
463            let llm_config = self
464                .code_config
465                .llm_config(provider_name, model_id)
466                .with_context(|| {
467                    format!("provider '{provider_name}' or model '{model_id}' not found in config")
468                })?;
469
470            crate::llm::create_client_with_config(llm_config)
471        } else {
472            self.llm_client.clone()
473        };
474
475        self.build_session(workspace.into(), llm_client, &opts)
476    }
477
478    /// Resume a previously saved session by ID.
479    ///
480    /// Loads the session data from the store, rebuilds the `AgentSession` with
481    /// the saved conversation history, and returns it ready for continued use.
482    ///
483    /// The `options` must include a `session_store` (or `with_file_session_store`)
484    /// that contains the saved session.
485    pub fn resume_session(
486        &self,
487        session_id: &str,
488        options: SessionOptions,
489    ) -> Result<AgentSession> {
490        let store = options.session_store.as_ref().ok_or_else(|| {
491            crate::error::CodeError::Session(
492                "resume_session requires a session_store in SessionOptions".to_string(),
493            )
494        })?;
495
496        // Load session data from store
497        let data = match tokio::runtime::Handle::try_current() {
498            Ok(handle) => tokio::task::block_in_place(|| handle.block_on(store.load(session_id)))
499                .map_err(|e| {
500                crate::error::CodeError::Session(format!(
501                    "Failed to load session {}: {}",
502                    session_id, e
503                ))
504            })?,
505            Err(_) => {
506                return Err(crate::error::CodeError::Session(
507                    "No async runtime available for session resume".to_string(),
508                ))
509            }
510        };
511
512        let data = data.ok_or_else(|| {
513            crate::error::CodeError::Session(format!("Session not found: {}", session_id))
514        })?;
515
516        // Build session with the saved workspace
517        let mut opts = options;
518        opts.session_id = Some(data.id.clone());
519
520        let llm_client = if let Some(ref model) = opts.model {
521            let (provider_name, model_id) = model
522                .split_once('/')
523                .context("model format must be 'provider/model'")?;
524            let llm_config = self
525                .code_config
526                .llm_config(provider_name, model_id)
527                .with_context(|| {
528                    format!("provider '{provider_name}' or model '{model_id}' not found")
529                })?;
530            crate::llm::create_client_with_config(llm_config)
531        } else {
532            self.llm_client.clone()
533        };
534
535        let session = self.build_session(data.config.workspace.clone(), llm_client, &opts)?;
536
537        // Restore conversation history
538        *session.history.write().unwrap() = data.messages;
539
540        Ok(session)
541    }
542
543    fn build_session(
544        &self,
545        workspace: String,
546        llm_client: Arc<dyn LlmClient>,
547        opts: &SessionOptions,
548    ) -> Result<AgentSession> {
549        let canonical =
550            std::fs::canonicalize(&workspace).unwrap_or_else(|_| PathBuf::from(&workspace));
551
552        let tool_executor = Arc::new(ToolExecutor::new(canonical.display().to_string()));
553        let tool_defs = tool_executor.definitions();
554
555        // Augment system prompt with skill instructions
556        let mut system_prompt = self.config.system_prompt.clone();
557        if let Some(ref registry) = opts.skill_registry {
558            let skill_prompt = registry.to_system_prompt();
559            if !skill_prompt.is_empty() {
560                system_prompt = match system_prompt {
561                    Some(existing) => Some(format!("{}\n\n{}", existing, skill_prompt)),
562                    None => Some(skill_prompt),
563                };
564            }
565        }
566
567        let base = self.config.clone();
568        let config = AgentConfig {
569            system_prompt,
570            tools: tool_defs,
571            permission_checker: opts.permission_checker.clone(),
572            confirmation_manager: opts.confirmation_manager.clone(),
573            context_providers: opts.context_providers.clone(),
574            planning_enabled: opts.planning_enabled,
575            goal_tracking: opts.goal_tracking,
576            skill_registry: opts.skill_registry.clone(),
577            max_parse_retries: opts.max_parse_retries.unwrap_or(base.max_parse_retries),
578            tool_timeout_ms: opts.tool_timeout_ms.or(base.tool_timeout_ms),
579            circuit_breaker_threshold: opts
580                .circuit_breaker_threshold
581                .unwrap_or(base.circuit_breaker_threshold),
582            ..base
583        };
584
585        // Create lane queue if configured
586        let command_queue = if let Some(ref queue_config) = opts.queue_config {
587            let (event_tx, _) = broadcast::channel(256);
588            let session_id = uuid::Uuid::new_v4().to_string();
589            let rt = tokio::runtime::Handle::try_current();
590
591            match rt {
592                Ok(handle) => {
593                    // We're inside an async runtime — use block_in_place
594                    let queue = tokio::task::block_in_place(|| {
595                        handle.block_on(SessionLaneQueue::new(
596                            &session_id,
597                            queue_config.clone(),
598                            event_tx,
599                        ))
600                    });
601                    match queue {
602                        Ok(q) => {
603                            // Start the queue
604                            let q = Arc::new(q);
605                            let q2 = Arc::clone(&q);
606                            tokio::task::block_in_place(|| {
607                                handle.block_on(async { q2.start().await.ok() })
608                            });
609                            Some(q)
610                        }
611                        Err(e) => {
612                            tracing::warn!("Failed to create session lane queue: {}", e);
613                            None
614                        }
615                    }
616                }
617                Err(_) => {
618                    tracing::warn!(
619                        "No async runtime available for queue creation — queue disabled"
620                    );
621                    None
622                }
623            }
624        } else {
625            None
626        };
627
628        // Create tool context with search config if available
629        let mut tool_context = ToolContext::new(canonical.clone());
630        if let Some(ref search_config) = self.code_config.search {
631            tool_context = tool_context.with_search_config(search_config.clone());
632        }
633
634        // Wire sandbox when configured.
635        #[cfg(feature = "sandbox")]
636        if let Some(ref sandbox_cfg) = opts.sandbox_config {
637            let handle: Arc<dyn crate::sandbox::BashSandbox> =
638                Arc::new(crate::sandbox::BoxSandboxHandle::new(
639                    sandbox_cfg.clone(),
640                    canonical.display().to_string(),
641                ));
642            // Update the registry's default context so that direct
643            // `AgentSession::bash()` calls also use the sandbox.
644            tool_executor.registry().set_sandbox(Arc::clone(&handle));
645            tool_context = tool_context.with_sandbox(handle);
646        }
647        #[cfg(not(feature = "sandbox"))]
648        if opts.sandbox_config.is_some() {
649            tracing::warn!(
650                "sandbox_config is set but the `sandbox` Cargo feature is not enabled \
651                 — bash commands will run locally"
652            );
653        }
654
655        // Resolve memory store: explicit store takes priority, then file_memory_dir
656        let memory = {
657            let store = if let Some(ref store) = opts.memory_store {
658                Some(Arc::clone(store))
659            } else if let Some(ref dir) = opts.file_memory_dir {
660                match tokio::runtime::Handle::try_current() {
661                    Ok(handle) => {
662                        let dir = dir.clone();
663                        match tokio::task::block_in_place(|| {
664                            handle.block_on(crate::memory::FileMemoryStore::new(dir))
665                        }) {
666                            Ok(store) => {
667                                Some(Arc::new(store) as Arc<dyn crate::memory::MemoryStore>)
668                            }
669                            Err(e) => {
670                                tracing::warn!("Failed to create file memory store: {}", e);
671                                None
672                            }
673                        }
674                    }
675                    Err(_) => {
676                        tracing::warn!(
677                            "No async runtime available for file memory store — memory disabled"
678                        );
679                        None
680                    }
681                }
682            } else {
683                None
684            };
685            store.map(crate::memory::AgentMemory::new)
686        };
687
688        let session_id = opts
689            .session_id
690            .clone()
691            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
692
693        Ok(AgentSession {
694            llm_client,
695            tool_executor,
696            tool_context,
697            config,
698            workspace: canonical,
699            session_id,
700            history: RwLock::new(Vec::new()),
701            command_queue,
702            memory,
703            session_store: opts.session_store.clone(),
704            auto_save: opts.auto_save,
705        })
706    }
707}
708
709// ============================================================================
710// AgentSession
711// ============================================================================
712
713/// Workspace-bound session. All LLM and tool operations happen here.
714///
715/// History is automatically accumulated after each `send()` call.
716/// Use `history()` to retrieve the current conversation log.
717pub struct AgentSession {
718    llm_client: Arc<dyn LlmClient>,
719    tool_executor: Arc<ToolExecutor>,
720    tool_context: ToolContext,
721    config: AgentConfig,
722    workspace: PathBuf,
723    /// Unique session identifier.
724    session_id: String,
725    /// Internal conversation history, auto-updated after each `send()`.
726    history: RwLock<Vec<Message>>,
727    /// Optional lane queue for priority-based tool execution.
728    command_queue: Option<Arc<SessionLaneQueue>>,
729    /// Optional long-term memory.
730    memory: Option<crate::memory::AgentMemory>,
731    /// Optional session store for persistence.
732    session_store: Option<Arc<dyn crate::store::SessionStore>>,
733    /// Auto-save after each `send()`.
734    auto_save: bool,
735}
736
737impl std::fmt::Debug for AgentSession {
738    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
739        f.debug_struct("AgentSession")
740            .field("session_id", &self.session_id)
741            .field("workspace", &self.workspace.display().to_string())
742            .field("auto_save", &self.auto_save)
743            .finish()
744    }
745}
746
747impl AgentSession {
748    /// Build an `AgentLoop` with the session's configuration.
749    ///
750    /// Propagates the lane queue (if configured) for external task handling.
751    fn build_agent_loop(&self) -> AgentLoop {
752        let mut agent_loop = AgentLoop::new(
753            self.llm_client.clone(),
754            self.tool_executor.clone(),
755            self.tool_context.clone(),
756            self.config.clone(),
757        );
758        if let Some(ref queue) = self.command_queue {
759            agent_loop = agent_loop.with_queue(Arc::clone(queue));
760        }
761        agent_loop
762    }
763
764    /// Send a prompt and wait for the complete response.
765    ///
766    /// When `history` is `None`, uses (and auto-updates) the session's
767    /// internal conversation history. When `Some`, uses the provided
768    /// history instead (the internal history is **not** modified).
769    pub async fn send(&self, prompt: &str, history: Option<&[Message]>) -> Result<AgentResult> {
770        let agent_loop = self.build_agent_loop();
771
772        let use_internal = history.is_none();
773        let effective_history = match history {
774            Some(h) => h.to_vec(),
775            None => self.history.read().unwrap().clone(),
776        };
777
778        let result = agent_loop.execute(&effective_history, prompt, None).await?;
779
780        // Auto-accumulate: only update internal history when no custom
781        // history was provided.
782        if use_internal {
783            *self.history.write().unwrap() = result.messages.clone();
784
785            // Auto-save if configured
786            if self.auto_save {
787                if let Err(e) = self.save().await {
788                    tracing::warn!("Auto-save failed for session {}: {}", self.session_id, e);
789                }
790            }
791        }
792
793        Ok(result)
794    }
795
796    /// Send a prompt with image attachments and wait for the complete response.
797    ///
798    /// Images are included as multi-modal content blocks in the user message.
799    /// Requires a vision-capable model (e.g., Claude Sonnet, GPT-4o).
800    pub async fn send_with_attachments(
801        &self,
802        prompt: &str,
803        attachments: &[crate::llm::Attachment],
804        history: Option<&[Message]>,
805    ) -> Result<AgentResult> {
806        // Build a user message with text + images, then pass it as the last
807        // history entry. We use an empty prompt so execute_loop doesn't add
808        // a duplicate user message.
809        let use_internal = history.is_none();
810        let mut effective_history = match history {
811            Some(h) => h.to_vec(),
812            None => self.history.read().unwrap().clone(),
813        };
814        effective_history.push(Message::user_with_attachments(prompt, attachments));
815
816        let agent_loop = self.build_agent_loop();
817        let result = agent_loop
818            .execute_from_messages(effective_history, None, None)
819            .await?;
820
821        if use_internal {
822            *self.history.write().unwrap() = result.messages.clone();
823            if self.auto_save {
824                if let Err(e) = self.save().await {
825                    tracing::warn!("Auto-save failed for session {}: {}", self.session_id, e);
826                }
827            }
828        }
829
830        Ok(result)
831    }
832
833    /// Stream a prompt with image attachments.
834    ///
835    /// Images are included as multi-modal content blocks in the user message.
836    /// Requires a vision-capable model (e.g., Claude Sonnet, GPT-4o).
837    pub async fn stream_with_attachments(
838        &self,
839        prompt: &str,
840        attachments: &[crate::llm::Attachment],
841        history: Option<&[Message]>,
842    ) -> Result<(mpsc::Receiver<AgentEvent>, JoinHandle<()>)> {
843        let (tx, rx) = mpsc::channel(256);
844        let mut effective_history = match history {
845            Some(h) => h.to_vec(),
846            None => self.history.read().unwrap().clone(),
847        };
848        effective_history.push(Message::user_with_attachments(prompt, attachments));
849
850        let agent_loop = self.build_agent_loop();
851        let handle = tokio::spawn(async move {
852            let _ = agent_loop
853                .execute_from_messages(effective_history, None, Some(tx))
854                .await;
855        });
856
857        Ok((rx, handle))
858    }
859
860    /// Send a prompt and stream events back.
861    ///
862    /// When `history` is `None`, uses the session's internal history
863    /// (note: streaming does **not** auto-update internal history since
864    /// the result is consumed asynchronously via the channel).
865    /// When `Some`, uses the provided history instead.
866    pub async fn stream(
867        &self,
868        prompt: &str,
869        history: Option<&[Message]>,
870    ) -> Result<(mpsc::Receiver<AgentEvent>, JoinHandle<()>)> {
871        let (tx, rx) = mpsc::channel(256);
872        let agent_loop = self.build_agent_loop();
873        let effective_history = match history {
874            Some(h) => h.to_vec(),
875            None => self.history.read().unwrap().clone(),
876        };
877        let prompt = prompt.to_string();
878
879        let handle = tokio::spawn(async move {
880            let _ = agent_loop
881                .execute(&effective_history, &prompt, Some(tx))
882                .await;
883        });
884
885        Ok((rx, handle))
886    }
887
888    /// Return a snapshot of the session's conversation history.
889    pub fn history(&self) -> Vec<Message> {
890        self.history.read().unwrap().clone()
891    }
892
893    /// Return a reference to the session's memory, if configured.
894    pub fn memory(&self) -> Option<&crate::memory::AgentMemory> {
895        self.memory.as_ref()
896    }
897
898    /// Return the session ID.
899    pub fn session_id(&self) -> &str {
900        &self.session_id
901    }
902
903    /// Save the session to the configured store.
904    ///
905    /// Returns `Ok(())` if saved successfully, or if no store is configured (no-op).
906    pub async fn save(&self) -> Result<()> {
907        let store = match &self.session_store {
908            Some(s) => s,
909            None => return Ok(()),
910        };
911
912        let history = self.history.read().unwrap().clone();
913        let now = chrono::Utc::now().timestamp();
914
915        let data = crate::store::SessionData {
916            id: self.session_id.clone(),
917            config: crate::session::SessionConfig {
918                name: String::new(),
919                workspace: self.workspace.display().to_string(),
920                system_prompt: self.config.system_prompt.clone(),
921                max_context_length: 200_000,
922                auto_compact: false,
923                auto_compact_threshold: crate::session::DEFAULT_AUTO_COMPACT_THRESHOLD,
924                storage_type: crate::config::StorageBackend::File,
925                queue_config: None,
926                confirmation_policy: None,
927                permission_policy: None,
928                parent_id: None,
929                security_config: None,
930                hook_engine: None,
931                planning_enabled: self.config.planning_enabled,
932                goal_tracking: self.config.goal_tracking,
933            },
934            state: crate::session::SessionState::Active,
935            messages: history,
936            context_usage: crate::session::ContextUsage::default(),
937            total_usage: crate::llm::TokenUsage::default(),
938            total_cost: 0.0,
939            model_name: None,
940            cost_records: Vec::new(),
941            tool_names: crate::store::SessionData::tool_names_from_definitions(&self.config.tools),
942            thinking_enabled: false,
943            thinking_budget: None,
944            created_at: now,
945            updated_at: now,
946            llm_config: None,
947            tasks: Vec::new(),
948            parent_id: None,
949        };
950
951        store.save(&data).await?;
952        tracing::debug!("Session {} saved", self.session_id);
953        Ok(())
954    }
955
956    /// Read a file from the workspace.
957    pub async fn read_file(&self, path: &str) -> Result<String> {
958        let args = serde_json::json!({ "file_path": path });
959        let result = self.tool_executor.execute("read", &args).await?;
960        Ok(result.output)
961    }
962
963    /// Execute a bash command in the workspace.
964    ///
965    /// When a sandbox is configured via [`SessionOptions::with_sandbox()`],
966    /// the command is routed through the A3S Box sandbox.
967    pub async fn bash(&self, command: &str) -> Result<String> {
968        let args = serde_json::json!({ "command": command });
969        let result = self
970            .tool_executor
971            .execute_with_context("bash", &args, &self.tool_context)
972            .await?;
973        Ok(result.output)
974    }
975
976    /// Search for files matching a glob pattern.
977    pub async fn glob(&self, pattern: &str) -> Result<Vec<String>> {
978        let args = serde_json::json!({ "pattern": pattern });
979        let result = self.tool_executor.execute("glob", &args).await?;
980        let files: Vec<String> = result
981            .output
982            .lines()
983            .filter(|l| !l.is_empty())
984            .map(|l| l.to_string())
985            .collect();
986        Ok(files)
987    }
988
989    /// Search file contents with a regex pattern.
990    pub async fn grep(&self, pattern: &str) -> Result<String> {
991        let args = serde_json::json!({ "pattern": pattern });
992        let result = self.tool_executor.execute("grep", &args).await?;
993        Ok(result.output)
994    }
995
996    /// Execute a tool by name, bypassing the LLM.
997    pub async fn tool(&self, name: &str, args: serde_json::Value) -> Result<ToolCallResult> {
998        let result = self.tool_executor.execute(name, &args).await?;
999        Ok(ToolCallResult {
1000            name: name.to_string(),
1001            output: result.output,
1002            exit_code: result.exit_code,
1003        })
1004    }
1005
1006    // ========================================================================
1007    // Queue API
1008    // ========================================================================
1009
1010    /// Returns whether this session has a lane queue configured.
1011    pub fn has_queue(&self) -> bool {
1012        self.command_queue.is_some()
1013    }
1014
1015    /// Configure a lane's handler mode (Internal/External/Hybrid).
1016    ///
1017    /// Only effective when a queue is configured via `SessionOptions::with_queue_config`.
1018    pub async fn set_lane_handler(&self, lane: SessionLane, config: LaneHandlerConfig) {
1019        if let Some(ref queue) = self.command_queue {
1020            queue.set_lane_handler(lane, config).await;
1021        }
1022    }
1023
1024    /// Complete an external task by ID.
1025    ///
1026    /// Returns `true` if the task was found and completed, `false` if not found.
1027    pub async fn complete_external_task(&self, task_id: &str, result: ExternalTaskResult) -> bool {
1028        if let Some(ref queue) = self.command_queue {
1029            queue.complete_external_task(task_id, result).await
1030        } else {
1031            false
1032        }
1033    }
1034
1035    /// Get pending external tasks awaiting completion by an external handler.
1036    pub async fn pending_external_tasks(&self) -> Vec<ExternalTask> {
1037        if let Some(ref queue) = self.command_queue {
1038            queue.pending_external_tasks().await
1039        } else {
1040            Vec::new()
1041        }
1042    }
1043
1044    /// Get queue statistics (pending, active, external counts per lane).
1045    pub async fn queue_stats(&self) -> SessionQueueStats {
1046        if let Some(ref queue) = self.command_queue {
1047            queue.stats().await
1048        } else {
1049            SessionQueueStats::default()
1050        }
1051    }
1052
1053    /// Get a metrics snapshot from the queue (if metrics are enabled).
1054    pub async fn queue_metrics(&self) -> Option<MetricsSnapshot> {
1055        if let Some(ref queue) = self.command_queue {
1056            queue.metrics_snapshot().await
1057        } else {
1058            None
1059        }
1060    }
1061
1062    /// Get dead letters from the queue's DLQ (if DLQ is enabled).
1063    pub async fn dead_letters(&self) -> Vec<DeadLetter> {
1064        if let Some(ref queue) = self.command_queue {
1065            queue.dead_letters().await
1066        } else {
1067            Vec::new()
1068        }
1069    }
1070}
1071
1072// ============================================================================
1073// Tests
1074// ============================================================================
1075
1076#[cfg(test)]
1077mod tests {
1078    use super::*;
1079    use crate::config::{ModelConfig, ModelModalities, ProviderConfig};
1080    use crate::store::SessionStore;
1081
1082    fn test_config() -> CodeConfig {
1083        CodeConfig {
1084            default_model: Some("anthropic/claude-sonnet-4-20250514".to_string()),
1085            providers: vec![
1086                ProviderConfig {
1087                    name: "anthropic".to_string(),
1088                    api_key: Some("test-key".to_string()),
1089                    base_url: None,
1090                    models: vec![ModelConfig {
1091                        id: "claude-sonnet-4-20250514".to_string(),
1092                        name: "Claude Sonnet 4".to_string(),
1093                        family: "claude-sonnet".to_string(),
1094                        api_key: None,
1095                        base_url: None,
1096                        attachment: false,
1097                        reasoning: false,
1098                        tool_call: true,
1099                        temperature: true,
1100                        release_date: None,
1101                        modalities: ModelModalities::default(),
1102                        cost: Default::default(),
1103                        limit: Default::default(),
1104                    }],
1105                },
1106                ProviderConfig {
1107                    name: "openai".to_string(),
1108                    api_key: Some("test-openai-key".to_string()),
1109                    base_url: None,
1110                    models: vec![ModelConfig {
1111                        id: "gpt-4o".to_string(),
1112                        name: "GPT-4o".to_string(),
1113                        family: "gpt-4".to_string(),
1114                        api_key: None,
1115                        base_url: None,
1116                        attachment: false,
1117                        reasoning: false,
1118                        tool_call: true,
1119                        temperature: true,
1120                        release_date: None,
1121                        modalities: ModelModalities::default(),
1122                        cost: Default::default(),
1123                        limit: Default::default(),
1124                    }],
1125                },
1126            ],
1127            ..Default::default()
1128        }
1129    }
1130
1131    #[tokio::test]
1132    async fn test_from_config() {
1133        let agent = Agent::from_config(test_config()).await;
1134        assert!(agent.is_ok());
1135    }
1136
1137    #[tokio::test]
1138    async fn test_session_default() {
1139        let agent = Agent::from_config(test_config()).await.unwrap();
1140        let session = agent.session("/tmp/test-workspace", None);
1141        assert!(session.is_ok());
1142        let debug = format!("{:?}", session.unwrap());
1143        assert!(debug.contains("AgentSession"));
1144    }
1145
1146    #[tokio::test]
1147    async fn test_session_with_model_override() {
1148        let agent = Agent::from_config(test_config()).await.unwrap();
1149        let opts = SessionOptions::new().with_model("openai/gpt-4o");
1150        let session = agent.session("/tmp/test-workspace", Some(opts));
1151        assert!(session.is_ok());
1152    }
1153
1154    #[tokio::test]
1155    async fn test_session_with_invalid_model_format() {
1156        let agent = Agent::from_config(test_config()).await.unwrap();
1157        let opts = SessionOptions::new().with_model("gpt-4o");
1158        let session = agent.session("/tmp/test-workspace", Some(opts));
1159        assert!(session.is_err());
1160    }
1161
1162    #[tokio::test]
1163    async fn test_session_with_model_not_found() {
1164        let agent = Agent::from_config(test_config()).await.unwrap();
1165        let opts = SessionOptions::new().with_model("openai/nonexistent");
1166        let session = agent.session("/tmp/test-workspace", Some(opts));
1167        assert!(session.is_err());
1168    }
1169
1170    #[tokio::test]
1171    async fn test_new_with_hcl_string() {
1172        let hcl = r#"
1173            default_model = "anthropic/claude-sonnet-4-20250514"
1174            providers {
1175                name    = "anthropic"
1176                api_key = "test-key"
1177                models {
1178                    id   = "claude-sonnet-4-20250514"
1179                    name = "Claude Sonnet 4"
1180                }
1181            }
1182        "#;
1183        let agent = Agent::new(hcl).await;
1184        assert!(agent.is_ok());
1185    }
1186
1187    #[tokio::test]
1188    async fn test_create_alias_hcl() {
1189        let hcl = r#"
1190            default_model = "anthropic/claude-sonnet-4-20250514"
1191            providers {
1192                name    = "anthropic"
1193                api_key = "test-key"
1194                models {
1195                    id   = "claude-sonnet-4-20250514"
1196                    name = "Claude Sonnet 4"
1197                }
1198            }
1199        "#;
1200        let agent = Agent::create(hcl).await;
1201        assert!(agent.is_ok());
1202    }
1203
1204    #[tokio::test]
1205    async fn test_create_and_new_produce_same_result() {
1206        let hcl = r#"
1207            default_model = "anthropic/claude-sonnet-4-20250514"
1208            providers {
1209                name    = "anthropic"
1210                api_key = "test-key"
1211                models {
1212                    id   = "claude-sonnet-4-20250514"
1213                    name = "Claude Sonnet 4"
1214                }
1215            }
1216        "#;
1217        let agent_new = Agent::new(hcl).await;
1218        let agent_create = Agent::create(hcl).await;
1219        assert!(agent_new.is_ok());
1220        assert!(agent_create.is_ok());
1221
1222        // Both should produce working sessions
1223        let session_new = agent_new.unwrap().session("/tmp/test-ws-new", None);
1224        let session_create = agent_create.unwrap().session("/tmp/test-ws-create", None);
1225        assert!(session_new.is_ok());
1226        assert!(session_create.is_ok());
1227    }
1228
1229    #[test]
1230    fn test_from_config_requires_default_model() {
1231        let rt = tokio::runtime::Runtime::new().unwrap();
1232        let config = CodeConfig {
1233            providers: vec![ProviderConfig {
1234                name: "anthropic".to_string(),
1235                api_key: Some("test-key".to_string()),
1236                base_url: None,
1237                models: vec![],
1238            }],
1239            ..Default::default()
1240        };
1241        let result = rt.block_on(Agent::from_config(config));
1242        assert!(result.is_err());
1243    }
1244
1245    #[tokio::test]
1246    async fn test_history_empty_on_new_session() {
1247        let agent = Agent::from_config(test_config()).await.unwrap();
1248        let session = agent.session("/tmp/test-workspace", None).unwrap();
1249        assert!(session.history().is_empty());
1250    }
1251
1252    #[tokio::test]
1253    async fn test_session_options_with_agent_dir() {
1254        let opts = SessionOptions::new()
1255            .with_agent_dir("/tmp/agents")
1256            .with_agent_dir("/tmp/more-agents");
1257        assert_eq!(opts.agent_dirs.len(), 2);
1258        assert_eq!(opts.agent_dirs[0], PathBuf::from("/tmp/agents"));
1259        assert_eq!(opts.agent_dirs[1], PathBuf::from("/tmp/more-agents"));
1260    }
1261
1262    // ========================================================================
1263    // Queue Integration Tests
1264    // ========================================================================
1265
1266    #[test]
1267    fn test_session_options_with_queue_config() {
1268        let qc = SessionQueueConfig::default().with_lane_features();
1269        let opts = SessionOptions::new().with_queue_config(qc.clone());
1270        assert!(opts.queue_config.is_some());
1271
1272        let config = opts.queue_config.unwrap();
1273        assert!(config.enable_dlq);
1274        assert!(config.enable_metrics);
1275        assert!(config.enable_alerts);
1276        assert_eq!(config.default_timeout_ms, Some(60_000));
1277    }
1278
1279    #[tokio::test(flavor = "multi_thread")]
1280    async fn test_session_with_queue_config() {
1281        let agent = Agent::from_config(test_config()).await.unwrap();
1282        let qc = SessionQueueConfig::default();
1283        let opts = SessionOptions::new().with_queue_config(qc);
1284        let session = agent.session("/tmp/test-workspace-queue", Some(opts));
1285        assert!(session.is_ok());
1286        let session = session.unwrap();
1287        assert!(session.has_queue());
1288    }
1289
1290    #[tokio::test]
1291    async fn test_session_without_queue_config() {
1292        let agent = Agent::from_config(test_config()).await.unwrap();
1293        let session = agent.session("/tmp/test-workspace-noqueue", None).unwrap();
1294        assert!(!session.has_queue());
1295    }
1296
1297    #[tokio::test]
1298    async fn test_session_queue_stats_without_queue() {
1299        let agent = Agent::from_config(test_config()).await.unwrap();
1300        let session = agent.session("/tmp/test-workspace-stats", None).unwrap();
1301        let stats = session.queue_stats().await;
1302        // Without a queue, stats should have zero values
1303        assert_eq!(stats.total_pending, 0);
1304        assert_eq!(stats.total_active, 0);
1305    }
1306
1307    #[tokio::test(flavor = "multi_thread")]
1308    async fn test_session_queue_stats_with_queue() {
1309        let agent = Agent::from_config(test_config()).await.unwrap();
1310        let qc = SessionQueueConfig::default();
1311        let opts = SessionOptions::new().with_queue_config(qc);
1312        let session = agent
1313            .session("/tmp/test-workspace-qstats", Some(opts))
1314            .unwrap();
1315        let stats = session.queue_stats().await;
1316        // Fresh queue with no commands should have zero stats
1317        assert_eq!(stats.total_pending, 0);
1318        assert_eq!(stats.total_active, 0);
1319    }
1320
1321    #[tokio::test(flavor = "multi_thread")]
1322    async fn test_session_pending_external_tasks_empty() {
1323        let agent = Agent::from_config(test_config()).await.unwrap();
1324        let qc = SessionQueueConfig::default();
1325        let opts = SessionOptions::new().with_queue_config(qc);
1326        let session = agent
1327            .session("/tmp/test-workspace-ext", Some(opts))
1328            .unwrap();
1329        let tasks = session.pending_external_tasks().await;
1330        assert!(tasks.is_empty());
1331    }
1332
1333    #[tokio::test(flavor = "multi_thread")]
1334    async fn test_session_dead_letters_empty() {
1335        let agent = Agent::from_config(test_config()).await.unwrap();
1336        let qc = SessionQueueConfig::default().with_dlq(Some(100));
1337        let opts = SessionOptions::new().with_queue_config(qc);
1338        let session = agent
1339            .session("/tmp/test-workspace-dlq", Some(opts))
1340            .unwrap();
1341        let dead = session.dead_letters().await;
1342        assert!(dead.is_empty());
1343    }
1344
1345    #[tokio::test(flavor = "multi_thread")]
1346    async fn test_session_queue_metrics_disabled() {
1347        let agent = Agent::from_config(test_config()).await.unwrap();
1348        // Metrics not enabled
1349        let qc = SessionQueueConfig::default();
1350        let opts = SessionOptions::new().with_queue_config(qc);
1351        let session = agent
1352            .session("/tmp/test-workspace-nomet", Some(opts))
1353            .unwrap();
1354        let metrics = session.queue_metrics().await;
1355        assert!(metrics.is_none());
1356    }
1357
1358    #[tokio::test(flavor = "multi_thread")]
1359    async fn test_session_queue_metrics_enabled() {
1360        let agent = Agent::from_config(test_config()).await.unwrap();
1361        let qc = SessionQueueConfig::default().with_metrics();
1362        let opts = SessionOptions::new().with_queue_config(qc);
1363        let session = agent
1364            .session("/tmp/test-workspace-met", Some(opts))
1365            .unwrap();
1366        let metrics = session.queue_metrics().await;
1367        assert!(metrics.is_some());
1368    }
1369
1370    #[tokio::test(flavor = "multi_thread")]
1371    async fn test_session_set_lane_handler() {
1372        let agent = Agent::from_config(test_config()).await.unwrap();
1373        let qc = SessionQueueConfig::default();
1374        let opts = SessionOptions::new().with_queue_config(qc);
1375        let session = agent
1376            .session("/tmp/test-workspace-handler", Some(opts))
1377            .unwrap();
1378
1379        // Set Execute lane to External mode
1380        session
1381            .set_lane_handler(
1382                SessionLane::Execute,
1383                LaneHandlerConfig {
1384                    mode: crate::queue::TaskHandlerMode::External,
1385                    timeout_ms: 30_000,
1386                },
1387            )
1388            .await;
1389
1390        // No panic = success. The handler config is stored internally.
1391        // We can't directly read it back but we verify no errors.
1392    }
1393
1394    // ========================================================================
1395    // Session Persistence Tests
1396    // ========================================================================
1397
1398    #[tokio::test(flavor = "multi_thread")]
1399    async fn test_session_has_id() {
1400        let agent = Agent::from_config(test_config()).await.unwrap();
1401        let session = agent.session("/tmp/test-ws-id", None).unwrap();
1402        // Auto-generated UUID
1403        assert!(!session.session_id().is_empty());
1404        assert_eq!(session.session_id().len(), 36); // UUID format
1405    }
1406
1407    #[tokio::test(flavor = "multi_thread")]
1408    async fn test_session_explicit_id() {
1409        let agent = Agent::from_config(test_config()).await.unwrap();
1410        let opts = SessionOptions::new().with_session_id("my-session-42");
1411        let session = agent.session("/tmp/test-ws-eid", Some(opts)).unwrap();
1412        assert_eq!(session.session_id(), "my-session-42");
1413    }
1414
1415    #[tokio::test(flavor = "multi_thread")]
1416    async fn test_session_save_no_store() {
1417        let agent = Agent::from_config(test_config()).await.unwrap();
1418        let session = agent.session("/tmp/test-ws-save", None).unwrap();
1419        // save() is a no-op when no store is configured
1420        session.save().await.unwrap();
1421    }
1422
1423    #[tokio::test(flavor = "multi_thread")]
1424    async fn test_session_save_and_load() {
1425        let store = Arc::new(crate::store::MemorySessionStore::new());
1426        let agent = Agent::from_config(test_config()).await.unwrap();
1427
1428        let opts = SessionOptions::new()
1429            .with_session_store(store.clone())
1430            .with_session_id("persist-test");
1431        let session = agent.session("/tmp/test-ws-persist", Some(opts)).unwrap();
1432
1433        // Save empty session
1434        session.save().await.unwrap();
1435
1436        // Verify it was stored
1437        assert!(store.exists("persist-test").await.unwrap());
1438
1439        let data = store.load("persist-test").await.unwrap().unwrap();
1440        assert_eq!(data.id, "persist-test");
1441        assert!(data.messages.is_empty());
1442    }
1443
1444    #[tokio::test(flavor = "multi_thread")]
1445    async fn test_session_save_with_history() {
1446        let store = Arc::new(crate::store::MemorySessionStore::new());
1447        let agent = Agent::from_config(test_config()).await.unwrap();
1448
1449        let opts = SessionOptions::new()
1450            .with_session_store(store.clone())
1451            .with_session_id("history-test");
1452        let session = agent.session("/tmp/test-ws-hist", Some(opts)).unwrap();
1453
1454        // Manually inject history
1455        {
1456            let mut h = session.history.write().unwrap();
1457            h.push(Message::user("Hello"));
1458            h.push(Message::user("How are you?"));
1459        }
1460
1461        session.save().await.unwrap();
1462
1463        let data = store.load("history-test").await.unwrap().unwrap();
1464        assert_eq!(data.messages.len(), 2);
1465    }
1466
1467    #[tokio::test(flavor = "multi_thread")]
1468    async fn test_resume_session() {
1469        let store = Arc::new(crate::store::MemorySessionStore::new());
1470        let agent = Agent::from_config(test_config()).await.unwrap();
1471
1472        // Create and save a session with history
1473        let opts = SessionOptions::new()
1474            .with_session_store(store.clone())
1475            .with_session_id("resume-test");
1476        let session = agent.session("/tmp/test-ws-resume", Some(opts)).unwrap();
1477        {
1478            let mut h = session.history.write().unwrap();
1479            h.push(Message::user("What is Rust?"));
1480            h.push(Message::user("Tell me more"));
1481        }
1482        session.save().await.unwrap();
1483
1484        // Resume the session
1485        let opts2 = SessionOptions::new().with_session_store(store.clone());
1486        let resumed = agent.resume_session("resume-test", opts2).unwrap();
1487
1488        assert_eq!(resumed.session_id(), "resume-test");
1489        let history = resumed.history();
1490        assert_eq!(history.len(), 2);
1491        assert_eq!(history[0].text(), "What is Rust?");
1492    }
1493
1494    #[tokio::test(flavor = "multi_thread")]
1495    async fn test_resume_session_not_found() {
1496        let store = Arc::new(crate::store::MemorySessionStore::new());
1497        let agent = Agent::from_config(test_config()).await.unwrap();
1498
1499        let opts = SessionOptions::new().with_session_store(store.clone());
1500        let result = agent.resume_session("nonexistent", opts);
1501        assert!(result.is_err());
1502        assert!(result.unwrap_err().to_string().contains("not found"));
1503    }
1504
1505    #[tokio::test(flavor = "multi_thread")]
1506    async fn test_resume_session_no_store() {
1507        let agent = Agent::from_config(test_config()).await.unwrap();
1508        let opts = SessionOptions::new();
1509        let result = agent.resume_session("any-id", opts);
1510        assert!(result.is_err());
1511        assert!(result.unwrap_err().to_string().contains("session_store"));
1512    }
1513
1514    #[tokio::test(flavor = "multi_thread")]
1515    async fn test_file_session_store_persistence() {
1516        let dir = tempfile::TempDir::new().unwrap();
1517        let store = Arc::new(
1518            crate::store::FileSessionStore::new(dir.path())
1519                .await
1520                .unwrap(),
1521        );
1522        let agent = Agent::from_config(test_config()).await.unwrap();
1523
1524        // Save
1525        let opts = SessionOptions::new()
1526            .with_session_store(store.clone())
1527            .with_session_id("file-persist");
1528        let session = agent
1529            .session("/tmp/test-ws-file-persist", Some(opts))
1530            .unwrap();
1531        {
1532            let mut h = session.history.write().unwrap();
1533            h.push(Message::user("test message"));
1534        }
1535        session.save().await.unwrap();
1536
1537        // Load from a fresh store instance pointing to same dir
1538        let store2 = Arc::new(
1539            crate::store::FileSessionStore::new(dir.path())
1540                .await
1541                .unwrap(),
1542        );
1543        let data = store2.load("file-persist").await.unwrap().unwrap();
1544        assert_eq!(data.messages.len(), 1);
1545    }
1546
1547    #[tokio::test(flavor = "multi_thread")]
1548    async fn test_session_options_builders() {
1549        let opts = SessionOptions::new()
1550            .with_session_id("test-id")
1551            .with_auto_save(true);
1552        assert_eq!(opts.session_id, Some("test-id".to_string()));
1553        assert!(opts.auto_save);
1554    }
1555
1556    // ========================================================================
1557    // Sandbox Tests
1558    // ========================================================================
1559
1560    #[test]
1561    fn test_session_options_with_sandbox_sets_config() {
1562        use crate::sandbox::SandboxConfig;
1563        let cfg = SandboxConfig {
1564            image: "ubuntu:22.04".into(),
1565            memory_mb: 1024,
1566            ..SandboxConfig::default()
1567        };
1568        let opts = SessionOptions::new().with_sandbox(cfg);
1569        assert!(opts.sandbox_config.is_some());
1570        let sc = opts.sandbox_config.unwrap();
1571        assert_eq!(sc.image, "ubuntu:22.04");
1572        assert_eq!(sc.memory_mb, 1024);
1573    }
1574
1575    #[test]
1576    fn test_session_options_default_has_no_sandbox() {
1577        let opts = SessionOptions::default();
1578        assert!(opts.sandbox_config.is_none());
1579    }
1580
1581    #[tokio::test]
1582    async fn test_session_debug_includes_sandbox_config() {
1583        use crate::sandbox::SandboxConfig;
1584        let opts = SessionOptions::new().with_sandbox(SandboxConfig::default());
1585        let debug = format!("{:?}", opts);
1586        assert!(debug.contains("sandbox_config"));
1587    }
1588
1589    #[tokio::test]
1590    async fn test_session_build_with_sandbox_config_no_feature_warn() {
1591        // When feature is not enabled, build_session should still succeed
1592        // (it just logs a warning). With feature enabled, it creates a handle.
1593        let agent = Agent::from_config(test_config()).await.unwrap();
1594        let opts = SessionOptions::new().with_sandbox(crate::sandbox::SandboxConfig::default());
1595        // build_session should not fail even if sandbox feature is off
1596        let session = agent.session("/tmp/test-sandbox-session", Some(opts));
1597        assert!(session.is_ok());
1598    }
1599}