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_dirs: Vec<PathBuf>,
83 pub skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
85 pub memory_store: Option<Arc<dyn MemoryStore>>,
87 pub(crate) file_memory_dir: Option<PathBuf>,
89 pub session_store: Option<Arc<dyn crate::store::SessionStore>>,
91 pub session_id: Option<String>,
93 pub auto_save: bool,
95 pub max_parse_retries: Option<u32>,
98 pub tool_timeout_ms: Option<u64>,
101 pub circuit_breaker_threshold: Option<u32>,
105 pub sandbox_config: Option<crate::sandbox::SandboxConfig>,
111 pub auto_compact: bool,
113 pub auto_compact_threshold: Option<f32>,
116 pub continuation_enabled: Option<bool>,
119 pub max_continuation_turns: Option<u32>,
122 pub mcp_manager: Option<Arc<crate::mcp::manager::McpManager>>,
127 pub temperature: Option<f32>,
129 pub thinking_budget: Option<usize>,
131 pub prompt_slots: Option<SystemPromptSlots>,
137}
138
139impl std::fmt::Debug for SessionOptions {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 f.debug_struct("SessionOptions")
142 .field("model", &self.model)
143 .field("agent_dirs", &self.agent_dirs)
144 .field("skill_dirs", &self.skill_dirs)
145 .field("queue_config", &self.queue_config)
146 .field("security_provider", &self.security_provider.is_some())
147 .field("context_providers", &self.context_providers.len())
148 .field("confirmation_manager", &self.confirmation_manager.is_some())
149 .field("permission_checker", &self.permission_checker.is_some())
150 .field("planning_enabled", &self.planning_enabled)
151 .field("goal_tracking", &self.goal_tracking)
152 .field(
153 "skill_registry",
154 &self
155 .skill_registry
156 .as_ref()
157 .map(|r| format!("{} skills", r.len())),
158 )
159 .field("memory_store", &self.memory_store.is_some())
160 .field("session_store", &self.session_store.is_some())
161 .field("session_id", &self.session_id)
162 .field("auto_save", &self.auto_save)
163 .field("max_parse_retries", &self.max_parse_retries)
164 .field("tool_timeout_ms", &self.tool_timeout_ms)
165 .field("circuit_breaker_threshold", &self.circuit_breaker_threshold)
166 .field("sandbox_config", &self.sandbox_config)
167 .field("auto_compact", &self.auto_compact)
168 .field("auto_compact_threshold", &self.auto_compact_threshold)
169 .field("continuation_enabled", &self.continuation_enabled)
170 .field("max_continuation_turns", &self.max_continuation_turns)
171 .field("mcp_manager", &self.mcp_manager.is_some())
172 .field("temperature", &self.temperature)
173 .field("thinking_budget", &self.thinking_budget)
174 .field("prompt_slots", &self.prompt_slots.is_some())
175 .finish()
176 }
177}
178
179impl SessionOptions {
180 pub fn new() -> Self {
181 Self::default()
182 }
183
184 pub fn with_model(mut self, model: impl Into<String>) -> Self {
185 self.model = Some(model.into());
186 self
187 }
188
189 pub fn with_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
190 self.agent_dirs.push(dir.into());
191 self
192 }
193
194 pub fn with_queue_config(mut self, config: SessionQueueConfig) -> Self {
195 self.queue_config = Some(config);
196 self
197 }
198
199 pub fn with_default_security(mut self) -> Self {
201 self.security_provider = Some(Arc::new(crate::security::DefaultSecurityProvider::new()));
202 self
203 }
204
205 pub fn with_security_provider(
207 mut self,
208 provider: Arc<dyn crate::security::SecurityProvider>,
209 ) -> Self {
210 self.security_provider = Some(provider);
211 self
212 }
213
214 pub fn with_fs_context(mut self, root_path: impl Into<PathBuf>) -> Self {
216 let config = crate::context::FileSystemContextConfig::new(root_path);
217 self.context_providers
218 .push(Arc::new(crate::context::FileSystemContextProvider::new(
219 config,
220 )));
221 self
222 }
223
224 pub fn with_context_provider(
226 mut self,
227 provider: Arc<dyn crate::context::ContextProvider>,
228 ) -> Self {
229 self.context_providers.push(provider);
230 self
231 }
232
233 pub fn with_confirmation_manager(
235 mut self,
236 manager: Arc<dyn crate::hitl::ConfirmationProvider>,
237 ) -> Self {
238 self.confirmation_manager = Some(manager);
239 self
240 }
241
242 pub fn with_permission_checker(
244 mut self,
245 checker: Arc<dyn crate::permissions::PermissionChecker>,
246 ) -> Self {
247 self.permission_checker = Some(checker);
248 self
249 }
250
251 pub fn with_permissive_policy(self) -> Self {
258 self.with_permission_checker(Arc::new(crate::permissions::PermissionPolicy::permissive()))
259 }
260
261 pub fn with_planning(mut self, enabled: bool) -> Self {
263 self.planning_enabled = enabled;
264 self
265 }
266
267 pub fn with_goal_tracking(mut self, enabled: bool) -> Self {
269 self.goal_tracking = enabled;
270 self
271 }
272
273 pub fn with_builtin_skills(mut self) -> Self {
275 self.skill_registry = Some(Arc::new(crate::skills::SkillRegistry::with_builtins()));
276 self
277 }
278
279 pub fn with_skill_registry(mut self, registry: Arc<crate::skills::SkillRegistry>) -> Self {
281 self.skill_registry = Some(registry);
282 self
283 }
284
285 pub fn with_skill_dirs(mut self, dirs: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
288 self.skill_dirs.extend(dirs.into_iter().map(Into::into));
289 self
290 }
291
292 pub fn with_skills_from_dir(mut self, dir: impl AsRef<std::path::Path>) -> Self {
294 let registry = self
295 .skill_registry
296 .unwrap_or_else(|| Arc::new(crate::skills::SkillRegistry::new()));
297 if let Err(e) = registry.load_from_dir(&dir) {
298 tracing::warn!(
299 dir = %dir.as_ref().display(),
300 error = %e,
301 "Failed to load skills from directory — continuing without them"
302 );
303 }
304 self.skill_registry = Some(registry);
305 self
306 }
307
308 pub fn with_memory(mut self, store: Arc<dyn MemoryStore>) -> Self {
310 self.memory_store = Some(store);
311 self
312 }
313
314 pub fn with_file_memory(mut self, dir: impl Into<PathBuf>) -> Self {
320 self.file_memory_dir = Some(dir.into());
321 self
322 }
323
324 pub fn with_session_store(mut self, store: Arc<dyn crate::store::SessionStore>) -> Self {
326 self.session_store = Some(store);
327 self
328 }
329
330 pub fn with_file_session_store(mut self, dir: impl Into<PathBuf>) -> Self {
332 let dir = dir.into();
333 match tokio::runtime::Handle::try_current() {
334 Ok(handle) => {
335 match tokio::task::block_in_place(|| {
336 handle.block_on(crate::store::FileSessionStore::new(dir))
337 }) {
338 Ok(store) => {
339 self.session_store =
340 Some(Arc::new(store) as Arc<dyn crate::store::SessionStore>);
341 }
342 Err(e) => {
343 tracing::warn!("Failed to create file session store: {}", e);
344 }
345 }
346 }
347 Err(_) => {
348 tracing::warn!(
349 "No async runtime available for file session store — persistence disabled"
350 );
351 }
352 }
353 self
354 }
355
356 pub fn with_session_id(mut self, id: impl Into<String>) -> Self {
358 self.session_id = Some(id.into());
359 self
360 }
361
362 pub fn with_auto_save(mut self, enabled: bool) -> Self {
364 self.auto_save = enabled;
365 self
366 }
367
368 pub fn with_parse_retries(mut self, max: u32) -> Self {
374 self.max_parse_retries = Some(max);
375 self
376 }
377
378 pub fn with_tool_timeout(mut self, timeout_ms: u64) -> Self {
384 self.tool_timeout_ms = Some(timeout_ms);
385 self
386 }
387
388 pub fn with_circuit_breaker(mut self, threshold: u32) -> Self {
394 self.circuit_breaker_threshold = Some(threshold);
395 self
396 }
397
398 pub fn with_resilience_defaults(self) -> Self {
404 self.with_parse_retries(2)
405 .with_tool_timeout(120_000)
406 .with_circuit_breaker(3)
407 }
408
409 pub fn with_sandbox(mut self, config: crate::sandbox::SandboxConfig) -> Self {
428 self.sandbox_config = Some(config);
429 self
430 }
431
432 pub fn with_auto_compact(mut self, enabled: bool) -> Self {
437 self.auto_compact = enabled;
438 self
439 }
440
441 pub fn with_auto_compact_threshold(mut self, threshold: f32) -> Self {
443 self.auto_compact_threshold = Some(threshold.clamp(0.0, 1.0));
444 self
445 }
446
447 pub fn with_continuation(mut self, enabled: bool) -> Self {
452 self.continuation_enabled = Some(enabled);
453 self
454 }
455
456 pub fn with_max_continuation_turns(mut self, turns: u32) -> Self {
458 self.max_continuation_turns = Some(turns);
459 self
460 }
461
462 pub fn with_mcp(mut self, manager: Arc<crate::mcp::manager::McpManager>) -> Self {
467 self.mcp_manager = Some(manager);
468 self
469 }
470
471 pub fn with_temperature(mut self, temperature: f32) -> Self {
472 self.temperature = Some(temperature);
473 self
474 }
475
476 pub fn with_thinking_budget(mut self, budget: usize) -> Self {
477 self.thinking_budget = Some(budget);
478 self
479 }
480
481 pub fn with_prompt_slots(mut self, slots: SystemPromptSlots) -> Self {
486 self.prompt_slots = Some(slots);
487 self
488 }
489}
490
491pub struct Agent {
500 llm_client: Arc<dyn LlmClient>,
501 code_config: CodeConfig,
502 config: AgentConfig,
503}
504
505impl std::fmt::Debug for Agent {
506 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507 f.debug_struct("Agent").finish()
508 }
509}
510
511impl Agent {
512 pub async fn new(config_source: impl Into<String>) -> Result<Self> {
516 let source = config_source.into();
517
518 let expanded = if source.starts_with("~/") {
520 if let Some(home) = std::env::var_os("HOME") {
521 format!("{}/{}", home.to_string_lossy(), &source[2..])
522 } else {
523 source.clone()
524 }
525 } else {
526 source.clone()
527 };
528
529 let path = Path::new(&expanded);
530
531 let config = if path.extension().is_some() && path.exists() {
532 CodeConfig::from_file(path)
533 .with_context(|| format!("Failed to load config: {}", path.display()))?
534 } else {
535 CodeConfig::from_hcl(&source).context("Failed to parse config as HCL string")?
537 };
538
539 Self::from_config(config).await
540 }
541
542 pub async fn create(config_source: impl Into<String>) -> Result<Self> {
547 Self::new(config_source).await
548 }
549
550 pub async fn from_config(config: CodeConfig) -> Result<Self> {
552 let llm_config = config
553 .default_llm_config()
554 .context("default_model must be set in 'provider/model' format with a valid API key")?;
555 let llm_client = crate::llm::create_client_with_config(llm_config);
556
557 let agent_config = AgentConfig {
558 max_tool_rounds: config
559 .max_tool_rounds
560 .unwrap_or(AgentConfig::default().max_tool_rounds),
561 ..AgentConfig::default()
562 };
563
564 let mut agent = Agent {
565 llm_client,
566 code_config: config,
567 config: agent_config,
568 };
569
570 let registry = Arc::new(crate::skills::SkillRegistry::with_builtins());
572 for dir in &agent.code_config.skill_dirs.clone() {
573 if let Err(e) = registry.load_from_dir(dir) {
574 tracing::warn!(
575 dir = %dir.display(),
576 error = %e,
577 "Failed to load skills from directory — skipping"
578 );
579 }
580 }
581 agent.config.skill_registry = Some(registry);
582
583 Ok(agent)
584 }
585
586 pub fn session(
591 &self,
592 workspace: impl Into<String>,
593 options: Option<SessionOptions>,
594 ) -> Result<AgentSession> {
595 let opts = options.unwrap_or_default();
596
597 let llm_client = if let Some(ref model) = opts.model {
598 let (provider_name, model_id) = model
599 .split_once('/')
600 .context("model format must be 'provider/model' (e.g., 'openai/gpt-4o')")?;
601
602 let mut llm_config = self
603 .code_config
604 .llm_config(provider_name, model_id)
605 .with_context(|| {
606 format!("provider '{provider_name}' or model '{model_id}' not found in config")
607 })?;
608
609 if let Some(temp) = opts.temperature {
610 llm_config = llm_config.with_temperature(temp);
611 }
612 if let Some(budget) = opts.thinking_budget {
613 llm_config = llm_config.with_thinking_budget(budget);
614 }
615
616 crate::llm::create_client_with_config(llm_config)
617 } else {
618 if opts.temperature.is_some() || opts.thinking_budget.is_some() {
619 tracing::warn!(
620 "temperature/thinking_budget set without model override — these will be ignored. \
621 Use with_model() to apply LLM parameter overrides."
622 );
623 }
624 self.llm_client.clone()
625 };
626
627 self.build_session(workspace.into(), llm_client, &opts)
628 }
629
630 pub fn resume_session(
638 &self,
639 session_id: &str,
640 options: SessionOptions,
641 ) -> Result<AgentSession> {
642 let store = options.session_store.as_ref().ok_or_else(|| {
643 crate::error::CodeError::Session(
644 "resume_session requires a session_store in SessionOptions".to_string(),
645 )
646 })?;
647
648 let data = match tokio::runtime::Handle::try_current() {
650 Ok(handle) => tokio::task::block_in_place(|| handle.block_on(store.load(session_id)))
651 .map_err(|e| {
652 crate::error::CodeError::Session(format!(
653 "Failed to load session {}: {}",
654 session_id, e
655 ))
656 })?,
657 Err(_) => {
658 return Err(crate::error::CodeError::Session(
659 "No async runtime available for session resume".to_string(),
660 ))
661 }
662 };
663
664 let data = data.ok_or_else(|| {
665 crate::error::CodeError::Session(format!("Session not found: {}", session_id))
666 })?;
667
668 let mut opts = options;
670 opts.session_id = Some(data.id.clone());
671
672 let llm_client = if let Some(ref model) = opts.model {
673 let (provider_name, model_id) = model
674 .split_once('/')
675 .context("model format must be 'provider/model'")?;
676 let llm_config = self
677 .code_config
678 .llm_config(provider_name, model_id)
679 .with_context(|| {
680 format!("provider '{provider_name}' or model '{model_id}' not found")
681 })?;
682 crate::llm::create_client_with_config(llm_config)
683 } else {
684 self.llm_client.clone()
685 };
686
687 let session = self.build_session(data.config.workspace.clone(), llm_client, &opts)?;
688
689 *session.history.write().unwrap() = data.messages;
691
692 Ok(session)
693 }
694
695 fn build_session(
696 &self,
697 workspace: String,
698 llm_client: Arc<dyn LlmClient>,
699 opts: &SessionOptions,
700 ) -> Result<AgentSession> {
701 let canonical =
702 std::fs::canonicalize(&workspace).unwrap_or_else(|_| PathBuf::from(&workspace));
703
704 let tool_executor = Arc::new(ToolExecutor::new(canonical.display().to_string()));
705
706 if let Some(ref mcp) = opts.mcp_manager {
708 let mcp_clone = Arc::clone(mcp);
709 let all_tools = tokio::task::block_in_place(|| {
710 tokio::runtime::Handle::current().block_on(mcp_clone.get_all_tools())
711 });
712 let mut by_server: std::collections::HashMap<String, Vec<crate::mcp::McpTool>> =
714 std::collections::HashMap::new();
715 for (server, tool) in all_tools {
716 by_server.entry(server).or_default().push(tool);
717 }
718 for (server_name, tools) in by_server {
719 for tool in
720 crate::mcp::tools::create_mcp_tools(&server_name, tools, Arc::clone(mcp))
721 {
722 tool_executor.register_dynamic_tool(tool);
723 }
724 }
725 }
726
727 let tool_defs = tool_executor.definitions();
728
729 let mut prompt_slots = opts
731 .prompt_slots
732 .clone()
733 .unwrap_or_else(|| self.config.prompt_slots.clone());
734
735 let base_registry = self
739 .config
740 .skill_registry
741 .as_deref()
742 .map(|r| r.fork())
743 .unwrap_or_else(crate::skills::SkillRegistry::with_builtins);
744 if let Some(ref r) = opts.skill_registry {
746 for skill in r.all() {
747 base_registry.register_unchecked(skill);
748 }
749 }
750 for dir in &opts.skill_dirs {
752 if let Err(e) = base_registry.load_from_dir(dir) {
753 tracing::warn!(
754 dir = %dir.display(),
755 error = %e,
756 "Failed to load session skill dir — skipping"
757 );
758 }
759 }
760 let effective_registry = Arc::new(base_registry);
761
762 let skill_prompt = effective_registry.to_system_prompt();
764 if !skill_prompt.is_empty() {
765 prompt_slots.extra = match prompt_slots.extra {
766 Some(existing) => Some(format!("{}\n\n{}", existing, skill_prompt)),
767 None => Some(skill_prompt),
768 };
769 }
770
771 let mut init_warning: Option<String> = None;
773 let memory = {
774 let store = if let Some(ref store) = opts.memory_store {
775 Some(Arc::clone(store))
776 } else if let Some(ref dir) = opts.file_memory_dir {
777 match tokio::runtime::Handle::try_current() {
778 Ok(handle) => {
779 let dir = dir.clone();
780 match tokio::task::block_in_place(|| {
781 handle.block_on(FileMemoryStore::new(dir))
782 }) {
783 Ok(store) => Some(Arc::new(store) as Arc<dyn MemoryStore>),
784 Err(e) => {
785 let msg = format!("Failed to create file memory store: {}", e);
786 tracing::warn!("{}", msg);
787 init_warning = Some(msg);
788 None
789 }
790 }
791 }
792 Err(_) => {
793 let msg =
794 "No async runtime available for file memory store — memory disabled"
795 .to_string();
796 tracing::warn!("{}", msg);
797 init_warning = Some(msg);
798 None
799 }
800 }
801 } else {
802 None
803 };
804 store.map(|s| Arc::new(crate::memory::AgentMemory::new(s)))
805 };
806
807 let base = self.config.clone();
808 let config = AgentConfig {
809 prompt_slots,
810 tools: tool_defs,
811 security_provider: opts.security_provider.clone(),
812 permission_checker: opts.permission_checker.clone(),
813 confirmation_manager: opts.confirmation_manager.clone(),
814 context_providers: opts.context_providers.clone(),
815 planning_enabled: opts.planning_enabled,
816 goal_tracking: opts.goal_tracking,
817 skill_registry: Some(effective_registry),
818 max_parse_retries: opts.max_parse_retries.unwrap_or(base.max_parse_retries),
819 tool_timeout_ms: opts.tool_timeout_ms.or(base.tool_timeout_ms),
820 circuit_breaker_threshold: opts
821 .circuit_breaker_threshold
822 .unwrap_or(base.circuit_breaker_threshold),
823 auto_compact: opts.auto_compact,
824 auto_compact_threshold: opts
825 .auto_compact_threshold
826 .unwrap_or(crate::session::DEFAULT_AUTO_COMPACT_THRESHOLD),
827 max_context_tokens: base.max_context_tokens,
828 llm_client: Some(Arc::clone(&llm_client)),
829 memory: memory.clone(),
830 continuation_enabled: opts
831 .continuation_enabled
832 .unwrap_or(base.continuation_enabled),
833 max_continuation_turns: opts
834 .max_continuation_turns
835 .unwrap_or(base.max_continuation_turns),
836 ..base
837 };
838
839 let (agent_event_tx, _) = broadcast::channel::<crate::agent::AgentEvent>(256);
842 let command_queue = if let Some(ref queue_config) = opts.queue_config {
843 let session_id = uuid::Uuid::new_v4().to_string();
844 let rt = tokio::runtime::Handle::try_current();
845
846 match rt {
847 Ok(handle) => {
848 let queue = tokio::task::block_in_place(|| {
850 handle.block_on(SessionLaneQueue::new(
851 &session_id,
852 queue_config.clone(),
853 agent_event_tx.clone(),
854 ))
855 });
856 match queue {
857 Ok(q) => {
858 let q = Arc::new(q);
860 let q2 = Arc::clone(&q);
861 tokio::task::block_in_place(|| {
862 handle.block_on(async { q2.start().await.ok() })
863 });
864 Some(q)
865 }
866 Err(e) => {
867 tracing::warn!("Failed to create session lane queue: {}", e);
868 None
869 }
870 }
871 }
872 Err(_) => {
873 tracing::warn!(
874 "No async runtime available for queue creation — queue disabled"
875 );
876 None
877 }
878 }
879 } else {
880 None
881 };
882
883 let mut tool_context = ToolContext::new(canonical.clone());
885 if let Some(ref search_config) = self.code_config.search {
886 tool_context = tool_context.with_search_config(search_config.clone());
887 }
888 tool_context = tool_context.with_agent_event_tx(agent_event_tx);
889
890 #[cfg(feature = "sandbox")]
892 if let Some(ref sandbox_cfg) = opts.sandbox_config {
893 let handle: Arc<dyn crate::sandbox::BashSandbox> =
894 Arc::new(crate::sandbox::BoxSandboxHandle::new(
895 sandbox_cfg.clone(),
896 canonical.display().to_string(),
897 ));
898 tool_executor.registry().set_sandbox(Arc::clone(&handle));
901 tool_context = tool_context.with_sandbox(handle);
902 }
903 #[cfg(not(feature = "sandbox"))]
904 if opts.sandbox_config.is_some() {
905 tracing::warn!(
906 "sandbox_config is set but the `sandbox` Cargo feature is not enabled \
907 — bash commands will run locally"
908 );
909 }
910
911 let session_id = opts
912 .session_id
913 .clone()
914 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
915
916 let session_store = if opts.session_store.is_some() {
918 opts.session_store.clone()
919 } else if let Some(ref dir) = self.code_config.sessions_dir {
920 match tokio::runtime::Handle::try_current() {
921 Ok(handle) => {
922 let dir = dir.clone();
923 match tokio::task::block_in_place(|| {
924 handle.block_on(crate::store::FileSessionStore::new(dir))
925 }) {
926 Ok(store) => Some(Arc::new(store) as Arc<dyn crate::store::SessionStore>),
927 Err(e) => {
928 tracing::warn!("Failed to create session store from sessions_dir: {}", e);
929 None
930 }
931 }
932 }
933 Err(_) => {
934 tracing::warn!("No async runtime for sessions_dir store — persistence disabled");
935 None
936 }
937 }
938 } else {
939 None
940 };
941
942 Ok(AgentSession {
943 llm_client,
944 tool_executor,
945 tool_context,
946 memory: config.memory.clone(),
947 config,
948 workspace: canonical,
949 session_id,
950 history: RwLock::new(Vec::new()),
951 command_queue,
952 session_store,
953 auto_save: opts.auto_save,
954 hook_engine: Arc::new(crate::hooks::HookEngine::new()),
955 init_warning,
956 command_registry: CommandRegistry::new(),
957 model_name: opts
958 .model
959 .clone()
960 .or_else(|| self.code_config.default_model.clone())
961 .unwrap_or_else(|| "unknown".to_string()),
962 })
963 }
964}
965
966pub struct AgentSession {
975 llm_client: Arc<dyn LlmClient>,
976 tool_executor: Arc<ToolExecutor>,
977 tool_context: ToolContext,
978 config: AgentConfig,
979 workspace: PathBuf,
980 session_id: String,
982 history: RwLock<Vec<Message>>,
984 command_queue: Option<Arc<SessionLaneQueue>>,
986 memory: Option<Arc<crate::memory::AgentMemory>>,
988 session_store: Option<Arc<dyn crate::store::SessionStore>>,
990 auto_save: bool,
992 hook_engine: Arc<crate::hooks::HookEngine>,
994 init_warning: Option<String>,
996 command_registry: CommandRegistry,
998 model_name: String,
1000}
1001
1002impl std::fmt::Debug for AgentSession {
1003 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1004 f.debug_struct("AgentSession")
1005 .field("session_id", &self.session_id)
1006 .field("workspace", &self.workspace.display().to_string())
1007 .field("auto_save", &self.auto_save)
1008 .finish()
1009 }
1010}
1011
1012impl AgentSession {
1013 fn build_agent_loop(&self) -> AgentLoop {
1017 let mut config = self.config.clone();
1018 config.hook_engine =
1019 Some(Arc::clone(&self.hook_engine) as Arc<dyn crate::hooks::HookExecutor>);
1020 let mut agent_loop = AgentLoop::new(
1021 self.llm_client.clone(),
1022 self.tool_executor.clone(),
1023 self.tool_context.clone(),
1024 config,
1025 );
1026 if let Some(ref queue) = self.command_queue {
1027 agent_loop = agent_loop.with_queue(Arc::clone(queue));
1028 }
1029 agent_loop
1030 }
1031
1032 fn build_command_context(&self) -> CommandContext {
1034 let history = self.history.read().unwrap();
1035
1036 let tool_names: Vec<String> = self.config.tools.iter().map(|t| t.name.clone()).collect();
1038
1039 let mut mcp_map: std::collections::HashMap<String, usize> =
1041 std::collections::HashMap::new();
1042 for name in &tool_names {
1043 if let Some(rest) = name.strip_prefix("mcp__") {
1044 if let Some((server, _)) = rest.split_once("__") {
1045 *mcp_map.entry(server.to_string()).or_default() += 1;
1046 }
1047 }
1048 }
1049 let mut mcp_servers: Vec<(String, usize)> = mcp_map.into_iter().collect();
1050 mcp_servers.sort_by(|a, b| a.0.cmp(&b.0));
1051
1052 CommandContext {
1053 session_id: self.session_id.clone(),
1054 workspace: self.workspace.display().to_string(),
1055 model: self.model_name.clone(),
1056 history_len: history.len(),
1057 total_tokens: 0,
1058 total_cost: 0.0,
1059 tool_names,
1060 mcp_servers,
1061 }
1062 }
1063
1064 pub fn command_registry(&self) -> &CommandRegistry {
1066 &self.command_registry
1067 }
1068
1069 pub fn register_command(&mut self, cmd: Arc<dyn crate::commands::SlashCommand>) {
1071 self.command_registry.register(cmd);
1072 }
1073
1074 pub async fn send(&self, prompt: &str, history: Option<&[Message]>) -> Result<AgentResult> {
1083 if CommandRegistry::is_command(prompt) {
1085 let ctx = self.build_command_context();
1086 if let Some(output) = self.command_registry.dispatch(prompt, &ctx) {
1087 return Ok(AgentResult {
1088 text: output.text,
1089 messages: history
1090 .map(|h| h.to_vec())
1091 .unwrap_or_else(|| self.history.read().unwrap().clone()),
1092 tool_calls_count: 0,
1093 usage: crate::llm::TokenUsage::default(),
1094 });
1095 }
1096 }
1097
1098 if let Some(ref w) = self.init_warning {
1099 tracing::warn!(session_id = %self.session_id, "Session init warning: {}", w);
1100 }
1101 let agent_loop = self.build_agent_loop();
1102
1103 let use_internal = history.is_none();
1104 let effective_history = match history {
1105 Some(h) => h.to_vec(),
1106 None => self.history.read().unwrap().clone(),
1107 };
1108
1109 let result = agent_loop.execute(&effective_history, prompt, None).await?;
1110
1111 if use_internal {
1114 *self.history.write().unwrap() = result.messages.clone();
1115
1116 if self.auto_save {
1118 if let Err(e) = self.save().await {
1119 tracing::warn!("Auto-save failed for session {}: {}", self.session_id, e);
1120 }
1121 }
1122 }
1123
1124 Ok(result)
1125 }
1126
1127 pub async fn send_with_attachments(
1132 &self,
1133 prompt: &str,
1134 attachments: &[crate::llm::Attachment],
1135 history: Option<&[Message]>,
1136 ) -> Result<AgentResult> {
1137 let use_internal = history.is_none();
1141 let mut effective_history = match history {
1142 Some(h) => h.to_vec(),
1143 None => self.history.read().unwrap().clone(),
1144 };
1145 effective_history.push(Message::user_with_attachments(prompt, attachments));
1146
1147 let agent_loop = self.build_agent_loop();
1148 let result = agent_loop
1149 .execute_from_messages(effective_history, None, None)
1150 .await?;
1151
1152 if use_internal {
1153 *self.history.write().unwrap() = result.messages.clone();
1154 if self.auto_save {
1155 if let Err(e) = self.save().await {
1156 tracing::warn!("Auto-save failed for session {}: {}", self.session_id, e);
1157 }
1158 }
1159 }
1160
1161 Ok(result)
1162 }
1163
1164 pub async fn stream_with_attachments(
1169 &self,
1170 prompt: &str,
1171 attachments: &[crate::llm::Attachment],
1172 history: Option<&[Message]>,
1173 ) -> Result<(mpsc::Receiver<AgentEvent>, JoinHandle<()>)> {
1174 let (tx, rx) = mpsc::channel(256);
1175 let mut effective_history = match history {
1176 Some(h) => h.to_vec(),
1177 None => self.history.read().unwrap().clone(),
1178 };
1179 effective_history.push(Message::user_with_attachments(prompt, attachments));
1180
1181 let agent_loop = self.build_agent_loop();
1182 let handle = tokio::spawn(async move {
1183 let _ = agent_loop
1184 .execute_from_messages(effective_history, None, Some(tx))
1185 .await;
1186 });
1187
1188 Ok((rx, handle))
1189 }
1190
1191 pub async fn stream(
1201 &self,
1202 prompt: &str,
1203 history: Option<&[Message]>,
1204 ) -> Result<(mpsc::Receiver<AgentEvent>, JoinHandle<()>)> {
1205 if CommandRegistry::is_command(prompt) {
1207 let ctx = self.build_command_context();
1208 if let Some(output) = self.command_registry.dispatch(prompt, &ctx) {
1209 let (tx, rx) = mpsc::channel(256);
1210 let handle = tokio::spawn(async move {
1211 let _ = tx
1212 .send(AgentEvent::TextDelta {
1213 text: output.text.clone(),
1214 })
1215 .await;
1216 let _ = tx
1217 .send(AgentEvent::End {
1218 text: output.text.clone(),
1219 usage: crate::llm::TokenUsage::default(),
1220 })
1221 .await;
1222 });
1223 return Ok((rx, handle));
1224 }
1225 }
1226
1227 let (tx, rx) = mpsc::channel(256);
1228 let agent_loop = self.build_agent_loop();
1229 let effective_history = match history {
1230 Some(h) => h.to_vec(),
1231 None => self.history.read().unwrap().clone(),
1232 };
1233 let prompt = prompt.to_string();
1234
1235 let handle = tokio::spawn(async move {
1236 let _ = agent_loop
1237 .execute(&effective_history, &prompt, Some(tx))
1238 .await;
1239 });
1240
1241 Ok((rx, handle))
1242 }
1243
1244 pub fn history(&self) -> Vec<Message> {
1246 self.history.read().unwrap().clone()
1247 }
1248
1249 pub fn memory(&self) -> Option<&Arc<crate::memory::AgentMemory>> {
1251 self.memory.as_ref()
1252 }
1253
1254 pub fn id(&self) -> &str {
1256 &self.session_id
1257 }
1258
1259 pub fn workspace(&self) -> &std::path::Path {
1261 &self.workspace
1262 }
1263
1264 pub fn init_warning(&self) -> Option<&str> {
1266 self.init_warning.as_deref()
1267 }
1268
1269 pub fn session_id(&self) -> &str {
1271 &self.session_id
1272 }
1273
1274 pub fn register_hook(&self, hook: crate::hooks::Hook) {
1280 self.hook_engine.register(hook);
1281 }
1282
1283 pub fn unregister_hook(&self, hook_id: &str) -> Option<crate::hooks::Hook> {
1285 self.hook_engine.unregister(hook_id)
1286 }
1287
1288 pub fn register_hook_handler(
1290 &self,
1291 hook_id: &str,
1292 handler: Arc<dyn crate::hooks::HookHandler>,
1293 ) {
1294 self.hook_engine.register_handler(hook_id, handler);
1295 }
1296
1297 pub fn unregister_hook_handler(&self, hook_id: &str) {
1299 self.hook_engine.unregister_handler(hook_id);
1300 }
1301
1302 pub fn hook_count(&self) -> usize {
1304 self.hook_engine.hook_count()
1305 }
1306
1307 pub async fn save(&self) -> Result<()> {
1311 let store = match &self.session_store {
1312 Some(s) => s,
1313 None => return Ok(()),
1314 };
1315
1316 let history = self.history.read().unwrap().clone();
1317 let now = chrono::Utc::now().timestamp();
1318
1319 let data = crate::store::SessionData {
1320 id: self.session_id.clone(),
1321 config: crate::session::SessionConfig {
1322 name: String::new(),
1323 workspace: self.workspace.display().to_string(),
1324 system_prompt: Some(self.config.prompt_slots.build()),
1325 max_context_length: 200_000,
1326 auto_compact: false,
1327 auto_compact_threshold: crate::session::DEFAULT_AUTO_COMPACT_THRESHOLD,
1328 storage_type: crate::config::StorageBackend::File,
1329 queue_config: None,
1330 confirmation_policy: None,
1331 permission_policy: None,
1332 parent_id: None,
1333 security_config: None,
1334 hook_engine: None,
1335 planning_enabled: self.config.planning_enabled,
1336 goal_tracking: self.config.goal_tracking,
1337 },
1338 state: crate::session::SessionState::Active,
1339 messages: history,
1340 context_usage: crate::session::ContextUsage::default(),
1341 total_usage: crate::llm::TokenUsage::default(),
1342 total_cost: 0.0,
1343 model_name: None,
1344 cost_records: Vec::new(),
1345 tool_names: crate::store::SessionData::tool_names_from_definitions(&self.config.tools),
1346 thinking_enabled: false,
1347 thinking_budget: None,
1348 created_at: now,
1349 updated_at: now,
1350 llm_config: None,
1351 tasks: Vec::new(),
1352 parent_id: None,
1353 };
1354
1355 store.save(&data).await?;
1356 tracing::debug!("Session {} saved", self.session_id);
1357 Ok(())
1358 }
1359
1360 pub async fn read_file(&self, path: &str) -> Result<String> {
1362 let args = serde_json::json!({ "file_path": path });
1363 let result = self.tool_executor.execute("read", &args).await?;
1364 Ok(result.output)
1365 }
1366
1367 pub async fn bash(&self, command: &str) -> Result<String> {
1372 let args = serde_json::json!({ "command": command });
1373 let result = self
1374 .tool_executor
1375 .execute_with_context("bash", &args, &self.tool_context)
1376 .await?;
1377 Ok(result.output)
1378 }
1379
1380 pub async fn glob(&self, pattern: &str) -> Result<Vec<String>> {
1382 let args = serde_json::json!({ "pattern": pattern });
1383 let result = self.tool_executor.execute("glob", &args).await?;
1384 let files: Vec<String> = result
1385 .output
1386 .lines()
1387 .filter(|l| !l.is_empty())
1388 .map(|l| l.to_string())
1389 .collect();
1390 Ok(files)
1391 }
1392
1393 pub async fn grep(&self, pattern: &str) -> Result<String> {
1395 let args = serde_json::json!({ "pattern": pattern });
1396 let result = self.tool_executor.execute("grep", &args).await?;
1397 Ok(result.output)
1398 }
1399
1400 pub async fn tool(&self, name: &str, args: serde_json::Value) -> Result<ToolCallResult> {
1402 let result = self.tool_executor.execute(name, &args).await?;
1403 Ok(ToolCallResult {
1404 name: name.to_string(),
1405 output: result.output,
1406 exit_code: result.exit_code,
1407 })
1408 }
1409
1410 pub fn has_queue(&self) -> bool {
1416 self.command_queue.is_some()
1417 }
1418
1419 pub async fn set_lane_handler(&self, lane: SessionLane, config: LaneHandlerConfig) {
1423 if let Some(ref queue) = self.command_queue {
1424 queue.set_lane_handler(lane, config).await;
1425 }
1426 }
1427
1428 pub async fn complete_external_task(&self, task_id: &str, result: ExternalTaskResult) -> bool {
1432 if let Some(ref queue) = self.command_queue {
1433 queue.complete_external_task(task_id, result).await
1434 } else {
1435 false
1436 }
1437 }
1438
1439 pub async fn pending_external_tasks(&self) -> Vec<ExternalTask> {
1441 if let Some(ref queue) = self.command_queue {
1442 queue.pending_external_tasks().await
1443 } else {
1444 Vec::new()
1445 }
1446 }
1447
1448 pub async fn queue_stats(&self) -> SessionQueueStats {
1450 if let Some(ref queue) = self.command_queue {
1451 queue.stats().await
1452 } else {
1453 SessionQueueStats::default()
1454 }
1455 }
1456
1457 pub async fn queue_metrics(&self) -> Option<MetricsSnapshot> {
1459 if let Some(ref queue) = self.command_queue {
1460 queue.metrics_snapshot().await
1461 } else {
1462 None
1463 }
1464 }
1465
1466 pub async fn dead_letters(&self) -> Vec<DeadLetter> {
1468 if let Some(ref queue) = self.command_queue {
1469 queue.dead_letters().await
1470 } else {
1471 Vec::new()
1472 }
1473 }
1474}
1475
1476#[cfg(test)]
1481mod tests {
1482 use super::*;
1483 use crate::config::{ModelConfig, ModelModalities, ProviderConfig};
1484 use crate::store::SessionStore;
1485
1486 fn test_config() -> CodeConfig {
1487 CodeConfig {
1488 default_model: Some("anthropic/claude-sonnet-4-20250514".to_string()),
1489 providers: vec![
1490 ProviderConfig {
1491 name: "anthropic".to_string(),
1492 api_key: Some("test-key".to_string()),
1493 base_url: None,
1494 models: vec![ModelConfig {
1495 id: "claude-sonnet-4-20250514".to_string(),
1496 name: "Claude Sonnet 4".to_string(),
1497 family: "claude-sonnet".to_string(),
1498 api_key: None,
1499 base_url: None,
1500 attachment: false,
1501 reasoning: false,
1502 tool_call: true,
1503 temperature: true,
1504 release_date: None,
1505 modalities: ModelModalities::default(),
1506 cost: Default::default(),
1507 limit: Default::default(),
1508 }],
1509 },
1510 ProviderConfig {
1511 name: "openai".to_string(),
1512 api_key: Some("test-openai-key".to_string()),
1513 base_url: None,
1514 models: vec![ModelConfig {
1515 id: "gpt-4o".to_string(),
1516 name: "GPT-4o".to_string(),
1517 family: "gpt-4".to_string(),
1518 api_key: None,
1519 base_url: None,
1520 attachment: false,
1521 reasoning: false,
1522 tool_call: true,
1523 temperature: true,
1524 release_date: None,
1525 modalities: ModelModalities::default(),
1526 cost: Default::default(),
1527 limit: Default::default(),
1528 }],
1529 },
1530 ],
1531 ..Default::default()
1532 }
1533 }
1534
1535 #[tokio::test]
1536 async fn test_from_config() {
1537 let agent = Agent::from_config(test_config()).await;
1538 assert!(agent.is_ok());
1539 }
1540
1541 #[tokio::test]
1542 async fn test_session_default() {
1543 let agent = Agent::from_config(test_config()).await.unwrap();
1544 let session = agent.session("/tmp/test-workspace", None);
1545 assert!(session.is_ok());
1546 let debug = format!("{:?}", session.unwrap());
1547 assert!(debug.contains("AgentSession"));
1548 }
1549
1550 #[tokio::test]
1551 async fn test_session_with_model_override() {
1552 let agent = Agent::from_config(test_config()).await.unwrap();
1553 let opts = SessionOptions::new().with_model("openai/gpt-4o");
1554 let session = agent.session("/tmp/test-workspace", Some(opts));
1555 assert!(session.is_ok());
1556 }
1557
1558 #[tokio::test]
1559 async fn test_session_with_invalid_model_format() {
1560 let agent = Agent::from_config(test_config()).await.unwrap();
1561 let opts = SessionOptions::new().with_model("gpt-4o");
1562 let session = agent.session("/tmp/test-workspace", Some(opts));
1563 assert!(session.is_err());
1564 }
1565
1566 #[tokio::test]
1567 async fn test_session_with_model_not_found() {
1568 let agent = Agent::from_config(test_config()).await.unwrap();
1569 let opts = SessionOptions::new().with_model("openai/nonexistent");
1570 let session = agent.session("/tmp/test-workspace", Some(opts));
1571 assert!(session.is_err());
1572 }
1573
1574 #[tokio::test]
1575 async fn test_new_with_hcl_string() {
1576 let hcl = r#"
1577 default_model = "anthropic/claude-sonnet-4-20250514"
1578 providers {
1579 name = "anthropic"
1580 api_key = "test-key"
1581 models {
1582 id = "claude-sonnet-4-20250514"
1583 name = "Claude Sonnet 4"
1584 }
1585 }
1586 "#;
1587 let agent = Agent::new(hcl).await;
1588 assert!(agent.is_ok());
1589 }
1590
1591 #[tokio::test]
1592 async fn test_create_alias_hcl() {
1593 let hcl = r#"
1594 default_model = "anthropic/claude-sonnet-4-20250514"
1595 providers {
1596 name = "anthropic"
1597 api_key = "test-key"
1598 models {
1599 id = "claude-sonnet-4-20250514"
1600 name = "Claude Sonnet 4"
1601 }
1602 }
1603 "#;
1604 let agent = Agent::create(hcl).await;
1605 assert!(agent.is_ok());
1606 }
1607
1608 #[tokio::test]
1609 async fn test_create_and_new_produce_same_result() {
1610 let hcl = r#"
1611 default_model = "anthropic/claude-sonnet-4-20250514"
1612 providers {
1613 name = "anthropic"
1614 api_key = "test-key"
1615 models {
1616 id = "claude-sonnet-4-20250514"
1617 name = "Claude Sonnet 4"
1618 }
1619 }
1620 "#;
1621 let agent_new = Agent::new(hcl).await;
1622 let agent_create = Agent::create(hcl).await;
1623 assert!(agent_new.is_ok());
1624 assert!(agent_create.is_ok());
1625
1626 let session_new = agent_new.unwrap().session("/tmp/test-ws-new", None);
1628 let session_create = agent_create.unwrap().session("/tmp/test-ws-create", None);
1629 assert!(session_new.is_ok());
1630 assert!(session_create.is_ok());
1631 }
1632
1633 #[test]
1634 fn test_from_config_requires_default_model() {
1635 let rt = tokio::runtime::Runtime::new().unwrap();
1636 let config = CodeConfig {
1637 providers: vec![ProviderConfig {
1638 name: "anthropic".to_string(),
1639 api_key: Some("test-key".to_string()),
1640 base_url: None,
1641 models: vec![],
1642 }],
1643 ..Default::default()
1644 };
1645 let result = rt.block_on(Agent::from_config(config));
1646 assert!(result.is_err());
1647 }
1648
1649 #[tokio::test]
1650 async fn test_history_empty_on_new_session() {
1651 let agent = Agent::from_config(test_config()).await.unwrap();
1652 let session = agent.session("/tmp/test-workspace", None).unwrap();
1653 assert!(session.history().is_empty());
1654 }
1655
1656 #[tokio::test]
1657 async fn test_session_options_with_agent_dir() {
1658 let opts = SessionOptions::new()
1659 .with_agent_dir("/tmp/agents")
1660 .with_agent_dir("/tmp/more-agents");
1661 assert_eq!(opts.agent_dirs.len(), 2);
1662 assert_eq!(opts.agent_dirs[0], PathBuf::from("/tmp/agents"));
1663 assert_eq!(opts.agent_dirs[1], PathBuf::from("/tmp/more-agents"));
1664 }
1665
1666 #[test]
1671 fn test_session_options_with_queue_config() {
1672 let qc = SessionQueueConfig::default().with_lane_features();
1673 let opts = SessionOptions::new().with_queue_config(qc.clone());
1674 assert!(opts.queue_config.is_some());
1675
1676 let config = opts.queue_config.unwrap();
1677 assert!(config.enable_dlq);
1678 assert!(config.enable_metrics);
1679 assert!(config.enable_alerts);
1680 assert_eq!(config.default_timeout_ms, Some(60_000));
1681 }
1682
1683 #[tokio::test(flavor = "multi_thread")]
1684 async fn test_session_with_queue_config() {
1685 let agent = Agent::from_config(test_config()).await.unwrap();
1686 let qc = SessionQueueConfig::default();
1687 let opts = SessionOptions::new().with_queue_config(qc);
1688 let session = agent.session("/tmp/test-workspace-queue", Some(opts));
1689 assert!(session.is_ok());
1690 let session = session.unwrap();
1691 assert!(session.has_queue());
1692 }
1693
1694 #[tokio::test]
1695 async fn test_session_without_queue_config() {
1696 let agent = Agent::from_config(test_config()).await.unwrap();
1697 let session = agent.session("/tmp/test-workspace-noqueue", None).unwrap();
1698 assert!(!session.has_queue());
1699 }
1700
1701 #[tokio::test]
1702 async fn test_session_queue_stats_without_queue() {
1703 let agent = Agent::from_config(test_config()).await.unwrap();
1704 let session = agent.session("/tmp/test-workspace-stats", None).unwrap();
1705 let stats = session.queue_stats().await;
1706 assert_eq!(stats.total_pending, 0);
1708 assert_eq!(stats.total_active, 0);
1709 }
1710
1711 #[tokio::test(flavor = "multi_thread")]
1712 async fn test_session_queue_stats_with_queue() {
1713 let agent = Agent::from_config(test_config()).await.unwrap();
1714 let qc = SessionQueueConfig::default();
1715 let opts = SessionOptions::new().with_queue_config(qc);
1716 let session = agent
1717 .session("/tmp/test-workspace-qstats", Some(opts))
1718 .unwrap();
1719 let stats = session.queue_stats().await;
1720 assert_eq!(stats.total_pending, 0);
1722 assert_eq!(stats.total_active, 0);
1723 }
1724
1725 #[tokio::test(flavor = "multi_thread")]
1726 async fn test_session_pending_external_tasks_empty() {
1727 let agent = Agent::from_config(test_config()).await.unwrap();
1728 let qc = SessionQueueConfig::default();
1729 let opts = SessionOptions::new().with_queue_config(qc);
1730 let session = agent
1731 .session("/tmp/test-workspace-ext", Some(opts))
1732 .unwrap();
1733 let tasks = session.pending_external_tasks().await;
1734 assert!(tasks.is_empty());
1735 }
1736
1737 #[tokio::test(flavor = "multi_thread")]
1738 async fn test_session_dead_letters_empty() {
1739 let agent = Agent::from_config(test_config()).await.unwrap();
1740 let qc = SessionQueueConfig::default().with_dlq(Some(100));
1741 let opts = SessionOptions::new().with_queue_config(qc);
1742 let session = agent
1743 .session("/tmp/test-workspace-dlq", Some(opts))
1744 .unwrap();
1745 let dead = session.dead_letters().await;
1746 assert!(dead.is_empty());
1747 }
1748
1749 #[tokio::test(flavor = "multi_thread")]
1750 async fn test_session_queue_metrics_disabled() {
1751 let agent = Agent::from_config(test_config()).await.unwrap();
1752 let qc = SessionQueueConfig::default();
1754 let opts = SessionOptions::new().with_queue_config(qc);
1755 let session = agent
1756 .session("/tmp/test-workspace-nomet", Some(opts))
1757 .unwrap();
1758 let metrics = session.queue_metrics().await;
1759 assert!(metrics.is_none());
1760 }
1761
1762 #[tokio::test(flavor = "multi_thread")]
1763 async fn test_session_queue_metrics_enabled() {
1764 let agent = Agent::from_config(test_config()).await.unwrap();
1765 let qc = SessionQueueConfig::default().with_metrics();
1766 let opts = SessionOptions::new().with_queue_config(qc);
1767 let session = agent
1768 .session("/tmp/test-workspace-met", Some(opts))
1769 .unwrap();
1770 let metrics = session.queue_metrics().await;
1771 assert!(metrics.is_some());
1772 }
1773
1774 #[tokio::test(flavor = "multi_thread")]
1775 async fn test_session_set_lane_handler() {
1776 let agent = Agent::from_config(test_config()).await.unwrap();
1777 let qc = SessionQueueConfig::default();
1778 let opts = SessionOptions::new().with_queue_config(qc);
1779 let session = agent
1780 .session("/tmp/test-workspace-handler", Some(opts))
1781 .unwrap();
1782
1783 session
1785 .set_lane_handler(
1786 SessionLane::Execute,
1787 LaneHandlerConfig {
1788 mode: crate::queue::TaskHandlerMode::External,
1789 timeout_ms: 30_000,
1790 },
1791 )
1792 .await;
1793
1794 }
1797
1798 #[tokio::test(flavor = "multi_thread")]
1803 async fn test_session_has_id() {
1804 let agent = Agent::from_config(test_config()).await.unwrap();
1805 let session = agent.session("/tmp/test-ws-id", None).unwrap();
1806 assert!(!session.session_id().is_empty());
1808 assert_eq!(session.session_id().len(), 36); }
1810
1811 #[tokio::test(flavor = "multi_thread")]
1812 async fn test_session_explicit_id() {
1813 let agent = Agent::from_config(test_config()).await.unwrap();
1814 let opts = SessionOptions::new().with_session_id("my-session-42");
1815 let session = agent.session("/tmp/test-ws-eid", Some(opts)).unwrap();
1816 assert_eq!(session.session_id(), "my-session-42");
1817 }
1818
1819 #[tokio::test(flavor = "multi_thread")]
1820 async fn test_session_save_no_store() {
1821 let agent = Agent::from_config(test_config()).await.unwrap();
1822 let session = agent.session("/tmp/test-ws-save", None).unwrap();
1823 session.save().await.unwrap();
1825 }
1826
1827 #[tokio::test(flavor = "multi_thread")]
1828 async fn test_session_save_and_load() {
1829 let store = Arc::new(crate::store::MemorySessionStore::new());
1830 let agent = Agent::from_config(test_config()).await.unwrap();
1831
1832 let opts = SessionOptions::new()
1833 .with_session_store(store.clone())
1834 .with_session_id("persist-test");
1835 let session = agent.session("/tmp/test-ws-persist", Some(opts)).unwrap();
1836
1837 session.save().await.unwrap();
1839
1840 assert!(store.exists("persist-test").await.unwrap());
1842
1843 let data = store.load("persist-test").await.unwrap().unwrap();
1844 assert_eq!(data.id, "persist-test");
1845 assert!(data.messages.is_empty());
1846 }
1847
1848 #[tokio::test(flavor = "multi_thread")]
1849 async fn test_session_save_with_history() {
1850 let store = Arc::new(crate::store::MemorySessionStore::new());
1851 let agent = Agent::from_config(test_config()).await.unwrap();
1852
1853 let opts = SessionOptions::new()
1854 .with_session_store(store.clone())
1855 .with_session_id("history-test");
1856 let session = agent.session("/tmp/test-ws-hist", Some(opts)).unwrap();
1857
1858 {
1860 let mut h = session.history.write().unwrap();
1861 h.push(Message::user("Hello"));
1862 h.push(Message::user("How are you?"));
1863 }
1864
1865 session.save().await.unwrap();
1866
1867 let data = store.load("history-test").await.unwrap().unwrap();
1868 assert_eq!(data.messages.len(), 2);
1869 }
1870
1871 #[tokio::test(flavor = "multi_thread")]
1872 async fn test_resume_session() {
1873 let store = Arc::new(crate::store::MemorySessionStore::new());
1874 let agent = Agent::from_config(test_config()).await.unwrap();
1875
1876 let opts = SessionOptions::new()
1878 .with_session_store(store.clone())
1879 .with_session_id("resume-test");
1880 let session = agent.session("/tmp/test-ws-resume", Some(opts)).unwrap();
1881 {
1882 let mut h = session.history.write().unwrap();
1883 h.push(Message::user("What is Rust?"));
1884 h.push(Message::user("Tell me more"));
1885 }
1886 session.save().await.unwrap();
1887
1888 let opts2 = SessionOptions::new().with_session_store(store.clone());
1890 let resumed = agent.resume_session("resume-test", opts2).unwrap();
1891
1892 assert_eq!(resumed.session_id(), "resume-test");
1893 let history = resumed.history();
1894 assert_eq!(history.len(), 2);
1895 assert_eq!(history[0].text(), "What is Rust?");
1896 }
1897
1898 #[tokio::test(flavor = "multi_thread")]
1899 async fn test_resume_session_not_found() {
1900 let store = Arc::new(crate::store::MemorySessionStore::new());
1901 let agent = Agent::from_config(test_config()).await.unwrap();
1902
1903 let opts = SessionOptions::new().with_session_store(store.clone());
1904 let result = agent.resume_session("nonexistent", opts);
1905 assert!(result.is_err());
1906 assert!(result.unwrap_err().to_string().contains("not found"));
1907 }
1908
1909 #[tokio::test(flavor = "multi_thread")]
1910 async fn test_resume_session_no_store() {
1911 let agent = Agent::from_config(test_config()).await.unwrap();
1912 let opts = SessionOptions::new();
1913 let result = agent.resume_session("any-id", opts);
1914 assert!(result.is_err());
1915 assert!(result.unwrap_err().to_string().contains("session_store"));
1916 }
1917
1918 #[tokio::test(flavor = "multi_thread")]
1919 async fn test_file_session_store_persistence() {
1920 let dir = tempfile::TempDir::new().unwrap();
1921 let store = Arc::new(
1922 crate::store::FileSessionStore::new(dir.path())
1923 .await
1924 .unwrap(),
1925 );
1926 let agent = Agent::from_config(test_config()).await.unwrap();
1927
1928 let opts = SessionOptions::new()
1930 .with_session_store(store.clone())
1931 .with_session_id("file-persist");
1932 let session = agent
1933 .session("/tmp/test-ws-file-persist", Some(opts))
1934 .unwrap();
1935 {
1936 let mut h = session.history.write().unwrap();
1937 h.push(Message::user("test message"));
1938 }
1939 session.save().await.unwrap();
1940
1941 let store2 = Arc::new(
1943 crate::store::FileSessionStore::new(dir.path())
1944 .await
1945 .unwrap(),
1946 );
1947 let data = store2.load("file-persist").await.unwrap().unwrap();
1948 assert_eq!(data.messages.len(), 1);
1949 }
1950
1951 #[tokio::test(flavor = "multi_thread")]
1952 async fn test_session_options_builders() {
1953 let opts = SessionOptions::new()
1954 .with_session_id("test-id")
1955 .with_auto_save(true);
1956 assert_eq!(opts.session_id, Some("test-id".to_string()));
1957 assert!(opts.auto_save);
1958 }
1959
1960 #[test]
1965 fn test_session_options_with_sandbox_sets_config() {
1966 use crate::sandbox::SandboxConfig;
1967 let cfg = SandboxConfig {
1968 image: "ubuntu:22.04".into(),
1969 memory_mb: 1024,
1970 ..SandboxConfig::default()
1971 };
1972 let opts = SessionOptions::new().with_sandbox(cfg);
1973 assert!(opts.sandbox_config.is_some());
1974 let sc = opts.sandbox_config.unwrap();
1975 assert_eq!(sc.image, "ubuntu:22.04");
1976 assert_eq!(sc.memory_mb, 1024);
1977 }
1978
1979 #[test]
1980 fn test_session_options_default_has_no_sandbox() {
1981 let opts = SessionOptions::default();
1982 assert!(opts.sandbox_config.is_none());
1983 }
1984
1985 #[tokio::test]
1986 async fn test_session_debug_includes_sandbox_config() {
1987 use crate::sandbox::SandboxConfig;
1988 let opts = SessionOptions::new().with_sandbox(SandboxConfig::default());
1989 let debug = format!("{:?}", opts);
1990 assert!(debug.contains("sandbox_config"));
1991 }
1992
1993 #[tokio::test]
1994 async fn test_session_build_with_sandbox_config_no_feature_warn() {
1995 let agent = Agent::from_config(test_config()).await.unwrap();
1998 let opts = SessionOptions::new().with_sandbox(crate::sandbox::SandboxConfig::default());
1999 let session = agent.session("/tmp/test-sandbox-session", Some(opts));
2001 assert!(session.is_ok());
2002 }
2003
2004 #[tokio::test(flavor = "multi_thread")]
2009 async fn test_session_with_memory_store() {
2010 use a3s_memory::InMemoryStore;
2011 let store = Arc::new(InMemoryStore::new());
2012 let agent = Agent::from_config(test_config()).await.unwrap();
2013 let opts = SessionOptions::new().with_memory(store);
2014 let session = agent.session("/tmp/test-ws-memory", Some(opts)).unwrap();
2015 assert!(session.memory().is_some());
2016 }
2017
2018 #[tokio::test(flavor = "multi_thread")]
2019 async fn test_session_without_memory_store() {
2020 let agent = Agent::from_config(test_config()).await.unwrap();
2021 let session = agent.session("/tmp/test-ws-no-memory", None).unwrap();
2022 assert!(session.memory().is_none());
2023 }
2024
2025 #[tokio::test(flavor = "multi_thread")]
2026 async fn test_session_memory_wired_into_config() {
2027 use a3s_memory::InMemoryStore;
2028 let store = Arc::new(InMemoryStore::new());
2029 let agent = Agent::from_config(test_config()).await.unwrap();
2030 let opts = SessionOptions::new().with_memory(store);
2031 let session = agent
2032 .session("/tmp/test-ws-mem-config", Some(opts))
2033 .unwrap();
2034 assert!(session.memory().is_some());
2036 }
2037
2038 #[tokio::test(flavor = "multi_thread")]
2039 async fn test_session_with_file_memory() {
2040 let dir = tempfile::TempDir::new().unwrap();
2041 let agent = Agent::from_config(test_config()).await.unwrap();
2042 let opts = SessionOptions::new().with_file_memory(dir.path());
2043 let session = agent.session("/tmp/test-ws-file-mem", Some(opts)).unwrap();
2044 assert!(session.memory().is_some());
2045 }
2046
2047 #[tokio::test(flavor = "multi_thread")]
2048 async fn test_memory_remember_and_recall() {
2049 use a3s_memory::InMemoryStore;
2050 let store = Arc::new(InMemoryStore::new());
2051 let agent = Agent::from_config(test_config()).await.unwrap();
2052 let opts = SessionOptions::new().with_memory(store);
2053 let session = agent
2054 .session("/tmp/test-ws-mem-recall", Some(opts))
2055 .unwrap();
2056
2057 let memory = session.memory().unwrap();
2058 memory
2059 .remember_success("write a file", &["write".to_string()], "done")
2060 .await
2061 .unwrap();
2062
2063 let results = memory.recall_similar("write", 5).await.unwrap();
2064 assert!(!results.is_empty());
2065 let stats = memory.stats().await.unwrap();
2066 assert_eq!(stats.long_term_count, 1);
2067 }
2068
2069 #[tokio::test(flavor = "multi_thread")]
2074 async fn test_session_tool_timeout_configured() {
2075 let agent = Agent::from_config(test_config()).await.unwrap();
2076 let opts = SessionOptions::new().with_tool_timeout(5000);
2077 let session = agent.session("/tmp/test-ws-timeout", Some(opts)).unwrap();
2078 assert!(!session.id().is_empty());
2079 }
2080
2081 #[tokio::test(flavor = "multi_thread")]
2086 async fn test_session_without_queue_builds_ok() {
2087 let agent = Agent::from_config(test_config()).await.unwrap();
2088 let session = agent.session("/tmp/test-ws-no-queue", None).unwrap();
2089 assert!(!session.id().is_empty());
2090 }
2091
2092 #[tokio::test(flavor = "multi_thread")]
2097 async fn test_concurrent_history_reads() {
2098 let agent = Agent::from_config(test_config()).await.unwrap();
2099 let session = Arc::new(agent.session("/tmp/test-ws-concurrent", None).unwrap());
2100
2101 let handles: Vec<_> = (0..10)
2102 .map(|_| {
2103 let s = Arc::clone(&session);
2104 tokio::spawn(async move { s.history().len() })
2105 })
2106 .collect();
2107
2108 for h in handles {
2109 h.await.unwrap();
2110 }
2111 }
2112
2113 #[tokio::test(flavor = "multi_thread")]
2118 async fn test_session_no_init_warning_without_file_memory() {
2119 let agent = Agent::from_config(test_config()).await.unwrap();
2120 let session = agent.session("/tmp/test-ws-no-warn", None).unwrap();
2121 assert!(session.init_warning().is_none());
2122 }
2123
2124 #[tokio::test(flavor = "multi_thread")]
2125 async fn test_session_with_mcp_manager_builds_ok() {
2126 use crate::mcp::manager::McpManager;
2127 let mcp = Arc::new(McpManager::new());
2128 let agent = Agent::from_config(test_config()).await.unwrap();
2129 let opts = SessionOptions::new().with_mcp(mcp);
2130 let session = agent.session("/tmp/test-ws-mcp", Some(opts)).unwrap();
2132 assert!(!session.id().is_empty());
2133 }
2134}