1use 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 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 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}