Skip to main content

aidens_app_kit/
lib.rs

1//! Safe app builder and profile expansion.
2
3use aidens_config::load_config_file;
4use aidens_contracts::{
5    AiDENsAppPlanV1, ApiHonestyReportV1, CanonicalToolSideEffectClass, ConfigApplyReportDraftV1,
6    ConfigApplyReportV1, MemoryModeV1, ReportLevelV1, RiskDisclosureV1,
7};
8use aidens_plan_kit::{assemble_execution_plan, ExecutionPlanAssemblyInputV1};
9use aidens_provider_kit::{provider_readiness_for_spec, ProviderSpecV1};
10use aidens_receipts::CanonicalEventLogConfig;
11use aidens_runner::{AiDENsRunInput, AiDENsRunner};
12use aidens_tool_kit::{registry_from_enabled_bundles, ToolExposurePolicyV1, ToolRegistryV1};
13use anyhow::bail;
14use std::path::{Path, PathBuf};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum AiDENsProfile {
18    ChatOnly,
19    CodingAgent,
20    MemoryAgent,
21    AutonomousDaemon,
22    ResearchWorkbench,
23}
24
25impl AiDENsProfile {
26    pub fn all() -> [Self; 5] {
27        [
28            Self::ChatOnly,
29            Self::CodingAgent,
30            Self::MemoryAgent,
31            Self::AutonomousDaemon,
32            Self::ResearchWorkbench,
33        ]
34    }
35
36    pub fn from_id(profile_id: &str) -> Option<Self> {
37        match profile_id.trim().to_ascii_lowercase().as_str() {
38            "chat" | "chat-only" => Some(Self::ChatOnly),
39            "coding" | "coding-agent" => Some(Self::CodingAgent),
40            "memory" | "memory-agent" => Some(Self::MemoryAgent),
41            "daemon" | "autonomous-daemon" => Some(Self::AutonomousDaemon),
42            "research" | "research-workbench" => Some(Self::ResearchWorkbench),
43            _ => None,
44        }
45    }
46
47    pub fn id(self) -> &'static str {
48        match self {
49            Self::ChatOnly => "chat-only",
50            Self::CodingAgent => "coding-agent",
51            Self::MemoryAgent => "memory-agent",
52            Self::AutonomousDaemon => "autonomous-daemon",
53            Self::ResearchWorkbench => "research-workbench",
54        }
55    }
56
57    pub fn product_surface_status(self) -> &'static str {
58        match self {
59            Self::ChatOnly | Self::CodingAgent => "supported",
60            Self::MemoryAgent => "partial/proof-only",
61            Self::AutonomousDaemon => "partial/safe-mode",
62            Self::ResearchWorkbench => "deferred/example-only",
63        }
64    }
65
66    pub fn product_surface_note(self) -> &'static str {
67        match self {
68            Self::ChatOnly => "supported chat profile with no tools by default",
69            Self::CodingAgent => "supported coding profile; side-effect tools remain permit-gated",
70            Self::MemoryAgent => "partial proof surface; canonical memory crates own memory truth",
71            Self::AutonomousDaemon => "partial safe-mode surface; autonomous operation is deferred",
72            Self::ResearchWorkbench => "deferred/example-only; not a complete product surface",
73        }
74    }
75
76    pub fn runtime_defaults(
77        self,
78        app_id: impl Into<String>,
79    ) -> anyhow::Result<AiDENsRuntimeDefaultsV1> {
80        let app_id = app_id.into();
81        let plan = self.expand(app_id.clone())?;
82        let provider_route = match self {
83            Self::CodingAgent => "explicit-mock-fixture",
84            _ => "disabled",
85        };
86        Ok(AiDENsRuntimeDefaultsV1 {
87            app_id,
88            profile_id: self.id().into(),
89            support_tier: self.product_surface_status().into(),
90            provider_route: provider_route.into(),
91            receipt_store_root: default_receipt_store_root(&plan.app_id, &plan.receipt_level),
92            sandbox_root: ".".into(),
93            receipt_level: plan.receipt_level,
94            enabled_tool_bundles: plan.enabled_tool_bundles,
95            disabled_tool_bundles: plan.disabled_tool_bundles,
96            permit_policy: "side-effect-tools-require-explicit-scoped-permits".into(),
97            hidden_defaults: false,
98            memory_mode: match self {
99                Self::ChatOnly | Self::CodingAgent => aidens_contracts::MemoryModeV1::Disabled,
100                Self::MemoryAgent | Self::AutonomousDaemon | Self::ResearchWorkbench => {
101                    aidens_contracts::MemoryModeV1::Optional
102                }
103            },
104            governance_enabled: matches!(self, Self::AutonomousDaemon),
105            kernel_reasoning_enabled: matches!(self, Self::ResearchWorkbench),
106        })
107    }
108
109    pub fn expand(self, app_id: impl Into<String>) -> anyhow::Result<AiDENsAppPlanV1> {
110        let app_id = app_id.into();
111        let input = match self {
112            Self::CodingAgent => ExecutionPlanAssemblyInputV1 {
113                app_id,
114                profile_id: self.id().into(),
115                provider_required: true,
116                memory_mode: MemoryModeV1::Optional,
117                receipt_level: ReportLevelV1::Full,
118                dangerous_auto_approval: false,
119                risk_disclosures: coding_agent_risks(),
120                enabled_tool_bundles: vec![
121                    "repo-read".into(),
122                    "repo-list".into(),
123                    "file-stat".into(),
124                    "repo-search".into(),
125                    "patch-propose".into(),
126                    "patch-apply".into(),
127                    "run-checks".into(),
128                ],
129                disabled_tool_bundles: vec![
130                    "shell-auto".into(),
131                    "network-auto".into(),
132                    "file-write-auto".into(),
133                    "dangerous-auto-approval".into(),
134                ],
135            },
136            _ => ExecutionPlanAssemblyInputV1 {
137                app_id,
138                profile_id: self.id().into(),
139                provider_required: true,
140                memory_mode: MemoryModeV1::Disabled,
141                receipt_level: ReportLevelV1::Standard,
142                dangerous_auto_approval: false,
143                risk_disclosures: Vec::new(),
144                enabled_tool_bundles: Vec::new(),
145                disabled_tool_bundles: Vec::new(),
146            },
147        };
148        Ok(assemble_execution_plan(input)
149            .map_err(anyhow::Error::msg)?
150            .plan)
151    }
152}
153
154fn coding_agent_risks() -> Vec<RiskDisclosureV1> {
155    [
156        (
157            CanonicalToolSideEffectClass::Write,
158            "file writes require an explicit permit",
159        ),
160        (
161            CanonicalToolSideEffectClass::Admin,
162            "shell execution requires an explicit permit",
163        ),
164        (
165            CanonicalToolSideEffectClass::Analysis,
166            "network access requires an explicit permit",
167        ),
168    ]
169    .into_iter()
170    .map(|(risk_class, reason)| RiskDisclosureV1 {
171        risk_class,
172        granted_by_default: false,
173        permit_required: true,
174        reason: reason.into(),
175    })
176    .collect()
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct AiDENsRuntimeDefaultsV1 {
181    pub app_id: String,
182    pub profile_id: String,
183    pub support_tier: String,
184    pub provider_route: String,
185    pub receipt_store_root: Option<String>,
186    pub sandbox_root: String,
187    pub receipt_level: ReportLevelV1,
188    pub enabled_tool_bundles: Vec<String>,
189    pub disabled_tool_bundles: Vec<String>,
190    pub permit_policy: String,
191    pub hidden_defaults: bool,
192    pub memory_mode: aidens_contracts::MemoryModeV1,
193    pub governance_enabled: bool,
194    pub kernel_reasoning_enabled: bool,
195}
196
197#[derive(Debug, Clone)]
198pub struct AiDENsApp {
199    runner: AiDENsRunner,
200    plan: AiDENsAppPlanV1,
201    config_apply_receipt: Option<ConfigApplyReportV1>,
202    api_honesty_receipts: Vec<ApiHonestyReportV1>,
203}
204
205impl AiDENsApp {
206    pub fn builder() -> AiDENsAppBuilder {
207        AiDENsAppBuilder::default()
208    }
209
210    /// One-liner quickstart: build a mock agent and run a prompt.
211    pub async fn chat(prompt: impl Into<String>) -> anyhow::Result<aidens_runner::AiDENsRunOutput> {
212        let plan = AiDENsProfile::ChatOnly.expand("quickstart-chat")?;
213        let app = AiDENsApp::from_plan(plan)
214            .mock_provider("You are a helpful assistant.")
215            .build()
216            .await?;
217        app.run_once(prompt).await
218    }
219
220    /// One-liner with a real provider: build an agent with the given profile
221    /// and provider spec, run a prompt.
222    pub async fn run_with(
223        profile: AiDENsProfile,
224        provider: ProviderSpecV1,
225        prompt: impl Into<String>,
226    ) -> anyhow::Result<aidens_runner::AiDENsRunOutput> {
227        let plan = profile.expand("quickstart")?;
228        let app = AiDENsApp::from_plan(plan)
229            .provider_spec(provider)
230            .build()
231            .await?;
232        app.run_once(prompt).await
233    }
234
235    pub async fn run(&self) -> anyhow::Result<()> {
236        let output = self.run_once("doctor-run").await?;
237        println!("{}", output.text);
238        Ok(())
239    }
240
241    pub async fn run_once(
242        &self,
243        prompt: impl Into<String>,
244    ) -> anyhow::Result<aidens_runner::AiDENsRunOutput> {
245        self.runner.run(AiDENsRunInput::new(prompt)).await
246    }
247
248    pub fn plan(&self) -> &AiDENsAppPlanV1 {
249        &self.plan
250    }
251
252    pub fn provider_route(&self) -> aidens_contracts::ProviderRouteReportV1 {
253        self.runner.provider_route()
254    }
255
256    pub fn tool_ids(&self) -> Vec<String> {
257        self.runner.tool_ids()
258    }
259
260    pub fn list_tools(&self) -> Vec<String> {
261        self.tool_ids()
262    }
263
264    pub fn executable_tool_ids(&self) -> Vec<String> {
265        self.runner.executable_tool_ids()
266    }
267
268    pub fn inspect_tools(&self) -> aidens_contracts::ToolExposureSetV1 {
269        self.runner.tool_exposure()
270    }
271
272    pub fn config_apply_receipt(&self) -> Option<&ConfigApplyReportV1> {
273        self.config_apply_receipt.as_ref()
274    }
275
276    pub fn api_honesty_receipts(&self) -> &[ApiHonestyReportV1] {
277        &self.api_honesty_receipts
278    }
279
280    pub fn from_plan(plan: AiDENsAppPlanV1) -> AiDENsAppFromPlanBuilder {
281        AiDENsAppFromPlanBuilder {
282            plan,
283            provider_spec: None,
284            tools: None,
285        }
286    }
287
288    pub fn from_config(config_file: impl Into<String>) -> AiDENsAppBuilder {
289        Self::builder().config_file(config_file)
290    }
291}
292
293#[derive(Debug, Clone)]
294pub struct AiDENsAppBuilder {
295    name: String,
296    profile: AiDENsProfile,
297    config_file: Option<String>,
298    tools: Option<ToolRegistryV1>,
299}
300
301impl Default for AiDENsAppBuilder {
302    fn default() -> Self {
303        Self {
304            name: "aidens-app".into(),
305            profile: AiDENsProfile::ChatOnly,
306            config_file: None,
307            tools: None,
308        }
309    }
310}
311
312impl AiDENsAppBuilder {
313    pub fn name(mut self, name: impl Into<String>) -> Self {
314        self.name = name.into();
315        self
316    }
317
318    pub fn profile(mut self, profile: AiDENsProfile) -> Self {
319        self.profile = profile;
320        self
321    }
322
323    pub fn config_file(mut self, config_file: impl Into<String>) -> Self {
324        self.config_file = Some(config_file.into());
325        self
326    }
327
328    pub fn tools(mut self, tools: ToolRegistryV1) -> Self {
329        self.tools = Some(tools);
330        self
331    }
332
333    pub async fn build(self) -> anyhow::Result<AiDENsApp> {
334        let mut plan = self.profile.expand(&self.name)?;
335        let mut runner = AiDENsRunner::builder();
336        let mut tools = self.tools;
337        let explicit_tools = tools.is_some();
338        let mut sandbox_root = None;
339        let mut config_source = None;
340        let mut receipt_store_root = default_receipt_store_root(&plan.app_id, &plan.receipt_level);
341        let mut memory_store_root = None;
342        let mut api_honesty_receipts = Vec::new();
343        let mut config_tool_bundles = Vec::new();
344        if let Some(config_file) = self.config_file {
345            let loaded = load_config_file(config_file)?;
346            config_source = Some(format!("loaded {}", loaded.path.display()));
347            let profile = config_profile_or_default(&loaded.config, self.profile)?;
348            plan = profile.expand(loaded.config.app_id.clone())?;
349            plan.memory_mode = loaded.config.memory_mode.clone();
350            plan.receipt_level = loaded.config.receipt_level.clone();
351            plan.enabled_tool_bundles = loaded.config.tools.enabled_bundles.clone();
352            receipt_store_root = receipt_store_root_for_config(&loaded.config, &loaded.path);
353            memory_store_root = memory_store_root_for_config(&loaded.config, &loaded.path);
354            config_tool_bundles = loaded.config.tools.enabled_bundles.clone();
355            sandbox_root = loaded.config.tools.sandbox_root.clone();
356            runner = runner
357                .provider_spec(provider_spec_from_config(&loaded.config.provider))
358                .receipt_level(loaded.config.receipt_level.clone());
359            if explicit_tools {
360                api_honesty_receipts.push(ApiHonestyReportV1::honored(
361                    "AiDENsAppBuilder::tools",
362                    vec!["custom-tool-registry".into()],
363                    vec!["custom-tool-registry".into()],
364                ));
365            } else {
366                tools = Some(registry_from_enabled_bundles(
367                    &loaded.config.tools.enabled_bundles,
368                    loaded.config.tools.sandbox_root.as_deref(),
369                ));
370            }
371        }
372        plan.validate().map_err(anyhow::Error::msg)?;
373        ensure_memory_store_policy(&plan, memory_store_root.as_deref())?;
374        let final_tools = tools.unwrap_or_else(|| {
375            if plan.enabled_tool_bundles.is_empty() {
376                ToolRegistryV1::default()
377            } else {
378                registry_from_enabled_bundles(&plan.enabled_tool_bundles, Some("."))
379            }
380        });
381        runner = runner
382            .tools(final_tools.clone())
383            .receipt_level(plan.receipt_level.clone());
384        if let Some(root) = receipt_store_root.clone() {
385            runner = runner.canonical_receipt_log_config(CanonicalEventLogConfig::for_root(root));
386        }
387        let runner = runner.app_id(plan.app_id.clone()).build()?;
388        let config_apply_receipt = config_source.map(|source| {
389            let route = runner.provider_route();
390            let policy = ToolExposurePolicyV1::read_only_default().for_provider_route(&route);
391            let exposure = final_tools.plan_exposure(&policy);
392            let mut reason_codes = Vec::new();
393            if explicit_tools && !config_tool_bundles.is_empty() {
394                reason_codes.push("builder-tools-override-config-tool-bundles".into());
395            }
396            if let Some(store) = runner.canonical_receipt_log_config() {
397                reason_codes.push(format!(
398                    "canonical-receipt-log:{}",
399                    store.root_path.display()
400                ));
401            } else {
402                reason_codes.push("receipt-store-not-configured".into());
403            }
404            if let Some(root) = memory_store_root.as_deref() {
405                reason_codes.push(format!("durable-memory-store:{root}"));
406            } else if plan.memory_mode == MemoryModeV1::Required {
407                reason_codes.push("memory-required-without-durable-store".into());
408            }
409            ConfigApplyReportV1::new(ConfigApplyReportDraftV1 {
410                app_id: plan.app_id.clone(),
411                config_source: source,
412                provider_route: route,
413                tool_exposure: exposure,
414                memory_mode: plan.memory_mode.clone(),
415                receipt_level: plan.receipt_level.clone(),
416                enabled_tool_bundles: config_tool_bundles,
417                sandbox_root,
418                applied: true,
419                reason_codes,
420            })
421        });
422        Ok(AiDENsApp {
423            runner,
424            plan,
425            config_apply_receipt,
426            api_honesty_receipts,
427        })
428    }
429}
430
431fn config_profile_or_default(
432    cfg: &aidens_config::AiDENsConfigV1,
433    default: AiDENsProfile,
434) -> anyhow::Result<AiDENsProfile> {
435    match cfg.profile_id.as_deref() {
436        Some(profile_id) => AiDENsProfile::from_id(profile_id)
437            .ok_or_else(|| anyhow::anyhow!("unknown AiDENs profile: {profile_id}")),
438        None => Ok(default),
439    }
440}
441
442fn provider_spec_from_config(provider: &aidens_config::ProviderConfigV1) -> ProviderSpecV1 {
443    ProviderSpecV1 {
444        kind: provider.kind.clone(),
445        model: provider.model.clone(),
446        api_key: provider.api_key.clone(),
447        base_url: provider.base_url.clone(),
448        mock_response: provider.mock_response.clone(),
449    }
450}
451
452#[derive(Debug, Clone)]
453pub struct AiDENsAppFromPlanBuilder {
454    plan: AiDENsAppPlanV1,
455    provider_spec: Option<ProviderSpecV1>,
456    tools: Option<ToolRegistryV1>,
457}
458
459impl AiDENsAppFromPlanBuilder {
460    pub fn provider_spec(mut self, provider_spec: ProviderSpecV1) -> Self {
461        self.provider_spec = Some(provider_spec);
462        self
463    }
464
465    pub fn mock_provider(mut self, response: impl Into<String>) -> Self {
466        self.provider_spec = Some(ProviderSpecV1 {
467            kind: "mock".into(),
468            mock_response: Some(response.into()),
469            ..ProviderSpecV1::default()
470        });
471        self
472    }
473
474    pub fn tools(mut self, tools: ToolRegistryV1) -> Self {
475        self.tools = Some(tools);
476        self
477    }
478
479    pub async fn build(self) -> anyhow::Result<AiDENsApp> {
480        self.plan.validate().map_err(anyhow::Error::msg)?;
481        ensure_memory_store_policy(&self.plan, None)?;
482        let provider_spec = match self.provider_spec {
483            Some(provider_spec) => provider_spec,
484            None if self.plan.provider_required => {
485                bail!(
486                    "provider-unbound: AiDENsApp::from_plan requires provider_spec for provider_required plans"
487                )
488            }
489            None => ProviderSpecV1::new("disabled"),
490        };
491        let readiness = provider_readiness_for_spec(&provider_spec);
492        if self.plan.provider_required && !readiness.configured {
493            bail!("provider-unbound: provider_required plan cannot use disabled provider")
494        }
495        if self.plan.provider_required && !readiness.executable {
496            bail!(
497                "provider-blocked: provider_required plan cannot build executable runtime: {}",
498                readiness.reason_codes.join(",")
499            )
500        }
501        let tools = self.tools.unwrap_or_else(|| {
502            if self.plan.enabled_tool_bundles.is_empty() {
503                ToolRegistryV1::default()
504            } else {
505                registry_from_enabled_bundles(&self.plan.enabled_tool_bundles, Some("."))
506            }
507        });
508        let runner = AiDENsRunner::builder()
509            .app_id(self.plan.app_id.clone())
510            .provider_spec(provider_spec)
511            .tools(tools)
512            .receipt_level(self.plan.receipt_level.clone());
513        let runner = if let Some(root) =
514            default_receipt_store_root(&self.plan.app_id, &self.plan.receipt_level)
515        {
516            runner.canonical_receipt_log_config(CanonicalEventLogConfig::for_root(root))
517        } else {
518            runner
519        };
520        let runner = runner.build()?;
521        Ok(AiDENsApp {
522            runner,
523            plan: self.plan,
524            config_apply_receipt: None,
525            api_honesty_receipts: vec![ApiHonestyReportV1::honored(
526                "AiDENsApp::from_plan",
527                vec![
528                    "plan".into(),
529                    "provider_spec".into(),
530                    "tool_registry".into(),
531                ],
532                vec![
533                    "plan".into(),
534                    "provider_spec".into(),
535                    "tool_registry".into(),
536                ],
537            )],
538        })
539    }
540}
541
542fn receipt_store_root_for_config(
543    cfg: &aidens_config::AiDENsConfigV1,
544    config_path: &Path,
545) -> Option<String> {
546    let Some(root) = cfg.receipts.store_root.clone() else {
547        return default_receipt_store_root(&cfg.app_id, &cfg.receipt_level);
548    };
549    let path = PathBuf::from(root);
550    if path.is_absolute() {
551        return Some(path.display().to_string());
552    }
553    Some(
554        config_path
555            .parent()
556            .unwrap_or_else(|| Path::new("."))
557            .join(path)
558            .display()
559            .to_string(),
560    )
561}
562
563fn memory_store_root_for_config(
564    cfg: &aidens_config::AiDENsConfigV1,
565    config_path: &Path,
566) -> Option<String> {
567    let root = cfg.memory.store_root.clone()?;
568    let path = PathBuf::from(root);
569    if path.is_absolute() {
570        return Some(path.display().to_string());
571    }
572    Some(
573        config_path
574            .parent()
575            .unwrap_or_else(|| Path::new("."))
576            .join(path)
577            .display()
578            .to_string(),
579    )
580}
581
582fn ensure_memory_store_policy(
583    plan: &AiDENsAppPlanV1,
584    memory_store_root: Option<&str>,
585) -> anyhow::Result<()> {
586    if plan.memory_mode == MemoryModeV1::Required && memory_store_root.is_none() {
587        bail!(
588            "memory-required-without-durable-store: memory_mode=required needs [memory].store_root"
589        )
590    }
591    Ok(())
592}
593
594fn default_receipt_store_root(app_id: &str, receipt_level: &ReportLevelV1) -> Option<String> {
595    if receipt_level == &ReportLevelV1::Minimal {
596        return None;
597    }
598    Some(format!(
599        "target/aidens-receipts/{}",
600        receipt_store_segment(app_id)
601    ))
602}
603
604fn receipt_store_segment(app_id: &str) -> String {
605    let mut segment = String::new();
606    let mut last_dash = false;
607    for ch in app_id.trim().chars() {
608        let next = if ch.is_ascii_alphanumeric() || ch == '_' {
609            ch.to_ascii_lowercase()
610        } else {
611            '-'
612        };
613        if next == '-' {
614            if !last_dash {
615                segment.push(next);
616            }
617            last_dash = true;
618        } else {
619            segment.push(next);
620            last_dash = false;
621        }
622    }
623    let segment = segment.trim_matches('-').to_string();
624    if segment.is_empty() {
625        "aidens-app".into()
626    } else {
627        segment
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    #[test]
636    fn coding_profile_expands_to_visible_plan_without_dangerous_auto_grants() {
637        let plan = AiDENsProfile::CodingAgent.expand("agent").unwrap();
638
639        assert!(!plan.dangerous_auto_approval);
640        assert!(!plan.risk_disclosures.is_empty());
641        assert!(plan
642            .risk_disclosures
643            .iter()
644            .all(|risk| !risk.granted_by_default && risk.permit_required));
645        assert!(plan
646            .disabled_tool_bundles
647            .contains(&"dangerous-auto-approval".into()));
648        assert!(plan.risk_summary().contains("permit_required=true"));
649    }
650
651    #[test]
652    fn p28_profile_expansion_is_result_bearing_not_panic_based() {
653        let plan = AiDENsProfile::CodingAgent
654            .expand("p28-result-bearing-agent")
655            .expect("valid built-in profile expands");
656        assert_eq!(plan.profile_id, "coding-agent");
657        assert!(plan.validate().is_ok());
658    }
659
660    #[test]
661    fn product_profile_catalog_marks_partial_surfaces() {
662        let catalog = AiDENsProfile::all()
663            .into_iter()
664            .map(|profile| (profile.id(), profile.product_surface_status()))
665            .collect::<Vec<_>>();
666
667        assert!(catalog.contains(&("chat-only", "supported")));
668        assert!(catalog.contains(&("coding-agent", "supported")));
669        assert!(catalog.contains(&("memory-agent", "partial/proof-only")));
670        assert!(catalog.contains(&("autonomous-daemon", "partial/safe-mode")));
671        assert!(catalog.contains(&("research-workbench", "deferred/example-only")));
672    }
673
674    #[test]
675    fn runtime_defaults_are_visible_receipt_first_and_permit_gated() {
676        let defaults = AiDENsProfile::CodingAgent
677            .runtime_defaults("generated-agent")
678            .unwrap();
679
680        assert_eq!(defaults.profile_id, "coding-agent");
681        assert_eq!(defaults.provider_route, "explicit-mock-fixture");
682        assert_eq!(defaults.receipt_level, ReportLevelV1::Full);
683        assert_eq!(
684            defaults.receipt_store_root.as_deref(),
685            Some("target/aidens-receipts/generated-agent")
686        );
687        assert_eq!(defaults.sandbox_root, ".");
688        assert!(!defaults.hidden_defaults);
689        assert!(defaults
690            .disabled_tool_bundles
691            .contains(&"dangerous-auto-approval".into()));
692        assert!(defaults.permit_policy.contains("explicit"));
693    }
694
695    #[tokio::test]
696    async fn builder_loads_provider_from_config_file() {
697        let dir = std::env::temp_dir().join(format!("aidens-app-kit-test-{}", std::process::id()));
698        std::fs::create_dir_all(&dir).unwrap();
699        let path = dir.join("aidens.toml");
700        std::fs::write(
701            &path,
702            r#"
703app_id = "configured-agent"
704memory_mode = "optional"
705receipt_level = "full"
706
707[provider]
708kind = "mock"
709model = "mock-model"
710mock_response = "configured response"
711
712[receipts]
713store_root = "receipts"
714"#,
715        )
716        .unwrap();
717
718        let app = AiDENsApp::builder()
719            .name("ignored")
720            .profile(AiDENsProfile::CodingAgent)
721            .config_file(path.display().to_string())
722            .build()
723            .await
724            .unwrap();
725
726        assert_eq!(app.plan().app_id, "configured-agent");
727        let output = app.run_once("hello").await.unwrap();
728        assert_eq!(output.text, "configured response");
729        assert_eq!(output.receipt.provider_route.unwrap().route_label, "mock");
730        assert!(!output.durable_receipt_records.is_empty());
731        assert!(dir
732            .join("receipts")
733            .join("canonical-receipts.ndjson")
734            .exists());
735        let _ = std::fs::remove_dir_all(&dir);
736    }
737
738    #[tokio::test]
739    async fn builder_rejects_unknown_profile_without_fallback() {
740        let dir = std::env::temp_dir().join(format!(
741            "aidens-app-kit-profile-test-{}",
742            std::process::id()
743        ));
744        std::fs::create_dir_all(&dir).unwrap();
745        let path = dir.join("aidens.toml");
746        std::fs::write(
747            &path,
748            r#"
749app_id = "configured-agent"
750profile_id = "mystery-agent"
751memory_mode = "disabled"
752receipt_level = "standard"
753
754[provider]
755kind = "mock"
756mock_response = "configured response"
757"#,
758        )
759        .unwrap();
760
761        let error = AiDENsApp::builder()
762            .config_file(path.display().to_string())
763            .build()
764            .await
765            .unwrap_err();
766
767        assert!(error.to_string().contains("unknown AiDENs profile"));
768        let _ = std::fs::remove_dir_all(&dir);
769    }
770
771    #[tokio::test]
772    async fn from_plan_requires_explicit_provider_for_provider_required_plan() {
773        let plan = AiDENsProfile::CodingAgent
774            .expand("from-plan-agent")
775            .unwrap();
776
777        let error = AiDENsApp::from_plan(plan).build().await.unwrap_err();
778
779        assert!(error.to_string().contains("provider-unbound"));
780    }
781
782    #[tokio::test]
783    async fn from_plan_rejects_disabled_provider_for_provider_required_plan() {
784        let plan = AiDENsProfile::CodingAgent
785            .expand("from-plan-agent")
786            .unwrap();
787
788        let error = AiDENsApp::from_plan(plan)
789            .provider_spec(ProviderSpecV1::new("disabled"))
790            .build()
791            .await
792            .unwrap_err();
793
794        assert!(error.to_string().contains("provider-unbound"));
795        assert!(error.to_string().contains("disabled provider"));
796    }
797
798    #[tokio::test]
799    async fn from_plan_honors_bound_provider_and_plan_tools() {
800        let plan = AiDENsProfile::CodingAgent
801            .expand("from-plan-agent")
802            .unwrap();
803        let receipt_root = Path::new("target")
804            .join("aidens-receipts")
805            .join("from-plan-agent");
806        let _ = std::fs::remove_dir_all(&receipt_root);
807
808        let app = AiDENsApp::from_plan(plan)
809            .mock_provider("plan response")
810            .build()
811            .await
812            .unwrap();
813
814        assert_eq!(app.provider_route().route_label, "mock");
815        assert!(app.tool_ids().contains(&"aidens:repo-read:1".into()));
816        assert!(app
817            .api_honesty_receipts()
818            .iter()
819            .all(ApiHonestyReportV1::all_inputs_honored));
820        let output = app.run_once("hello").await.unwrap();
821        assert_eq!(output.text, "plan response");
822        let _ = std::fs::remove_dir_all(&receipt_root);
823    }
824
825    #[tokio::test]
826    async fn explicit_builder_tool_registry_is_preserved_over_config_bundles() {
827        let dir =
828            std::env::temp_dir().join(format!("aidens-app-kit-tools-test-{}", std::process::id()));
829        let _ = std::fs::remove_dir_all(&dir);
830        std::fs::create_dir_all(&dir).unwrap();
831        let path = dir.join("aidens.toml");
832        std::fs::write(
833            &path,
834            r#"
835app_id = "configured-agent"
836profile_id = "coding-agent"
837memory_mode = "optional"
838receipt_level = "full"
839
840[provider]
841kind = "mock"
842model = "mock-model"
843mock_response = "configured response"
844
845[tools]
846sandbox_root = "."
847enabled_bundles = ["safe-coding"]
848
849[receipts]
850store_root = "receipts"
851"#,
852        )
853        .unwrap();
854
855        let app = AiDENsApp::builder()
856            .config_file(path.display().to_string())
857            .tools(ToolRegistryV1::default())
858            .build()
859            .await
860            .unwrap();
861
862        assert!(app.tool_ids().is_empty());
863        assert!(app.list_tools().is_empty());
864        assert!(app.inspect_tools().exposed_tool_ids.is_empty());
865        let receipt = app.config_apply_receipt().expect("config apply receipt");
866        assert!(receipt
867            .reason_codes
868            .contains(&"builder-tools-override-config-tool-bundles".into()));
869        assert!(app
870            .api_honesty_receipts()
871            .iter()
872            .any(ApiHonestyReportV1::all_inputs_honored));
873
874        let _ = std::fs::remove_dir_all(&dir);
875    }
876}