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