Skip to main content

a3s_code_core/
agent_api.rs

1//! Agent Facade API
2//!
3//! High-level, ergonomic API for using A3S Code as an embedded library.
4//!
5//! ## Example
6//!
7//! ```rust,no_run
8//! use a3s_code_core::Agent;
9//!
10//! # async fn run() -> anyhow::Result<()> {
11//! let agent = Agent::new("agent.hcl").await?;
12//! let session = agent.session("/my-project", None)?;
13//! let result = session.send("Explain the auth module", None).await?;
14//! println!("{}", result.text);
15//! # Ok(())
16//! # }
17//! ```
18
19use crate::agent::{AgentConfig, AgentEvent, AgentLoop, AgentResult};
20use crate::config::CodeConfig;
21use crate::error::Result;
22use crate::llm::{LlmClient, Message};
23use crate::queue::{
24    ExternalTask, ExternalTaskResult, LaneHandlerConfig, SessionLane, SessionQueueConfig,
25    SessionQueueStats,
26};
27use crate::session_lane_queue::SessionLaneQueue;
28use crate::tools::{ToolContext, ToolExecutor};
29use a3s_lane::{DeadLetter, MetricsSnapshot};
30use anyhow::Context;
31use std::path::{Path, PathBuf};
32use std::sync::{Arc, RwLock};
33use tokio::sync::{broadcast, mpsc};
34use tokio::task::JoinHandle;
35
36// ============================================================================
37// ToolCallResult
38// ============================================================================
39
40/// Result of a direct tool execution (no LLM).
41#[derive(Debug, Clone)]
42pub struct ToolCallResult {
43    pub name: String,
44    pub output: String,
45    pub exit_code: i32,
46}
47
48// ============================================================================
49// SessionOptions
50// ============================================================================
51
52/// Optional per-session overrides.
53#[derive(Clone)]
54pub struct SessionOptions {
55    /// Override the default model. Format: `"provider/model"` (e.g., `"openai/gpt-4o"`).
56    pub model: Option<String>,
57    /// Extra directories to scan for agent files.
58    /// Merged with any global `agent_dirs` from [`CodeConfig`].
59    pub agent_dirs: Vec<PathBuf>,
60    /// Optional queue configuration for lane-based tool execution.
61    ///
62    /// When set, enables priority-based tool scheduling with parallel execution
63    /// of read-only (Query-lane) tools, DLQ, metrics, and external task handling.
64    pub queue_config: Option<SessionQueueConfig>,
65    /// Optional security provider for taint tracking and output sanitization
66    pub security_provider: Option<Arc<dyn crate::security::SecurityProvider>>,
67    /// Optional context providers for RAG
68    pub context_providers: Vec<Arc<dyn crate::context::ContextProvider>>,
69    /// Optional confirmation manager for HITL
70    pub confirmation_manager: Option<Arc<dyn crate::hitl::ConfirmationProvider>>,
71    /// Optional permission checker
72    pub permission_checker: Option<Arc<dyn crate::permissions::PermissionChecker>>,
73    /// Enable planning
74    pub planning_enabled: bool,
75    /// Enable goal tracking
76    pub goal_tracking: bool,
77    /// Optional skill registry for instruction injection
78    pub skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
79}
80
81impl std::fmt::Debug for SessionOptions {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("SessionOptions")
84            .field("model", &self.model)
85            .field("agent_dirs", &self.agent_dirs)
86            .field("queue_config", &self.queue_config)
87            .field("security_provider", &self.security_provider.is_some())
88            .field("context_providers", &self.context_providers.len())
89            .field("confirmation_manager", &self.confirmation_manager.is_some())
90            .field("permission_checker", &self.permission_checker.is_some())
91            .field("planning_enabled", &self.planning_enabled)
92            .field("goal_tracking", &self.goal_tracking)
93            .field("skill_registry", &self.skill_registry.as_ref().map(|r| format!("{} skills", r.len())))
94            .finish()
95    }
96}
97
98impl SessionOptions {
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    pub fn with_model(mut self, model: impl Into<String>) -> Self {
104        self.model = Some(model.into());
105        self
106    }
107
108    pub fn with_agent_dir(mut self, dir: impl Into<PathBuf>) -> Self {
109        self.agent_dirs.push(dir.into());
110        self
111    }
112
113    pub fn with_queue_config(mut self, config: SessionQueueConfig) -> Self {
114        self.queue_config = Some(config);
115        self
116    }
117
118    /// Enable default security provider with taint tracking and output sanitization
119    pub fn with_default_security(mut self) -> Self {
120        self.security_provider = Some(Arc::new(crate::security::DefaultSecurityProvider::new()));
121        self
122    }
123
124    /// Set a custom security provider
125    pub fn with_security_provider(mut self, provider: Arc<dyn crate::security::SecurityProvider>) -> Self {
126        self.security_provider = Some(provider);
127        self
128    }
129
130    /// Add a file system context provider for simple RAG
131    pub fn with_fs_context(mut self, root_path: impl Into<PathBuf>) -> Self {
132        let config = crate::context::FileSystemContextConfig::new(root_path);
133        self.context_providers.push(Arc::new(crate::context::FileSystemContextProvider::new(config)));
134        self
135    }
136
137    /// Add a custom context provider
138    pub fn with_context_provider(mut self, provider: Arc<dyn crate::context::ContextProvider>) -> Self {
139        self.context_providers.push(provider);
140        self
141    }
142
143    /// Set a confirmation manager for HITL
144    pub fn with_confirmation_manager(mut self, manager: Arc<dyn crate::hitl::ConfirmationProvider>) -> Self {
145        self.confirmation_manager = Some(manager);
146        self
147    }
148
149    /// Set a permission checker
150    pub fn with_permission_checker(mut self, checker: Arc<dyn crate::permissions::PermissionChecker>) -> Self {
151        self.permission_checker = Some(checker);
152        self
153    }
154
155    /// Enable planning
156    pub fn with_planning(mut self, enabled: bool) -> Self {
157        self.planning_enabled = enabled;
158        self
159    }
160
161    /// Enable goal tracking
162    pub fn with_goal_tracking(mut self, enabled: bool) -> Self {
163        self.goal_tracking = enabled;
164        self
165    }
166
167    /// Add a skill registry with built-in skills
168    pub fn with_builtin_skills(mut self) -> Self {
169        self.skill_registry = Some(Arc::new(crate::skills::SkillRegistry::with_builtins()));
170        self
171    }
172
173    /// Add a custom skill registry
174    pub fn with_skill_registry(mut self, registry: Arc<crate::skills::SkillRegistry>) -> Self {
175        self.skill_registry = Some(registry);
176        self
177    }
178
179    /// Load skills from a directory
180    pub fn with_skills_from_dir(mut self, dir: impl AsRef<std::path::Path>) -> Self {
181        let registry = self.skill_registry.unwrap_or_else(|| Arc::new(crate::skills::SkillRegistry::new()));
182        let _ = registry.load_from_dir(dir);
183        self.skill_registry = Some(registry);
184        self
185    }
186}
187
188impl Default for SessionOptions {
189    fn default() -> Self {
190        Self {
191            model: None,
192            agent_dirs: Vec::new(),
193            queue_config: None,
194            security_provider: None,
195            context_providers: Vec::new(),
196            confirmation_manager: None,
197            permission_checker: None,
198            planning_enabled: false,
199            goal_tracking: false,
200            skill_registry: None,
201        }
202    }
203}
204
205// ============================================================================
206// Agent
207// ============================================================================
208
209/// High-level agent facade.
210///
211/// Holds the LLM client and agent config. Workspace-independent.
212/// Use [`Agent::session()`] to bind to a workspace.
213pub struct Agent {
214    llm_client: Arc<dyn LlmClient>,
215    code_config: CodeConfig,
216    config: AgentConfig,
217}
218
219impl std::fmt::Debug for Agent {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        f.debug_struct("Agent").finish()
222    }
223}
224
225impl Agent {
226    /// Create from a config file path or inline config string.
227    ///
228    /// Auto-detects: file path (.hcl/.json) vs inline JSON vs inline HCL.
229    pub async fn new(config_source: impl Into<String>) -> Result<Self> {
230        let source = config_source.into();
231        let path = Path::new(&source);
232
233        let config = if path.extension().is_some() && path.exists() {
234            CodeConfig::from_file(path)
235                .with_context(|| format!("Failed to load config: {}", path.display()))?
236        } else {
237            // Try to parse as HCL string
238            CodeConfig::from_hcl(&source)
239                .context("Failed to parse config as HCL string")?
240        };
241
242        Self::from_config(config).await
243    }
244
245    /// Create from a config file path or inline config string.
246    ///
247    /// Alias for [`Agent::new()`] — provides a consistent API with
248    /// the Python and Node.js SDKs.
249    pub async fn create(config_source: impl Into<String>) -> Result<Self> {
250        Self::new(config_source).await
251    }
252
253    /// Create from a [`CodeConfig`] struct.
254    pub async fn from_config(config: CodeConfig) -> Result<Self> {
255        let llm_config = config
256            .default_llm_config()
257            .context("default_model must be set in 'provider/model' format with a valid API key")?;
258        let llm_client = crate::llm::create_client_with_config(llm_config);
259
260        let agent_config = AgentConfig {
261            max_tool_rounds: config
262                .max_tool_rounds
263                .unwrap_or(AgentConfig::default().max_tool_rounds),
264            ..AgentConfig::default()
265        };
266
267        Ok(Agent {
268            llm_client,
269            code_config: config,
270            config: agent_config,
271        })
272    }
273
274    /// Bind to a workspace directory, returning an [`AgentSession`].
275    ///
276    /// Pass `None` for defaults, or `Some(SessionOptions)` to override
277    /// the model, agent directories for this session.
278    pub fn session(
279        &self,
280        workspace: impl Into<String>,
281        options: Option<SessionOptions>,
282    ) -> Result<AgentSession> {
283        let opts = options.unwrap_or_default();
284
285        let llm_client = if let Some(ref model) = opts.model {
286            let (provider_name, model_id) = model
287                .split_once('/')
288                .context("model format must be 'provider/model' (e.g., 'openai/gpt-4o')")?;
289
290            let llm_config = self
291                .code_config
292                .llm_config(provider_name, model_id)
293                .with_context(|| {
294                    format!("provider '{provider_name}' or model '{model_id}' not found in config")
295                })?;
296
297            crate::llm::create_client_with_config(llm_config)
298        } else {
299            self.llm_client.clone()
300        };
301
302        self.build_session(workspace.into(), llm_client, &opts)
303    }
304
305    fn build_session(
306        &self,
307        workspace: String,
308        llm_client: Arc<dyn LlmClient>,
309        opts: &SessionOptions,
310    ) -> Result<AgentSession> {
311        let canonical =
312            std::fs::canonicalize(&workspace).unwrap_or_else(|_| PathBuf::from(&workspace));
313
314        let tool_executor = Arc::new(ToolExecutor::new(canonical.display().to_string()));
315        let tool_defs = tool_executor.definitions();
316
317        // Augment system prompt with skill instructions
318        let mut system_prompt = self.config.system_prompt.clone();
319        if let Some(ref registry) = opts.skill_registry {
320            let skill_prompt = registry.to_system_prompt();
321            if !skill_prompt.is_empty() {
322                system_prompt = match system_prompt {
323                    Some(existing) => Some(format!("{}\n\n{}", existing, skill_prompt)),
324                    None => Some(skill_prompt),
325                };
326            }
327        }
328
329        let config = AgentConfig {
330            system_prompt,
331            tools: tool_defs,
332            permission_checker: opts.permission_checker.clone(),
333            confirmation_manager: opts.confirmation_manager.clone(),
334            context_providers: opts.context_providers.clone(),
335            planning_enabled: opts.planning_enabled,
336            goal_tracking: opts.goal_tracking,
337            skill_registry: opts.skill_registry.clone(),
338            ..self.config.clone()
339        };
340
341        // Create lane queue if configured
342        let command_queue = if let Some(ref queue_config) = opts.queue_config {
343            let (event_tx, _) = broadcast::channel(256);
344            let session_id = uuid::Uuid::new_v4().to_string();
345            let rt = tokio::runtime::Handle::try_current();
346            match rt {
347                Ok(handle) => {
348                    // We're inside an async runtime — use block_in_place
349                    let queue = tokio::task::block_in_place(|| {
350                        handle.block_on(SessionLaneQueue::new(
351                            &session_id,
352                            queue_config.clone(),
353                            event_tx,
354                        ))
355                    });
356                    match queue {
357                        Ok(q) => {
358                            // Start the queue
359                            let q = Arc::new(q);
360                            let q2 = Arc::clone(&q);
361                            tokio::task::block_in_place(|| {
362                                handle.block_on(async { q2.start().await.ok() })
363                            });
364                            Some(q)
365                        }
366                        Err(e) => {
367                            tracing::warn!("Failed to create session lane queue: {}", e);
368                            None
369                        }
370                    }
371                }
372                Err(_) => {
373                    tracing::warn!(
374                        "No async runtime available for queue creation — queue disabled"
375                    );
376                    None
377                }
378            }
379        } else {
380            None
381        };
382
383        // Create tool context with search config if available
384        let mut tool_context = ToolContext::new(canonical.clone());
385        if let Some(ref search_config) = self.code_config.search {
386            tool_context = tool_context.with_search_config(search_config.clone());
387        }
388
389        Ok(AgentSession {
390            llm_client,
391            tool_executor,
392            tool_context,
393            config,
394            workspace: canonical,
395            history: RwLock::new(Vec::new()),
396            command_queue,
397        })
398    }
399}
400
401// ============================================================================
402// AgentSession
403// ============================================================================
404
405/// Workspace-bound session. All LLM and tool operations happen here.
406///
407/// History is automatically accumulated after each `send()` call.
408/// Use `history()` to retrieve the current conversation log.
409pub struct AgentSession {
410    llm_client: Arc<dyn LlmClient>,
411    tool_executor: Arc<ToolExecutor>,
412    tool_context: ToolContext,
413    config: AgentConfig,
414    workspace: PathBuf,
415    /// Internal conversation history, auto-updated after each `send()`.
416    history: RwLock<Vec<Message>>,
417    /// Optional lane queue for priority-based tool execution with parallelism.
418    command_queue: Option<Arc<SessionLaneQueue>>,
419}
420
421impl std::fmt::Debug for AgentSession {
422    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423        f.debug_struct("AgentSession")
424            .field("workspace", &self.workspace.display().to_string())
425            .finish()
426    }
427}
428
429impl AgentSession {
430    /// Build an `AgentLoop` with the session's configuration.
431    ///
432    /// Propagates the lane queue (if configured) to enable parallel
433    /// Query-lane tool execution.
434    fn build_agent_loop(&self) -> AgentLoop {
435        let mut agent_loop = AgentLoop::new(
436            self.llm_client.clone(),
437            self.tool_executor.clone(),
438            self.tool_context.clone(),
439            self.config.clone(),
440        );
441        if let Some(ref queue) = self.command_queue {
442            agent_loop = agent_loop.with_queue(Arc::clone(queue));
443        }
444        agent_loop
445    }
446
447    /// Send a prompt and wait for the complete response.
448    ///
449    /// When `history` is `None`, uses (and auto-updates) the session's
450    /// internal conversation history. When `Some`, uses the provided
451    /// history instead (the internal history is **not** modified).
452    pub async fn send(&self, prompt: &str, history: Option<&[Message]>) -> Result<AgentResult> {
453        let agent_loop = self.build_agent_loop();
454
455        let use_internal = history.is_none();
456        let effective_history = match history {
457            Some(h) => h.to_vec(),
458            None => self.history.read().unwrap().clone(),
459        };
460
461        let result = agent_loop.execute(&effective_history, prompt, None).await?;
462
463        // Auto-accumulate: only update internal history when no custom
464        // history was provided.
465        if use_internal {
466            *self.history.write().unwrap() = result.messages.clone();
467        }
468
469        Ok(result)
470    }
471
472    /// Send a prompt and stream events back.
473    ///
474    /// When `history` is `None`, uses the session's internal history
475    /// (note: streaming does **not** auto-update internal history since
476    /// the result is consumed asynchronously via the channel).
477    /// When `Some`, uses the provided history instead.
478    pub async fn stream(
479        &self,
480        prompt: &str,
481        history: Option<&[Message]>,
482    ) -> Result<(mpsc::Receiver<AgentEvent>, JoinHandle<()>)> {
483        let (tx, rx) = mpsc::channel(256);
484        let agent_loop = self.build_agent_loop();
485        let effective_history = match history {
486            Some(h) => h.to_vec(),
487            None => self.history.read().unwrap().clone(),
488        };
489        let prompt = prompt.to_string();
490
491        let handle = tokio::spawn(async move {
492            let _ = agent_loop
493                .execute(&effective_history, &prompt, Some(tx))
494                .await;
495        });
496
497        Ok((rx, handle))
498    }
499
500    /// Return a snapshot of the session's conversation history.
501    pub fn history(&self) -> Vec<Message> {
502        self.history.read().unwrap().clone()
503    }
504
505    /// Read a file from the workspace.
506    pub async fn read_file(&self, path: &str) -> Result<String> {
507        let args = serde_json::json!({ "file_path": path });
508        let result = self.tool_executor.execute("read", &args).await?;
509        Ok(result.output)
510    }
511
512    /// Execute a bash command in the workspace.
513    pub async fn bash(&self, command: &str) -> Result<String> {
514        let args = serde_json::json!({ "command": command });
515        let result = self.tool_executor.execute("bash", &args).await?;
516        Ok(result.output)
517    }
518
519    /// Search for files matching a glob pattern.
520    pub async fn glob(&self, pattern: &str) -> Result<Vec<String>> {
521        let args = serde_json::json!({ "pattern": pattern });
522        let result = self.tool_executor.execute("glob", &args).await?;
523        let files: Vec<String> = result
524            .output
525            .lines()
526            .filter(|l| !l.is_empty())
527            .map(|l| l.to_string())
528            .collect();
529        Ok(files)
530    }
531
532    /// Search file contents with a regex pattern.
533    pub async fn grep(&self, pattern: &str) -> Result<String> {
534        let args = serde_json::json!({ "pattern": pattern });
535        let result = self.tool_executor.execute("grep", &args).await?;
536        Ok(result.output)
537    }
538
539    /// Execute a tool by name, bypassing the LLM.
540    pub async fn tool(&self, name: &str, args: serde_json::Value) -> Result<ToolCallResult> {
541        let result = self.tool_executor.execute(name, &args).await?;
542        Ok(ToolCallResult {
543            name: name.to_string(),
544            output: result.output,
545            exit_code: result.exit_code,
546        })
547    }
548
549    // ========================================================================
550    // Queue API
551    // ========================================================================
552
553    /// Returns whether this session has a lane queue configured.
554    pub fn has_queue(&self) -> bool {
555        self.command_queue.is_some()
556    }
557
558    /// Configure a lane's handler mode (Internal/External/Hybrid).
559    ///
560    /// Only effective when a queue is configured via `SessionOptions::with_queue_config`.
561    pub async fn set_lane_handler(&self, lane: SessionLane, config: LaneHandlerConfig) {
562        if let Some(ref queue) = self.command_queue {
563            queue.set_lane_handler(lane, config).await;
564        }
565    }
566
567    /// Complete an external task by ID.
568    ///
569    /// Returns `true` if the task was found and completed, `false` if not found.
570    pub async fn complete_external_task(&self, task_id: &str, result: ExternalTaskResult) -> bool {
571        if let Some(ref queue) = self.command_queue {
572            queue.complete_external_task(task_id, result).await
573        } else {
574            false
575        }
576    }
577
578    /// Get pending external tasks awaiting completion by an external handler.
579    pub async fn pending_external_tasks(&self) -> Vec<ExternalTask> {
580        if let Some(ref queue) = self.command_queue {
581            queue.pending_external_tasks().await
582        } else {
583            Vec::new()
584        }
585    }
586
587    /// Get queue statistics (pending, active, external counts per lane).
588    pub async fn queue_stats(&self) -> SessionQueueStats {
589        if let Some(ref queue) = self.command_queue {
590            queue.stats().await
591        } else {
592            SessionQueueStats::default()
593        }
594    }
595
596    /// Get a metrics snapshot from the queue (if metrics are enabled).
597    pub async fn queue_metrics(&self) -> Option<MetricsSnapshot> {
598        if let Some(ref queue) = self.command_queue {
599            queue.metrics_snapshot().await
600        } else {
601            None
602        }
603    }
604
605    /// Get dead letters from the queue's DLQ (if DLQ is enabled).
606    pub async fn dead_letters(&self) -> Vec<DeadLetter> {
607        if let Some(ref queue) = self.command_queue {
608            queue.dead_letters().await
609        } else {
610            Vec::new()
611        }
612    }
613}
614
615// ============================================================================
616// Tests
617// ============================================================================
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use crate::config::{ModelConfig, ModelModalities, ProviderConfig};
623
624    fn test_config() -> CodeConfig {
625        CodeConfig {
626            default_model: Some("anthropic/claude-sonnet-4-20250514".to_string()),
627            providers: vec![
628                ProviderConfig {
629                    name: "anthropic".to_string(),
630                    api_key: Some("test-key".to_string()),
631                    base_url: None,
632                    models: vec![ModelConfig {
633                        id: "claude-sonnet-4-20250514".to_string(),
634                        name: "Claude Sonnet 4".to_string(),
635                        family: "claude-sonnet".to_string(),
636                        api_key: None,
637                        base_url: None,
638                        attachment: false,
639                        reasoning: false,
640                        tool_call: true,
641                        temperature: true,
642                        release_date: None,
643                        modalities: ModelModalities::default(),
644                        cost: Default::default(),
645                        limit: Default::default(),
646                    }],
647                },
648                ProviderConfig {
649                    name: "openai".to_string(),
650                    api_key: Some("test-openai-key".to_string()),
651                    base_url: None,
652                    models: vec![ModelConfig {
653                        id: "gpt-4o".to_string(),
654                        name: "GPT-4o".to_string(),
655                        family: "gpt-4".to_string(),
656                        api_key: None,
657                        base_url: None,
658                        attachment: false,
659                        reasoning: false,
660                        tool_call: true,
661                        temperature: true,
662                        release_date: None,
663                        modalities: ModelModalities::default(),
664                        cost: Default::default(),
665                        limit: Default::default(),
666                    }],
667                },
668            ],
669            ..Default::default()
670        }
671    }
672
673    #[tokio::test]
674    async fn test_from_config() {
675        let agent = Agent::from_config(test_config()).await;
676        assert!(agent.is_ok());
677    }
678
679    #[tokio::test]
680    async fn test_session_default() {
681        let agent = Agent::from_config(test_config()).await.unwrap();
682        let session = agent.session("/tmp/test-workspace", None);
683        assert!(session.is_ok());
684        let debug = format!("{:?}", session.unwrap());
685        assert!(debug.contains("AgentSession"));
686    }
687
688    #[tokio::test]
689    async fn test_session_with_model_override() {
690        let agent = Agent::from_config(test_config()).await.unwrap();
691        let opts = SessionOptions::new().with_model("openai/gpt-4o");
692        let session = agent.session("/tmp/test-workspace", Some(opts));
693        assert!(session.is_ok());
694    }
695
696    #[tokio::test]
697    async fn test_session_with_invalid_model_format() {
698        let agent = Agent::from_config(test_config()).await.unwrap();
699        let opts = SessionOptions::new().with_model("gpt-4o");
700        let session = agent.session("/tmp/test-workspace", Some(opts));
701        assert!(session.is_err());
702    }
703
704    #[tokio::test]
705    async fn test_session_with_model_not_found() {
706        let agent = Agent::from_config(test_config()).await.unwrap();
707        let opts = SessionOptions::new().with_model("openai/nonexistent");
708        let session = agent.session("/tmp/test-workspace", Some(opts));
709        assert!(session.is_err());
710    }
711
712    #[tokio::test]
713    async fn test_new_with_hcl_string() {
714        let hcl = r#"
715            default_model = "anthropic/claude-sonnet-4-20250514"
716            providers {
717                name    = "anthropic"
718                api_key = "test-key"
719                models {
720                    id   = "claude-sonnet-4-20250514"
721                    name = "Claude Sonnet 4"
722                }
723            }
724        "#;
725        let agent = Agent::new(hcl).await;
726        assert!(agent.is_ok());
727    }
728
729    #[tokio::test]
730    async fn test_create_alias_hcl() {
731        let hcl = r#"
732            default_model = "anthropic/claude-sonnet-4-20250514"
733            providers {
734                name    = "anthropic"
735                api_key = "test-key"
736                models {
737                    id   = "claude-sonnet-4-20250514"
738                    name = "Claude Sonnet 4"
739                }
740            }
741        "#;
742        let agent = Agent::create(hcl).await;
743        assert!(agent.is_ok());
744    }
745
746    #[tokio::test]
747    async fn test_create_and_new_produce_same_result() {
748        let hcl = r#"
749            default_model = "anthropic/claude-sonnet-4-20250514"
750            providers {
751                name    = "anthropic"
752                api_key = "test-key"
753                models {
754                    id   = "claude-sonnet-4-20250514"
755                    name = "Claude Sonnet 4"
756                }
757            }
758        "#;
759        let agent_new = Agent::new(hcl).await;
760        let agent_create = Agent::create(hcl).await;
761        assert!(agent_new.is_ok());
762        assert!(agent_create.is_ok());
763
764        // Both should produce working sessions
765        let session_new = agent_new.unwrap().session("/tmp/test-ws-new", None);
766        let session_create = agent_create.unwrap().session("/tmp/test-ws-create", None);
767        assert!(session_new.is_ok());
768        assert!(session_create.is_ok());
769    }
770
771    #[test]
772    fn test_from_config_requires_default_model() {
773        let rt = tokio::runtime::Runtime::new().unwrap();
774        let config = CodeConfig {
775            providers: vec![ProviderConfig {
776                name: "anthropic".to_string(),
777                api_key: Some("test-key".to_string()),
778                base_url: None,
779                models: vec![],
780            }],
781            ..Default::default()
782        };
783        let result = rt.block_on(Agent::from_config(config));
784        assert!(result.is_err());
785    }
786
787    #[tokio::test]
788    async fn test_history_empty_on_new_session() {
789        let agent = Agent::from_config(test_config()).await.unwrap();
790        let session = agent.session("/tmp/test-workspace", None).unwrap();
791        assert!(session.history().is_empty());
792    }
793
794
795    #[tokio::test]
796    async fn test_session_options_with_agent_dir() {
797        let opts = SessionOptions::new()
798            .with_agent_dir("/tmp/agents")
799            .with_agent_dir("/tmp/more-agents");
800        assert_eq!(opts.agent_dirs.len(), 2);
801        assert_eq!(opts.agent_dirs[0], PathBuf::from("/tmp/agents"));
802        assert_eq!(opts.agent_dirs[1], PathBuf::from("/tmp/more-agents"));
803    }
804
805
806
807
808    // ========================================================================
809    // Queue Integration Tests
810    // ========================================================================
811
812    #[test]
813    fn test_session_options_with_queue_config() {
814        let qc = SessionQueueConfig::default().with_lane_features();
815        let opts = SessionOptions::new().with_queue_config(qc.clone());
816        assert!(opts.queue_config.is_some());
817
818        let config = opts.queue_config.unwrap();
819        assert!(config.enable_dlq);
820        assert!(config.enable_metrics);
821        assert!(config.enable_alerts);
822        assert_eq!(config.default_timeout_ms, Some(60_000));
823    }
824
825    #[tokio::test(flavor = "multi_thread")]
826    async fn test_session_with_queue_config() {
827        let agent = Agent::from_config(test_config()).await.unwrap();
828        let qc = SessionQueueConfig::default();
829        let opts = SessionOptions::new().with_queue_config(qc);
830        let session = agent.session("/tmp/test-workspace-queue", Some(opts));
831        assert!(session.is_ok());
832        let session = session.unwrap();
833        assert!(session.has_queue());
834    }
835
836    #[tokio::test]
837    async fn test_session_without_queue_config() {
838        let agent = Agent::from_config(test_config()).await.unwrap();
839        let session = agent.session("/tmp/test-workspace-noqueue", None).unwrap();
840        assert!(!session.has_queue());
841    }
842
843    #[tokio::test]
844    async fn test_session_queue_stats_without_queue() {
845        let agent = Agent::from_config(test_config()).await.unwrap();
846        let session = agent.session("/tmp/test-workspace-stats", None).unwrap();
847        let stats = session.queue_stats().await;
848        // Without a queue, stats should have zero values
849        assert_eq!(stats.total_pending, 0);
850        assert_eq!(stats.total_active, 0);
851    }
852
853    #[tokio::test(flavor = "multi_thread")]
854    async fn test_session_queue_stats_with_queue() {
855        let agent = Agent::from_config(test_config()).await.unwrap();
856        let qc = SessionQueueConfig::default();
857        let opts = SessionOptions::new().with_queue_config(qc);
858        let session = agent
859            .session("/tmp/test-workspace-qstats", Some(opts))
860            .unwrap();
861        let stats = session.queue_stats().await;
862        // Fresh queue with no commands should have zero stats
863        assert_eq!(stats.total_pending, 0);
864        assert_eq!(stats.total_active, 0);
865    }
866
867    #[tokio::test(flavor = "multi_thread")]
868    async fn test_session_pending_external_tasks_empty() {
869        let agent = Agent::from_config(test_config()).await.unwrap();
870        let qc = SessionQueueConfig::default();
871        let opts = SessionOptions::new().with_queue_config(qc);
872        let session = agent
873            .session("/tmp/test-workspace-ext", Some(opts))
874            .unwrap();
875        let tasks = session.pending_external_tasks().await;
876        assert!(tasks.is_empty());
877    }
878
879    #[tokio::test(flavor = "multi_thread")]
880    async fn test_session_dead_letters_empty() {
881        let agent = Agent::from_config(test_config()).await.unwrap();
882        let qc = SessionQueueConfig::default().with_dlq(Some(100));
883        let opts = SessionOptions::new().with_queue_config(qc);
884        let session = agent
885            .session("/tmp/test-workspace-dlq", Some(opts))
886            .unwrap();
887        let dead = session.dead_letters().await;
888        assert!(dead.is_empty());
889    }
890
891    #[tokio::test(flavor = "multi_thread")]
892    async fn test_session_queue_metrics_disabled() {
893        let agent = Agent::from_config(test_config()).await.unwrap();
894        // Metrics not enabled
895        let qc = SessionQueueConfig::default();
896        let opts = SessionOptions::new().with_queue_config(qc);
897        let session = agent
898            .session("/tmp/test-workspace-nomet", Some(opts))
899            .unwrap();
900        let metrics = session.queue_metrics().await;
901        assert!(metrics.is_none());
902    }
903
904    #[tokio::test(flavor = "multi_thread")]
905    async fn test_session_queue_metrics_enabled() {
906        let agent = Agent::from_config(test_config()).await.unwrap();
907        let qc = SessionQueueConfig::default().with_metrics();
908        let opts = SessionOptions::new().with_queue_config(qc);
909        let session = agent
910            .session("/tmp/test-workspace-met", Some(opts))
911            .unwrap();
912        let metrics = session.queue_metrics().await;
913        assert!(metrics.is_some());
914    }
915
916    #[tokio::test(flavor = "multi_thread")]
917    async fn test_session_set_lane_handler() {
918        let agent = Agent::from_config(test_config()).await.unwrap();
919        let qc = SessionQueueConfig::default();
920        let opts = SessionOptions::new().with_queue_config(qc);
921        let session = agent
922            .session("/tmp/test-workspace-handler", Some(opts))
923            .unwrap();
924
925        // Set Execute lane to External mode
926        session
927            .set_lane_handler(
928                SessionLane::Execute,
929                LaneHandlerConfig {
930                    mode: crate::queue::TaskHandlerMode::External,
931                    timeout_ms: 30_000,
932                },
933            )
934            .await;
935
936        // No panic = success. The handler config is stored internally.
937        // We can't directly read it back but we verify no errors.
938    }
939}