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::commands::{CommandContext, CommandRegistry};
21use crate::config::CodeConfig;
22use crate::error::Result;
23use crate::llm::{LlmClient, Message};
24use crate::prompts::SystemPromptSlots;
25use crate::queue::{
26    ExternalTask, ExternalTaskResult, LaneHandlerConfig, SessionLane, SessionQueueConfig,
27    SessionQueueStats,
28};
29use crate::session_lane_queue::SessionLaneQueue;
30use crate::tools::{ToolContext, ToolExecutor};
31use a3s_lane::{DeadLetter, MetricsSnapshot};
32use a3s_memory::{FileMemoryStore, MemoryStore};
33use anyhow::Context;
34use std::path::{Path, PathBuf};
35use std::sync::{Arc, RwLock};
36use tokio::sync::{broadcast, mpsc};
37use tokio::task::JoinHandle;
38
39// ============================================================================
40// ToolCallResult
41// ============================================================================
42
43/// Result of a direct tool execution (no LLM).
44#[derive(Debug, Clone)]
45pub struct ToolCallResult {
46    pub name: String,
47    pub output: String,
48    pub exit_code: i32,
49}
50
51// ============================================================================
52// SessionOptions
53// ============================================================================
54
55/// Optional per-session overrides.
56#[derive(Clone, Default)]
57pub struct SessionOptions {
58    /// Override the default model. Format: `"provider/model"` (e.g., `"openai/gpt-4o"`).
59    pub model: Option<String>,
60    /// Extra directories to scan for agent files.
61    /// Merged with any global `agent_dirs` from [`CodeConfig`].
62    pub agent_dirs: Vec<PathBuf>,
63    /// Optional queue configuration for lane-based tool execution.
64    ///
65    /// When set, enables priority-based tool scheduling with parallel execution
66    /// of read-only (Query-lane) tools, DLQ, metrics, and external task handling.
67    pub queue_config: Option<SessionQueueConfig>,
68    /// Optional security provider for taint tracking and output sanitization
69    pub security_provider: Option<Arc<dyn crate::security::SecurityProvider>>,
70    /// Optional context providers for RAG
71    pub context_providers: Vec<Arc<dyn crate::context::ContextProvider>>,
72    /// Optional confirmation manager for HITL
73    pub confirmation_manager: Option<Arc<dyn crate::hitl::ConfirmationProvider>>,
74    /// Optional permission checker
75    pub permission_checker: Option<Arc<dyn crate::permissions::PermissionChecker>>,
76    /// Enable planning
77    pub planning_enabled: bool,
78    /// Enable goal tracking
79    pub goal_tracking: bool,
80    /// Extra directories to scan for skill files (*.md).
81    /// Merged with any global `skill_dirs` from [`CodeConfig`].
82    pub skill_dirs: Vec<PathBuf>,
83    /// Optional skill registry for instruction injection
84    pub skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
85    /// Optional memory store for long-term memory persistence
86    pub memory_store: Option<Arc<dyn MemoryStore>>,
87    /// Deferred file memory directory — constructed async in `build_session()`
88    pub(crate) file_memory_dir: Option<PathBuf>,
89    /// Optional session store for persistence
90    pub session_store: Option<Arc<dyn crate::store::SessionStore>>,
91    /// Explicit session ID (auto-generated if not set)
92    pub session_id: Option<String>,
93    /// Auto-save after each `send()` call
94    pub auto_save: bool,
95    /// Max consecutive parse errors before aborting (overrides default of 2).
96    /// `None` uses the `AgentConfig` default.
97    pub max_parse_retries: Option<u32>,
98    /// Per-tool execution timeout in milliseconds.
99    /// `None` = no timeout (default).
100    pub tool_timeout_ms: Option<u64>,
101    /// Circuit-breaker threshold: max consecutive LLM API failures before
102    /// aborting in non-streaming mode (overrides default of 3).
103    /// `None` uses the `AgentConfig` default.
104    pub circuit_breaker_threshold: Option<u32>,
105    /// Optional sandbox configuration.
106    ///
107    /// When set, `bash` tool commands are routed through an A3S Box MicroVM
108    /// sandbox instead of `std::process::Command`. Requires the `sandbox`
109    /// Cargo feature to be enabled.
110    pub sandbox_config: Option<crate::sandbox::SandboxConfig>,
111    /// Enable auto-compaction when context usage exceeds threshold.
112    pub auto_compact: bool,
113    /// Context usage percentage threshold for auto-compaction (0.0 - 1.0).
114    /// Default: 0.80 (80%).
115    pub auto_compact_threshold: Option<f32>,
116    /// Inject a continuation message when the LLM stops without completing the task.
117    /// `None` uses the `AgentConfig` default (true).
118    pub continuation_enabled: Option<bool>,
119    /// Maximum continuation injections per execution.
120    /// `None` uses the `AgentConfig` default (3).
121    pub max_continuation_turns: Option<u32>,
122    /// Optional MCP manager for connecting to external MCP servers.
123    ///
124    /// When set, all tools from connected MCP servers are registered and
125    /// available during agent execution with names like `mcp__server__tool`.
126    pub mcp_manager: Option<Arc<crate::mcp::manager::McpManager>>,
127    /// Sampling temperature (0.0–1.0). Overrides the provider default.
128    pub temperature: Option<f32>,
129    /// Extended thinking budget in tokens (Anthropic only).
130    pub thinking_budget: Option<usize>,
131    /// Slot-based system prompt customization.
132    ///
133    /// When set, overrides the agent-level prompt slots for this session.
134    /// Users can customize role, guidelines, response style, and extra instructions
135    /// without losing the core agentic capabilities.
136    pub prompt_slots: Option<SystemPromptSlots>,
137}
138
139impl std::fmt::Debug for SessionOptions {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        f.debug_struct("SessionOptions")
142            .field("model", &self.model)
143            .field("agent_dirs", &self.agent_dirs)
144            .field("skill_dirs", &self.skill_dirs)
145            .field("queue_config", &self.queue_config)
146            .field("security_provider", &self.security_provider.is_some())
147            .field("context_providers", &self.context_providers.len())
148            .field("confirmation_manager", &self.confirmation_manager.is_some())
149            .field("permission_checker", &self.permission_checker.is_some())
150            .field("planning_enabled", &self.planning_enabled)
151            .field("goal_tracking", &self.goal_tracking)
152            .field(
153                "skill_registry",
154                &self
155                    .skill_registry
156                    .as_ref()
157                    .map(|r| format!("{} skills", r.len())),
158            )
159            .field("memory_store", &self.memory_store.is_some())
160            .field("session_store", &self.session_store.is_some())
161            .field("session_id", &self.session_id)
162            .field("auto_save", &self.auto_save)
163            .field("max_parse_retries", &self.max_parse_retries)
164            .field("tool_timeout_ms", &self.tool_timeout_ms)
165            .field("circuit_breaker_threshold", &self.circuit_breaker_threshold)
166            .field("sandbox_config", &self.sandbox_config)
167            .field("auto_compact", &self.auto_compact)
168            .field("auto_compact_threshold", &self.auto_compact_threshold)
169            .field("continuation_enabled", &self.continuation_enabled)
170            .field("max_continuation_turns", &self.max_continuation_turns)
171            .field("mcp_manager", &self.mcp_manager.is_some())
172            .field("temperature", &self.temperature)
173            .field("thinking_budget", &self.thinking_budget)
174            .field("prompt_slots", &self.prompt_slots.is_some())
175            .finish()
176    }
177}
178
179impl SessionOptions {
180    pub fn new() -> Self {
181        Self::default()
182    }
183
184    pub fn with_model(mut self, model: impl Into<String>) -> Self {
185        self.model = Some(model.into());
186        self
187    }
188
189    pub fn with_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
190        self.agent_dirs.push(dir.into());
191        self
192    }
193
194    pub fn with_queue_config(mut self, config: SessionQueueConfig) -> Self {
195        self.queue_config = Some(config);
196        self
197    }
198
199    /// Enable default security provider with taint tracking and output sanitization
200    pub fn with_default_security(mut self) -> Self {
201        self.security_provider = Some(Arc::new(crate::security::DefaultSecurityProvider::new()));
202        self
203    }
204
205    /// Set a custom security provider
206    pub fn with_security_provider(
207        mut self,
208        provider: Arc<dyn crate::security::SecurityProvider>,
209    ) -> Self {
210        self.security_provider = Some(provider);
211        self
212    }
213
214    /// Add a file system context provider for simple RAG
215    pub fn with_fs_context(mut self, root_path: impl Into<PathBuf>) -> Self {
216        let config = crate::context::FileSystemContextConfig::new(root_path);
217        self.context_providers
218            .push(Arc::new(crate::context::FileSystemContextProvider::new(
219                config,
220            )));
221        self
222    }
223
224    /// Add a custom context provider
225    pub fn with_context_provider(
226        mut self,
227        provider: Arc<dyn crate::context::ContextProvider>,
228    ) -> Self {
229        self.context_providers.push(provider);
230        self
231    }
232
233    /// Set a confirmation manager for HITL
234    pub fn with_confirmation_manager(
235        mut self,
236        manager: Arc<dyn crate::hitl::ConfirmationProvider>,
237    ) -> Self {
238        self.confirmation_manager = Some(manager);
239        self
240    }
241
242    /// Set a permission checker
243    pub fn with_permission_checker(
244        mut self,
245        checker: Arc<dyn crate::permissions::PermissionChecker>,
246    ) -> Self {
247        self.permission_checker = Some(checker);
248        self
249    }
250
251    /// Allow all tool execution without confirmation (permissive mode).
252    ///
253    /// Use this for automated scripts, demos, and CI environments where
254    /// human-in-the-loop confirmation is not needed. Without this (or a
255    /// custom permission checker), the default is `Ask`, which requires a
256    /// HITL confirmation manager to be configured.
257    pub fn with_permissive_policy(self) -> Self {
258        self.with_permission_checker(Arc::new(crate::permissions::PermissionPolicy::permissive()))
259    }
260
261    /// Enable planning
262    pub fn with_planning(mut self, enabled: bool) -> Self {
263        self.planning_enabled = enabled;
264        self
265    }
266
267    /// Enable goal tracking
268    pub fn with_goal_tracking(mut self, enabled: bool) -> Self {
269        self.goal_tracking = enabled;
270        self
271    }
272
273    /// Add a skill registry with built-in skills
274    pub fn with_builtin_skills(mut self) -> Self {
275        self.skill_registry = Some(Arc::new(crate::skills::SkillRegistry::with_builtins()));
276        self
277    }
278
279    /// Add a custom skill registry
280    pub fn with_skill_registry(mut self, registry: Arc<crate::skills::SkillRegistry>) -> Self {
281        self.skill_registry = Some(registry);
282        self
283    }
284
285    /// Add skill directories to scan for skill files (*.md).
286    /// Merged with any global `skill_dirs` from [`CodeConfig`] at session build time.
287    pub fn with_skill_dirs(mut self, dirs: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
288        self.skill_dirs.extend(dirs.into_iter().map(Into::into));
289        self
290    }
291
292    /// Load skills from a directory (eager — scans immediately into a registry).
293    pub fn with_skills_from_dir(mut self, dir: impl AsRef<std::path::Path>) -> Self {
294        let registry = self
295            .skill_registry
296            .unwrap_or_else(|| Arc::new(crate::skills::SkillRegistry::new()));
297        if let Err(e) = registry.load_from_dir(&dir) {
298            tracing::warn!(
299                dir = %dir.as_ref().display(),
300                error = %e,
301                "Failed to load skills from directory — continuing without them"
302            );
303        }
304        self.skill_registry = Some(registry);
305        self
306    }
307
308    /// Set a custom memory store
309    pub fn with_memory(mut self, store: Arc<dyn MemoryStore>) -> Self {
310        self.memory_store = Some(store);
311        self
312    }
313
314    /// Use a file-based memory store at the given directory.
315    ///
316    /// The store is created lazily when the session is built (requires async).
317    /// This stores the directory path; `FileMemoryStore::new()` is called during
318    /// session construction.
319    pub fn with_file_memory(mut self, dir: impl Into<PathBuf>) -> Self {
320        self.file_memory_dir = Some(dir.into());
321        self
322    }
323
324    /// Set a session store for persistence
325    pub fn with_session_store(mut self, store: Arc<dyn crate::store::SessionStore>) -> Self {
326        self.session_store = Some(store);
327        self
328    }
329
330    /// Use a file-based session store at the given directory
331    pub fn with_file_session_store(mut self, dir: impl Into<PathBuf>) -> Self {
332        let dir = dir.into();
333        match tokio::runtime::Handle::try_current() {
334            Ok(handle) => {
335                match tokio::task::block_in_place(|| {
336                    handle.block_on(crate::store::FileSessionStore::new(dir))
337                }) {
338                    Ok(store) => {
339                        self.session_store =
340                            Some(Arc::new(store) as Arc<dyn crate::store::SessionStore>);
341                    }
342                    Err(e) => {
343                        tracing::warn!("Failed to create file session store: {}", e);
344                    }
345                }
346            }
347            Err(_) => {
348                tracing::warn!(
349                    "No async runtime available for file session store — persistence disabled"
350                );
351            }
352        }
353        self
354    }
355
356    /// Set an explicit session ID (auto-generated UUID if not set)
357    pub fn with_session_id(mut self, id: impl Into<String>) -> Self {
358        self.session_id = Some(id.into());
359        self
360    }
361
362    /// Enable auto-save after each `send()` call
363    pub fn with_auto_save(mut self, enabled: bool) -> Self {
364        self.auto_save = enabled;
365        self
366    }
367
368    /// Set the maximum number of consecutive malformed-tool-args errors before
369    /// the agent loop bails.
370    ///
371    /// Default: 2 (the LLM gets two chances to self-correct before the session
372    /// is aborted).
373    pub fn with_parse_retries(mut self, max: u32) -> Self {
374        self.max_parse_retries = Some(max);
375        self
376    }
377
378    /// Set a per-tool execution timeout.
379    ///
380    /// When set, each tool execution is wrapped in `tokio::time::timeout`.
381    /// A timeout produces an error message that is fed back to the LLM
382    /// (the session continues).
383    pub fn with_tool_timeout(mut self, timeout_ms: u64) -> Self {
384        self.tool_timeout_ms = Some(timeout_ms);
385        self
386    }
387
388    /// Set the circuit-breaker threshold.
389    ///
390    /// In non-streaming mode, the agent retries transient LLM API failures up
391    /// to this many times (with exponential backoff) before aborting.
392    /// Default: 3 attempts.
393    pub fn with_circuit_breaker(mut self, threshold: u32) -> Self {
394        self.circuit_breaker_threshold = Some(threshold);
395        self
396    }
397
398    /// Enable all resilience defaults with sensible values:
399    ///
400    /// - `max_parse_retries = 2`
401    /// - `tool_timeout_ms = 120_000` (2 minutes)
402    /// - `circuit_breaker_threshold = 3`
403    pub fn with_resilience_defaults(self) -> Self {
404        self.with_parse_retries(2)
405            .with_tool_timeout(120_000)
406            .with_circuit_breaker(3)
407    }
408
409    /// Route `bash` tool execution through an A3S Box MicroVM sandbox.
410    ///
411    /// The workspace directory is mounted read-write at `/workspace` inside
412    /// the sandbox. Requires the `sandbox` Cargo feature; without it a warning
413    /// is logged and bash commands continue to run locally.
414    ///
415    /// # Example
416    ///
417    /// ```rust,no_run
418    /// use a3s_code_core::{SessionOptions, SandboxConfig};
419    ///
420    /// SessionOptions::new().with_sandbox(SandboxConfig {
421    ///     image: "ubuntu:22.04".into(),
422    ///     memory_mb: 512,
423    ///     network: false,
424    ///     ..SandboxConfig::default()
425    /// });
426    /// ```
427    pub fn with_sandbox(mut self, config: crate::sandbox::SandboxConfig) -> Self {
428        self.sandbox_config = Some(config);
429        self
430    }
431
432    /// Enable auto-compaction when context usage exceeds threshold.
433    ///
434    /// When enabled, the agent loop automatically prunes large tool outputs
435    /// and summarizes old messages when context usage exceeds the threshold.
436    pub fn with_auto_compact(mut self, enabled: bool) -> Self {
437        self.auto_compact = enabled;
438        self
439    }
440
441    /// Set the auto-compact threshold (0.0 - 1.0). Default: 0.80 (80%).
442    pub fn with_auto_compact_threshold(mut self, threshold: f32) -> Self {
443        self.auto_compact_threshold = Some(threshold.clamp(0.0, 1.0));
444        self
445    }
446
447    /// Enable or disable continuation injection (default: enabled).
448    ///
449    /// When enabled, the loop injects a continuation message when the LLM stops
450    /// calling tools before the task appears complete, nudging it to keep working.
451    pub fn with_continuation(mut self, enabled: bool) -> Self {
452        self.continuation_enabled = Some(enabled);
453        self
454    }
455
456    /// Set the maximum number of continuation injections per execution (default: 3).
457    pub fn with_max_continuation_turns(mut self, turns: u32) -> Self {
458        self.max_continuation_turns = Some(turns);
459        self
460    }
461
462    /// Set an MCP manager to connect to external MCP servers.
463    ///
464    /// All tools from connected servers will be available during execution
465    /// with names like `mcp__<server>__<tool>`.
466    pub fn with_mcp(mut self, manager: Arc<crate::mcp::manager::McpManager>) -> Self {
467        self.mcp_manager = Some(manager);
468        self
469    }
470
471    pub fn with_temperature(mut self, temperature: f32) -> Self {
472        self.temperature = Some(temperature);
473        self
474    }
475
476    pub fn with_thinking_budget(mut self, budget: usize) -> Self {
477        self.thinking_budget = Some(budget);
478        self
479    }
480
481    /// Set slot-based system prompt customization for this session.
482    ///
483    /// Allows customizing role, guidelines, response style, and extra instructions
484    /// without overriding the core agentic capabilities.
485    pub fn with_prompt_slots(mut self, slots: SystemPromptSlots) -> Self {
486        self.prompt_slots = Some(slots);
487        self
488    }
489}
490
491// ============================================================================
492// Agent
493// ============================================================================
494
495/// High-level agent facade.
496///
497/// Holds the LLM client and agent config. Workspace-independent.
498/// Use [`Agent::session()`] to bind to a workspace.
499pub struct Agent {
500    llm_client: Arc<dyn LlmClient>,
501    code_config: CodeConfig,
502    config: AgentConfig,
503}
504
505impl std::fmt::Debug for Agent {
506    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507        f.debug_struct("Agent").finish()
508    }
509}
510
511impl Agent {
512    /// Create from a config file path or inline config string.
513    ///
514    /// Auto-detects: file path (.hcl/.json) vs inline JSON vs inline HCL.
515    pub async fn new(config_source: impl Into<String>) -> Result<Self> {
516        let source = config_source.into();
517
518        // Expand leading `~/` to the user's home directory (cross-platform)
519        let expanded = if let Some(rest) = source.strip_prefix("~/") {
520            let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
521            if let Some(home) = home {
522                format!("{}/{}", home.to_string_lossy(), rest)
523            } else {
524                source.clone()
525            }
526        } else {
527            source.clone()
528        };
529
530        let path = Path::new(&expanded);
531
532        let config = if path.extension().is_some() && path.exists() {
533            CodeConfig::from_file(path)
534                .with_context(|| format!("Failed to load config: {}", path.display()))?
535        } else {
536            // Try to parse as HCL string
537            CodeConfig::from_hcl(&source).context("Failed to parse config as HCL string")?
538        };
539
540        Self::from_config(config).await
541    }
542
543    /// Create from a config file path or inline config string.
544    ///
545    /// Alias for [`Agent::new()`] — provides a consistent API with
546    /// the Python and Node.js SDKs.
547    pub async fn create(config_source: impl Into<String>) -> Result<Self> {
548        Self::new(config_source).await
549    }
550
551    /// Create from a [`CodeConfig`] struct.
552    pub async fn from_config(config: CodeConfig) -> Result<Self> {
553        let llm_config = config
554            .default_llm_config()
555            .context("default_model must be set in 'provider/model' format with a valid API key")?;
556        let llm_client = crate::llm::create_client_with_config(llm_config);
557
558        let agent_config = AgentConfig {
559            max_tool_rounds: config
560                .max_tool_rounds
561                .unwrap_or(AgentConfig::default().max_tool_rounds),
562            ..AgentConfig::default()
563        };
564
565        let mut agent = Agent {
566            llm_client,
567            code_config: config,
568            config: agent_config,
569        };
570
571        // Always initialize the skill registry with built-in skills, then load any user-defined dirs
572        let registry = Arc::new(crate::skills::SkillRegistry::with_builtins());
573        for dir in &agent.code_config.skill_dirs.clone() {
574            if let Err(e) = registry.load_from_dir(dir) {
575                tracing::warn!(
576                    dir = %dir.display(),
577                    error = %e,
578                    "Failed to load skills from directory — skipping"
579                );
580            }
581        }
582        agent.config.skill_registry = Some(registry);
583
584        Ok(agent)
585    }
586
587    /// Bind to a workspace directory, returning an [`AgentSession`].
588    ///
589    /// Pass `None` for defaults, or `Some(SessionOptions)` to override
590    /// the model, agent directories for this session.
591    pub fn session(
592        &self,
593        workspace: impl Into<String>,
594        options: Option<SessionOptions>,
595    ) -> Result<AgentSession> {
596        let opts = options.unwrap_or_default();
597
598        let llm_client = if let Some(ref model) = opts.model {
599            let (provider_name, model_id) = model
600                .split_once('/')
601                .context("model format must be 'provider/model' (e.g., 'openai/gpt-4o')")?;
602
603            let mut llm_config = self
604                .code_config
605                .llm_config(provider_name, model_id)
606                .with_context(|| {
607                    format!("provider '{provider_name}' or model '{model_id}' not found in config")
608                })?;
609
610            if let Some(temp) = opts.temperature {
611                llm_config = llm_config.with_temperature(temp);
612            }
613            if let Some(budget) = opts.thinking_budget {
614                llm_config = llm_config.with_thinking_budget(budget);
615            }
616
617            crate::llm::create_client_with_config(llm_config)
618        } else {
619            if opts.temperature.is_some() || opts.thinking_budget.is_some() {
620                tracing::warn!(
621                    "temperature/thinking_budget set without model override — these will be ignored. \
622                     Use with_model() to apply LLM parameter overrides."
623                );
624            }
625            self.llm_client.clone()
626        };
627
628        self.build_session(workspace.into(), llm_client, &opts)
629    }
630
631    /// Resume a previously saved session by ID.
632    ///
633    /// Loads the session data from the store, rebuilds the `AgentSession` with
634    /// the saved conversation history, and returns it ready for continued use.
635    ///
636    /// The `options` must include a `session_store` (or `with_file_session_store`)
637    /// that contains the saved session.
638    pub fn resume_session(
639        &self,
640        session_id: &str,
641        options: SessionOptions,
642    ) -> Result<AgentSession> {
643        let store = options.session_store.as_ref().ok_or_else(|| {
644            crate::error::CodeError::Session(
645                "resume_session requires a session_store in SessionOptions".to_string(),
646            )
647        })?;
648
649        // Load session data from store
650        let data = match tokio::runtime::Handle::try_current() {
651            Ok(handle) => tokio::task::block_in_place(|| handle.block_on(store.load(session_id)))
652                .map_err(|e| {
653                crate::error::CodeError::Session(format!(
654                    "Failed to load session {}: {}",
655                    session_id, e
656                ))
657            })?,
658            Err(_) => {
659                return Err(crate::error::CodeError::Session(
660                    "No async runtime available for session resume".to_string(),
661                ))
662            }
663        };
664
665        let data = data.ok_or_else(|| {
666            crate::error::CodeError::Session(format!("Session not found: {}", session_id))
667        })?;
668
669        // Build session with the saved workspace
670        let mut opts = options;
671        opts.session_id = Some(data.id.clone());
672
673        let llm_client = if let Some(ref model) = opts.model {
674            let (provider_name, model_id) = model
675                .split_once('/')
676                .context("model format must be 'provider/model'")?;
677            let llm_config = self
678                .code_config
679                .llm_config(provider_name, model_id)
680                .with_context(|| {
681                    format!("provider '{provider_name}' or model '{model_id}' not found")
682                })?;
683            crate::llm::create_client_with_config(llm_config)
684        } else {
685            self.llm_client.clone()
686        };
687
688        let session = self.build_session(data.config.workspace.clone(), llm_client, &opts)?;
689
690        // Restore conversation history
691        *session.history.write().unwrap() = data.messages;
692
693        Ok(session)
694    }
695
696    fn build_session(
697        &self,
698        workspace: String,
699        llm_client: Arc<dyn LlmClient>,
700        opts: &SessionOptions,
701    ) -> Result<AgentSession> {
702        let canonical =
703            std::fs::canonicalize(&workspace).unwrap_or_else(|_| PathBuf::from(&workspace));
704
705        let tool_executor = Arc::new(ToolExecutor::new(canonical.display().to_string()));
706
707        // Register MCP tools before taking tool definitions snapshot
708        if let Some(ref mcp) = opts.mcp_manager {
709            let mcp_clone = Arc::clone(mcp);
710            let all_tools = tokio::task::block_in_place(|| {
711                tokio::runtime::Handle::current().block_on(mcp_clone.get_all_tools())
712            });
713            // Group by server name and register
714            let mut by_server: std::collections::HashMap<String, Vec<crate::mcp::McpTool>> =
715                std::collections::HashMap::new();
716            for (server, tool) in all_tools {
717                by_server.entry(server).or_default().push(tool);
718            }
719            for (server_name, tools) in by_server {
720                for tool in
721                    crate::mcp::tools::create_mcp_tools(&server_name, tools, Arc::clone(mcp))
722                {
723                    tool_executor.register_dynamic_tool(tool);
724                }
725            }
726        }
727
728        let tool_defs = tool_executor.definitions();
729
730        // Build prompt slots: start from session options or agent-level config
731        let mut prompt_slots = opts
732            .prompt_slots
733            .clone()
734            .unwrap_or_else(|| self.config.prompt_slots.clone());
735
736        // Build effective skill registry: fork the agent-level registry (builtins + global
737        // skill_dirs), then layer session-level skills on top. Forking ensures session skills
738        // never pollute the shared agent-level registry.
739        let base_registry = self
740            .config
741            .skill_registry
742            .as_deref()
743            .map(|r| r.fork())
744            .unwrap_or_else(crate::skills::SkillRegistry::with_builtins);
745        // Merge explicit session registry on top of the fork
746        if let Some(ref r) = opts.skill_registry {
747            for skill in r.all() {
748                base_registry.register_unchecked(skill);
749            }
750        }
751        // Load session-level skill dirs
752        for dir in &opts.skill_dirs {
753            if let Err(e) = base_registry.load_from_dir(dir) {
754                tracing::warn!(
755                    dir = %dir.display(),
756                    error = %e,
757                    "Failed to load session skill dir — skipping"
758                );
759            }
760        }
761        let effective_registry = Arc::new(base_registry);
762
763        // Append skill directory listing to the extra prompt slot
764        let skill_prompt = effective_registry.to_system_prompt();
765        if !skill_prompt.is_empty() {
766            prompt_slots.extra = match prompt_slots.extra {
767                Some(existing) => Some(format!("{}\n\n{}", existing, skill_prompt)),
768                None => Some(skill_prompt),
769            };
770        }
771
772        // Resolve memory store: explicit store takes priority, then file_memory_dir
773        let mut init_warning: Option<String> = None;
774        let memory = {
775            let store = if let Some(ref store) = opts.memory_store {
776                Some(Arc::clone(store))
777            } else if let Some(ref dir) = opts.file_memory_dir {
778                match tokio::runtime::Handle::try_current() {
779                    Ok(handle) => {
780                        let dir = dir.clone();
781                        match tokio::task::block_in_place(|| {
782                            handle.block_on(FileMemoryStore::new(dir))
783                        }) {
784                            Ok(store) => Some(Arc::new(store) as Arc<dyn MemoryStore>),
785                            Err(e) => {
786                                let msg = format!("Failed to create file memory store: {}", e);
787                                tracing::warn!("{}", msg);
788                                init_warning = Some(msg);
789                                None
790                            }
791                        }
792                    }
793                    Err(_) => {
794                        let msg =
795                            "No async runtime available for file memory store — memory disabled"
796                                .to_string();
797                        tracing::warn!("{}", msg);
798                        init_warning = Some(msg);
799                        None
800                    }
801                }
802            } else {
803                None
804            };
805            store.map(|s| Arc::new(crate::memory::AgentMemory::new(s)))
806        };
807
808        let base = self.config.clone();
809        let config = AgentConfig {
810            prompt_slots,
811            tools: tool_defs,
812            security_provider: opts.security_provider.clone(),
813            permission_checker: opts.permission_checker.clone(),
814            confirmation_manager: opts.confirmation_manager.clone(),
815            context_providers: opts.context_providers.clone(),
816            planning_enabled: opts.planning_enabled,
817            goal_tracking: opts.goal_tracking,
818            skill_registry: Some(effective_registry),
819            max_parse_retries: opts.max_parse_retries.unwrap_or(base.max_parse_retries),
820            tool_timeout_ms: opts.tool_timeout_ms.or(base.tool_timeout_ms),
821            circuit_breaker_threshold: opts
822                .circuit_breaker_threshold
823                .unwrap_or(base.circuit_breaker_threshold),
824            auto_compact: opts.auto_compact,
825            auto_compact_threshold: opts
826                .auto_compact_threshold
827                .unwrap_or(crate::session::DEFAULT_AUTO_COMPACT_THRESHOLD),
828            max_context_tokens: base.max_context_tokens,
829            llm_client: Some(Arc::clone(&llm_client)),
830            memory: memory.clone(),
831            continuation_enabled: opts
832                .continuation_enabled
833                .unwrap_or(base.continuation_enabled),
834            max_continuation_turns: opts
835                .max_continuation_turns
836                .unwrap_or(base.max_continuation_turns),
837            ..base
838        };
839
840        // Create lane queue if configured
841        // A shared broadcast channel is used for both queue events and subagent events.
842        let (agent_event_tx, _) = broadcast::channel::<crate::agent::AgentEvent>(256);
843        let command_queue = if let Some(ref queue_config) = opts.queue_config {
844            let session_id = uuid::Uuid::new_v4().to_string();
845            let rt = tokio::runtime::Handle::try_current();
846
847            match rt {
848                Ok(handle) => {
849                    // We're inside an async runtime — use block_in_place
850                    let queue = tokio::task::block_in_place(|| {
851                        handle.block_on(SessionLaneQueue::new(
852                            &session_id,
853                            queue_config.clone(),
854                            agent_event_tx.clone(),
855                        ))
856                    });
857                    match queue {
858                        Ok(q) => {
859                            // Start the queue
860                            let q = Arc::new(q);
861                            let q2 = Arc::clone(&q);
862                            tokio::task::block_in_place(|| {
863                                handle.block_on(async { q2.start().await.ok() })
864                            });
865                            Some(q)
866                        }
867                        Err(e) => {
868                            tracing::warn!("Failed to create session lane queue: {}", e);
869                            None
870                        }
871                    }
872                }
873                Err(_) => {
874                    tracing::warn!(
875                        "No async runtime available for queue creation — queue disabled"
876                    );
877                    None
878                }
879            }
880        } else {
881            None
882        };
883
884        // Create tool context with search config if available
885        let mut tool_context = ToolContext::new(canonical.clone());
886        if let Some(ref search_config) = self.code_config.search {
887            tool_context = tool_context.with_search_config(search_config.clone());
888        }
889        tool_context = tool_context.with_agent_event_tx(agent_event_tx);
890
891        // Wire sandbox when configured.
892        #[cfg(feature = "sandbox")]
893        if let Some(ref sandbox_cfg) = opts.sandbox_config {
894            let handle: Arc<dyn crate::sandbox::BashSandbox> =
895                Arc::new(crate::sandbox::BoxSandboxHandle::new(
896                    sandbox_cfg.clone(),
897                    canonical.display().to_string(),
898                ));
899            // Update the registry's default context so that direct
900            // `AgentSession::bash()` calls also use the sandbox.
901            tool_executor.registry().set_sandbox(Arc::clone(&handle));
902            tool_context = tool_context.with_sandbox(handle);
903        }
904        #[cfg(not(feature = "sandbox"))]
905        if opts.sandbox_config.is_some() {
906            tracing::warn!(
907                "sandbox_config is set but the `sandbox` Cargo feature is not enabled \
908                 — bash commands will run locally"
909            );
910        }
911
912        let session_id = opts
913            .session_id
914            .clone()
915            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
916
917        // Resolve session store: explicit opts store > config sessions_dir > None
918        let session_store = if opts.session_store.is_some() {
919            opts.session_store.clone()
920        } else if let Some(ref dir) = self.code_config.sessions_dir {
921            match tokio::runtime::Handle::try_current() {
922                Ok(handle) => {
923                    let dir = dir.clone();
924                    match tokio::task::block_in_place(|| {
925                        handle.block_on(crate::store::FileSessionStore::new(dir))
926                    }) {
927                        Ok(store) => Some(Arc::new(store) as Arc<dyn crate::store::SessionStore>),
928                        Err(e) => {
929                            tracing::warn!(
930                                "Failed to create session store from sessions_dir: {}",
931                                e
932                            );
933                            None
934                        }
935                    }
936                }
937                Err(_) => {
938                    tracing::warn!(
939                        "No async runtime for sessions_dir store — persistence disabled"
940                    );
941                    None
942                }
943            }
944        } else {
945            None
946        };
947
948        Ok(AgentSession {
949            llm_client,
950            tool_executor,
951            tool_context,
952            memory: config.memory.clone(),
953            config,
954            workspace: canonical,
955            session_id,
956            history: RwLock::new(Vec::new()),
957            command_queue,
958            session_store,
959            auto_save: opts.auto_save,
960            hook_engine: Arc::new(crate::hooks::HookEngine::new()),
961            init_warning,
962            command_registry: CommandRegistry::new(),
963            model_name: opts
964                .model
965                .clone()
966                .or_else(|| self.code_config.default_model.clone())
967                .unwrap_or_else(|| "unknown".to_string()),
968        })
969    }
970}
971
972// ============================================================================
973// AgentSession
974// ============================================================================
975
976/// Workspace-bound session. All LLM and tool operations happen here.
977///
978/// History is automatically accumulated after each `send()` call.
979/// Use `history()` to retrieve the current conversation log.
980pub struct AgentSession {
981    llm_client: Arc<dyn LlmClient>,
982    tool_executor: Arc<ToolExecutor>,
983    tool_context: ToolContext,
984    config: AgentConfig,
985    workspace: PathBuf,
986    /// Unique session identifier.
987    session_id: String,
988    /// Internal conversation history, auto-updated after each `send()`.
989    history: RwLock<Vec<Message>>,
990    /// Optional lane queue for priority-based tool execution.
991    command_queue: Option<Arc<SessionLaneQueue>>,
992    /// Optional long-term memory.
993    memory: Option<Arc<crate::memory::AgentMemory>>,
994    /// Optional session store for persistence.
995    session_store: Option<Arc<dyn crate::store::SessionStore>>,
996    /// Auto-save after each `send()`.
997    auto_save: bool,
998    /// Hook engine for lifecycle event interception.
999    hook_engine: Arc<crate::hooks::HookEngine>,
1000    /// Deferred init warning: emitted as PersistenceFailed on first send() if set.
1001    init_warning: Option<String>,
1002    /// Slash command registry for `/command` dispatch.
1003    command_registry: CommandRegistry,
1004    /// Model identifier for display (e.g., "anthropic/claude-sonnet-4-20250514").
1005    model_name: String,
1006}
1007
1008impl std::fmt::Debug for AgentSession {
1009    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1010        f.debug_struct("AgentSession")
1011            .field("session_id", &self.session_id)
1012            .field("workspace", &self.workspace.display().to_string())
1013            .field("auto_save", &self.auto_save)
1014            .finish()
1015    }
1016}
1017
1018impl AgentSession {
1019    /// Build an `AgentLoop` with the session's configuration.
1020    ///
1021    /// Propagates the lane queue (if configured) for external task handling.
1022    fn build_agent_loop(&self) -> AgentLoop {
1023        let mut config = self.config.clone();
1024        config.hook_engine =
1025            Some(Arc::clone(&self.hook_engine) as Arc<dyn crate::hooks::HookExecutor>);
1026        let mut agent_loop = AgentLoop::new(
1027            self.llm_client.clone(),
1028            self.tool_executor.clone(),
1029            self.tool_context.clone(),
1030            config,
1031        );
1032        if let Some(ref queue) = self.command_queue {
1033            agent_loop = agent_loop.with_queue(Arc::clone(queue));
1034        }
1035        agent_loop
1036    }
1037
1038    /// Build a `CommandContext` from the current session state.
1039    fn build_command_context(&self) -> CommandContext {
1040        let history = self.history.read().unwrap();
1041
1042        // Collect tool names from config
1043        let tool_names: Vec<String> = self.config.tools.iter().map(|t| t.name.clone()).collect();
1044
1045        // Derive MCP server info from tool names
1046        let mut mcp_map: std::collections::HashMap<String, usize> =
1047            std::collections::HashMap::new();
1048        for name in &tool_names {
1049            if let Some(rest) = name.strip_prefix("mcp__") {
1050                if let Some((server, _)) = rest.split_once("__") {
1051                    *mcp_map.entry(server.to_string()).or_default() += 1;
1052                }
1053            }
1054        }
1055        let mut mcp_servers: Vec<(String, usize)> = mcp_map.into_iter().collect();
1056        mcp_servers.sort_by(|a, b| a.0.cmp(&b.0));
1057
1058        CommandContext {
1059            session_id: self.session_id.clone(),
1060            workspace: self.workspace.display().to_string(),
1061            model: self.model_name.clone(),
1062            history_len: history.len(),
1063            total_tokens: 0,
1064            total_cost: 0.0,
1065            tool_names,
1066            mcp_servers,
1067        }
1068    }
1069
1070    /// Get a reference to the slash command registry.
1071    pub fn command_registry(&self) -> &CommandRegistry {
1072        &self.command_registry
1073    }
1074
1075    /// Register a custom slash command.
1076    pub fn register_command(&mut self, cmd: Arc<dyn crate::commands::SlashCommand>) {
1077        self.command_registry.register(cmd);
1078    }
1079
1080    /// Send a prompt and wait for the complete response.
1081    ///
1082    /// When `history` is `None`, uses (and auto-updates) the session's
1083    /// internal conversation history. When `Some`, uses the provided
1084    /// history instead (the internal history is **not** modified).
1085    ///
1086    /// If the prompt starts with `/`, it is dispatched as a slash command
1087    /// and the result is returned without calling the LLM.
1088    pub async fn send(&self, prompt: &str, history: Option<&[Message]>) -> Result<AgentResult> {
1089        // Slash command interception
1090        if CommandRegistry::is_command(prompt) {
1091            let ctx = self.build_command_context();
1092            if let Some(output) = self.command_registry.dispatch(prompt, &ctx) {
1093                return Ok(AgentResult {
1094                    text: output.text,
1095                    messages: history
1096                        .map(|h| h.to_vec())
1097                        .unwrap_or_else(|| self.history.read().unwrap().clone()),
1098                    tool_calls_count: 0,
1099                    usage: crate::llm::TokenUsage::default(),
1100                });
1101            }
1102        }
1103
1104        if let Some(ref w) = self.init_warning {
1105            tracing::warn!(session_id = %self.session_id, "Session init warning: {}", w);
1106        }
1107        let agent_loop = self.build_agent_loop();
1108
1109        let use_internal = history.is_none();
1110        let effective_history = match history {
1111            Some(h) => h.to_vec(),
1112            None => self.history.read().unwrap().clone(),
1113        };
1114
1115        let result = agent_loop.execute(&effective_history, prompt, None).await?;
1116
1117        // Auto-accumulate: only update internal history when no custom
1118        // history was provided.
1119        if use_internal {
1120            *self.history.write().unwrap() = result.messages.clone();
1121
1122            // Auto-save if configured
1123            if self.auto_save {
1124                if let Err(e) = self.save().await {
1125                    tracing::warn!("Auto-save failed for session {}: {}", self.session_id, e);
1126                }
1127            }
1128        }
1129
1130        Ok(result)
1131    }
1132
1133    /// Send a prompt with image attachments and wait for the complete response.
1134    ///
1135    /// Images are included as multi-modal content blocks in the user message.
1136    /// Requires a vision-capable model (e.g., Claude Sonnet, GPT-4o).
1137    pub async fn send_with_attachments(
1138        &self,
1139        prompt: &str,
1140        attachments: &[crate::llm::Attachment],
1141        history: Option<&[Message]>,
1142    ) -> Result<AgentResult> {
1143        // Build a user message with text + images, then pass it as the last
1144        // history entry. We use an empty prompt so execute_loop doesn't add
1145        // a duplicate user message.
1146        let use_internal = history.is_none();
1147        let mut effective_history = match history {
1148            Some(h) => h.to_vec(),
1149            None => self.history.read().unwrap().clone(),
1150        };
1151        effective_history.push(Message::user_with_attachments(prompt, attachments));
1152
1153        let agent_loop = self.build_agent_loop();
1154        let result = agent_loop
1155            .execute_from_messages(effective_history, None, None)
1156            .await?;
1157
1158        if use_internal {
1159            *self.history.write().unwrap() = result.messages.clone();
1160            if self.auto_save {
1161                if let Err(e) = self.save().await {
1162                    tracing::warn!("Auto-save failed for session {}: {}", self.session_id, e);
1163                }
1164            }
1165        }
1166
1167        Ok(result)
1168    }
1169
1170    /// Stream a prompt with image attachments.
1171    ///
1172    /// Images are included as multi-modal content blocks in the user message.
1173    /// Requires a vision-capable model (e.g., Claude Sonnet, GPT-4o).
1174    pub async fn stream_with_attachments(
1175        &self,
1176        prompt: &str,
1177        attachments: &[crate::llm::Attachment],
1178        history: Option<&[Message]>,
1179    ) -> Result<(mpsc::Receiver<AgentEvent>, JoinHandle<()>)> {
1180        let (tx, rx) = mpsc::channel(256);
1181        let mut effective_history = match history {
1182            Some(h) => h.to_vec(),
1183            None => self.history.read().unwrap().clone(),
1184        };
1185        effective_history.push(Message::user_with_attachments(prompt, attachments));
1186
1187        let agent_loop = self.build_agent_loop();
1188        let handle = tokio::spawn(async move {
1189            let _ = agent_loop
1190                .execute_from_messages(effective_history, None, Some(tx))
1191                .await;
1192        });
1193
1194        Ok((rx, handle))
1195    }
1196
1197    /// Send a prompt and stream events back.
1198    ///
1199    /// When `history` is `None`, uses the session's internal history
1200    /// (note: streaming does **not** auto-update internal history since
1201    /// the result is consumed asynchronously via the channel).
1202    /// When `Some`, uses the provided history instead.
1203    ///
1204    /// If the prompt starts with `/`, it is dispatched as a slash command
1205    /// and the result is emitted as a single `TextDelta` + `End` event.
1206    pub async fn stream(
1207        &self,
1208        prompt: &str,
1209        history: Option<&[Message]>,
1210    ) -> Result<(mpsc::Receiver<AgentEvent>, JoinHandle<()>)> {
1211        // Slash command interception for streaming
1212        if CommandRegistry::is_command(prompt) {
1213            let ctx = self.build_command_context();
1214            if let Some(output) = self.command_registry.dispatch(prompt, &ctx) {
1215                let (tx, rx) = mpsc::channel(256);
1216                let handle = tokio::spawn(async move {
1217                    let _ = tx
1218                        .send(AgentEvent::TextDelta {
1219                            text: output.text.clone(),
1220                        })
1221                        .await;
1222                    let _ = tx
1223                        .send(AgentEvent::End {
1224                            text: output.text.clone(),
1225                            usage: crate::llm::TokenUsage::default(),
1226                        })
1227                        .await;
1228                });
1229                return Ok((rx, handle));
1230            }
1231        }
1232
1233        let (tx, rx) = mpsc::channel(256);
1234        let agent_loop = self.build_agent_loop();
1235        let effective_history = match history {
1236            Some(h) => h.to_vec(),
1237            None => self.history.read().unwrap().clone(),
1238        };
1239        let prompt = prompt.to_string();
1240
1241        let handle = tokio::spawn(async move {
1242            let _ = agent_loop
1243                .execute(&effective_history, &prompt, Some(tx))
1244                .await;
1245        });
1246
1247        Ok((rx, handle))
1248    }
1249
1250    /// Return a snapshot of the session's conversation history.
1251    pub fn history(&self) -> Vec<Message> {
1252        self.history.read().unwrap().clone()
1253    }
1254
1255    /// Return a reference to the session's memory, if configured.
1256    pub fn memory(&self) -> Option<&Arc<crate::memory::AgentMemory>> {
1257        self.memory.as_ref()
1258    }
1259
1260    /// Return the session ID.
1261    pub fn id(&self) -> &str {
1262        &self.session_id
1263    }
1264
1265    /// Return the session workspace path.
1266    pub fn workspace(&self) -> &std::path::Path {
1267        &self.workspace
1268    }
1269
1270    /// Return any deferred init warning (e.g. memory store failed to initialize).
1271    pub fn init_warning(&self) -> Option<&str> {
1272        self.init_warning.as_deref()
1273    }
1274
1275    /// Return the session ID.
1276    pub fn session_id(&self) -> &str {
1277        &self.session_id
1278    }
1279
1280    // ========================================================================
1281    // Hook API
1282    // ========================================================================
1283
1284    /// Register a hook for lifecycle event interception.
1285    pub fn register_hook(&self, hook: crate::hooks::Hook) {
1286        self.hook_engine.register(hook);
1287    }
1288
1289    /// Unregister a hook by ID.
1290    pub fn unregister_hook(&self, hook_id: &str) -> Option<crate::hooks::Hook> {
1291        self.hook_engine.unregister(hook_id)
1292    }
1293
1294    /// Register a handler for a specific hook.
1295    pub fn register_hook_handler(
1296        &self,
1297        hook_id: &str,
1298        handler: Arc<dyn crate::hooks::HookHandler>,
1299    ) {
1300        self.hook_engine.register_handler(hook_id, handler);
1301    }
1302
1303    /// Unregister a hook handler by hook ID.
1304    pub fn unregister_hook_handler(&self, hook_id: &str) {
1305        self.hook_engine.unregister_handler(hook_id);
1306    }
1307
1308    /// Get the number of registered hooks.
1309    pub fn hook_count(&self) -> usize {
1310        self.hook_engine.hook_count()
1311    }
1312
1313    /// Save the session to the configured store.
1314    ///
1315    /// Returns `Ok(())` if saved successfully, or if no store is configured (no-op).
1316    pub async fn save(&self) -> Result<()> {
1317        let store = match &self.session_store {
1318            Some(s) => s,
1319            None => return Ok(()),
1320        };
1321
1322        let history = self.history.read().unwrap().clone();
1323        let now = chrono::Utc::now().timestamp();
1324
1325        let data = crate::store::SessionData {
1326            id: self.session_id.clone(),
1327            config: crate::session::SessionConfig {
1328                name: String::new(),
1329                workspace: self.workspace.display().to_string(),
1330                system_prompt: Some(self.config.prompt_slots.build()),
1331                max_context_length: 200_000,
1332                auto_compact: false,
1333                auto_compact_threshold: crate::session::DEFAULT_AUTO_COMPACT_THRESHOLD,
1334                storage_type: crate::config::StorageBackend::File,
1335                queue_config: None,
1336                confirmation_policy: None,
1337                permission_policy: None,
1338                parent_id: None,
1339                security_config: None,
1340                hook_engine: None,
1341                planning_enabled: self.config.planning_enabled,
1342                goal_tracking: self.config.goal_tracking,
1343            },
1344            state: crate::session::SessionState::Active,
1345            messages: history,
1346            context_usage: crate::session::ContextUsage::default(),
1347            total_usage: crate::llm::TokenUsage::default(),
1348            total_cost: 0.0,
1349            model_name: None,
1350            cost_records: Vec::new(),
1351            tool_names: crate::store::SessionData::tool_names_from_definitions(&self.config.tools),
1352            thinking_enabled: false,
1353            thinking_budget: None,
1354            created_at: now,
1355            updated_at: now,
1356            llm_config: None,
1357            tasks: Vec::new(),
1358            parent_id: None,
1359        };
1360
1361        store.save(&data).await?;
1362        tracing::debug!("Session {} saved", self.session_id);
1363        Ok(())
1364    }
1365
1366    /// Read a file from the workspace.
1367    pub async fn read_file(&self, path: &str) -> Result<String> {
1368        let args = serde_json::json!({ "file_path": path });
1369        let result = self.tool_executor.execute("read", &args).await?;
1370        Ok(result.output)
1371    }
1372
1373    /// Execute a bash command in the workspace.
1374    ///
1375    /// When a sandbox is configured via [`SessionOptions::with_sandbox()`],
1376    /// the command is routed through the A3S Box sandbox.
1377    pub async fn bash(&self, command: &str) -> Result<String> {
1378        let args = serde_json::json!({ "command": command });
1379        let result = self
1380            .tool_executor
1381            .execute_with_context("bash", &args, &self.tool_context)
1382            .await?;
1383        Ok(result.output)
1384    }
1385
1386    /// Search for files matching a glob pattern.
1387    pub async fn glob(&self, pattern: &str) -> Result<Vec<String>> {
1388        let args = serde_json::json!({ "pattern": pattern });
1389        let result = self.tool_executor.execute("glob", &args).await?;
1390        let files: Vec<String> = result
1391            .output
1392            .lines()
1393            .filter(|l| !l.is_empty())
1394            .map(|l| l.to_string())
1395            .collect();
1396        Ok(files)
1397    }
1398
1399    /// Search file contents with a regex pattern.
1400    pub async fn grep(&self, pattern: &str) -> Result<String> {
1401        let args = serde_json::json!({ "pattern": pattern });
1402        let result = self.tool_executor.execute("grep", &args).await?;
1403        Ok(result.output)
1404    }
1405
1406    /// Execute a tool by name, bypassing the LLM.
1407    pub async fn tool(&self, name: &str, args: serde_json::Value) -> Result<ToolCallResult> {
1408        let result = self.tool_executor.execute(name, &args).await?;
1409        Ok(ToolCallResult {
1410            name: name.to_string(),
1411            output: result.output,
1412            exit_code: result.exit_code,
1413        })
1414    }
1415
1416    // ========================================================================
1417    // Queue API
1418    // ========================================================================
1419
1420    /// Returns whether this session has a lane queue configured.
1421    pub fn has_queue(&self) -> bool {
1422        self.command_queue.is_some()
1423    }
1424
1425    /// Configure a lane's handler mode (Internal/External/Hybrid).
1426    ///
1427    /// Only effective when a queue is configured via `SessionOptions::with_queue_config`.
1428    pub async fn set_lane_handler(&self, lane: SessionLane, config: LaneHandlerConfig) {
1429        if let Some(ref queue) = self.command_queue {
1430            queue.set_lane_handler(lane, config).await;
1431        }
1432    }
1433
1434    /// Complete an external task by ID.
1435    ///
1436    /// Returns `true` if the task was found and completed, `false` if not found.
1437    pub async fn complete_external_task(&self, task_id: &str, result: ExternalTaskResult) -> bool {
1438        if let Some(ref queue) = self.command_queue {
1439            queue.complete_external_task(task_id, result).await
1440        } else {
1441            false
1442        }
1443    }
1444
1445    /// Get pending external tasks awaiting completion by an external handler.
1446    pub async fn pending_external_tasks(&self) -> Vec<ExternalTask> {
1447        if let Some(ref queue) = self.command_queue {
1448            queue.pending_external_tasks().await
1449        } else {
1450            Vec::new()
1451        }
1452    }
1453
1454    /// Get queue statistics (pending, active, external counts per lane).
1455    pub async fn queue_stats(&self) -> SessionQueueStats {
1456        if let Some(ref queue) = self.command_queue {
1457            queue.stats().await
1458        } else {
1459            SessionQueueStats::default()
1460        }
1461    }
1462
1463    /// Get a metrics snapshot from the queue (if metrics are enabled).
1464    pub async fn queue_metrics(&self) -> Option<MetricsSnapshot> {
1465        if let Some(ref queue) = self.command_queue {
1466            queue.metrics_snapshot().await
1467        } else {
1468            None
1469        }
1470    }
1471
1472    /// Get dead letters from the queue's DLQ (if DLQ is enabled).
1473    pub async fn dead_letters(&self) -> Vec<DeadLetter> {
1474        if let Some(ref queue) = self.command_queue {
1475            queue.dead_letters().await
1476        } else {
1477            Vec::new()
1478        }
1479    }
1480}
1481
1482// ============================================================================
1483// Tests
1484// ============================================================================
1485
1486#[cfg(test)]
1487mod tests {
1488    use super::*;
1489    use crate::config::{ModelConfig, ModelModalities, ProviderConfig};
1490    use crate::store::SessionStore;
1491
1492    fn test_config() -> CodeConfig {
1493        CodeConfig {
1494            default_model: Some("anthropic/claude-sonnet-4-20250514".to_string()),
1495            providers: vec![
1496                ProviderConfig {
1497                    name: "anthropic".to_string(),
1498                    api_key: Some("test-key".to_string()),
1499                    base_url: None,
1500                    models: vec![ModelConfig {
1501                        id: "claude-sonnet-4-20250514".to_string(),
1502                        name: "Claude Sonnet 4".to_string(),
1503                        family: "claude-sonnet".to_string(),
1504                        api_key: None,
1505                        base_url: None,
1506                        attachment: false,
1507                        reasoning: false,
1508                        tool_call: true,
1509                        temperature: true,
1510                        release_date: None,
1511                        modalities: ModelModalities::default(),
1512                        cost: Default::default(),
1513                        limit: Default::default(),
1514                    }],
1515                },
1516                ProviderConfig {
1517                    name: "openai".to_string(),
1518                    api_key: Some("test-openai-key".to_string()),
1519                    base_url: None,
1520                    models: vec![ModelConfig {
1521                        id: "gpt-4o".to_string(),
1522                        name: "GPT-4o".to_string(),
1523                        family: "gpt-4".to_string(),
1524                        api_key: None,
1525                        base_url: None,
1526                        attachment: false,
1527                        reasoning: false,
1528                        tool_call: true,
1529                        temperature: true,
1530                        release_date: None,
1531                        modalities: ModelModalities::default(),
1532                        cost: Default::default(),
1533                        limit: Default::default(),
1534                    }],
1535                },
1536            ],
1537            ..Default::default()
1538        }
1539    }
1540
1541    #[tokio::test]
1542    async fn test_from_config() {
1543        let agent = Agent::from_config(test_config()).await;
1544        assert!(agent.is_ok());
1545    }
1546
1547    #[tokio::test]
1548    async fn test_session_default() {
1549        let agent = Agent::from_config(test_config()).await.unwrap();
1550        let session = agent.session("/tmp/test-workspace", None);
1551        assert!(session.is_ok());
1552        let debug = format!("{:?}", session.unwrap());
1553        assert!(debug.contains("AgentSession"));
1554    }
1555
1556    #[tokio::test]
1557    async fn test_session_with_model_override() {
1558        let agent = Agent::from_config(test_config()).await.unwrap();
1559        let opts = SessionOptions::new().with_model("openai/gpt-4o");
1560        let session = agent.session("/tmp/test-workspace", Some(opts));
1561        assert!(session.is_ok());
1562    }
1563
1564    #[tokio::test]
1565    async fn test_session_with_invalid_model_format() {
1566        let agent = Agent::from_config(test_config()).await.unwrap();
1567        let opts = SessionOptions::new().with_model("gpt-4o");
1568        let session = agent.session("/tmp/test-workspace", Some(opts));
1569        assert!(session.is_err());
1570    }
1571
1572    #[tokio::test]
1573    async fn test_session_with_model_not_found() {
1574        let agent = Agent::from_config(test_config()).await.unwrap();
1575        let opts = SessionOptions::new().with_model("openai/nonexistent");
1576        let session = agent.session("/tmp/test-workspace", Some(opts));
1577        assert!(session.is_err());
1578    }
1579
1580    #[tokio::test]
1581    async fn test_new_with_hcl_string() {
1582        let hcl = r#"
1583            default_model = "anthropic/claude-sonnet-4-20250514"
1584            providers {
1585                name    = "anthropic"
1586                api_key = "test-key"
1587                models {
1588                    id   = "claude-sonnet-4-20250514"
1589                    name = "Claude Sonnet 4"
1590                }
1591            }
1592        "#;
1593        let agent = Agent::new(hcl).await;
1594        assert!(agent.is_ok());
1595    }
1596
1597    #[tokio::test]
1598    async fn test_create_alias_hcl() {
1599        let hcl = r#"
1600            default_model = "anthropic/claude-sonnet-4-20250514"
1601            providers {
1602                name    = "anthropic"
1603                api_key = "test-key"
1604                models {
1605                    id   = "claude-sonnet-4-20250514"
1606                    name = "Claude Sonnet 4"
1607                }
1608            }
1609        "#;
1610        let agent = Agent::create(hcl).await;
1611        assert!(agent.is_ok());
1612    }
1613
1614    #[tokio::test]
1615    async fn test_create_and_new_produce_same_result() {
1616        let hcl = r#"
1617            default_model = "anthropic/claude-sonnet-4-20250514"
1618            providers {
1619                name    = "anthropic"
1620                api_key = "test-key"
1621                models {
1622                    id   = "claude-sonnet-4-20250514"
1623                    name = "Claude Sonnet 4"
1624                }
1625            }
1626        "#;
1627        let agent_new = Agent::new(hcl).await;
1628        let agent_create = Agent::create(hcl).await;
1629        assert!(agent_new.is_ok());
1630        assert!(agent_create.is_ok());
1631
1632        // Both should produce working sessions
1633        let session_new = agent_new.unwrap().session("/tmp/test-ws-new", None);
1634        let session_create = agent_create.unwrap().session("/tmp/test-ws-create", None);
1635        assert!(session_new.is_ok());
1636        assert!(session_create.is_ok());
1637    }
1638
1639    #[test]
1640    fn test_from_config_requires_default_model() {
1641        let rt = tokio::runtime::Runtime::new().unwrap();
1642        let config = CodeConfig {
1643            providers: vec![ProviderConfig {
1644                name: "anthropic".to_string(),
1645                api_key: Some("test-key".to_string()),
1646                base_url: None,
1647                models: vec![],
1648            }],
1649            ..Default::default()
1650        };
1651        let result = rt.block_on(Agent::from_config(config));
1652        assert!(result.is_err());
1653    }
1654
1655    #[tokio::test]
1656    async fn test_history_empty_on_new_session() {
1657        let agent = Agent::from_config(test_config()).await.unwrap();
1658        let session = agent.session("/tmp/test-workspace", None).unwrap();
1659        assert!(session.history().is_empty());
1660    }
1661
1662    #[tokio::test]
1663    async fn test_session_options_with_agent_dir() {
1664        let opts = SessionOptions::new()
1665            .with_agent_dir("/tmp/agents")
1666            .with_agent_dir("/tmp/more-agents");
1667        assert_eq!(opts.agent_dirs.len(), 2);
1668        assert_eq!(opts.agent_dirs[0], PathBuf::from("/tmp/agents"));
1669        assert_eq!(opts.agent_dirs[1], PathBuf::from("/tmp/more-agents"));
1670    }
1671
1672    // ========================================================================
1673    // Queue Integration Tests
1674    // ========================================================================
1675
1676    #[test]
1677    fn test_session_options_with_queue_config() {
1678        let qc = SessionQueueConfig::default().with_lane_features();
1679        let opts = SessionOptions::new().with_queue_config(qc.clone());
1680        assert!(opts.queue_config.is_some());
1681
1682        let config = opts.queue_config.unwrap();
1683        assert!(config.enable_dlq);
1684        assert!(config.enable_metrics);
1685        assert!(config.enable_alerts);
1686        assert_eq!(config.default_timeout_ms, Some(60_000));
1687    }
1688
1689    #[tokio::test(flavor = "multi_thread")]
1690    async fn test_session_with_queue_config() {
1691        let agent = Agent::from_config(test_config()).await.unwrap();
1692        let qc = SessionQueueConfig::default();
1693        let opts = SessionOptions::new().with_queue_config(qc);
1694        let session = agent.session("/tmp/test-workspace-queue", Some(opts));
1695        assert!(session.is_ok());
1696        let session = session.unwrap();
1697        assert!(session.has_queue());
1698    }
1699
1700    #[tokio::test]
1701    async fn test_session_without_queue_config() {
1702        let agent = Agent::from_config(test_config()).await.unwrap();
1703        let session = agent.session("/tmp/test-workspace-noqueue", None).unwrap();
1704        assert!(!session.has_queue());
1705    }
1706
1707    #[tokio::test]
1708    async fn test_session_queue_stats_without_queue() {
1709        let agent = Agent::from_config(test_config()).await.unwrap();
1710        let session = agent.session("/tmp/test-workspace-stats", None).unwrap();
1711        let stats = session.queue_stats().await;
1712        // Without a queue, stats should have zero values
1713        assert_eq!(stats.total_pending, 0);
1714        assert_eq!(stats.total_active, 0);
1715    }
1716
1717    #[tokio::test(flavor = "multi_thread")]
1718    async fn test_session_queue_stats_with_queue() {
1719        let agent = Agent::from_config(test_config()).await.unwrap();
1720        let qc = SessionQueueConfig::default();
1721        let opts = SessionOptions::new().with_queue_config(qc);
1722        let session = agent
1723            .session("/tmp/test-workspace-qstats", Some(opts))
1724            .unwrap();
1725        let stats = session.queue_stats().await;
1726        // Fresh queue with no commands should have zero stats
1727        assert_eq!(stats.total_pending, 0);
1728        assert_eq!(stats.total_active, 0);
1729    }
1730
1731    #[tokio::test(flavor = "multi_thread")]
1732    async fn test_session_pending_external_tasks_empty() {
1733        let agent = Agent::from_config(test_config()).await.unwrap();
1734        let qc = SessionQueueConfig::default();
1735        let opts = SessionOptions::new().with_queue_config(qc);
1736        let session = agent
1737            .session("/tmp/test-workspace-ext", Some(opts))
1738            .unwrap();
1739        let tasks = session.pending_external_tasks().await;
1740        assert!(tasks.is_empty());
1741    }
1742
1743    #[tokio::test(flavor = "multi_thread")]
1744    async fn test_session_dead_letters_empty() {
1745        let agent = Agent::from_config(test_config()).await.unwrap();
1746        let qc = SessionQueueConfig::default().with_dlq(Some(100));
1747        let opts = SessionOptions::new().with_queue_config(qc);
1748        let session = agent
1749            .session("/tmp/test-workspace-dlq", Some(opts))
1750            .unwrap();
1751        let dead = session.dead_letters().await;
1752        assert!(dead.is_empty());
1753    }
1754
1755    #[tokio::test(flavor = "multi_thread")]
1756    async fn test_session_queue_metrics_disabled() {
1757        let agent = Agent::from_config(test_config()).await.unwrap();
1758        // Metrics not enabled
1759        let qc = SessionQueueConfig::default();
1760        let opts = SessionOptions::new().with_queue_config(qc);
1761        let session = agent
1762            .session("/tmp/test-workspace-nomet", Some(opts))
1763            .unwrap();
1764        let metrics = session.queue_metrics().await;
1765        assert!(metrics.is_none());
1766    }
1767
1768    #[tokio::test(flavor = "multi_thread")]
1769    async fn test_session_queue_metrics_enabled() {
1770        let agent = Agent::from_config(test_config()).await.unwrap();
1771        let qc = SessionQueueConfig::default().with_metrics();
1772        let opts = SessionOptions::new().with_queue_config(qc);
1773        let session = agent
1774            .session("/tmp/test-workspace-met", Some(opts))
1775            .unwrap();
1776        let metrics = session.queue_metrics().await;
1777        assert!(metrics.is_some());
1778    }
1779
1780    #[tokio::test(flavor = "multi_thread")]
1781    async fn test_session_set_lane_handler() {
1782        let agent = Agent::from_config(test_config()).await.unwrap();
1783        let qc = SessionQueueConfig::default();
1784        let opts = SessionOptions::new().with_queue_config(qc);
1785        let session = agent
1786            .session("/tmp/test-workspace-handler", Some(opts))
1787            .unwrap();
1788
1789        // Set Execute lane to External mode
1790        session
1791            .set_lane_handler(
1792                SessionLane::Execute,
1793                LaneHandlerConfig {
1794                    mode: crate::queue::TaskHandlerMode::External,
1795                    timeout_ms: 30_000,
1796                },
1797            )
1798            .await;
1799
1800        // No panic = success. The handler config is stored internally.
1801        // We can't directly read it back but we verify no errors.
1802    }
1803
1804    // ========================================================================
1805    // Session Persistence Tests
1806    // ========================================================================
1807
1808    #[tokio::test(flavor = "multi_thread")]
1809    async fn test_session_has_id() {
1810        let agent = Agent::from_config(test_config()).await.unwrap();
1811        let session = agent.session("/tmp/test-ws-id", None).unwrap();
1812        // Auto-generated UUID
1813        assert!(!session.session_id().is_empty());
1814        assert_eq!(session.session_id().len(), 36); // UUID format
1815    }
1816
1817    #[tokio::test(flavor = "multi_thread")]
1818    async fn test_session_explicit_id() {
1819        let agent = Agent::from_config(test_config()).await.unwrap();
1820        let opts = SessionOptions::new().with_session_id("my-session-42");
1821        let session = agent.session("/tmp/test-ws-eid", Some(opts)).unwrap();
1822        assert_eq!(session.session_id(), "my-session-42");
1823    }
1824
1825    #[tokio::test(flavor = "multi_thread")]
1826    async fn test_session_save_no_store() {
1827        let agent = Agent::from_config(test_config()).await.unwrap();
1828        let session = agent.session("/tmp/test-ws-save", None).unwrap();
1829        // save() is a no-op when no store is configured
1830        session.save().await.unwrap();
1831    }
1832
1833    #[tokio::test(flavor = "multi_thread")]
1834    async fn test_session_save_and_load() {
1835        let store = Arc::new(crate::store::MemorySessionStore::new());
1836        let agent = Agent::from_config(test_config()).await.unwrap();
1837
1838        let opts = SessionOptions::new()
1839            .with_session_store(store.clone())
1840            .with_session_id("persist-test");
1841        let session = agent.session("/tmp/test-ws-persist", Some(opts)).unwrap();
1842
1843        // Save empty session
1844        session.save().await.unwrap();
1845
1846        // Verify it was stored
1847        assert!(store.exists("persist-test").await.unwrap());
1848
1849        let data = store.load("persist-test").await.unwrap().unwrap();
1850        assert_eq!(data.id, "persist-test");
1851        assert!(data.messages.is_empty());
1852    }
1853
1854    #[tokio::test(flavor = "multi_thread")]
1855    async fn test_session_save_with_history() {
1856        let store = Arc::new(crate::store::MemorySessionStore::new());
1857        let agent = Agent::from_config(test_config()).await.unwrap();
1858
1859        let opts = SessionOptions::new()
1860            .with_session_store(store.clone())
1861            .with_session_id("history-test");
1862        let session = agent.session("/tmp/test-ws-hist", Some(opts)).unwrap();
1863
1864        // Manually inject history
1865        {
1866            let mut h = session.history.write().unwrap();
1867            h.push(Message::user("Hello"));
1868            h.push(Message::user("How are you?"));
1869        }
1870
1871        session.save().await.unwrap();
1872
1873        let data = store.load("history-test").await.unwrap().unwrap();
1874        assert_eq!(data.messages.len(), 2);
1875    }
1876
1877    #[tokio::test(flavor = "multi_thread")]
1878    async fn test_resume_session() {
1879        let store = Arc::new(crate::store::MemorySessionStore::new());
1880        let agent = Agent::from_config(test_config()).await.unwrap();
1881
1882        // Create and save a session with history
1883        let opts = SessionOptions::new()
1884            .with_session_store(store.clone())
1885            .with_session_id("resume-test");
1886        let session = agent.session("/tmp/test-ws-resume", Some(opts)).unwrap();
1887        {
1888            let mut h = session.history.write().unwrap();
1889            h.push(Message::user("What is Rust?"));
1890            h.push(Message::user("Tell me more"));
1891        }
1892        session.save().await.unwrap();
1893
1894        // Resume the session
1895        let opts2 = SessionOptions::new().with_session_store(store.clone());
1896        let resumed = agent.resume_session("resume-test", opts2).unwrap();
1897
1898        assert_eq!(resumed.session_id(), "resume-test");
1899        let history = resumed.history();
1900        assert_eq!(history.len(), 2);
1901        assert_eq!(history[0].text(), "What is Rust?");
1902    }
1903
1904    #[tokio::test(flavor = "multi_thread")]
1905    async fn test_resume_session_not_found() {
1906        let store = Arc::new(crate::store::MemorySessionStore::new());
1907        let agent = Agent::from_config(test_config()).await.unwrap();
1908
1909        let opts = SessionOptions::new().with_session_store(store.clone());
1910        let result = agent.resume_session("nonexistent", opts);
1911        assert!(result.is_err());
1912        assert!(result.unwrap_err().to_string().contains("not found"));
1913    }
1914
1915    #[tokio::test(flavor = "multi_thread")]
1916    async fn test_resume_session_no_store() {
1917        let agent = Agent::from_config(test_config()).await.unwrap();
1918        let opts = SessionOptions::new();
1919        let result = agent.resume_session("any-id", opts);
1920        assert!(result.is_err());
1921        assert!(result.unwrap_err().to_string().contains("session_store"));
1922    }
1923
1924    #[tokio::test(flavor = "multi_thread")]
1925    async fn test_file_session_store_persistence() {
1926        let dir = tempfile::TempDir::new().unwrap();
1927        let store = Arc::new(
1928            crate::store::FileSessionStore::new(dir.path())
1929                .await
1930                .unwrap(),
1931        );
1932        let agent = Agent::from_config(test_config()).await.unwrap();
1933
1934        // Save
1935        let opts = SessionOptions::new()
1936            .with_session_store(store.clone())
1937            .with_session_id("file-persist");
1938        let session = agent
1939            .session("/tmp/test-ws-file-persist", Some(opts))
1940            .unwrap();
1941        {
1942            let mut h = session.history.write().unwrap();
1943            h.push(Message::user("test message"));
1944        }
1945        session.save().await.unwrap();
1946
1947        // Load from a fresh store instance pointing to same dir
1948        let store2 = Arc::new(
1949            crate::store::FileSessionStore::new(dir.path())
1950                .await
1951                .unwrap(),
1952        );
1953        let data = store2.load("file-persist").await.unwrap().unwrap();
1954        assert_eq!(data.messages.len(), 1);
1955    }
1956
1957    #[tokio::test(flavor = "multi_thread")]
1958    async fn test_session_options_builders() {
1959        let opts = SessionOptions::new()
1960            .with_session_id("test-id")
1961            .with_auto_save(true);
1962        assert_eq!(opts.session_id, Some("test-id".to_string()));
1963        assert!(opts.auto_save);
1964    }
1965
1966    // ========================================================================
1967    // Sandbox Tests
1968    // ========================================================================
1969
1970    #[test]
1971    fn test_session_options_with_sandbox_sets_config() {
1972        use crate::sandbox::SandboxConfig;
1973        let cfg = SandboxConfig {
1974            image: "ubuntu:22.04".into(),
1975            memory_mb: 1024,
1976            ..SandboxConfig::default()
1977        };
1978        let opts = SessionOptions::new().with_sandbox(cfg);
1979        assert!(opts.sandbox_config.is_some());
1980        let sc = opts.sandbox_config.unwrap();
1981        assert_eq!(sc.image, "ubuntu:22.04");
1982        assert_eq!(sc.memory_mb, 1024);
1983    }
1984
1985    #[test]
1986    fn test_session_options_default_has_no_sandbox() {
1987        let opts = SessionOptions::default();
1988        assert!(opts.sandbox_config.is_none());
1989    }
1990
1991    #[tokio::test]
1992    async fn test_session_debug_includes_sandbox_config() {
1993        use crate::sandbox::SandboxConfig;
1994        let opts = SessionOptions::new().with_sandbox(SandboxConfig::default());
1995        let debug = format!("{:?}", opts);
1996        assert!(debug.contains("sandbox_config"));
1997    }
1998
1999    #[tokio::test]
2000    async fn test_session_build_with_sandbox_config_no_feature_warn() {
2001        // When feature is not enabled, build_session should still succeed
2002        // (it just logs a warning). With feature enabled, it creates a handle.
2003        let agent = Agent::from_config(test_config()).await.unwrap();
2004        let opts = SessionOptions::new().with_sandbox(crate::sandbox::SandboxConfig::default());
2005        // build_session should not fail even if sandbox feature is off
2006        let session = agent.session("/tmp/test-sandbox-session", Some(opts));
2007        assert!(session.is_ok());
2008    }
2009
2010    // ========================================================================
2011    // Memory Integration Tests
2012    // ========================================================================
2013
2014    #[tokio::test(flavor = "multi_thread")]
2015    async fn test_session_with_memory_store() {
2016        use a3s_memory::InMemoryStore;
2017        let store = Arc::new(InMemoryStore::new());
2018        let agent = Agent::from_config(test_config()).await.unwrap();
2019        let opts = SessionOptions::new().with_memory(store);
2020        let session = agent.session("/tmp/test-ws-memory", Some(opts)).unwrap();
2021        assert!(session.memory().is_some());
2022    }
2023
2024    #[tokio::test(flavor = "multi_thread")]
2025    async fn test_session_without_memory_store() {
2026        let agent = Agent::from_config(test_config()).await.unwrap();
2027        let session = agent.session("/tmp/test-ws-no-memory", None).unwrap();
2028        assert!(session.memory().is_none());
2029    }
2030
2031    #[tokio::test(flavor = "multi_thread")]
2032    async fn test_session_memory_wired_into_config() {
2033        use a3s_memory::InMemoryStore;
2034        let store = Arc::new(InMemoryStore::new());
2035        let agent = Agent::from_config(test_config()).await.unwrap();
2036        let opts = SessionOptions::new().with_memory(store);
2037        let session = agent
2038            .session("/tmp/test-ws-mem-config", Some(opts))
2039            .unwrap();
2040        // memory is accessible via the public session API
2041        assert!(session.memory().is_some());
2042    }
2043
2044    #[tokio::test(flavor = "multi_thread")]
2045    async fn test_session_with_file_memory() {
2046        let dir = tempfile::TempDir::new().unwrap();
2047        let agent = Agent::from_config(test_config()).await.unwrap();
2048        let opts = SessionOptions::new().with_file_memory(dir.path());
2049        let session = agent.session("/tmp/test-ws-file-mem", Some(opts)).unwrap();
2050        assert!(session.memory().is_some());
2051    }
2052
2053    #[tokio::test(flavor = "multi_thread")]
2054    async fn test_memory_remember_and_recall() {
2055        use a3s_memory::InMemoryStore;
2056        let store = Arc::new(InMemoryStore::new());
2057        let agent = Agent::from_config(test_config()).await.unwrap();
2058        let opts = SessionOptions::new().with_memory(store);
2059        let session = agent
2060            .session("/tmp/test-ws-mem-recall", Some(opts))
2061            .unwrap();
2062
2063        let memory = session.memory().unwrap();
2064        memory
2065            .remember_success("write a file", &["write".to_string()], "done")
2066            .await
2067            .unwrap();
2068
2069        let results = memory.recall_similar("write", 5).await.unwrap();
2070        assert!(!results.is_empty());
2071        let stats = memory.stats().await.unwrap();
2072        assert_eq!(stats.long_term_count, 1);
2073    }
2074
2075    // ========================================================================
2076    // Tool timeout tests
2077    // ========================================================================
2078
2079    #[tokio::test(flavor = "multi_thread")]
2080    async fn test_session_tool_timeout_configured() {
2081        let agent = Agent::from_config(test_config()).await.unwrap();
2082        let opts = SessionOptions::new().with_tool_timeout(5000);
2083        let session = agent.session("/tmp/test-ws-timeout", Some(opts)).unwrap();
2084        assert!(!session.id().is_empty());
2085    }
2086
2087    // ========================================================================
2088    // Queue fallback tests
2089    // ========================================================================
2090
2091    #[tokio::test(flavor = "multi_thread")]
2092    async fn test_session_without_queue_builds_ok() {
2093        let agent = Agent::from_config(test_config()).await.unwrap();
2094        let session = agent.session("/tmp/test-ws-no-queue", None).unwrap();
2095        assert!(!session.id().is_empty());
2096    }
2097
2098    // ========================================================================
2099    // Concurrent history access tests
2100    // ========================================================================
2101
2102    #[tokio::test(flavor = "multi_thread")]
2103    async fn test_concurrent_history_reads() {
2104        let agent = Agent::from_config(test_config()).await.unwrap();
2105        let session = Arc::new(agent.session("/tmp/test-ws-concurrent", None).unwrap());
2106
2107        let handles: Vec<_> = (0..10)
2108            .map(|_| {
2109                let s = Arc::clone(&session);
2110                tokio::spawn(async move { s.history().len() })
2111            })
2112            .collect();
2113
2114        for h in handles {
2115            h.await.unwrap();
2116        }
2117    }
2118
2119    // ========================================================================
2120    // init_warning tests
2121    // ========================================================================
2122
2123    #[tokio::test(flavor = "multi_thread")]
2124    async fn test_session_no_init_warning_without_file_memory() {
2125        let agent = Agent::from_config(test_config()).await.unwrap();
2126        let session = agent.session("/tmp/test-ws-no-warn", None).unwrap();
2127        assert!(session.init_warning().is_none());
2128    }
2129
2130    #[tokio::test(flavor = "multi_thread")]
2131    async fn test_session_with_mcp_manager_builds_ok() {
2132        use crate::mcp::manager::McpManager;
2133        let mcp = Arc::new(McpManager::new());
2134        let agent = Agent::from_config(test_config()).await.unwrap();
2135        let opts = SessionOptions::new().with_mcp(mcp);
2136        // No servers connected — should build fine with zero MCP tools registered
2137        let session = agent.session("/tmp/test-ws-mcp", Some(opts)).unwrap();
2138        assert!(!session.id().is_empty());
2139    }
2140}