1use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6use typed_builder::TypedBuilder;
7
8use super::{
9 DEFAULT_MODEL, capabilities::CapabilitiesConfig, mcp::McpServer, models::GeminiConfig,
10};
11use crate::{
12 hooks::HookEntry, policies::PolicyRule, tools::ToolDefinition, triggers::TriggerEntry,
13};
14
15#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub struct SystemInstructionSection {
18 pub content: String,
20 #[serde(default = "default_section_title")]
22 pub title: String,
23}
24
25fn default_section_title() -> String {
26 "user_system_instructions".to_owned()
27}
28
29fn default_model_name() -> String {
30 DEFAULT_MODEL.to_owned()
31}
32
33#[non_exhaustive]
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(untagged)]
40pub enum SystemInstructions {
41 Custom(String),
43 Templated {
45 #[serde(default)]
47 identity: Option<String>,
48 #[serde(default)]
50 sections: Vec<SystemInstructionSection>,
51 },
52}
53
54impl SystemInstructions {
55 #[must_use]
57 pub fn custom(text: impl Into<String>) -> Self {
58 Self::Custom(text.into())
59 }
60}
61
62impl From<&str> for SystemInstructions {
63 fn from(s: &str) -> Self {
64 Self::custom(s)
65 }
66}
67
68impl From<String> for SystemInstructions {
69 fn from(s: String) -> Self {
70 Self::custom(s)
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(transparent)]
79pub struct JsonSchema(serde_json::Value);
80
81impl JsonSchema {
82 #[must_use]
83 pub const fn new(value: serde_json::Value) -> Self {
85 Self(value)
86 }
87
88 #[must_use]
89 pub const fn as_value(&self) -> &serde_json::Value {
91 &self.0
92 }
93
94 pub fn validate(&self) -> Result<(), &'static str> {
104 if self.0.is_object() {
105 Ok(())
106 } else {
107 Err("JSON Schema must be a JSON object at the top level")
108 }
109 }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder)]
150#[builder(field_defaults(default))]
151pub struct AgentConfig {
152 #[serde(default = "default_model_name")]
154 #[builder(default = DEFAULT_MODEL.to_owned(), setter(into))]
155 pub model: String,
156 #[serde(default)]
158 #[builder(setter(into, strip_option))]
159 pub api_key: Option<String>,
160 #[builder(setter(into, strip_option))]
162 pub system_instructions: Option<SystemInstructions>,
163 #[serde(default)]
164 #[builder(setter(strip_option))]
166 pub capabilities: Option<CapabilitiesConfig>,
167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<PathBuf>>| v.into_iter().map(Into::into).collect()))]
173 pub workspaces: Vec<PathBuf>,
174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<ToolDefinition>>| v.into_iter().map(Into::into).collect()))]
177 pub tools: Vec<ToolDefinition>,
178 #[serde(default = "default_policies")]
179 #[builder(default = default_policies(), setter(transform = |v: impl IntoIterator<Item = impl Into<PolicyRule>>| v.into_iter().map(Into::into).collect()))]
181 pub policies: Vec<PolicyRule>,
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
183 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<TriggerEntry>>| v.into_iter().map(Into::into).collect()))]
185 pub triggers: Vec<TriggerEntry>,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<HookEntry>>| v.into_iter().map(Into::into).collect()))]
189 pub hooks: Vec<HookEntry>,
190 #[serde(
191 default,
192 skip_serializing_if = "Vec::is_empty",
193 rename = "skills_paths"
194 )]
195 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<PathBuf>>| v.into_iter().map(Into::into).collect()))]
199 pub skills: Vec<PathBuf>,
200
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<McpServer>>| v.into_iter().map(Into::into).collect()))]
204 pub mcp_servers: Vec<McpServer>,
205 #[serde(default)]
207 #[builder(setter(into, strip_option))]
208 pub conversation_id: Option<String>,
209 #[serde(default)]
211 #[builder(setter(into, strip_option))]
212 pub save_dir: Option<PathBuf>,
213 #[serde(default)]
215 #[builder(setter(into, strip_option))]
216 pub app_data_dir: Option<PathBuf>,
217 #[serde(default)]
219 #[builder(setter(strip_option))]
220 pub response_schema: Option<JsonSchema>,
221 #[serde(default, rename = "gemini_config")]
228 #[builder(setter(strip_option))]
229 pub gemini: Option<GeminiConfig>,
230 #[serde(default, skip_serializing_if = "Vec::is_empty")]
240 #[builder(default, setter(transform = |v: impl IntoIterator<Item = impl Into<crate::types::ConversationMessage>>| v.into_iter().map(Into::into).collect()))]
241 pub initial_history: Vec<crate::types::ConversationMessage>,
242}
243
244impl Default for AgentConfig {
245 fn default() -> Self {
246 Self::builder().build()
247 }
248}
249
250impl AgentConfig {
251 #[must_use]
259 pub fn effective_api_key(&self) -> Option<String> {
260 self.gemini
261 .as_ref()
262 .and_then(|g| g.models.default.api_key.clone())
263 .or_else(|| self.gemini.as_ref().and_then(|g| g.api_key.clone()))
264 .or_else(|| self.api_key.clone())
265 .or_else(|| std::env::var("GEMINI_API_KEY").ok())
267 }
268
269 #[must_use]
273 pub fn custom_tool_names(&self) -> Vec<String> {
274 self.tools.iter().map(|t| t.name.clone()).collect()
275 }
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, Default)]
288pub struct LocalAgentConfig {
289 #[serde(flatten)]
291 pub agent: AgentConfig,
292}
293
294impl LocalAgentConfig {
295 #[must_use]
297 pub const fn new(agent: AgentConfig) -> Self {
298 Self { agent }
299 }
300}
301
302impl From<AgentConfig> for LocalAgentConfig {
303 fn from(agent: AgentConfig) -> Self {
304 Self::new(agent)
305 }
306}
307
308fn default_policies() -> Vec<PolicyRule> {
311 vec![
312 PolicyRule::Deny("run_command".to_string()),
313 PolicyRule::AllowAll,
314 ]
315}
316
317#[cfg(test)]
318#[allow(deprecated)]
320mod tests {
321 use pyo3::types::PyAnyMethods;
322
323 use super::{
324 super::{
325 DEFAULT_IMAGE_GENERATION_MODEL,
326 capabilities::BuiltinTools,
327 models::{
328 GenerationConfig, ModelConfig, ModelEntry, ThinkingLevel, default_image_model_entry,
329 },
330 },
331 *,
332 };
333
334 #[derive(schemars::JsonSchema)]
335 struct CustomToolParams {}
336
337 #[test]
338 fn test_roundtrip_serialization() {
339 let config = AgentConfig {
340 system_instructions: Some(SystemInstructions::Custom("Be helpful".to_string())),
341 capabilities: Some(CapabilitiesConfig {
342 enable_subagents: true,
343 enabled_tools: Some(vec![BuiltinTools::ListDir]),
344 compaction_threshold: Some(4000),
345 ..CapabilitiesConfig::default()
346 }),
347 workspaces: vec![PathBuf::from("/tmp")],
348 ..AgentConfig::default()
349 };
350
351 let json = serde_json::to_string(&config).unwrap();
352 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
353 assert_eq!(parsed.workspaces.len(), 1);
354 assert_eq!(
355 parsed.capabilities.unwrap().enabled_tools.unwrap()[0],
356 BuiltinTools::ListDir
357 );
358 }
359
360 #[test]
361 fn agent_config_builder_with_gemini() {
362 let gemini = GeminiConfig {
363 api_key: Some("test-key".to_string()),
364 base_url: None,
365 models: ModelConfig::default(),
366 };
367 let config = AgentConfig::builder().gemini(gemini).build();
368 let gemini_cfg = config.gemini.expect("gemini should be Some");
369 assert_eq!(gemini_cfg.api_key.as_deref(), Some("test-key"));
370 assert_eq!(gemini_cfg.models.default.name, DEFAULT_MODEL);
371 }
372
373 #[test]
374 fn agent_config_builder_gemini_with_thinking_level() {
375 let gemini = GeminiConfig {
376 api_key: None,
377 base_url: None,
378 models: ModelConfig {
379 default: ModelEntry {
380 name: "gemini-3.5-flash".to_string(),
381 api_key: None,
382 generation: GenerationConfig {
383 thinking_level: Some(ThinkingLevel::High),
384 },
385 },
386 image_generation: default_image_model_entry(),
387 },
388 };
389 let config = AgentConfig::builder().gemini(gemini).build();
390 let gemini_cfg = config.gemini.expect("gemini should be Some");
391 assert_eq!(
392 gemini_cfg.models.default.generation.thinking_level,
393 Some(ThinkingLevel::High)
394 );
395 assert_eq!(gemini_cfg.models.default.name, "gemini-3.5-flash");
396 }
397
398 #[test]
399 fn agent_config_gemini_none_by_default() {
400 let config = AgentConfig::default();
401 assert!(config.gemini.is_none());
402 }
403
404 #[test]
405 fn agent_config_gemini_serde_roundtrip() {
406 let config = AgentConfig {
407 gemini: Some(GeminiConfig {
408 api_key: Some("roundtrip-key".to_string()),
409 base_url: None,
410 models: ModelConfig {
411 default: ModelEntry {
412 name: "gemini-3.5-flash".to_string(),
413 api_key: Some("model-key".to_string()),
414 generation: GenerationConfig {
415 thinking_level: Some(ThinkingLevel::Medium),
416 },
417 },
418 image_generation: default_image_model_entry(),
419 },
420 }),
421 ..AgentConfig::default()
422 };
423 let json = serde_json::to_string(&config).unwrap();
424 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
425 let gemini_cfg = parsed.gemini.expect("gemini should survive roundtrip");
426 assert_eq!(gemini_cfg.api_key.as_deref(), Some("roundtrip-key"));
427 assert_eq!(gemini_cfg.models.default.name, "gemini-3.5-flash");
428 assert_eq!(
429 gemini_cfg.models.default.api_key.as_deref(),
430 Some("model-key")
431 );
432 assert_eq!(
433 gemini_cfg.models.default.generation.thinking_level,
434 Some(ThinkingLevel::Medium)
435 );
436 }
437
438 #[test]
439 fn system_instructions_custom_serde() {
440 let instr = SystemInstructions::Custom("Be a helpful assistant".to_string());
441 let json = serde_json::to_string(&instr).unwrap();
442 let parsed: SystemInstructions = serde_json::from_str(&json).unwrap();
443 match parsed {
444 SystemInstructions::Custom(text) => assert_eq!(text, "Be a helpful assistant"),
445 SystemInstructions::Templated { .. } => {
446 panic!("Expected Custom, got Templated")
447 }
448 }
449 }
450
451 #[test]
452 fn system_instructions_templated_serde() {
453 let instr = SystemInstructions::Templated {
454 identity: Some("a security analyst".to_string()),
455 sections: vec![SystemInstructionSection {
456 content: "Always check permissions".to_string(),
457 title: "security".to_string(),
458 }],
459 };
460 let json = serde_json::to_string(&instr).unwrap();
461 let parsed: SystemInstructions = serde_json::from_str(&json).unwrap();
462 match parsed {
463 SystemInstructions::Templated { identity, sections } => {
464 assert_eq!(identity.as_deref(), Some("a security analyst"));
465 assert_eq!(sections.len(), 1);
466 assert_eq!(sections[0].content, "Always check permissions");
467 }
468 SystemInstructions::Custom(_) => {
469 panic!("Expected Templated, got Custom")
470 }
471 }
472 }
473
474 #[test]
475 fn agent_config_fully_populated_serde() {
476 let config = AgentConfig {
477 system_instructions: Some(SystemInstructions::Templated {
478 identity: Some("test-identity".to_string()),
479 sections: vec![],
480 }),
481 capabilities: Some(CapabilitiesConfig {
482 enable_subagents: true,
483 disabled_tools: Some(vec![BuiltinTools::RunCommand]),
484 compaction_threshold: Some(1000),
485 ..CapabilitiesConfig::default()
486 }),
487 workspaces: vec![PathBuf::from("/a"), PathBuf::from("/b")],
488 tools: vec![crate::tools::ToolDefinition {
489 name: "custom_tool".to_owned(),
490 description: "A custom tool".to_owned(),
491 parameter_schema: serde_json::to_value(schemars::schema_for!(CustomToolParams))
492 .unwrap(),
493 }],
494 policies: vec![PolicyRule::DenyAll],
495 triggers: vec![TriggerEntry {
496 name: "poll".to_owned(),
497 config: crate::triggers::TriggerConfig::every_secs(30),
498 message_template: "time to poll".to_owned(),
499 }],
500 hooks: vec![HookEntry {
501 name: "pre_gate".to_owned(),
502 point: crate::hooks::HookPoint::PreTurn,
503 callback_id: "cb_pre".to_owned(),
504 }],
505 skills: vec![PathBuf::from("/skills/foo")],
506 ..AgentConfig::default()
507 };
508 let json = serde_json::to_string(&config).unwrap();
509 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
510 assert_eq!(parsed.workspaces.len(), 2);
511 assert_eq!(parsed.tools.len(), 1);
512 assert_eq!(parsed.policies.len(), 1);
513 assert_eq!(parsed.triggers.len(), 1);
514 assert_eq!(parsed.hooks.len(), 1);
515 assert_eq!(parsed.skills.len(), 1);
516 }
517
518 #[test]
519 fn agent_config_empty_defaults_serde() {
520 let json = r#"{"system_instructions":null}"#;
521 let parsed: AgentConfig = serde_json::from_str(json).unwrap();
522 assert!(parsed.system_instructions.is_none());
523 assert!(parsed.capabilities.is_none());
524 assert!(parsed.workspaces.is_empty());
525 assert!(parsed.tools.is_empty());
526 assert_eq!(
527 parsed.policies,
528 vec![
529 PolicyRule::Deny("run_command".to_string()),
530 PolicyRule::AllowAll,
531 ]
532 );
533 assert!(parsed.triggers.is_empty());
534 assert!(parsed.hooks.is_empty());
535 assert!(parsed.skills.is_empty());
536
537 assert!(parsed.gemini.is_none());
538 }
539
540 #[test]
541 fn agent_config_all_optional_fields_roundtrip() {
542 let config = AgentConfig {
543 workspaces: vec![PathBuf::from("/ws")],
544 skills: vec![PathBuf::from("/skills/test")],
545
546 conversation_id: Some("conv-123".to_string()),
547 save_dir: Some(PathBuf::from("/save")),
548 app_data_dir: Some(PathBuf::from("/app")),
549 response_schema: Some(JsonSchema::new(serde_json::json!({"type": "object"}))),
550 ..AgentConfig::default()
551 };
552 let json = serde_json::to_string(&config).unwrap();
553 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
554 assert_eq!(parsed.workspaces.len(), 1);
555 assert_eq!(parsed.conversation_id.as_deref(), Some("conv-123"));
556 assert_eq!(parsed.save_dir.as_ref().unwrap(), &PathBuf::from("/save"));
557 assert!(parsed.response_schema.is_some());
558 }
559
560 #[test]
561 fn agent_config_custom_tools_and_builtin_tools_coexist() {
562 let custom_tool = crate::tools::ToolDefinition {
566 name: "my_custom_tool".to_owned(),
567 description: "Does something custom".to_owned(),
568 parameter_schema: serde_json::json!({"type": "object", "properties": {}}),
569 };
570 let config = AgentConfig {
571 tools: vec![custom_tool],
572 capabilities: Some(CapabilitiesConfig {
573 enabled_tools: Some(vec![BuiltinTools::ViewFile, BuiltinTools::RunCommand]),
574 ..CapabilitiesConfig::default()
575 }),
576 ..AgentConfig::default()
577 };
578
579 let json = serde_json::to_string(&config).unwrap();
581 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
582
583 assert_eq!(parsed.tools.len(), 1);
585 assert_eq!(parsed.tools[0].name, "my_custom_tool");
586
587 let caps = parsed.capabilities.as_ref().unwrap();
589 let enabled = caps.enabled_tools.as_ref().unwrap();
590 assert_eq!(enabled.len(), 2);
591 assert!(enabled.contains(&BuiltinTools::ViewFile));
592 assert!(enabled.contains(&BuiltinTools::RunCommand));
593
594 assert!(caps.validate().is_ok());
596 }
597
598 #[test]
599 fn agent_config_custom_tools_only_no_builtins() {
600 let config = AgentConfig {
602 tools: vec![crate::tools::ToolDefinition {
603 name: "fetch_data".to_owned(),
604 description: "Fetches data".to_owned(),
605 parameter_schema: serde_json::json!({"type": "object"}),
606 }],
607 capabilities: Some(CapabilitiesConfig::custom_tools_only()),
608 ..AgentConfig::default()
609 };
610
611 let caps = config.capabilities.as_ref().unwrap();
612 assert!(caps.enabled_tools.as_ref().unwrap().is_empty());
613 assert!(caps.validate().is_ok());
614 assert_eq!(config.tools.len(), 1);
615 }
616
617 #[test]
620 fn local_agent_config_default() {
621 let config = LocalAgentConfig::default();
622 assert_eq!(config.agent.model, DEFAULT_MODEL);
623 }
624
625 #[test]
626 fn local_agent_config_from_agent_config() {
627 let agent_cfg = AgentConfig {
628 model: "gemini-3.5-flash".to_string(),
629 ..AgentConfig::default()
630 };
631 let local: LocalAgentConfig = agent_cfg.into();
632 assert_eq!(local.agent.model, "gemini-3.5-flash");
633 }
634
635 #[test]
636 fn local_agent_config_serde_roundtrip() {
637 let config = LocalAgentConfig::new(AgentConfig::default());
638 let json = serde_json::to_string(&config).unwrap();
639 let parsed: LocalAgentConfig = serde_json::from_str(&json).unwrap();
640 assert_eq!(parsed.agent.model, DEFAULT_MODEL);
641 }
642
643 #[test]
649 fn skills_serializes_as_skills_paths() {
650 let config = AgentConfig::builder()
651 .skills(vec![PathBuf::from("/skill/a.md")])
652 .build();
653 let json = serde_json::to_string(&config).unwrap();
654 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
655 assert!(
656 v.get("skills_paths").is_some(),
657 "Expected JSON key 'skills_paths', got: {json}"
658 );
659 assert!(
660 v.get("skills").is_none(),
661 "Should not have 'skills' key in JSON"
662 );
663 }
664
665 #[test]
666 fn skills_paths_deserializes_to_skills_field() {
667 let json = r#"{"skills_paths": ["/skill/a.md"]}"#;
668 let config: AgentConfig = serde_json::from_str(json).unwrap();
669 assert_eq!(config.skills.len(), 1);
670 assert_eq!(config.skills[0], PathBuf::from("/skill/a.md"));
671 }
672
673 #[test]
674 fn gemini_serializes_as_gemini_config() {
675 let config = AgentConfig::builder()
676 .gemini(super::super::GeminiConfig::default())
677 .build();
678 let json = serde_json::to_string(&config).unwrap();
679 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
680 assert!(
681 v.get("gemini_config").is_some(),
682 "Expected JSON key 'gemini_config', got: {json}"
683 );
684 assert!(
685 v.get("gemini").is_none(),
686 "Should not have 'gemini' key in JSON"
687 );
688 }
689
690 #[test]
691 fn gemini_config_deserializes_to_gemini_field() {
692 let json = r#"{"gemini_config": {"api_key": "test-key"}}"#;
693 let config: AgentConfig = serde_json::from_str(json).unwrap();
694 assert_eq!(
695 config.gemini.as_ref().unwrap().api_key.as_deref(),
696 Some("test-key")
697 );
698 }
699
700 #[test]
703 fn empty_vecs_omitted_from_json() {
704 let config = AgentConfig::default();
705 let json = serde_json::to_string(&config).unwrap();
706 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
707 for key in &[
709 "workspaces",
710 "tools",
711 "triggers",
712 "hooks",
713 "skills_paths",
714 "mcp_servers",
715 ] {
716 assert!(
717 v.get(key).is_none(),
718 "Empty vec field '{key}' should be omitted from JSON, got: {json}"
719 );
720 }
721 assert!(
723 v.get("policies").is_some(),
724 "policies should always be serialized"
725 );
726 }
727
728 #[test]
729 fn populated_vecs_included_in_json() {
730 let config = AgentConfig::builder()
731 .skills(vec![PathBuf::from("/skill.md")])
732 .workspaces(vec![PathBuf::from("/ws")])
733 .build();
734 let json = serde_json::to_string(&config).unwrap();
735 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
736 assert!(
737 v.get("skills_paths").is_some(),
738 "Non-empty skills should be present"
739 );
740 assert!(
741 v.get("workspaces").is_some(),
742 "Non-empty workspaces should be present"
743 );
744 }
745
746 #[test]
749 fn default_policies_deny_run_command_allow_rest() {
750 let config = AgentConfig::default();
751 assert_eq!(config.policies.len(), 2);
752 assert_eq!(
753 config.policies[0],
754 PolicyRule::Deny("run_command".to_string())
755 );
756 assert_eq!(config.policies[1], PolicyRule::AllowAll);
757 }
758
759 fn py_str_attr(module: &str, attr: &str) -> String {
768 pyo3::Python::initialize();
769 pyo3::Python::attach(|py| {
770 crate::runtime::venv::configure_python_sys_path(py)
771 .unwrap_or_else(|e| panic!("Failed to configure python sys.path: {e}"));
772 let m = crate::runtime::py_scripts::import_serialized(py, module)
773 .unwrap_or_else(|e| panic!("Failed to import {module}: {e}"));
774 m.getattr(attr)
775 .unwrap_or_else(|e| panic!("Failed to get {module}.{attr}: {e}"))
776 .extract::<String>()
777 .unwrap_or_else(|e| panic!("Failed to extract {module}.{attr} as String: {e}"))
778 })
779 }
780
781 #[test]
782 fn default_model_matches_python_sdk() {
783 let py_val = py_str_attr("google.antigravity.types", "DEFAULT_MODEL");
784 assert_eq!(
785 DEFAULT_MODEL, py_val,
786 "Rust DEFAULT_MODEL ({DEFAULT_MODEL}) != Python SDK ({py_val})"
787 );
788 }
789
790 #[test]
791 fn default_image_model_matches_python_sdk() {
792 let py_val = py_str_attr("google.antigravity.types", "DEFAULT_IMAGE_GENERATION_MODEL");
793 assert_eq!(
794 DEFAULT_IMAGE_GENERATION_MODEL, py_val,
795 "Rust DEFAULT_IMAGE_GENERATION_MODEL ({DEFAULT_IMAGE_GENERATION_MODEL}) != Python SDK ({py_val})"
796 );
797 }
798
799 #[test]
802 fn effective_api_key_prefers_per_model_key() {
803 let config = AgentConfig::builder()
804 .api_key("top-level-key")
805 .gemini(super::super::GeminiConfig {
806 api_key: Some("shared-key".into()),
807 base_url: None,
808 models: super::super::ModelConfig {
809 default: super::super::ModelEntry {
810 name: "gemini-3.5-flash".into(),
811 api_key: Some("per-model-key".into()),
812 generation: super::super::GenerationConfig::default(),
813 },
814 image_generation: super::super::ModelEntry {
815 name: "imagen-4.0-generate-preview-06-03".into(),
816 api_key: None,
817 generation: super::super::GenerationConfig::default(),
818 },
819 },
820 })
821 .build();
822 assert_eq!(config.effective_api_key().as_deref(), Some("per-model-key"));
823 }
824
825 #[test]
826 fn effective_api_key_falls_back_to_gemini_shared_key() {
827 let config = AgentConfig::builder()
828 .gemini(super::super::GeminiConfig {
829 api_key: Some("shared-key".into()),
830 ..Default::default()
831 })
832 .build();
833 assert_eq!(config.effective_api_key().as_deref(), Some("shared-key"));
834 }
835
836 #[test]
837 fn effective_api_key_falls_back_to_top_level() {
838 let config = AgentConfig::builder().api_key("top-level-key").build();
839 assert_eq!(config.effective_api_key().as_deref(), Some("top-level-key"));
840 }
841
842 #[test]
843 fn effective_api_key_none_without_any_key() {
844 let config = AgentConfig::builder().build();
850 let result = config.effective_api_key();
851 match std::env::var("GEMINI_API_KEY").ok() {
853 Some(env_key) => assert_eq!(result.as_deref(), Some(env_key.as_str())),
854 None => assert!(result.is_none()),
855 }
856 }
857
858 #[test]
859 fn initial_history_empty_by_default() {
860 let config = AgentConfig::default();
861 assert!(config.initial_history.is_empty());
862
863 let json = serde_json::to_string(&config).unwrap();
865 assert!(
866 !json.contains("initial_history"),
867 "empty initial_history should be skipped in JSON"
868 );
869 }
870
871 #[test]
872 fn initial_history_roundtrip() {
873 use crate::types::{ConversationMessage, MessageRole};
874
875 let config = AgentConfig::builder()
876 .initial_history(vec![
877 ConversationMessage {
878 role: MessageRole::User,
879 content: "Hello".to_string(),
880 },
881 ConversationMessage {
882 role: MessageRole::Model,
883 content: "Hi there!".to_string(),
884 },
885 ])
886 .build();
887
888 assert_eq!(config.initial_history.len(), 2);
889
890 let json = serde_json::to_string(&config).unwrap();
891 assert!(json.contains("initial_history"));
892
893 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
894 assert_eq!(parsed.initial_history.len(), 2);
895 assert_eq!(parsed.initial_history[0].role, MessageRole::User);
896 assert_eq!(parsed.initial_history[0].content, "Hello");
897 assert_eq!(parsed.initial_history[1].role, MessageRole::Model);
898 assert_eq!(parsed.initial_history[1].content, "Hi there!");
899 }
900}