Skip to main content

imp_core/
builder.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use imp_llm::Model;
5
6use crate::agent::{Agent, AgentHandle};
7use crate::config::{Config, LuaCapabilityPolicy};
8use crate::error::Result;
9use crate::mana_prompt_context;
10use crate::resources;
11use crate::roles::Role;
12use crate::system_prompt::{self, Fact, TaskContext};
13use crate::tools::{LuaToolLoader, ToolRegistry};
14
15fn load_scoped_memory_block(
16    cwd: &std::path::Path,
17    path: &std::path::Path,
18    label: &str,
19    char_limit: usize,
20) -> Option<String> {
21    let store = crate::memory::MemoryStore::load(path, char_limit).ok()?;
22    let filtered: Vec<String> = store
23        .entries()
24        .iter()
25        .filter(|entry| !entry.contains("/tower") || cwd.to_string_lossy().contains("/tower"))
26        .cloned()
27        .collect();
28
29    if filtered.is_empty() {
30        return None;
31    }
32
33    let used: usize = filtered.iter().map(|e| e.len()).sum::<usize>()
34        + if filtered.len() > 1 {
35            (filtered.len() - 1) * 3
36        } else {
37            0
38        };
39    let pct = if char_limit > 0 {
40        (used as f64 / char_limit as f64 * 100.0) as u32
41    } else {
42        0
43    };
44    let bar = "══════════════════════════════════════════════";
45    Some(format!(
46        "{bar}\n{label} [{pct}% — {used}/{char_limit} chars]\n{bar}\n{}",
47        filtered.join("\n§\n")
48    ))
49}
50
51/// Builder for creating a fully wired [`Agent`] from config and context.
52///
53/// Handles resource discovery, hook loading, system prompt assembly, and tool
54/// registration so callers don't need to repeat this boilerplate.
55pub struct AgentBuilder {
56    config: Config,
57    cwd: PathBuf,
58    model: Model,
59    api_key: String,
60    role: Option<Role>,
61    task: Option<TaskContext>,
62    facts: Vec<Fact>,
63    /// Override the assembled system prompt entirely.
64    system_prompt_override: Option<String>,
65    /// Additional tool registrar called after native tools are registered.
66    #[allow(clippy::type_complexity)]
67    extra_tools: Option<Box<dyn FnOnce(&mut ToolRegistry) + Send>>,
68    /// Lua extension tool loader — called after native and extra tools.
69    ///
70    /// The imp-lua crate provides the actual implementation; the binary
71    /// crate wires it in to avoid a cyclic dependency between imp-core
72    /// and imp-lua.
73    #[allow(clippy::type_complexity)]
74    lua_tool_loader: Option<LuaToolLoader>,
75}
76
77impl AgentBuilder {
78    /// Create a new builder.
79    pub fn new(config: Config, cwd: PathBuf, model: Model, api_key: String) -> Self {
80        Self {
81            config,
82            cwd,
83            model,
84            api_key,
85            role: None,
86            task: None,
87            facts: Vec::new(),
88            system_prompt_override: None,
89            extra_tools: None,
90            lua_tool_loader: None,
91        }
92    }
93
94    /// Set the role for this agent.
95    pub fn role(mut self, role: Role) -> Self {
96        self.role = Some(role);
97        self
98    }
99
100    /// Set the task context (headless/task mode — Layer 5 of the system prompt).
101    pub fn task(mut self, task: TaskContext) -> Self {
102        self.task = Some(task);
103        self
104    }
105
106    /// Set task-specific facts to inject into the system prompt.
107    pub fn facts(mut self, facts: Vec<Fact>) -> Self {
108        self.facts = facts;
109        self
110    }
111
112    /// Override the assembled system prompt with a custom string.
113    /// When set, resource discovery and assembly are skipped.
114    pub fn system_prompt(mut self, prompt: String) -> Self {
115        self.system_prompt_override = Some(prompt);
116        self
117    }
118
119    /// Register additional tools after the native tools are registered.
120    pub fn extra_tools<F>(mut self, f: F) -> Self
121    where
122        F: FnOnce(&mut ToolRegistry) + Send + 'static,
123    {
124        self.extra_tools = Some(Box::new(f));
125        self
126    }
127
128    /// Register a Lua extension tool loader.
129    ///
130    /// The provided closure should discover `.lua` extensions, create a
131    /// `LuaRuntime`, and register the resulting tools onto the registry.
132    /// This is called after native + extra tools are registered but before
133    /// mode filtering.
134    ///
135    /// The binary crate typically calls this with a closure that invokes
136    /// `imp_lua::load_lua_extensions()`.
137    pub fn lua_tool_loader<F>(mut self, f: F) -> Self
138    where
139        F: Fn(&LuaCapabilityPolicy, &mut ToolRegistry) + Send + Sync + 'static,
140    {
141        self.lua_tool_loader = Some(Arc::new(f));
142        self
143    }
144
145    /// Build the agent, wiring config → thresholds, hooks, resources, and tools.
146    ///
147    /// Returns `(Agent, AgentHandle)` ready for use.
148    pub fn build(self) -> Result<(Agent, AgentHandle)> {
149        let (mut agent, handle) = Agent::new(self.model, self.cwd.clone());
150
151        // Wire API key
152        agent.api_key = self.api_key;
153
154        // Wire thinking level from config
155        if let Some(thinking) = self.config.thinking {
156            agent.thinking_level = thinking;
157        }
158
159        // Wire max turns from config
160        if let Some(max_turns) = self.config.max_turns {
161            agent.max_turns = max_turns;
162        }
163
164        // Wire max output tokens from config
165        if let Some(max_tokens) = self.config.max_tokens {
166            agent.max_tokens = Some(max_tokens);
167        }
168
169        // Wire context thresholds from config
170        agent.context_config = self.config.context.clone();
171
172        // Wire role overrides (role can further override thinking/max_turns)
173        if let Some(ref role) = self.role {
174            if let Some(thinking) = role.thinking_level {
175                agent.thinking_level = thinking;
176            }
177            if let Some(max_turns) = role.max_turns {
178                agent.max_turns = max_turns;
179            }
180            agent.role = Some(role.clone());
181        }
182
183        // Load hooks from config
184        agent.hooks.load_from_config(self.config.hooks.clone());
185
186        // Wire agent mode from config
187        agent.mode = self.config.mode;
188
189        // Wire guardrails config
190        agent.guardrail_config = self.config.guardrails.clone();
191        agent.guardrail_profile = if self.config.guardrails.is_enabled() {
192            Some(self.config.guardrails.resolve_effective_profile(&self.cwd))
193        } else {
194            None
195        };
196
197        // Wire read tool truncation from config
198        agent.read_max_lines = self.config.ui.read_max_lines;
199        agent.continue_policy = self.config.ui.continue_policy;
200        agent.config = Arc::new(self.config.clone());
201        agent.lua_tool_loader = self.lua_tool_loader.clone();
202
203        // Register native tools
204        register_native_tools(&mut agent.tools);
205
206        // Register any extra tools provided by the caller
207        if let Some(extra) = self.extra_tools {
208            extra(&mut agent.tools);
209        }
210
211        // Load Lua extension tools (provided by the binary crate via lua_tool_loader)
212        if let Some(lua_loader) = self.lua_tool_loader {
213            let lua_policy = self.config.lua.resolve_policy(agent.mode);
214            lua_loader(&lua_policy, &mut agent.tools);
215        }
216
217        // Load project-local TypeScript extension tools from .imp/extensions.
218        if let Err(err) =
219            crate::typescript_extensions::load_typescript_extensions(&self.cwd, &mut agent.tools)
220        {
221            eprintln!("Failed to load TypeScript extensions: {err}");
222        }
223
224        // Filter registered tools to those allowed by the mode.
225        // Full mode allows everything — no filtering needed.
226        if agent.mode != crate::config::AgentMode::Full {
227            let mode = agent.mode;
228            agent.tools.retain(|name| mode.allows_tool(name));
229        }
230
231        // Assemble system prompt
232        agent.system_prompt = if let Some(prompt) = self.system_prompt_override {
233            prompt
234        } else {
235            let user_config_dir = Config::user_config_dir();
236            let agents_md = resources::discover_agents_md(&self.cwd, &user_config_dir);
237            let soul = resources::discover_soul(&self.cwd, &user_config_dir);
238            let skills = resources::discover_skills(&self.cwd, &user_config_dir);
239            agent.has_mana_skill = skills.iter().any(|skill| skill.name == "mana");
240            agent.has_mana_basics_skill = skills.iter().any(|skill| skill.name == "mana-basics");
241            agent.has_mana_delegation_skill =
242                skills.iter().any(|skill| skill.name == "mana-delegation");
243
244            // Layer 6: Load agent memory if learning is enabled
245            let (memory_block, user_block) = if self.config.learning.enabled {
246                let mem = load_scoped_memory_block(
247                    &self.cwd,
248                    &user_config_dir.join("memory.md"),
249                    "MEMORY (your personal notes)",
250                    self.config.learning.memory_char_limit,
251                );
252
253                let user = load_scoped_memory_block(
254                    &self.cwd,
255                    &user_config_dir.join("user.md"),
256                    "USER PROFILE",
257                    self.config.learning.user_char_limit,
258                );
259
260                (mem, user)
261            } else {
262                (None, None)
263            };
264
265            let prompt_context = if self.facts.is_empty() {
266                mana_prompt_context::load_session_prompt_context(&self.cwd)
267            } else {
268                mana_prompt_context::SessionPromptContext {
269                    facts: self.facts.clone(),
270                    project_memory_status: None,
271                }
272            };
273
274            system_prompt::assemble(&system_prompt::AssembleParams {
275                tools: &agent.tools,
276                agents_md: &agents_md,
277                skills: &skills,
278                facts: &prompt_context.facts,
279                project_memory_status: prompt_context.project_memory_status.as_deref(),
280                personality: Some(&self.config.personality.profile),
281                soul: soul.as_ref(),
282                task: self.task.as_ref(),
283                role: self.role.as_ref(),
284                mode: &agent.mode,
285                memory: memory_block.as_deref(),
286                user_profile: user_block.as_deref(),
287                cwd: Some(&self.cwd),
288                learning_enabled: self.config.learning.enabled,
289                guardrail_profile: agent.guardrail_profile,
290            })
291            .text
292        };
293
294        Ok((agent, handle))
295    }
296}
297
298/// Register the standard set of native tools onto a tool registry.
299///
300/// This is the canonical list — update here when adding or removing tools.
301pub fn register_native_tools(tools: &mut ToolRegistry) {
302    use crate::tools::{
303        ask::AskTool, bash::BashTool, edit::EditTool, extend::ExtendTool, git::GitTool,
304        imp::ImpTool, mana::ManaTool, read::ReadTool, scan::ScanTool,
305        session_search::SessionSearchTool, web::WebTool, write::WriteTool,
306    };
307
308    tools.register(Arc::new(AskTool));
309    tools.register(Arc::new(BashTool::canonical()));
310    tools.register(Arc::new(EditTool));
311    tools.register(Arc::new(ExtendTool));
312    tools.register(Arc::new(GitTool));
313    tools.register(Arc::new(ImpTool));
314    tools.register(Arc::new(ManaTool::default()));
315    tools.register(Arc::new(ReadTool));
316    tools.register(Arc::new(WriteTool));
317    tools.register(Arc::new(ScanTool));
318    tools.register(Arc::new(SessionSearchTool));
319    tools.register(Arc::new(WebTool));
320
321    tools.register_alias("bash", "shell");
322    tools.register_alias("sh", "shell");
323    tools.register_alias("imp", "spawn");
324    tools.register_alias("multi_edit", "edit"); // legacy compatibility; edit is canonical for transaction edits
325    tools.register_alias("session_search", "recall");
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use std::pin::Pin;
332    use std::sync::Arc;
333
334    use async_trait::async_trait;
335    use futures_core::Stream;
336    use imp_llm::{
337        auth::{ApiKey, AuthStore},
338        model::{Capabilities, ModelMeta, ModelPricing},
339        provider::Provider,
340        Context, Model, RequestOptions, StreamEvent,
341    };
342
343    struct MockProvider;
344
345    #[async_trait]
346    impl Provider for MockProvider {
347        fn stream(
348            &self,
349            _model: &Model,
350            _context: Context,
351            _options: RequestOptions,
352            _api_key: &str,
353        ) -> Pin<Box<dyn Stream<Item = imp_llm::Result<StreamEvent>> + Send>> {
354            Box::pin(futures::stream::empty())
355        }
356
357        async fn resolve_auth(&self, _auth: &AuthStore) -> imp_llm::Result<ApiKey> {
358            Ok("test-key".to_string())
359        }
360
361        fn id(&self) -> &str {
362            "mock"
363        }
364
365        fn models(&self) -> &[ModelMeta] {
366            &[]
367        }
368    }
369
370    fn test_model() -> Model {
371        Model {
372            meta: ModelMeta {
373                id: "test-model".to_string(),
374                provider: "mock".to_string(),
375                name: "Test Model".to_string(),
376                context_window: 200_000,
377                max_output_tokens: 4096,
378                pricing: ModelPricing {
379                    input_per_mtok: 3.0,
380                    output_per_mtok: 15.0,
381                    cache_read_per_mtok: 0.3,
382                    cache_write_per_mtok: 3.75,
383                },
384                capabilities: Capabilities {
385                    reasoning: false,
386                    images: false,
387                    tool_use: true,
388                },
389            },
390            provider: Arc::new(MockProvider),
391        }
392    }
393
394    #[test]
395    fn builder_applies_config_max_turns() {
396        let config = Config {
397            max_turns: Some(42),
398            ..Default::default()
399        };
400
401        let (agent, _handle) =
402            AgentBuilder::new(config, PathBuf::from("/tmp"), test_model(), "key".into())
403                .build()
404                .unwrap();
405
406        assert_eq!(agent.max_turns, 42);
407    }
408
409    #[test]
410    fn builder_applies_config_max_tokens() {
411        let config = Config {
412            max_tokens: Some(2048),
413            ..Default::default()
414        };
415
416        let (agent, _handle) =
417            AgentBuilder::new(config, PathBuf::from("/tmp"), test_model(), "key".into())
418                .build()
419                .unwrap();
420
421        assert_eq!(agent.max_tokens, Some(2048));
422    }
423
424    #[test]
425    fn builder_applies_context_config_thresholds() {
426        let mut config = Config::default();
427        config.context.observation_mask_threshold = 0.5;
428        config.context.mask_window = 7;
429
430        let (agent, _handle) =
431            AgentBuilder::new(config, PathBuf::from("/tmp"), test_model(), "key".into())
432                .build()
433                .unwrap();
434
435        assert!((agent.context_config.observation_mask_threshold - 0.5).abs() < f64::EPSILON);
436        assert_eq!(agent.context_config.mask_window, 7);
437    }
438
439    #[test]
440    fn builder_default_config_uses_standard_thresholds() {
441        let (agent, _handle) = AgentBuilder::new(
442            Config::default(),
443            PathBuf::from("/tmp"),
444            test_model(),
445            "key".into(),
446        )
447        .build()
448        .unwrap();
449
450        assert!((agent.context_config.observation_mask_threshold - 0.6).abs() < f64::EPSILON);
451        assert_eq!(agent.context_config.mask_window, 10);
452    }
453
454    #[test]
455    fn builder_system_prompt_override_skips_discovery() {
456        let (agent, _handle) = AgentBuilder::new(
457            Config::default(),
458            PathBuf::from("/tmp"),
459            test_model(),
460            "key".into(),
461        )
462        .system_prompt("custom system prompt".into())
463        .build()
464        .unwrap();
465
466        assert_eq!(agent.system_prompt, "custom system prompt");
467    }
468
469    #[test]
470    fn builder_api_key_wired() {
471        let (agent, _handle) = AgentBuilder::new(
472            Config::default(),
473            PathBuf::from("/tmp"),
474            test_model(),
475            "my-api-key".into(),
476        )
477        .build()
478        .unwrap();
479
480        assert_eq!(agent.api_key, "my-api-key");
481    }
482
483    #[test]
484    fn builder_extra_tools_registered() {
485        use crate::tools::{Tool, ToolContext, ToolOutput};
486
487        struct DummyTool;
488
489        #[async_trait]
490        impl Tool for DummyTool {
491            fn name(&self) -> &str {
492                "dummy"
493            }
494            fn label(&self) -> &str {
495                "Dummy"
496            }
497            fn description(&self) -> &str {
498                "A dummy tool for testing"
499            }
500            fn parameters(&self) -> serde_json::Value {
501                serde_json::json!({"type": "object"})
502            }
503            fn is_readonly(&self) -> bool {
504                true
505            }
506            async fn execute(
507                &self,
508                _call_id: &str,
509                _params: serde_json::Value,
510                _ctx: ToolContext,
511            ) -> crate::error::Result<ToolOutput> {
512                Ok(ToolOutput::text("ok"))
513            }
514        }
515
516        let (agent, _handle) = AgentBuilder::new(
517            Config::default(),
518            PathBuf::from("/tmp"),
519            test_model(),
520            "key".into(),
521        )
522        .extra_tools(|tools| tools.register(Arc::new(DummyTool)))
523        .build()
524        .unwrap();
525
526        assert!(agent.tools.get("dummy").is_some());
527    }
528
529    #[test]
530    fn builder_registers_canonical_tools_and_compat_aliases() {
531        let (agent, _handle) = AgentBuilder::new(
532            Config::default(),
533            PathBuf::from("/tmp"),
534            test_model(),
535            "key".into(),
536        )
537        .build()
538        .unwrap();
539
540        assert!(agent.tools.get("shell").is_some());
541        assert!(agent.tools.get("bash").is_some());
542        assert!(agent.tools.get("sh").is_some());
543        assert!(agent.tools.get("spawn").is_some());
544        assert!(agent.tools.get("imp").is_some());
545        assert!(agent.tools.get("edit").is_some());
546        assert!(agent.tools.get("multi_edit").is_some());
547        assert!(agent.tools.get("memory").is_none());
548        assert!(agent.tools.get("recall").is_some());
549        assert!(agent.tools.get("session_search").is_some());
550        assert!(agent.tools.get("git").is_some());
551
552        let mut definition_names: Vec<_> = agent
553            .tools
554            .definitions()
555            .into_iter()
556            .map(|definition| definition.name)
557            .collect();
558        definition_names.sort();
559
560        assert!(definition_names.contains(&"shell".to_string()));
561        assert!(definition_names.contains(&"spawn".to_string()));
562        assert!(definition_names.contains(&"edit".to_string()));
563        assert!(!definition_names.contains(&"bash".to_string()));
564        assert!(!definition_names.contains(&"sh".to_string()));
565        assert!(!definition_names.contains(&"imp".to_string()));
566        assert!(!definition_names.contains(&"multi_edit".to_string()));
567        assert!(definition_names.contains(&"recall".to_string()));
568        assert!(!definition_names.contains(&"session_search".to_string()));
569        assert!(!definition_names.contains(&"memory".to_string()));
570    }
571
572    #[test]
573    fn builder_filters_tower_memory_outside_tower_projects() {
574        let temp = tempfile::TempDir::new().unwrap();
575        let prev = std::env::var_os("XDG_CONFIG_HOME");
576        std::env::set_var("XDG_CONFIG_HOME", temp.path());
577
578        let imp_dir = temp.path().join("imp");
579        std::fs::create_dir_all(&imp_dir).unwrap();
580        std::fs::write(
581            imp_dir.join("memory.md"),
582            "Project lives at /Users/asher/tower and uses root mana.",
583        )
584        .unwrap();
585        std::fs::write(
586            imp_dir.join("user.md"),
587            "User prefers root mana in /tower for Tower work.",
588        )
589        .unwrap();
590
591        let mut config = Config::default();
592        config.learning.enabled = true;
593
594        let (agent, _handle) = AgentBuilder::new(
595            config,
596            PathBuf::from("/tmp/not-tower/project"),
597            test_model(),
598            "key".into(),
599        )
600        .build()
601        .unwrap();
602
603        assert!(!agent.system_prompt.contains("/Users/asher/tower"));
604        assert!(!agent.system_prompt.contains("/tower for Tower work"));
605
606        if let Some(prev) = prev {
607            std::env::set_var("XDG_CONFIG_HOME", prev);
608        } else {
609            std::env::remove_var("XDG_CONFIG_HOME");
610        }
611    }
612
613    #[test]
614    fn builder_keeps_tower_memory_inside_tower_projects() {
615        let temp = tempfile::TempDir::new().unwrap();
616        let prev = std::env::var_os("XDG_CONFIG_HOME");
617        std::env::set_var("XDG_CONFIG_HOME", temp.path());
618
619        let imp_dir = temp.path().join("imp");
620        std::fs::create_dir_all(&imp_dir).unwrap();
621        std::fs::write(
622            imp_dir.join("memory.md"),
623            "Project lives at /Users/asher/tower and uses root mana.",
624        )
625        .unwrap();
626
627        let mut config = Config::default();
628        config.learning.enabled = true;
629
630        let (agent, _handle) = AgentBuilder::new(
631            config,
632            PathBuf::from("/Users/asher/tower/imp"),
633            test_model(),
634            "key".into(),
635        )
636        .build()
637        .unwrap();
638
639        assert!(agent.system_prompt.contains("/Users/asher/tower"));
640
641        if let Some(prev) = prev {
642            std::env::set_var("XDG_CONFIG_HOME", prev);
643        } else {
644            std::env::remove_var("XDG_CONFIG_HOME");
645        }
646    }
647
648    #[test]
649    fn builder_injects_mana_facts_into_system_prompt_when_available() {
650        let temp = tempfile::TempDir::new().unwrap();
651        let mana_dir = temp.path().join(".mana");
652        std::fs::create_dir(&mana_dir).unwrap();
653
654        let mut mana_config = mana_core::config::Config::default();
655        mana_config.project = "test".to_string();
656        mana_config.save(&mana_dir).unwrap();
657
658        let mut working = mana_core::unit::Unit::new("1", "Implement auth flow");
659        working.status = mana_core::unit::Status::InProgress;
660        working.paths = vec!["src/auth.rs".to_string()];
661        working.requires = vec!["AuthProvider".to_string()];
662        let working_slug = mana_core::util::title_to_slug(&working.title);
663        working
664            .to_file(mana_dir.join(format!("1-{}.md", working_slug)))
665            .unwrap();
666
667        let mut fact = mana_core::unit::Unit::new("2", "Auth uses RS256 signing");
668        fact.unit_type = "fact".to_string();
669        fact.paths = vec!["src/auth.rs".to_string()];
670        fact.produces = vec!["AuthProvider".to_string()];
671        fact.last_verified = Some(chrono::Utc::now() - chrono::Duration::hours(2));
672        let fact_slug = mana_core::util::title_to_slug(&fact.title);
673        fact.to_file(mana_dir.join(format!("2-{}.md", fact_slug)))
674            .unwrap();
675
676        let (agent, _handle) = AgentBuilder::new(
677            Config::default(),
678            temp.path().join("src"),
679            test_model(),
680            "key".into(),
681        )
682        .build()
683        .unwrap();
684
685        assert!(agent.system_prompt.contains("Project facts:"));
686        assert!(agent.system_prompt.contains("Auth uses RS256 signing"));
687        assert!(agent.system_prompt.contains("verified 2h ago"));
688        assert!(agent.system_prompt.contains("Project memory status:"));
689        assert!(agent.system_prompt.contains("Working on:"));
690        assert!(agent.system_prompt.contains("[1] Implement auth flow"));
691    }
692
693    #[test]
694    fn builder_injects_project_memory_status_into_system_prompt_when_available() {
695        let temp = tempfile::TempDir::new().unwrap();
696        let mana_dir = temp.path().join(".mana");
697        std::fs::create_dir(&mana_dir).unwrap();
698
699        let mut mana_config = mana_core::config::Config::default();
700        mana_config.project = "test".to_string();
701        mana_config.save(&mana_dir).unwrap();
702
703        let mut working = mana_core::unit::Unit::new("1", "Implement auth flow");
704        working.status = mana_core::unit::Status::InProgress;
705        working.claimed_by = Some("imp".to_string());
706        let working_slug = mana_core::util::title_to_slug(&working.title);
707        working
708            .to_file(mana_dir.join(format!("1-{}.md", working_slug)))
709            .unwrap();
710
711        let mut recent = mana_core::unit::Unit::new("3", "Recently closed cleanup");
712        recent.status = mana_core::unit::Status::Closed;
713        recent.closed_at = Some(chrono::Utc::now() - chrono::Duration::hours(2));
714        let recent_slug = mana_core::util::title_to_slug(&recent.title);
715        let archive_dir = mana_dir.join("archive").join("closed");
716        std::fs::create_dir_all(&archive_dir).unwrap();
717        recent
718            .to_file(archive_dir.join(format!("3-{}.md", recent_slug)))
719            .unwrap();
720
721        let (agent, _handle) = AgentBuilder::new(
722            Config::default(),
723            temp.path().join("src"),
724            test_model(),
725            "key".into(),
726        )
727        .build()
728        .unwrap();
729
730        assert!(agent.system_prompt.contains("Project memory status:"));
731        assert!(agent.system_prompt.contains("Working on:"));
732        assert!(agent.system_prompt.contains("[1] Implement auth flow"));
733        assert!(agent.system_prompt.contains("Recent work:"));
734        assert!(agent.system_prompt.contains("[3] Recently closed cleanup"));
735    }
736
737    #[test]
738    fn builder_task_fact_override_does_not_add_project_memory_status() {
739        let facts = vec![Fact {
740            text: "Auth uses RS256 signing".into(),
741            verified_ago: "2h ago".into(),
742        }];
743
744        let (agent, _handle) = AgentBuilder::new(
745            Config::default(),
746            PathBuf::from("/tmp"),
747            test_model(),
748            "key".into(),
749        )
750        .facts(facts)
751        .build()
752        .unwrap();
753
754        assert!(agent.system_prompt.contains("Project facts:"));
755        assert!(!agent.system_prompt.contains("Project memory status:"));
756    }
757
758    #[test]
759    fn builder_hooks_loaded_from_config() {
760        use crate::hooks::HookDef;
761
762        let mut config = Config::default();
763        config.hooks.push(HookDef {
764            event: "after_file_write".into(),
765            match_pattern: Some("*.rs".into()),
766            action: "shell".into(),
767            command: Some("echo hook fired".into()),
768            blocking: false,
769            threshold: None,
770        });
771
772        let (agent, _handle) =
773            AgentBuilder::new(config, PathBuf::from("/tmp"), test_model(), "key".into())
774                .build()
775                .unwrap();
776
777        // Hooks were loaded — the runner should have one registered hook
778        assert_eq!(agent.hooks.len(), 1);
779    }
780}