1use 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#[derive(Debug, Clone)]
45pub struct ToolCallResult {
46 pub name: String,
47 pub output: String,
48 pub exit_code: i32,
49}
50
51#[derive(Clone, Default)]
57pub struct SessionOptions {
58 pub model: Option<String>,
60 pub agent_dirs: Vec<PathBuf>,
63 pub queue_config: Option<SessionQueueConfig>,
68 pub security_provider: Option<Arc<dyn crate::security::SecurityProvider>>,
70 pub context_providers: Vec<Arc<dyn crate::context::ContextProvider>>,
72 pub confirmation_manager: Option<Arc<dyn crate::hitl::ConfirmationProvider>>,
74 pub permission_checker: Option<Arc<dyn crate::permissions::PermissionChecker>>,
76 pub planning_enabled: bool,
78 pub goal_tracking: bool,
80 pub skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
82 pub memory_store: Option<Arc<dyn MemoryStore>>,
84 pub(crate) file_memory_dir: Option<PathBuf>,
86 pub session_store: Option<Arc<dyn crate::store::SessionStore>>,
88 pub session_id: Option<String>,
90 pub auto_save: bool,
92 pub max_parse_retries: Option<u32>,
95 pub tool_timeout_ms: Option<u64>,
98 pub circuit_breaker_threshold: Option<u32>,
102 pub sandbox_config: Option<crate::sandbox::SandboxConfig>,
108 pub auto_compact: bool,
110 pub auto_compact_threshold: Option<f32>,
113 pub continuation_enabled: Option<bool>,
116 pub max_continuation_turns: Option<u32>,
119 pub mcp_manager: Option<Arc<crate::mcp::manager::McpManager>>,
124 pub temperature: Option<f32>,
126 pub thinking_budget: Option<usize>,
128 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 pub fn with_default_security(mut self) -> Self {
197 self.security_provider = Some(Arc::new(crate::security::DefaultSecurityProvider::new()));
198 self
199 }
200
201 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 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 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 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 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 pub fn with_permissive_policy(self) -> Self {
254 self.with_permission_checker(Arc::new(crate::permissions::PermissionPolicy::permissive()))
255 }
256
257 pub fn with_planning(mut self, enabled: bool) -> Self {
259 self.planning_enabled = enabled;
260 self
261 }
262
263 pub fn with_goal_tracking(mut self, enabled: bool) -> Self {
265 self.goal_tracking = enabled;
266 self
267 }
268
269 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 pub fn with_skill_registry(mut self, registry: Arc<crate::skills::SkillRegistry>) -> Self {
277 self.skill_registry = Some(registry);
278 self
279 }
280
281 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 pub fn with_memory(mut self, store: Arc<dyn MemoryStore>) -> Self {
299 self.memory_store = Some(store);
300 self
301 }
302
303 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 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 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 pub fn with_session_id(mut self, id: impl Into<String>) -> Self {
347 self.session_id = Some(id.into());
348 self
349 }
350
351 pub fn with_auto_save(mut self, enabled: bool) -> Self {
353 self.auto_save = enabled;
354 self
355 }
356
357 pub fn with_parse_retries(mut self, max: u32) -> Self {
363 self.max_parse_retries = Some(max);
364 self
365 }
366
367 pub fn with_tool_timeout(mut self, timeout_ms: u64) -> Self {
373 self.tool_timeout_ms = Some(timeout_ms);
374 self
375 }
376
377 pub fn with_circuit_breaker(mut self, threshold: u32) -> Self {
383 self.circuit_breaker_threshold = Some(threshold);
384 self
385 }
386
387 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 pub fn with_sandbox(mut self, config: crate::sandbox::SandboxConfig) -> Self {
417 self.sandbox_config = Some(config);
418 self
419 }
420
421 pub fn with_auto_compact(mut self, enabled: bool) -> Self {
426 self.auto_compact = enabled;
427 self
428 }
429
430 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 pub fn with_continuation(mut self, enabled: bool) -> Self {
441 self.continuation_enabled = Some(enabled);
442 self
443 }
444
445 pub fn with_max_continuation_turns(mut self, turns: u32) -> Self {
447 self.max_continuation_turns = Some(turns);
448 self
449 }
450
451 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 pub fn with_prompt_slots(mut self, slots: SystemPromptSlots) -> Self {
475 self.prompt_slots = Some(slots);
476 self
477 }
478}
479
480pub 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 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 CodeConfig::from_hcl(&source).context("Failed to parse config as HCL string")?
514 };
515
516 Self::from_config(config).await
517 }
518
519 pub async fn create(config_source: impl Into<String>) -> Result<Self> {
524 Self::new(config_source).await
525 }
526
527 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 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 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 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 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 *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 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 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 let mut prompt_slots = opts
693 .prompt_slots
694 .clone()
695 .unwrap_or_else(|| self.config.prompt_slots.clone());
696
697 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 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 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 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 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 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 #[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 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
877pub struct AgentSession {
886 llm_client: Arc<dyn LlmClient>,
887 tool_executor: Arc<ToolExecutor>,
888 tool_context: ToolContext,
889 config: AgentConfig,
890 workspace: PathBuf,
891 session_id: String,
893 history: RwLock<Vec<Message>>,
895 command_queue: Option<Arc<SessionLaneQueue>>,
897 memory: Option<Arc<crate::memory::AgentMemory>>,
899 session_store: Option<Arc<dyn crate::store::SessionStore>>,
901 auto_save: bool,
903 hook_engine: Arc<crate::hooks::HookEngine>,
905 init_warning: Option<String>,
907 command_registry: CommandRegistry,
909 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 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 fn build_command_context(&self) -> CommandContext {
945 let history = self.history.read().unwrap();
946
947 let tool_names: Vec<String> = self.config.tools.iter().map(|t| t.name.clone()).collect();
949
950 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 pub fn command_registry(&self) -> &CommandRegistry {
977 &self.command_registry
978 }
979
980 pub fn register_command(&mut self, cmd: Arc<dyn crate::commands::SlashCommand>) {
982 self.command_registry.register(cmd);
983 }
984
985 pub async fn send(&self, prompt: &str, history: Option<&[Message]>) -> Result<AgentResult> {
994 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 if use_internal {
1025 *self.history.write().unwrap() = result.messages.clone();
1026
1027 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 pub async fn send_with_attachments(
1043 &self,
1044 prompt: &str,
1045 attachments: &[crate::llm::Attachment],
1046 history: Option<&[Message]>,
1047 ) -> Result<AgentResult> {
1048 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 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 pub async fn stream(
1112 &self,
1113 prompt: &str,
1114 history: Option<&[Message]>,
1115 ) -> Result<(mpsc::Receiver<AgentEvent>, JoinHandle<()>)> {
1116 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 pub fn history(&self) -> Vec<Message> {
1157 self.history.read().unwrap().clone()
1158 }
1159
1160 pub fn memory(&self) -> Option<&Arc<crate::memory::AgentMemory>> {
1162 self.memory.as_ref()
1163 }
1164
1165 pub fn id(&self) -> &str {
1167 &self.session_id
1168 }
1169
1170 pub fn workspace(&self) -> &std::path::Path {
1172 &self.workspace
1173 }
1174
1175 pub fn init_warning(&self) -> Option<&str> {
1177 self.init_warning.as_deref()
1178 }
1179
1180 pub fn session_id(&self) -> &str {
1182 &self.session_id
1183 }
1184
1185 pub fn register_hook(&self, hook: crate::hooks::Hook) {
1191 self.hook_engine.register(hook);
1192 }
1193
1194 pub fn unregister_hook(&self, hook_id: &str) -> Option<crate::hooks::Hook> {
1196 self.hook_engine.unregister(hook_id)
1197 }
1198
1199 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 pub fn unregister_hook_handler(&self, hook_id: &str) {
1210 self.hook_engine.unregister_handler(hook_id);
1211 }
1212
1213 pub fn hook_count(&self) -> usize {
1215 self.hook_engine.hook_count()
1216 }
1217
1218 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 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 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 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 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 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 pub fn has_queue(&self) -> bool {
1327 self.command_queue.is_some()
1328 }
1329
1330 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 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 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 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 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 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#[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 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 #[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 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 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 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 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 }
1708
1709 #[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 assert!(!session.session_id().is_empty());
1719 assert_eq!(session.session_id().len(), 36); }
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 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 session.save().await.unwrap();
1750
1751 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 {
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 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 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 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 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 #[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 let agent = Agent::from_config(test_config()).await.unwrap();
1909 let opts = SessionOptions::new().with_sandbox(crate::sandbox::SandboxConfig::default());
1910 let session = agent.session("/tmp/test-sandbox-session", Some(opts));
1912 assert!(session.is_ok());
1913 }
1914
1915 #[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 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 #[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 #[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 #[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 #[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 let session = agent.session("/tmp/test-ws-mcp", Some(opts)).unwrap();
2043 assert!(!session.id().is_empty());
2044 }
2045}