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
519        let expanded = if source.starts_with("~/") {
520            if let Some(home) = std::env::var_os("HOME") {
521                format!("{}/{}", home.to_string_lossy(), &source[2..])
522            } else {
523                source.clone()
524            }
525        } else {
526            source.clone()
527        };
528
529        let path = Path::new(&expanded);
530
531        let config = if path.extension().is_some() && path.exists() {
532            CodeConfig::from_file(path)
533                .with_context(|| format!("Failed to load config: {}", path.display()))?
534        } else {
535            // Try to parse as HCL string
536            CodeConfig::from_hcl(&source).context("Failed to parse config as HCL string")?
537        };
538
539        Self::from_config(config).await
540    }
541
542    /// Create from a config file path or inline config string.
543    ///
544    /// Alias for [`Agent::new()`] — provides a consistent API with
545    /// the Python and Node.js SDKs.
546    pub async fn create(config_source: impl Into<String>) -> Result<Self> {
547        Self::new(config_source).await
548    }
549
550    /// Create from a [`CodeConfig`] struct.
551    pub async fn from_config(config: CodeConfig) -> Result<Self> {
552        let llm_config = config
553            .default_llm_config()
554            .context("default_model must be set in 'provider/model' format with a valid API key")?;
555        let llm_client = crate::llm::create_client_with_config(llm_config);
556
557        let agent_config = AgentConfig {
558            max_tool_rounds: config
559                .max_tool_rounds
560                .unwrap_or(AgentConfig::default().max_tool_rounds),
561            ..AgentConfig::default()
562        };
563
564        let mut agent = Agent {
565            llm_client,
566            code_config: config,
567            config: agent_config,
568        };
569
570        // Always initialize the skill registry with built-in skills, then load any user-defined dirs
571        let registry = Arc::new(crate::skills::SkillRegistry::with_builtins());
572        for dir in &agent.code_config.skill_dirs.clone() {
573            if let Err(e) = registry.load_from_dir(dir) {
574                tracing::warn!(
575                    dir = %dir.display(),
576                    error = %e,
577                    "Failed to load skills from directory — skipping"
578                );
579            }
580        }
581        agent.config.skill_registry = Some(registry);
582
583        Ok(agent)
584    }
585
586    /// Bind to a workspace directory, returning an [`AgentSession`].
587    ///
588    /// Pass `None` for defaults, or `Some(SessionOptions)` to override
589    /// the model, agent directories for this session.
590    pub fn session(
591        &self,
592        workspace: impl Into<String>,
593        options: Option<SessionOptions>,
594    ) -> Result<AgentSession> {
595        let opts = options.unwrap_or_default();
596
597        let llm_client = if let Some(ref model) = opts.model {
598            let (provider_name, model_id) = model
599                .split_once('/')
600                .context("model format must be 'provider/model' (e.g., 'openai/gpt-4o')")?;
601
602            let mut llm_config = self
603                .code_config
604                .llm_config(provider_name, model_id)
605                .with_context(|| {
606                    format!("provider '{provider_name}' or model '{model_id}' not found in config")
607                })?;
608
609            if let Some(temp) = opts.temperature {
610                llm_config = llm_config.with_temperature(temp);
611            }
612            if let Some(budget) = opts.thinking_budget {
613                llm_config = llm_config.with_thinking_budget(budget);
614            }
615
616            crate::llm::create_client_with_config(llm_config)
617        } else {
618            if opts.temperature.is_some() || opts.thinking_budget.is_some() {
619                tracing::warn!(
620                    "temperature/thinking_budget set without model override — these will be ignored. \
621                     Use with_model() to apply LLM parameter overrides."
622                );
623            }
624            self.llm_client.clone()
625        };
626
627        self.build_session(workspace.into(), llm_client, &opts)
628    }
629
630    /// Resume a previously saved session by ID.
631    ///
632    /// Loads the session data from the store, rebuilds the `AgentSession` with
633    /// the saved conversation history, and returns it ready for continued use.
634    ///
635    /// The `options` must include a `session_store` (or `with_file_session_store`)
636    /// that contains the saved session.
637    pub fn resume_session(
638        &self,
639        session_id: &str,
640        options: SessionOptions,
641    ) -> Result<AgentSession> {
642        let store = options.session_store.as_ref().ok_or_else(|| {
643            crate::error::CodeError::Session(
644                "resume_session requires a session_store in SessionOptions".to_string(),
645            )
646        })?;
647
648        // Load session data from store
649        let data = match tokio::runtime::Handle::try_current() {
650            Ok(handle) => tokio::task::block_in_place(|| handle.block_on(store.load(session_id)))
651                .map_err(|e| {
652                crate::error::CodeError::Session(format!(
653                    "Failed to load session {}: {}",
654                    session_id, e
655                ))
656            })?,
657            Err(_) => {
658                return Err(crate::error::CodeError::Session(
659                    "No async runtime available for session resume".to_string(),
660                ))
661            }
662        };
663
664        let data = data.ok_or_else(|| {
665            crate::error::CodeError::Session(format!("Session not found: {}", session_id))
666        })?;
667
668        // Build session with the saved workspace
669        let mut opts = options;
670        opts.session_id = Some(data.id.clone());
671
672        let llm_client = if let Some(ref model) = opts.model {
673            let (provider_name, model_id) = model
674                .split_once('/')
675                .context("model format must be 'provider/model'")?;
676            let llm_config = self
677                .code_config
678                .llm_config(provider_name, model_id)
679                .with_context(|| {
680                    format!("provider '{provider_name}' or model '{model_id}' not found")
681                })?;
682            crate::llm::create_client_with_config(llm_config)
683        } else {
684            self.llm_client.clone()
685        };
686
687        let session = self.build_session(data.config.workspace.clone(), llm_client, &opts)?;
688
689        // Restore conversation history
690        *session.history.write().unwrap() = data.messages;
691
692        Ok(session)
693    }
694
695    fn build_session(
696        &self,
697        workspace: String,
698        llm_client: Arc<dyn LlmClient>,
699        opts: &SessionOptions,
700    ) -> Result<AgentSession> {
701        let canonical =
702            std::fs::canonicalize(&workspace).unwrap_or_else(|_| PathBuf::from(&workspace));
703
704        let tool_executor = Arc::new(ToolExecutor::new(canonical.display().to_string()));
705
706        // Register MCP tools before taking tool definitions snapshot
707        if let Some(ref mcp) = opts.mcp_manager {
708            let mcp_clone = Arc::clone(mcp);
709            let all_tools = tokio::task::block_in_place(|| {
710                tokio::runtime::Handle::current().block_on(mcp_clone.get_all_tools())
711            });
712            // Group by server name and register
713            let mut by_server: std::collections::HashMap<String, Vec<crate::mcp::McpTool>> =
714                std::collections::HashMap::new();
715            for (server, tool) in all_tools {
716                by_server.entry(server).or_default().push(tool);
717            }
718            for (server_name, tools) in by_server {
719                for tool in
720                    crate::mcp::tools::create_mcp_tools(&server_name, tools, Arc::clone(mcp))
721                {
722                    tool_executor.register_dynamic_tool(tool);
723                }
724            }
725        }
726
727        let tool_defs = tool_executor.definitions();
728
729        // Build prompt slots: start from session options or agent-level config
730        let mut prompt_slots = opts
731            .prompt_slots
732            .clone()
733            .unwrap_or_else(|| self.config.prompt_slots.clone());
734
735        // Build effective skill registry: fork the agent-level registry (builtins + global
736        // skill_dirs), then layer session-level skills on top. Forking ensures session skills
737        // never pollute the shared agent-level registry.
738        let base_registry = self
739            .config
740            .skill_registry
741            .as_deref()
742            .map(|r| r.fork())
743            .unwrap_or_else(crate::skills::SkillRegistry::with_builtins);
744        // Merge explicit session registry on top of the fork
745        if let Some(ref r) = opts.skill_registry {
746            for skill in r.all() {
747                base_registry.register_unchecked(skill);
748            }
749        }
750        // Load session-level skill dirs
751        for dir in &opts.skill_dirs {
752            if let Err(e) = base_registry.load_from_dir(dir) {
753                tracing::warn!(
754                    dir = %dir.display(),
755                    error = %e,
756                    "Failed to load session skill dir — skipping"
757                );
758            }
759        }
760        let effective_registry = Arc::new(base_registry);
761
762        // Append skill directory listing to the extra prompt slot
763        let skill_prompt = effective_registry.to_system_prompt();
764        if !skill_prompt.is_empty() {
765            prompt_slots.extra = match prompt_slots.extra {
766                Some(existing) => Some(format!("{}\n\n{}", existing, skill_prompt)),
767                None => Some(skill_prompt),
768            };
769        }
770
771        // Resolve memory store: explicit store takes priority, then file_memory_dir
772        let mut init_warning: Option<String> = None;
773        let memory = {
774            let store = if let Some(ref store) = opts.memory_store {
775                Some(Arc::clone(store))
776            } else if let Some(ref dir) = opts.file_memory_dir {
777                match tokio::runtime::Handle::try_current() {
778                    Ok(handle) => {
779                        let dir = dir.clone();
780                        match tokio::task::block_in_place(|| {
781                            handle.block_on(FileMemoryStore::new(dir))
782                        }) {
783                            Ok(store) => Some(Arc::new(store) as Arc<dyn MemoryStore>),
784                            Err(e) => {
785                                let msg = format!("Failed to create file memory store: {}", e);
786                                tracing::warn!("{}", msg);
787                                init_warning = Some(msg);
788                                None
789                            }
790                        }
791                    }
792                    Err(_) => {
793                        let msg =
794                            "No async runtime available for file memory store — memory disabled"
795                                .to_string();
796                        tracing::warn!("{}", msg);
797                        init_warning = Some(msg);
798                        None
799                    }
800                }
801            } else {
802                None
803            };
804            store.map(|s| Arc::new(crate::memory::AgentMemory::new(s)))
805        };
806
807        let base = self.config.clone();
808        let config = AgentConfig {
809            prompt_slots,
810            tools: tool_defs,
811            security_provider: opts.security_provider.clone(),
812            permission_checker: opts.permission_checker.clone(),
813            confirmation_manager: opts.confirmation_manager.clone(),
814            context_providers: opts.context_providers.clone(),
815            planning_enabled: opts.planning_enabled,
816            goal_tracking: opts.goal_tracking,
817            skill_registry: Some(effective_registry),
818            max_parse_retries: opts.max_parse_retries.unwrap_or(base.max_parse_retries),
819            tool_timeout_ms: opts.tool_timeout_ms.or(base.tool_timeout_ms),
820            circuit_breaker_threshold: opts
821                .circuit_breaker_threshold
822                .unwrap_or(base.circuit_breaker_threshold),
823            auto_compact: opts.auto_compact,
824            auto_compact_threshold: opts
825                .auto_compact_threshold
826                .unwrap_or(crate::session::DEFAULT_AUTO_COMPACT_THRESHOLD),
827            max_context_tokens: base.max_context_tokens,
828            llm_client: Some(Arc::clone(&llm_client)),
829            memory: memory.clone(),
830            continuation_enabled: opts
831                .continuation_enabled
832                .unwrap_or(base.continuation_enabled),
833            max_continuation_turns: opts
834                .max_continuation_turns
835                .unwrap_or(base.max_continuation_turns),
836            ..base
837        };
838
839        // Create lane queue if configured
840        // A shared broadcast channel is used for both queue events and subagent events.
841        let (agent_event_tx, _) = broadcast::channel::<crate::agent::AgentEvent>(256);
842        let command_queue = if let Some(ref queue_config) = opts.queue_config {
843            let session_id = uuid::Uuid::new_v4().to_string();
844            let rt = tokio::runtime::Handle::try_current();
845
846            match rt {
847                Ok(handle) => {
848                    // We're inside an async runtime — use block_in_place
849                    let queue = tokio::task::block_in_place(|| {
850                        handle.block_on(SessionLaneQueue::new(
851                            &session_id,
852                            queue_config.clone(),
853                            agent_event_tx.clone(),
854                        ))
855                    });
856                    match queue {
857                        Ok(q) => {
858                            // Start the queue
859                            let q = Arc::new(q);
860                            let q2 = Arc::clone(&q);
861                            tokio::task::block_in_place(|| {
862                                handle.block_on(async { q2.start().await.ok() })
863                            });
864                            Some(q)
865                        }
866                        Err(e) => {
867                            tracing::warn!("Failed to create session lane queue: {}", e);
868                            None
869                        }
870                    }
871                }
872                Err(_) => {
873                    tracing::warn!(
874                        "No async runtime available for queue creation — queue disabled"
875                    );
876                    None
877                }
878            }
879        } else {
880            None
881        };
882
883        // Create tool context with search config if available
884        let mut tool_context = ToolContext::new(canonical.clone());
885        if let Some(ref search_config) = self.code_config.search {
886            tool_context = tool_context.with_search_config(search_config.clone());
887        }
888        tool_context = tool_context.with_agent_event_tx(agent_event_tx);
889
890        // Wire sandbox when configured.
891        #[cfg(feature = "sandbox")]
892        if let Some(ref sandbox_cfg) = opts.sandbox_config {
893            let handle: Arc<dyn crate::sandbox::BashSandbox> =
894                Arc::new(crate::sandbox::BoxSandboxHandle::new(
895                    sandbox_cfg.clone(),
896                    canonical.display().to_string(),
897                ));
898            // Update the registry's default context so that direct
899            // `AgentSession::bash()` calls also use the sandbox.
900            tool_executor.registry().set_sandbox(Arc::clone(&handle));
901            tool_context = tool_context.with_sandbox(handle);
902        }
903        #[cfg(not(feature = "sandbox"))]
904        if opts.sandbox_config.is_some() {
905            tracing::warn!(
906                "sandbox_config is set but the `sandbox` Cargo feature is not enabled \
907                 — bash commands will run locally"
908            );
909        }
910
911        let session_id = opts
912            .session_id
913            .clone()
914            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
915
916        // Resolve session store: explicit opts store > config sessions_dir > None
917        let session_store = if opts.session_store.is_some() {
918            opts.session_store.clone()
919        } else if let Some(ref dir) = self.code_config.sessions_dir {
920            match tokio::runtime::Handle::try_current() {
921                Ok(handle) => {
922                    let dir = dir.clone();
923                    match tokio::task::block_in_place(|| {
924                        handle.block_on(crate::store::FileSessionStore::new(dir))
925                    }) {
926                        Ok(store) => Some(Arc::new(store) as Arc<dyn crate::store::SessionStore>),
927                        Err(e) => {
928                            tracing::warn!("Failed to create session store from sessions_dir: {}", e);
929                            None
930                        }
931                    }
932                }
933                Err(_) => {
934                    tracing::warn!("No async runtime for sessions_dir store — persistence disabled");
935                    None
936                }
937            }
938        } else {
939            None
940        };
941
942        Ok(AgentSession {
943            llm_client,
944            tool_executor,
945            tool_context,
946            memory: config.memory.clone(),
947            config,
948            workspace: canonical,
949            session_id,
950            history: RwLock::new(Vec::new()),
951            command_queue,
952            session_store,
953            auto_save: opts.auto_save,
954            hook_engine: Arc::new(crate::hooks::HookEngine::new()),
955            init_warning,
956            command_registry: CommandRegistry::new(),
957            model_name: opts
958                .model
959                .clone()
960                .or_else(|| self.code_config.default_model.clone())
961                .unwrap_or_else(|| "unknown".to_string()),
962        })
963    }
964}
965
966// ============================================================================
967// AgentSession
968// ============================================================================
969
970/// Workspace-bound session. All LLM and tool operations happen here.
971///
972/// History is automatically accumulated after each `send()` call.
973/// Use `history()` to retrieve the current conversation log.
974pub struct AgentSession {
975    llm_client: Arc<dyn LlmClient>,
976    tool_executor: Arc<ToolExecutor>,
977    tool_context: ToolContext,
978    config: AgentConfig,
979    workspace: PathBuf,
980    /// Unique session identifier.
981    session_id: String,
982    /// Internal conversation history, auto-updated after each `send()`.
983    history: RwLock<Vec<Message>>,
984    /// Optional lane queue for priority-based tool execution.
985    command_queue: Option<Arc<SessionLaneQueue>>,
986    /// Optional long-term memory.
987    memory: Option<Arc<crate::memory::AgentMemory>>,
988    /// Optional session store for persistence.
989    session_store: Option<Arc<dyn crate::store::SessionStore>>,
990    /// Auto-save after each `send()`.
991    auto_save: bool,
992    /// Hook engine for lifecycle event interception.
993    hook_engine: Arc<crate::hooks::HookEngine>,
994    /// Deferred init warning: emitted as PersistenceFailed on first send() if set.
995    init_warning: Option<String>,
996    /// Slash command registry for `/command` dispatch.
997    command_registry: CommandRegistry,
998    /// Model identifier for display (e.g., "anthropic/claude-sonnet-4-20250514").
999    model_name: String,
1000}
1001
1002impl std::fmt::Debug for AgentSession {
1003    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1004        f.debug_struct("AgentSession")
1005            .field("session_id", &self.session_id)
1006            .field("workspace", &self.workspace.display().to_string())
1007            .field("auto_save", &self.auto_save)
1008            .finish()
1009    }
1010}
1011
1012impl AgentSession {
1013    /// Build an `AgentLoop` with the session's configuration.
1014    ///
1015    /// Propagates the lane queue (if configured) for external task handling.
1016    fn build_agent_loop(&self) -> AgentLoop {
1017        let mut config = self.config.clone();
1018        config.hook_engine =
1019            Some(Arc::clone(&self.hook_engine) as Arc<dyn crate::hooks::HookExecutor>);
1020        let mut agent_loop = AgentLoop::new(
1021            self.llm_client.clone(),
1022            self.tool_executor.clone(),
1023            self.tool_context.clone(),
1024            config,
1025        );
1026        if let Some(ref queue) = self.command_queue {
1027            agent_loop = agent_loop.with_queue(Arc::clone(queue));
1028        }
1029        agent_loop
1030    }
1031
1032    /// Build a `CommandContext` from the current session state.
1033    fn build_command_context(&self) -> CommandContext {
1034        let history = self.history.read().unwrap();
1035
1036        // Collect tool names from config
1037        let tool_names: Vec<String> = self.config.tools.iter().map(|t| t.name.clone()).collect();
1038
1039        // Derive MCP server info from tool names
1040        let mut mcp_map: std::collections::HashMap<String, usize> =
1041            std::collections::HashMap::new();
1042        for name in &tool_names {
1043            if let Some(rest) = name.strip_prefix("mcp__") {
1044                if let Some((server, _)) = rest.split_once("__") {
1045                    *mcp_map.entry(server.to_string()).or_default() += 1;
1046                }
1047            }
1048        }
1049        let mut mcp_servers: Vec<(String, usize)> = mcp_map.into_iter().collect();
1050        mcp_servers.sort_by(|a, b| a.0.cmp(&b.0));
1051
1052        CommandContext {
1053            session_id: self.session_id.clone(),
1054            workspace: self.workspace.display().to_string(),
1055            model: self.model_name.clone(),
1056            history_len: history.len(),
1057            total_tokens: 0,
1058            total_cost: 0.0,
1059            tool_names,
1060            mcp_servers,
1061        }
1062    }
1063
1064    /// Get a reference to the slash command registry.
1065    pub fn command_registry(&self) -> &CommandRegistry {
1066        &self.command_registry
1067    }
1068
1069    /// Register a custom slash command.
1070    pub fn register_command(&mut self, cmd: Arc<dyn crate::commands::SlashCommand>) {
1071        self.command_registry.register(cmd);
1072    }
1073
1074    /// Send a prompt and wait for the complete response.
1075    ///
1076    /// When `history` is `None`, uses (and auto-updates) the session's
1077    /// internal conversation history. When `Some`, uses the provided
1078    /// history instead (the internal history is **not** modified).
1079    ///
1080    /// If the prompt starts with `/`, it is dispatched as a slash command
1081    /// and the result is returned without calling the LLM.
1082    pub async fn send(&self, prompt: &str, history: Option<&[Message]>) -> Result<AgentResult> {
1083        // Slash command interception
1084        if CommandRegistry::is_command(prompt) {
1085            let ctx = self.build_command_context();
1086            if let Some(output) = self.command_registry.dispatch(prompt, &ctx) {
1087                return Ok(AgentResult {
1088                    text: output.text,
1089                    messages: history
1090                        .map(|h| h.to_vec())
1091                        .unwrap_or_else(|| self.history.read().unwrap().clone()),
1092                    tool_calls_count: 0,
1093                    usage: crate::llm::TokenUsage::default(),
1094                });
1095            }
1096        }
1097
1098        if let Some(ref w) = self.init_warning {
1099            tracing::warn!(session_id = %self.session_id, "Session init warning: {}", w);
1100        }
1101        let agent_loop = self.build_agent_loop();
1102
1103        let use_internal = history.is_none();
1104        let effective_history = match history {
1105            Some(h) => h.to_vec(),
1106            None => self.history.read().unwrap().clone(),
1107        };
1108
1109        let result = agent_loop.execute(&effective_history, prompt, None).await?;
1110
1111        // Auto-accumulate: only update internal history when no custom
1112        // history was provided.
1113        if use_internal {
1114            *self.history.write().unwrap() = result.messages.clone();
1115
1116            // Auto-save if configured
1117            if self.auto_save {
1118                if let Err(e) = self.save().await {
1119                    tracing::warn!("Auto-save failed for session {}: {}", self.session_id, e);
1120                }
1121            }
1122        }
1123
1124        Ok(result)
1125    }
1126
1127    /// Send a prompt with image attachments and wait for the complete response.
1128    ///
1129    /// Images are included as multi-modal content blocks in the user message.
1130    /// Requires a vision-capable model (e.g., Claude Sonnet, GPT-4o).
1131    pub async fn send_with_attachments(
1132        &self,
1133        prompt: &str,
1134        attachments: &[crate::llm::Attachment],
1135        history: Option<&[Message]>,
1136    ) -> Result<AgentResult> {
1137        // Build a user message with text + images, then pass it as the last
1138        // history entry. We use an empty prompt so execute_loop doesn't add
1139        // a duplicate user message.
1140        let use_internal = history.is_none();
1141        let mut effective_history = match history {
1142            Some(h) => h.to_vec(),
1143            None => self.history.read().unwrap().clone(),
1144        };
1145        effective_history.push(Message::user_with_attachments(prompt, attachments));
1146
1147        let agent_loop = self.build_agent_loop();
1148        let result = agent_loop
1149            .execute_from_messages(effective_history, None, None)
1150            .await?;
1151
1152        if use_internal {
1153            *self.history.write().unwrap() = result.messages.clone();
1154            if self.auto_save {
1155                if let Err(e) = self.save().await {
1156                    tracing::warn!("Auto-save failed for session {}: {}", self.session_id, e);
1157                }
1158            }
1159        }
1160
1161        Ok(result)
1162    }
1163
1164    /// Stream a prompt with image attachments.
1165    ///
1166    /// Images are included as multi-modal content blocks in the user message.
1167    /// Requires a vision-capable model (e.g., Claude Sonnet, GPT-4o).
1168    pub async fn stream_with_attachments(
1169        &self,
1170        prompt: &str,
1171        attachments: &[crate::llm::Attachment],
1172        history: Option<&[Message]>,
1173    ) -> Result<(mpsc::Receiver<AgentEvent>, JoinHandle<()>)> {
1174        let (tx, rx) = mpsc::channel(256);
1175        let mut effective_history = match history {
1176            Some(h) => h.to_vec(),
1177            None => self.history.read().unwrap().clone(),
1178        };
1179        effective_history.push(Message::user_with_attachments(prompt, attachments));
1180
1181        let agent_loop = self.build_agent_loop();
1182        let handle = tokio::spawn(async move {
1183            let _ = agent_loop
1184                .execute_from_messages(effective_history, None, Some(tx))
1185                .await;
1186        });
1187
1188        Ok((rx, handle))
1189    }
1190
1191    /// Send a prompt and stream events back.
1192    ///
1193    /// When `history` is `None`, uses the session's internal history
1194    /// (note: streaming does **not** auto-update internal history since
1195    /// the result is consumed asynchronously via the channel).
1196    /// When `Some`, uses the provided history instead.
1197    ///
1198    /// If the prompt starts with `/`, it is dispatched as a slash command
1199    /// and the result is emitted as a single `TextDelta` + `End` event.
1200    pub async fn stream(
1201        &self,
1202        prompt: &str,
1203        history: Option<&[Message]>,
1204    ) -> Result<(mpsc::Receiver<AgentEvent>, JoinHandle<()>)> {
1205        // Slash command interception for streaming
1206        if CommandRegistry::is_command(prompt) {
1207            let ctx = self.build_command_context();
1208            if let Some(output) = self.command_registry.dispatch(prompt, &ctx) {
1209                let (tx, rx) = mpsc::channel(256);
1210                let handle = tokio::spawn(async move {
1211                    let _ = tx
1212                        .send(AgentEvent::TextDelta {
1213                            text: output.text.clone(),
1214                        })
1215                        .await;
1216                    let _ = tx
1217                        .send(AgentEvent::End {
1218                            text: output.text.clone(),
1219                            usage: crate::llm::TokenUsage::default(),
1220                        })
1221                        .await;
1222                });
1223                return Ok((rx, handle));
1224            }
1225        }
1226
1227        let (tx, rx) = mpsc::channel(256);
1228        let agent_loop = self.build_agent_loop();
1229        let effective_history = match history {
1230            Some(h) => h.to_vec(),
1231            None => self.history.read().unwrap().clone(),
1232        };
1233        let prompt = prompt.to_string();
1234
1235        let handle = tokio::spawn(async move {
1236            let _ = agent_loop
1237                .execute(&effective_history, &prompt, Some(tx))
1238                .await;
1239        });
1240
1241        Ok((rx, handle))
1242    }
1243
1244    /// Return a snapshot of the session's conversation history.
1245    pub fn history(&self) -> Vec<Message> {
1246        self.history.read().unwrap().clone()
1247    }
1248
1249    /// Return a reference to the session's memory, if configured.
1250    pub fn memory(&self) -> Option<&Arc<crate::memory::AgentMemory>> {
1251        self.memory.as_ref()
1252    }
1253
1254    /// Return the session ID.
1255    pub fn id(&self) -> &str {
1256        &self.session_id
1257    }
1258
1259    /// Return the session workspace path.
1260    pub fn workspace(&self) -> &std::path::Path {
1261        &self.workspace
1262    }
1263
1264    /// Return any deferred init warning (e.g. memory store failed to initialize).
1265    pub fn init_warning(&self) -> Option<&str> {
1266        self.init_warning.as_deref()
1267    }
1268
1269    /// Return the session ID.
1270    pub fn session_id(&self) -> &str {
1271        &self.session_id
1272    }
1273
1274    // ========================================================================
1275    // Hook API
1276    // ========================================================================
1277
1278    /// Register a hook for lifecycle event interception.
1279    pub fn register_hook(&self, hook: crate::hooks::Hook) {
1280        self.hook_engine.register(hook);
1281    }
1282
1283    /// Unregister a hook by ID.
1284    pub fn unregister_hook(&self, hook_id: &str) -> Option<crate::hooks::Hook> {
1285        self.hook_engine.unregister(hook_id)
1286    }
1287
1288    /// Register a handler for a specific hook.
1289    pub fn register_hook_handler(
1290        &self,
1291        hook_id: &str,
1292        handler: Arc<dyn crate::hooks::HookHandler>,
1293    ) {
1294        self.hook_engine.register_handler(hook_id, handler);
1295    }
1296
1297    /// Unregister a hook handler by hook ID.
1298    pub fn unregister_hook_handler(&self, hook_id: &str) {
1299        self.hook_engine.unregister_handler(hook_id);
1300    }
1301
1302    /// Get the number of registered hooks.
1303    pub fn hook_count(&self) -> usize {
1304        self.hook_engine.hook_count()
1305    }
1306
1307    /// Save the session to the configured store.
1308    ///
1309    /// Returns `Ok(())` if saved successfully, or if no store is configured (no-op).
1310    pub async fn save(&self) -> Result<()> {
1311        let store = match &self.session_store {
1312            Some(s) => s,
1313            None => return Ok(()),
1314        };
1315
1316        let history = self.history.read().unwrap().clone();
1317        let now = chrono::Utc::now().timestamp();
1318
1319        let data = crate::store::SessionData {
1320            id: self.session_id.clone(),
1321            config: crate::session::SessionConfig {
1322                name: String::new(),
1323                workspace: self.workspace.display().to_string(),
1324                system_prompt: Some(self.config.prompt_slots.build()),
1325                max_context_length: 200_000,
1326                auto_compact: false,
1327                auto_compact_threshold: crate::session::DEFAULT_AUTO_COMPACT_THRESHOLD,
1328                storage_type: crate::config::StorageBackend::File,
1329                queue_config: None,
1330                confirmation_policy: None,
1331                permission_policy: None,
1332                parent_id: None,
1333                security_config: None,
1334                hook_engine: None,
1335                planning_enabled: self.config.planning_enabled,
1336                goal_tracking: self.config.goal_tracking,
1337            },
1338            state: crate::session::SessionState::Active,
1339            messages: history,
1340            context_usage: crate::session::ContextUsage::default(),
1341            total_usage: crate::llm::TokenUsage::default(),
1342            total_cost: 0.0,
1343            model_name: None,
1344            cost_records: Vec::new(),
1345            tool_names: crate::store::SessionData::tool_names_from_definitions(&self.config.tools),
1346            thinking_enabled: false,
1347            thinking_budget: None,
1348            created_at: now,
1349            updated_at: now,
1350            llm_config: None,
1351            tasks: Vec::new(),
1352            parent_id: None,
1353        };
1354
1355        store.save(&data).await?;
1356        tracing::debug!("Session {} saved", self.session_id);
1357        Ok(())
1358    }
1359
1360    /// Read a file from the workspace.
1361    pub async fn read_file(&self, path: &str) -> Result<String> {
1362        let args = serde_json::json!({ "file_path": path });
1363        let result = self.tool_executor.execute("read", &args).await?;
1364        Ok(result.output)
1365    }
1366
1367    /// Execute a bash command in the workspace.
1368    ///
1369    /// When a sandbox is configured via [`SessionOptions::with_sandbox()`],
1370    /// the command is routed through the A3S Box sandbox.
1371    pub async fn bash(&self, command: &str) -> Result<String> {
1372        let args = serde_json::json!({ "command": command });
1373        let result = self
1374            .tool_executor
1375            .execute_with_context("bash", &args, &self.tool_context)
1376            .await?;
1377        Ok(result.output)
1378    }
1379
1380    /// Search for files matching a glob pattern.
1381    pub async fn glob(&self, pattern: &str) -> Result<Vec<String>> {
1382        let args = serde_json::json!({ "pattern": pattern });
1383        let result = self.tool_executor.execute("glob", &args).await?;
1384        let files: Vec<String> = result
1385            .output
1386            .lines()
1387            .filter(|l| !l.is_empty())
1388            .map(|l| l.to_string())
1389            .collect();
1390        Ok(files)
1391    }
1392
1393    /// Search file contents with a regex pattern.
1394    pub async fn grep(&self, pattern: &str) -> Result<String> {
1395        let args = serde_json::json!({ "pattern": pattern });
1396        let result = self.tool_executor.execute("grep", &args).await?;
1397        Ok(result.output)
1398    }
1399
1400    /// Execute a tool by name, bypassing the LLM.
1401    pub async fn tool(&self, name: &str, args: serde_json::Value) -> Result<ToolCallResult> {
1402        let result = self.tool_executor.execute(name, &args).await?;
1403        Ok(ToolCallResult {
1404            name: name.to_string(),
1405            output: result.output,
1406            exit_code: result.exit_code,
1407        })
1408    }
1409
1410    // ========================================================================
1411    // Queue API
1412    // ========================================================================
1413
1414    /// Returns whether this session has a lane queue configured.
1415    pub fn has_queue(&self) -> bool {
1416        self.command_queue.is_some()
1417    }
1418
1419    /// Configure a lane's handler mode (Internal/External/Hybrid).
1420    ///
1421    /// Only effective when a queue is configured via `SessionOptions::with_queue_config`.
1422    pub async fn set_lane_handler(&self, lane: SessionLane, config: LaneHandlerConfig) {
1423        if let Some(ref queue) = self.command_queue {
1424            queue.set_lane_handler(lane, config).await;
1425        }
1426    }
1427
1428    /// Complete an external task by ID.
1429    ///
1430    /// Returns `true` if the task was found and completed, `false` if not found.
1431    pub async fn complete_external_task(&self, task_id: &str, result: ExternalTaskResult) -> bool {
1432        if let Some(ref queue) = self.command_queue {
1433            queue.complete_external_task(task_id, result).await
1434        } else {
1435            false
1436        }
1437    }
1438
1439    /// Get pending external tasks awaiting completion by an external handler.
1440    pub async fn pending_external_tasks(&self) -> Vec<ExternalTask> {
1441        if let Some(ref queue) = self.command_queue {
1442            queue.pending_external_tasks().await
1443        } else {
1444            Vec::new()
1445        }
1446    }
1447
1448    /// Get queue statistics (pending, active, external counts per lane).
1449    pub async fn queue_stats(&self) -> SessionQueueStats {
1450        if let Some(ref queue) = self.command_queue {
1451            queue.stats().await
1452        } else {
1453            SessionQueueStats::default()
1454        }
1455    }
1456
1457    /// Get a metrics snapshot from the queue (if metrics are enabled).
1458    pub async fn queue_metrics(&self) -> Option<MetricsSnapshot> {
1459        if let Some(ref queue) = self.command_queue {
1460            queue.metrics_snapshot().await
1461        } else {
1462            None
1463        }
1464    }
1465
1466    /// Get dead letters from the queue's DLQ (if DLQ is enabled).
1467    pub async fn dead_letters(&self) -> Vec<DeadLetter> {
1468        if let Some(ref queue) = self.command_queue {
1469            queue.dead_letters().await
1470        } else {
1471            Vec::new()
1472        }
1473    }
1474}
1475
1476// ============================================================================
1477// Tests
1478// ============================================================================
1479
1480#[cfg(test)]
1481mod tests {
1482    use super::*;
1483    use crate::config::{ModelConfig, ModelModalities, ProviderConfig};
1484    use crate::store::SessionStore;
1485
1486    fn test_config() -> CodeConfig {
1487        CodeConfig {
1488            default_model: Some("anthropic/claude-sonnet-4-20250514".to_string()),
1489            providers: vec![
1490                ProviderConfig {
1491                    name: "anthropic".to_string(),
1492                    api_key: Some("test-key".to_string()),
1493                    base_url: None,
1494                    models: vec![ModelConfig {
1495                        id: "claude-sonnet-4-20250514".to_string(),
1496                        name: "Claude Sonnet 4".to_string(),
1497                        family: "claude-sonnet".to_string(),
1498                        api_key: None,
1499                        base_url: None,
1500                        attachment: false,
1501                        reasoning: false,
1502                        tool_call: true,
1503                        temperature: true,
1504                        release_date: None,
1505                        modalities: ModelModalities::default(),
1506                        cost: Default::default(),
1507                        limit: Default::default(),
1508                    }],
1509                },
1510                ProviderConfig {
1511                    name: "openai".to_string(),
1512                    api_key: Some("test-openai-key".to_string()),
1513                    base_url: None,
1514                    models: vec![ModelConfig {
1515                        id: "gpt-4o".to_string(),
1516                        name: "GPT-4o".to_string(),
1517                        family: "gpt-4".to_string(),
1518                        api_key: None,
1519                        base_url: None,
1520                        attachment: false,
1521                        reasoning: false,
1522                        tool_call: true,
1523                        temperature: true,
1524                        release_date: None,
1525                        modalities: ModelModalities::default(),
1526                        cost: Default::default(),
1527                        limit: Default::default(),
1528                    }],
1529                },
1530            ],
1531            ..Default::default()
1532        }
1533    }
1534
1535    #[tokio::test]
1536    async fn test_from_config() {
1537        let agent = Agent::from_config(test_config()).await;
1538        assert!(agent.is_ok());
1539    }
1540
1541    #[tokio::test]
1542    async fn test_session_default() {
1543        let agent = Agent::from_config(test_config()).await.unwrap();
1544        let session = agent.session("/tmp/test-workspace", None);
1545        assert!(session.is_ok());
1546        let debug = format!("{:?}", session.unwrap());
1547        assert!(debug.contains("AgentSession"));
1548    }
1549
1550    #[tokio::test]
1551    async fn test_session_with_model_override() {
1552        let agent = Agent::from_config(test_config()).await.unwrap();
1553        let opts = SessionOptions::new().with_model("openai/gpt-4o");
1554        let session = agent.session("/tmp/test-workspace", Some(opts));
1555        assert!(session.is_ok());
1556    }
1557
1558    #[tokio::test]
1559    async fn test_session_with_invalid_model_format() {
1560        let agent = Agent::from_config(test_config()).await.unwrap();
1561        let opts = SessionOptions::new().with_model("gpt-4o");
1562        let session = agent.session("/tmp/test-workspace", Some(opts));
1563        assert!(session.is_err());
1564    }
1565
1566    #[tokio::test]
1567    async fn test_session_with_model_not_found() {
1568        let agent = Agent::from_config(test_config()).await.unwrap();
1569        let opts = SessionOptions::new().with_model("openai/nonexistent");
1570        let session = agent.session("/tmp/test-workspace", Some(opts));
1571        assert!(session.is_err());
1572    }
1573
1574    #[tokio::test]
1575    async fn test_new_with_hcl_string() {
1576        let hcl = r#"
1577            default_model = "anthropic/claude-sonnet-4-20250514"
1578            providers {
1579                name    = "anthropic"
1580                api_key = "test-key"
1581                models {
1582                    id   = "claude-sonnet-4-20250514"
1583                    name = "Claude Sonnet 4"
1584                }
1585            }
1586        "#;
1587        let agent = Agent::new(hcl).await;
1588        assert!(agent.is_ok());
1589    }
1590
1591    #[tokio::test]
1592    async fn test_create_alias_hcl() {
1593        let hcl = r#"
1594            default_model = "anthropic/claude-sonnet-4-20250514"
1595            providers {
1596                name    = "anthropic"
1597                api_key = "test-key"
1598                models {
1599                    id   = "claude-sonnet-4-20250514"
1600                    name = "Claude Sonnet 4"
1601                }
1602            }
1603        "#;
1604        let agent = Agent::create(hcl).await;
1605        assert!(agent.is_ok());
1606    }
1607
1608    #[tokio::test]
1609    async fn test_create_and_new_produce_same_result() {
1610        let hcl = r#"
1611            default_model = "anthropic/claude-sonnet-4-20250514"
1612            providers {
1613                name    = "anthropic"
1614                api_key = "test-key"
1615                models {
1616                    id   = "claude-sonnet-4-20250514"
1617                    name = "Claude Sonnet 4"
1618                }
1619            }
1620        "#;
1621        let agent_new = Agent::new(hcl).await;
1622        let agent_create = Agent::create(hcl).await;
1623        assert!(agent_new.is_ok());
1624        assert!(agent_create.is_ok());
1625
1626        // Both should produce working sessions
1627        let session_new = agent_new.unwrap().session("/tmp/test-ws-new", None);
1628        let session_create = agent_create.unwrap().session("/tmp/test-ws-create", None);
1629        assert!(session_new.is_ok());
1630        assert!(session_create.is_ok());
1631    }
1632
1633    #[test]
1634    fn test_from_config_requires_default_model() {
1635        let rt = tokio::runtime::Runtime::new().unwrap();
1636        let config = CodeConfig {
1637            providers: vec![ProviderConfig {
1638                name: "anthropic".to_string(),
1639                api_key: Some("test-key".to_string()),
1640                base_url: None,
1641                models: vec![],
1642            }],
1643            ..Default::default()
1644        };
1645        let result = rt.block_on(Agent::from_config(config));
1646        assert!(result.is_err());
1647    }
1648
1649    #[tokio::test]
1650    async fn test_history_empty_on_new_session() {
1651        let agent = Agent::from_config(test_config()).await.unwrap();
1652        let session = agent.session("/tmp/test-workspace", None).unwrap();
1653        assert!(session.history().is_empty());
1654    }
1655
1656    #[tokio::test]
1657    async fn test_session_options_with_agent_dir() {
1658        let opts = SessionOptions::new()
1659            .with_agent_dir("/tmp/agents")
1660            .with_agent_dir("/tmp/more-agents");
1661        assert_eq!(opts.agent_dirs.len(), 2);
1662        assert_eq!(opts.agent_dirs[0], PathBuf::from("/tmp/agents"));
1663        assert_eq!(opts.agent_dirs[1], PathBuf::from("/tmp/more-agents"));
1664    }
1665
1666    // ========================================================================
1667    // Queue Integration Tests
1668    // ========================================================================
1669
1670    #[test]
1671    fn test_session_options_with_queue_config() {
1672        let qc = SessionQueueConfig::default().with_lane_features();
1673        let opts = SessionOptions::new().with_queue_config(qc.clone());
1674        assert!(opts.queue_config.is_some());
1675
1676        let config = opts.queue_config.unwrap();
1677        assert!(config.enable_dlq);
1678        assert!(config.enable_metrics);
1679        assert!(config.enable_alerts);
1680        assert_eq!(config.default_timeout_ms, Some(60_000));
1681    }
1682
1683    #[tokio::test(flavor = "multi_thread")]
1684    async fn test_session_with_queue_config() {
1685        let agent = Agent::from_config(test_config()).await.unwrap();
1686        let qc = SessionQueueConfig::default();
1687        let opts = SessionOptions::new().with_queue_config(qc);
1688        let session = agent.session("/tmp/test-workspace-queue", Some(opts));
1689        assert!(session.is_ok());
1690        let session = session.unwrap();
1691        assert!(session.has_queue());
1692    }
1693
1694    #[tokio::test]
1695    async fn test_session_without_queue_config() {
1696        let agent = Agent::from_config(test_config()).await.unwrap();
1697        let session = agent.session("/tmp/test-workspace-noqueue", None).unwrap();
1698        assert!(!session.has_queue());
1699    }
1700
1701    #[tokio::test]
1702    async fn test_session_queue_stats_without_queue() {
1703        let agent = Agent::from_config(test_config()).await.unwrap();
1704        let session = agent.session("/tmp/test-workspace-stats", None).unwrap();
1705        let stats = session.queue_stats().await;
1706        // Without a queue, stats should have zero values
1707        assert_eq!(stats.total_pending, 0);
1708        assert_eq!(stats.total_active, 0);
1709    }
1710
1711    #[tokio::test(flavor = "multi_thread")]
1712    async fn test_session_queue_stats_with_queue() {
1713        let agent = Agent::from_config(test_config()).await.unwrap();
1714        let qc = SessionQueueConfig::default();
1715        let opts = SessionOptions::new().with_queue_config(qc);
1716        let session = agent
1717            .session("/tmp/test-workspace-qstats", Some(opts))
1718            .unwrap();
1719        let stats = session.queue_stats().await;
1720        // Fresh queue with no commands should have zero stats
1721        assert_eq!(stats.total_pending, 0);
1722        assert_eq!(stats.total_active, 0);
1723    }
1724
1725    #[tokio::test(flavor = "multi_thread")]
1726    async fn test_session_pending_external_tasks_empty() {
1727        let agent = Agent::from_config(test_config()).await.unwrap();
1728        let qc = SessionQueueConfig::default();
1729        let opts = SessionOptions::new().with_queue_config(qc);
1730        let session = agent
1731            .session("/tmp/test-workspace-ext", Some(opts))
1732            .unwrap();
1733        let tasks = session.pending_external_tasks().await;
1734        assert!(tasks.is_empty());
1735    }
1736
1737    #[tokio::test(flavor = "multi_thread")]
1738    async fn test_session_dead_letters_empty() {
1739        let agent = Agent::from_config(test_config()).await.unwrap();
1740        let qc = SessionQueueConfig::default().with_dlq(Some(100));
1741        let opts = SessionOptions::new().with_queue_config(qc);
1742        let session = agent
1743            .session("/tmp/test-workspace-dlq", Some(opts))
1744            .unwrap();
1745        let dead = session.dead_letters().await;
1746        assert!(dead.is_empty());
1747    }
1748
1749    #[tokio::test(flavor = "multi_thread")]
1750    async fn test_session_queue_metrics_disabled() {
1751        let agent = Agent::from_config(test_config()).await.unwrap();
1752        // Metrics not enabled
1753        let qc = SessionQueueConfig::default();
1754        let opts = SessionOptions::new().with_queue_config(qc);
1755        let session = agent
1756            .session("/tmp/test-workspace-nomet", Some(opts))
1757            .unwrap();
1758        let metrics = session.queue_metrics().await;
1759        assert!(metrics.is_none());
1760    }
1761
1762    #[tokio::test(flavor = "multi_thread")]
1763    async fn test_session_queue_metrics_enabled() {
1764        let agent = Agent::from_config(test_config()).await.unwrap();
1765        let qc = SessionQueueConfig::default().with_metrics();
1766        let opts = SessionOptions::new().with_queue_config(qc);
1767        let session = agent
1768            .session("/tmp/test-workspace-met", Some(opts))
1769            .unwrap();
1770        let metrics = session.queue_metrics().await;
1771        assert!(metrics.is_some());
1772    }
1773
1774    #[tokio::test(flavor = "multi_thread")]
1775    async fn test_session_set_lane_handler() {
1776        let agent = Agent::from_config(test_config()).await.unwrap();
1777        let qc = SessionQueueConfig::default();
1778        let opts = SessionOptions::new().with_queue_config(qc);
1779        let session = agent
1780            .session("/tmp/test-workspace-handler", Some(opts))
1781            .unwrap();
1782
1783        // Set Execute lane to External mode
1784        session
1785            .set_lane_handler(
1786                SessionLane::Execute,
1787                LaneHandlerConfig {
1788                    mode: crate::queue::TaskHandlerMode::External,
1789                    timeout_ms: 30_000,
1790                },
1791            )
1792            .await;
1793
1794        // No panic = success. The handler config is stored internally.
1795        // We can't directly read it back but we verify no errors.
1796    }
1797
1798    // ========================================================================
1799    // Session Persistence Tests
1800    // ========================================================================
1801
1802    #[tokio::test(flavor = "multi_thread")]
1803    async fn test_session_has_id() {
1804        let agent = Agent::from_config(test_config()).await.unwrap();
1805        let session = agent.session("/tmp/test-ws-id", None).unwrap();
1806        // Auto-generated UUID
1807        assert!(!session.session_id().is_empty());
1808        assert_eq!(session.session_id().len(), 36); // UUID format
1809    }
1810
1811    #[tokio::test(flavor = "multi_thread")]
1812    async fn test_session_explicit_id() {
1813        let agent = Agent::from_config(test_config()).await.unwrap();
1814        let opts = SessionOptions::new().with_session_id("my-session-42");
1815        let session = agent.session("/tmp/test-ws-eid", Some(opts)).unwrap();
1816        assert_eq!(session.session_id(), "my-session-42");
1817    }
1818
1819    #[tokio::test(flavor = "multi_thread")]
1820    async fn test_session_save_no_store() {
1821        let agent = Agent::from_config(test_config()).await.unwrap();
1822        let session = agent.session("/tmp/test-ws-save", None).unwrap();
1823        // save() is a no-op when no store is configured
1824        session.save().await.unwrap();
1825    }
1826
1827    #[tokio::test(flavor = "multi_thread")]
1828    async fn test_session_save_and_load() {
1829        let store = Arc::new(crate::store::MemorySessionStore::new());
1830        let agent = Agent::from_config(test_config()).await.unwrap();
1831
1832        let opts = SessionOptions::new()
1833            .with_session_store(store.clone())
1834            .with_session_id("persist-test");
1835        let session = agent.session("/tmp/test-ws-persist", Some(opts)).unwrap();
1836
1837        // Save empty session
1838        session.save().await.unwrap();
1839
1840        // Verify it was stored
1841        assert!(store.exists("persist-test").await.unwrap());
1842
1843        let data = store.load("persist-test").await.unwrap().unwrap();
1844        assert_eq!(data.id, "persist-test");
1845        assert!(data.messages.is_empty());
1846    }
1847
1848    #[tokio::test(flavor = "multi_thread")]
1849    async fn test_session_save_with_history() {
1850        let store = Arc::new(crate::store::MemorySessionStore::new());
1851        let agent = Agent::from_config(test_config()).await.unwrap();
1852
1853        let opts = SessionOptions::new()
1854            .with_session_store(store.clone())
1855            .with_session_id("history-test");
1856        let session = agent.session("/tmp/test-ws-hist", Some(opts)).unwrap();
1857
1858        // Manually inject history
1859        {
1860            let mut h = session.history.write().unwrap();
1861            h.push(Message::user("Hello"));
1862            h.push(Message::user("How are you?"));
1863        }
1864
1865        session.save().await.unwrap();
1866
1867        let data = store.load("history-test").await.unwrap().unwrap();
1868        assert_eq!(data.messages.len(), 2);
1869    }
1870
1871    #[tokio::test(flavor = "multi_thread")]
1872    async fn test_resume_session() {
1873        let store = Arc::new(crate::store::MemorySessionStore::new());
1874        let agent = Agent::from_config(test_config()).await.unwrap();
1875
1876        // Create and save a session with history
1877        let opts = SessionOptions::new()
1878            .with_session_store(store.clone())
1879            .with_session_id("resume-test");
1880        let session = agent.session("/tmp/test-ws-resume", Some(opts)).unwrap();
1881        {
1882            let mut h = session.history.write().unwrap();
1883            h.push(Message::user("What is Rust?"));
1884            h.push(Message::user("Tell me more"));
1885        }
1886        session.save().await.unwrap();
1887
1888        // Resume the session
1889        let opts2 = SessionOptions::new().with_session_store(store.clone());
1890        let resumed = agent.resume_session("resume-test", opts2).unwrap();
1891
1892        assert_eq!(resumed.session_id(), "resume-test");
1893        let history = resumed.history();
1894        assert_eq!(history.len(), 2);
1895        assert_eq!(history[0].text(), "What is Rust?");
1896    }
1897
1898    #[tokio::test(flavor = "multi_thread")]
1899    async fn test_resume_session_not_found() {
1900        let store = Arc::new(crate::store::MemorySessionStore::new());
1901        let agent = Agent::from_config(test_config()).await.unwrap();
1902
1903        let opts = SessionOptions::new().with_session_store(store.clone());
1904        let result = agent.resume_session("nonexistent", opts);
1905        assert!(result.is_err());
1906        assert!(result.unwrap_err().to_string().contains("not found"));
1907    }
1908
1909    #[tokio::test(flavor = "multi_thread")]
1910    async fn test_resume_session_no_store() {
1911        let agent = Agent::from_config(test_config()).await.unwrap();
1912        let opts = SessionOptions::new();
1913        let result = agent.resume_session("any-id", opts);
1914        assert!(result.is_err());
1915        assert!(result.unwrap_err().to_string().contains("session_store"));
1916    }
1917
1918    #[tokio::test(flavor = "multi_thread")]
1919    async fn test_file_session_store_persistence() {
1920        let dir = tempfile::TempDir::new().unwrap();
1921        let store = Arc::new(
1922            crate::store::FileSessionStore::new(dir.path())
1923                .await
1924                .unwrap(),
1925        );
1926        let agent = Agent::from_config(test_config()).await.unwrap();
1927
1928        // Save
1929        let opts = SessionOptions::new()
1930            .with_session_store(store.clone())
1931            .with_session_id("file-persist");
1932        let session = agent
1933            .session("/tmp/test-ws-file-persist", Some(opts))
1934            .unwrap();
1935        {
1936            let mut h = session.history.write().unwrap();
1937            h.push(Message::user("test message"));
1938        }
1939        session.save().await.unwrap();
1940
1941        // Load from a fresh store instance pointing to same dir
1942        let store2 = Arc::new(
1943            crate::store::FileSessionStore::new(dir.path())
1944                .await
1945                .unwrap(),
1946        );
1947        let data = store2.load("file-persist").await.unwrap().unwrap();
1948        assert_eq!(data.messages.len(), 1);
1949    }
1950
1951    #[tokio::test(flavor = "multi_thread")]
1952    async fn test_session_options_builders() {
1953        let opts = SessionOptions::new()
1954            .with_session_id("test-id")
1955            .with_auto_save(true);
1956        assert_eq!(opts.session_id, Some("test-id".to_string()));
1957        assert!(opts.auto_save);
1958    }
1959
1960    // ========================================================================
1961    // Sandbox Tests
1962    // ========================================================================
1963
1964    #[test]
1965    fn test_session_options_with_sandbox_sets_config() {
1966        use crate::sandbox::SandboxConfig;
1967        let cfg = SandboxConfig {
1968            image: "ubuntu:22.04".into(),
1969            memory_mb: 1024,
1970            ..SandboxConfig::default()
1971        };
1972        let opts = SessionOptions::new().with_sandbox(cfg);
1973        assert!(opts.sandbox_config.is_some());
1974        let sc = opts.sandbox_config.unwrap();
1975        assert_eq!(sc.image, "ubuntu:22.04");
1976        assert_eq!(sc.memory_mb, 1024);
1977    }
1978
1979    #[test]
1980    fn test_session_options_default_has_no_sandbox() {
1981        let opts = SessionOptions::default();
1982        assert!(opts.sandbox_config.is_none());
1983    }
1984
1985    #[tokio::test]
1986    async fn test_session_debug_includes_sandbox_config() {
1987        use crate::sandbox::SandboxConfig;
1988        let opts = SessionOptions::new().with_sandbox(SandboxConfig::default());
1989        let debug = format!("{:?}", opts);
1990        assert!(debug.contains("sandbox_config"));
1991    }
1992
1993    #[tokio::test]
1994    async fn test_session_build_with_sandbox_config_no_feature_warn() {
1995        // When feature is not enabled, build_session should still succeed
1996        // (it just logs a warning). With feature enabled, it creates a handle.
1997        let agent = Agent::from_config(test_config()).await.unwrap();
1998        let opts = SessionOptions::new().with_sandbox(crate::sandbox::SandboxConfig::default());
1999        // build_session should not fail even if sandbox feature is off
2000        let session = agent.session("/tmp/test-sandbox-session", Some(opts));
2001        assert!(session.is_ok());
2002    }
2003
2004    // ========================================================================
2005    // Memory Integration Tests
2006    // ========================================================================
2007
2008    #[tokio::test(flavor = "multi_thread")]
2009    async fn test_session_with_memory_store() {
2010        use a3s_memory::InMemoryStore;
2011        let store = Arc::new(InMemoryStore::new());
2012        let agent = Agent::from_config(test_config()).await.unwrap();
2013        let opts = SessionOptions::new().with_memory(store);
2014        let session = agent.session("/tmp/test-ws-memory", Some(opts)).unwrap();
2015        assert!(session.memory().is_some());
2016    }
2017
2018    #[tokio::test(flavor = "multi_thread")]
2019    async fn test_session_without_memory_store() {
2020        let agent = Agent::from_config(test_config()).await.unwrap();
2021        let session = agent.session("/tmp/test-ws-no-memory", None).unwrap();
2022        assert!(session.memory().is_none());
2023    }
2024
2025    #[tokio::test(flavor = "multi_thread")]
2026    async fn test_session_memory_wired_into_config() {
2027        use a3s_memory::InMemoryStore;
2028        let store = Arc::new(InMemoryStore::new());
2029        let agent = Agent::from_config(test_config()).await.unwrap();
2030        let opts = SessionOptions::new().with_memory(store);
2031        let session = agent
2032            .session("/tmp/test-ws-mem-config", Some(opts))
2033            .unwrap();
2034        // memory is accessible via the public session API
2035        assert!(session.memory().is_some());
2036    }
2037
2038    #[tokio::test(flavor = "multi_thread")]
2039    async fn test_session_with_file_memory() {
2040        let dir = tempfile::TempDir::new().unwrap();
2041        let agent = Agent::from_config(test_config()).await.unwrap();
2042        let opts = SessionOptions::new().with_file_memory(dir.path());
2043        let session = agent.session("/tmp/test-ws-file-mem", Some(opts)).unwrap();
2044        assert!(session.memory().is_some());
2045    }
2046
2047    #[tokio::test(flavor = "multi_thread")]
2048    async fn test_memory_remember_and_recall() {
2049        use a3s_memory::InMemoryStore;
2050        let store = Arc::new(InMemoryStore::new());
2051        let agent = Agent::from_config(test_config()).await.unwrap();
2052        let opts = SessionOptions::new().with_memory(store);
2053        let session = agent
2054            .session("/tmp/test-ws-mem-recall", Some(opts))
2055            .unwrap();
2056
2057        let memory = session.memory().unwrap();
2058        memory
2059            .remember_success("write a file", &["write".to_string()], "done")
2060            .await
2061            .unwrap();
2062
2063        let results = memory.recall_similar("write", 5).await.unwrap();
2064        assert!(!results.is_empty());
2065        let stats = memory.stats().await.unwrap();
2066        assert_eq!(stats.long_term_count, 1);
2067    }
2068
2069    // ========================================================================
2070    // Tool timeout tests
2071    // ========================================================================
2072
2073    #[tokio::test(flavor = "multi_thread")]
2074    async fn test_session_tool_timeout_configured() {
2075        let agent = Agent::from_config(test_config()).await.unwrap();
2076        let opts = SessionOptions::new().with_tool_timeout(5000);
2077        let session = agent.session("/tmp/test-ws-timeout", Some(opts)).unwrap();
2078        assert!(!session.id().is_empty());
2079    }
2080
2081    // ========================================================================
2082    // Queue fallback tests
2083    // ========================================================================
2084
2085    #[tokio::test(flavor = "multi_thread")]
2086    async fn test_session_without_queue_builds_ok() {
2087        let agent = Agent::from_config(test_config()).await.unwrap();
2088        let session = agent.session("/tmp/test-ws-no-queue", None).unwrap();
2089        assert!(!session.id().is_empty());
2090    }
2091
2092    // ========================================================================
2093    // Concurrent history access tests
2094    // ========================================================================
2095
2096    #[tokio::test(flavor = "multi_thread")]
2097    async fn test_concurrent_history_reads() {
2098        let agent = Agent::from_config(test_config()).await.unwrap();
2099        let session = Arc::new(agent.session("/tmp/test-ws-concurrent", None).unwrap());
2100
2101        let handles: Vec<_> = (0..10)
2102            .map(|_| {
2103                let s = Arc::clone(&session);
2104                tokio::spawn(async move { s.history().len() })
2105            })
2106            .collect();
2107
2108        for h in handles {
2109            h.await.unwrap();
2110        }
2111    }
2112
2113    // ========================================================================
2114    // init_warning tests
2115    // ========================================================================
2116
2117    #[tokio::test(flavor = "multi_thread")]
2118    async fn test_session_no_init_warning_without_file_memory() {
2119        let agent = Agent::from_config(test_config()).await.unwrap();
2120        let session = agent.session("/tmp/test-ws-no-warn", None).unwrap();
2121        assert!(session.init_warning().is_none());
2122    }
2123
2124    #[tokio::test(flavor = "multi_thread")]
2125    async fn test_session_with_mcp_manager_builds_ok() {
2126        use crate::mcp::manager::McpManager;
2127        let mcp = Arc::new(McpManager::new());
2128        let agent = Agent::from_config(test_config()).await.unwrap();
2129        let opts = SessionOptions::new().with_mcp(mcp);
2130        // No servers connected — should build fine with zero MCP tools registered
2131        let session = agent.session("/tmp/test-ws-mcp", Some(opts)).unwrap();
2132        assert!(!session.id().is_empty());
2133    }
2134}