1use std::fs;
35use std::path::{Path, PathBuf};
36
37use serde::{Deserialize, Serialize};
38
39pub use crate::chat_agents::OutcomeSharingPolicy as OutcomeSharing;
46
47pub const SENSITIVITY_PUBLIC: u8 = 0;
53
54pub const SENSITIVITY_LOCAL_DEFAULT: u8 = u8::MAX;
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum McpTransport {
68 Tcp { host: String, port: u16 },
70 Http {
72 url: String,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
76 credential_id: Option<String>,
77 },
78 Stdio { command: String, args: Vec<String> },
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum AgentBackendSpec {
88 LocalEngine { model_id: Option<String> },
91 RemoteMcp {
101 endpoint: String,
102 transport: McpTransport,
103 infer_tool: Option<String>,
104 model: Option<String>,
105 },
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum AgentSemanticFacet {
113 Classification,
114 Specialisation,
115 Geography,
116 Language,
117 Method,
118 Dataset,
119 Tool,
120 Constraint,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct AgentSemanticTag {
128 pub iri: String,
129 pub label: String,
130 pub facet: AgentSemanticFacet,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub broader_iri: Option<String>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
139pub struct AgentSemanticProfile {
140 #[serde(default)]
141 pub tags: Vec<AgentSemanticTag>,
142}
143
144impl AgentSemanticProfile {
145 pub fn focus_terms(&self) -> Vec<String> {
148 self.tags
149 .iter()
150 .map(|tag| tag.label.trim().to_ascii_lowercase())
151 .filter(|label| !label.is_empty())
152 .take(32)
153 .collect()
154 }
155
156 pub fn briefing(&self) -> String {
157 if self.tags.is_empty() {
158 return String::new();
159 }
160 let mut lines = Vec::new();
161 lines.push("[Agent semantic study profile — use as a bounded routing focus; it does not grant permissions]".to_string());
162 for tag in self.tags.iter().take(32) {
163 let facet = match tag.facet {
164 AgentSemanticFacet::Classification => "classification",
165 AgentSemanticFacet::Specialisation => "specialisation",
166 AgentSemanticFacet::Geography => "geography",
167 AgentSemanticFacet::Language => "language",
168 AgentSemanticFacet::Method => "method",
169 AgentSemanticFacet::Dataset => "dataset",
170 AgentSemanticFacet::Tool => "tool capability",
171 AgentSemanticFacet::Constraint => "constraint",
172 };
173 lines.push(format!("- {facet}: {} ({})", tag.label, tag.iri));
174 }
175 lines.join("\n")
176 }
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
184#[serde(rename_all = "snake_case")]
185pub enum ConversationAccess {
186 None,
187 #[default]
188 AddressedMessage,
189 SessionSummary,
190 PermittedHistory,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
195#[serde(rename_all = "snake_case")]
196pub enum RetrievalAccess {
197 None,
198 #[default]
199 PermittedScopes,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
204#[serde(rename_all = "snake_case")]
205pub enum AttachmentAccess {
206 None,
207 MetadataOnly,
208 #[default]
209 PermittedAttachments,
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
214#[serde(rename_all = "snake_case")]
215pub enum ContextVisibility {
216 #[default]
217 OwnerOnly,
218 NamedAgents,
219 SessionParticipants,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224pub struct AgentContextPolicy {
225 #[serde(default)]
226 pub conversation: ConversationAccess,
227 #[serde(default)]
228 pub retrieval: RetrievalAccess,
229 #[serde(default)]
230 pub attachments: AttachmentAccess,
231 #[serde(default)]
234 pub allowed_source_agents: Vec<String>,
235 #[serde(default)]
236 pub default_visibility: ContextVisibility,
237 #[serde(default)]
239 pub allowed_recipient_agents: Vec<String>,
240 #[serde(default)]
241 pub may_share_raw_prompt: bool,
242 #[serde(default)]
243 pub may_share_attachments: bool,
244 #[serde(default)]
245 pub may_share_graph_records: bool,
246 #[serde(default = "default_share_provenance")]
247 pub may_share_provenance: bool,
248 #[serde(default)]
250 pub require_turn_confirmation: bool,
251}
252
253const fn default_share_provenance() -> bool {
254 true
255}
256
257impl Default for AgentContextPolicy {
258 fn default() -> Self {
259 Self {
260 conversation: ConversationAccess::AddressedMessage,
261 retrieval: RetrievalAccess::PermittedScopes,
262 attachments: AttachmentAccess::PermittedAttachments,
263 allowed_source_agents: Vec::new(),
264 default_visibility: ContextVisibility::OwnerOnly,
265 allowed_recipient_agents: Vec::new(),
266 may_share_raw_prompt: false,
267 may_share_attachments: false,
268 may_share_graph_records: false,
269 may_share_provenance: true,
270 require_turn_confirmation: false,
271 }
272 }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
279pub struct AgentDataPolicy {
280 #[serde(default)]
283 pub allowed_ontology_ids: Vec<String>,
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
288#[serde(rename_all = "snake_case")]
289pub enum ModelResidencyPreference {
290 #[default]
291 OnDemand,
292 KeepWarm,
293 Pinned,
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
298#[serde(rename_all = "snake_case")]
299pub enum AgentJobPriority {
300 #[default]
301 Interactive,
302 Normal,
303 Background,
304}
305
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
308#[serde(rename_all = "snake_case")]
309pub enum RemoteConsentPolicy {
310 Never,
311 #[default]
312 PerTurn,
313 Preapproved,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320pub struct AgentExecutionPolicy {
321 #[serde(default)]
322 pub residency: ModelResidencyPreference,
323 #[serde(default)]
324 pub priority: AgentJobPriority,
325 #[serde(default = "default_max_parallel_turns")]
326 pub max_parallel_turns: u8,
327 #[serde(default)]
328 pub remote_consent: RemoteConsentPolicy,
329 #[serde(default)]
330 pub allow_scheduled_runs: bool,
331}
332
333const fn default_max_parallel_turns() -> u8 {
334 1
335}
336
337impl Default for AgentExecutionPolicy {
338 fn default() -> Self {
339 Self {
340 residency: ModelResidencyPreference::OnDemand,
341 priority: AgentJobPriority::Interactive,
342 max_parallel_turns: 1,
343 remote_consent: RemoteConsentPolicy::PerTurn,
344 allow_scheduled_runs: false,
345 }
346 }
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356pub struct AgentDefinition {
357 pub slug: String,
359 pub display_name: String,
361 pub description: String,
363 pub backend: AgentBackendSpec,
365 pub system_prompt: String,
367 #[serde(default)]
370 pub allowed_mcp_tools: Vec<String>,
371 #[serde(default)]
373 pub max_sensitivity: u8,
374 #[serde(default)]
376 pub outcome_sharing: OutcomeSharing,
377 #[serde(default)]
380 pub semantic_profile: AgentSemanticProfile,
381 #[serde(default)]
384 pub context_policy: AgentContextPolicy,
385 #[serde(default)]
388 pub data_policy: AgentDataPolicy,
389 #[serde(default)]
392 pub execution_policy: AgentExecutionPolicy,
393 #[serde(default)]
395 pub roster_version: u16,
396 pub enabled: bool,
398 pub created_at_unix: u64,
400 pub updated_at_unix: u64,
402}
403
404impl AgentDefinition {
405 pub fn new(
411 slug: impl Into<String>,
412 display_name: impl Into<String>,
413 description: impl Into<String>,
414 backend: AgentBackendSpec,
415 system_prompt: impl Into<String>,
416 ) -> Self {
417 let now = unix_now();
418 Self {
419 slug: slug.into(),
420 display_name: display_name.into(),
421 description: description.into(),
422 backend,
423 system_prompt: system_prompt.into(),
424 allowed_mcp_tools: Vec::new(),
425 max_sensitivity: SENSITIVITY_PUBLIC,
426 outcome_sharing: OutcomeSharing::default(),
427 semantic_profile: AgentSemanticProfile::default(),
428 context_policy: AgentContextPolicy::default(),
429 data_policy: AgentDataPolicy::default(),
430 execution_policy: AgentExecutionPolicy::default(),
431 roster_version: 1,
432 enabled: true,
433 created_at_unix: now,
434 updated_at_unix: now,
435 }
436 }
437
438 pub fn has_tool(&self, tool: &str) -> bool {
443 self.allowed_mcp_tools.iter().any(|t| t == "*" || t == tool)
444 }
445}
446
447pub fn default_local_agent() -> AgentDefinition {
458 let now = unix_now();
459 AgentDefinition {
460 slug: "local".to_string(),
461 display_name: "Your local agent".to_string(),
462 description: "Runs entirely on this device via the native Qualia inference engine. \
463 No data leaves the principal's control, so it is the preferred agent for all \
464 work — especially anything sensitive."
465 .to_string(),
466 backend: AgentBackendSpec::LocalEngine { model_id: None },
467 system_prompt: "You are a software agent acting on behalf of, and under the authority \
468 of, your human principal. You run locally on the principal's own device via the \
469 native engine; their data does not leave their control. Ground every answer in the \
470 principal's own records and cite the provenance you relied on; if you cannot ground \
471 a claim, say so plainly rather than inventing one. Spend the principal's time and \
472 resources only on the purpose they have declared, and defer to their explicit \
473 decisions at all times."
474 .to_string(),
475 allowed_mcp_tools: Vec::new(),
476 max_sensitivity: SENSITIVITY_LOCAL_DEFAULT,
477 outcome_sharing: OutcomeSharing::default(),
478 semantic_profile: AgentSemanticProfile::default(),
479 context_policy: AgentContextPolicy::default(),
480 data_policy: AgentDataPolicy::default(),
481 execution_policy: AgentExecutionPolicy::default(),
482 roster_version: 1,
483 enabled: true,
484 created_at_unix: now,
485 updated_at_unix: now,
486 }
487}
488
489pub fn bind_agent_did(principal_did: &str, session_id: &str) -> String {
501 crate::chat_agents::compile_sub_agent_did(principal_did, session_id)
502}
503
504fn roster_path(storage_root: &Path) -> PathBuf {
509 storage_root.join("Agents").join("roster.json")
510}
511
512fn unix_now() -> u64 {
513 std::time::SystemTime::now()
514 .duration_since(std::time::UNIX_EPOCH)
515 .unwrap_or_default()
516 .as_secs()
517}
518
519pub fn load_roster(storage_root: &Path) -> Vec<AgentDefinition> {
526 let roster = fs::read_to_string(roster_path(storage_root))
527 .ok()
528 .filter(|t| !t.trim().is_empty())
529 .and_then(|t| serde_json::from_str::<Vec<AgentDefinition>>(&t).ok())
530 .unwrap_or_default();
531
532 if roster.is_empty() {
533 vec![default_local_agent()]
534 } else {
535 roster
536 }
537}
538
539pub fn save_roster(storage_root: &Path, roster: &[AgentDefinition]) -> Result<(), String> {
542 let path = roster_path(storage_root);
543 if let Some(parent) = path.parent() {
544 fs::create_dir_all(parent).map_err(|e| e.to_string())?;
545 }
546 let text = serde_json::to_string_pretty(roster).map_err(|e| e.to_string())?;
547 fs::write(path, text).map_err(|e| e.to_string())
548}
549
550pub fn upsert_agent(storage_root: &Path, agent: AgentDefinition) -> Result<(), String> {
559 upsert_agent_at(storage_root, agent, unix_now())
560}
561
562pub fn upsert_agent_at(
565 storage_root: &Path,
566 mut agent: AgentDefinition,
567 now_unix: u64,
568) -> Result<(), String> {
569 validate_agent(&agent)?;
570 agent.updated_at_unix = now_unix;
571 let mut roster = load_roster(storage_root);
572 if let Some(slot) = roster.iter_mut().find(|a| a.slug == agent.slug) {
573 agent.created_at_unix = slot.created_at_unix;
575 *slot = agent;
576 } else {
577 roster.push(agent);
578 }
579 save_roster(storage_root, &roster)
580}
581
582pub fn validate_agent(agent: &AgentDefinition) -> Result<(), String> {
586 let slug = agent.slug.trim();
587 if slug.is_empty() || slug.len() > 64 {
588 return Err("agent slug must contain 1-64 characters".to_string());
589 }
590 if !slug
591 .bytes()
592 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
593 {
594 return Err(
595 "agent slug may contain only lowercase letters, digits, and hyphens".to_string(),
596 );
597 }
598 if agent.display_name.trim().is_empty() || agent.display_name.chars().count() > 96 {
599 return Err("agent name must contain 1-96 characters".to_string());
600 }
601 if agent.system_prompt.len() > 32 * 1024 {
602 return Err("agent instructions exceed the 32 KiB limit".to_string());
603 }
604 if agent.execution_policy.max_parallel_turns == 0
605 || agent.execution_policy.max_parallel_turns > 4
606 {
607 return Err("agent max_parallel_turns must be between 1 and 4".to_string());
608 }
609 if agent.semantic_profile.tags.len() > 32 {
610 return Err("agent semantic profile supports at most 32 tags".to_string());
611 }
612 for tag in &agent.semantic_profile.tags {
613 if tag.iri.trim().is_empty() || tag.iri.len() > 256 {
614 return Err(
615 "each agent semantic tag needs an IRI of at most 256 characters".to_string(),
616 );
617 }
618 if tag.label.trim().is_empty() || tag.label.chars().count() > 96 {
619 return Err("each agent semantic tag needs a label of 1-96 characters".to_string());
620 }
621 if tag
622 .broader_iri
623 .as_deref()
624 .is_some_and(|iri| iri.trim().is_empty() || iri.len() > 256)
625 {
626 return Err("agent semantic tag broader IRI is invalid".to_string());
627 }
628 }
629 if agent.data_policy.allowed_ontology_ids.len() > 32 {
630 return Err("agent data policy supports at most 32 ontology IDs".to_string());
631 }
632 if agent.data_policy.allowed_ontology_ids.iter().any(|id| {
633 id.trim().is_empty() || id.len() > 160 || id.contains(['/', '\\', '\0'])
634 }) {
635 return Err("agent data policy has an invalid ontology ID".to_string());
636 }
637 match &agent.backend {
638 AgentBackendSpec::LocalEngine { model_id } => {
639 if model_id.as_deref().is_some_and(|id| id.trim().is_empty()) {
640 return Err("local agent model_id must not be blank".to_string());
641 }
642 }
643 AgentBackendSpec::RemoteMcp { endpoint, .. } if endpoint.trim().is_empty() => {
644 return Err("remote agent endpoint is required".to_string());
645 }
646 AgentBackendSpec::RemoteMcp { .. } => {}
647 }
648 Ok(())
649}
650
651pub fn remove_agent(storage_root: &Path, slug: &str) -> Result<(), String> {
657 let mut roster = load_roster(storage_root);
658 roster.retain(|a| a.slug != slug);
659 save_roster(storage_root, &roster)
660}
661
662pub fn get_agent(storage_root: &Path, slug: &str) -> Option<AgentDefinition> {
664 load_roster(storage_root)
665 .into_iter()
666 .find(|a| a.slug == slug)
667}
668
669#[cfg(test)]
674mod tests {
675 use super::*;
676 use tempfile::tempdir;
677
678 fn remote(slug: &str, transport: McpTransport) -> AgentDefinition {
679 AgentDefinition {
680 slug: slug.to_string(),
681 display_name: format!("Remote {slug}"),
682 description: "external provider".to_string(),
683 backend: AgentBackendSpec::RemoteMcp {
684 endpoint: "https://provider.example/mcp".to_string(),
685 transport,
686 infer_tool: Some("infer".to_string()),
687 model: Some("big-model".to_string()),
688 },
689 system_prompt: "persona".to_string(),
690 allowed_mcp_tools: vec!["search".to_string()],
691 max_sensitivity: SENSITIVITY_PUBLIC,
692 outcome_sharing: OutcomeSharing::default(),
693 semantic_profile: AgentSemanticProfile::default(),
694 context_policy: AgentContextPolicy::default(),
695 data_policy: AgentDataPolicy::default(),
696 execution_policy: AgentExecutionPolicy::default(),
697 roster_version: 1,
698 enabled: true,
699 created_at_unix: 100,
700 updated_at_unix: 100,
701 }
702 }
703
704 #[test]
705 fn default_local_agent_shape() {
706 let a = default_local_agent();
707 assert_eq!(a.slug, "local");
708 assert_eq!(a.display_name, "Your local agent");
709 assert!(matches!(
710 a.backend,
711 AgentBackendSpec::LocalEngine { model_id: None }
712 ));
713 assert!(a.enabled);
714 assert!(a.allowed_mcp_tools.is_empty());
715 assert_eq!(a.max_sensitivity, SENSITIVITY_LOCAL_DEFAULT);
716 assert!(!a.has_tool("anything"));
718 }
719
720 #[test]
721 fn new_uses_conservative_defaults() {
722 let a = AgentDefinition::new(
723 "claude",
724 "Claude",
725 "remote",
726 AgentBackendSpec::RemoteMcp {
727 endpoint: "e".to_string(),
728 transport: McpTransport::Http {
729 url: "https://x/mcp".to_string(),
730 credential_id: None,
731 },
732 infer_tool: None,
733 model: None,
734 },
735 "persona",
736 );
737 assert!(a.enabled);
738 assert!(a.allowed_mcp_tools.is_empty());
739 assert_eq!(a.max_sensitivity, SENSITIVITY_PUBLIC);
740 assert_eq!(a.outcome_sharing, OutcomeSharing::default());
741 assert_eq!(a.context_policy, AgentContextPolicy::default());
742 assert_eq!(a.execution_policy, AgentExecutionPolicy::default());
743 assert!(a.semantic_profile.tags.is_empty());
744 assert_eq!(a.created_at_unix, a.updated_at_unix);
745 }
746
747 #[test]
748 fn legacy_roster_without_new_policies_loads_safe_defaults() {
749 let dir = tempdir().unwrap();
750 save_blob(
751 dir.path(),
752 r#"[{"slug":"legacy","display_name":"Legacy","description":"x","backend":{"local_engine":{"model_id":null}},"system_prompt":"","allowed_mcp_tools":[],"max_sensitivity":0,"outcome_sharing":{"visibility":"owner_only","share_provenance":true,"share_model_attribution":false,"allow_peer_llm_context":false,"allowed_dids":[]},"enabled":true,"created_at_unix":1,"updated_at_unix":1}]"#,
753 );
754 let legacy = get_agent(dir.path(), "legacy").unwrap();
755 assert_eq!(legacy.context_policy, AgentContextPolicy::default());
756 assert_eq!(legacy.execution_policy, AgentExecutionPolicy::default());
757 assert_eq!(legacy.roster_version, 0);
758 }
759
760 #[test]
761 fn rejects_invalid_stable_slug_and_parallelism() {
762 let dir = tempdir().unwrap();
763 let mut a = default_local_agent();
764 a.slug = "Not stable".to_string();
765 assert!(upsert_agent(dir.path(), a).is_err());
766
767 let mut a = default_local_agent();
768 a.execution_policy.max_parallel_turns = 5;
769 assert!(upsert_agent(dir.path(), a).is_err());
770 }
771
772 #[test]
773 fn semantic_profile_is_bounded_and_produces_routing_focus() {
774 let mut agent = default_local_agent();
775 agent.semantic_profile.tags = vec![
776 AgentSemanticTag {
777 iri: "q42:Researcher".to_string(),
778 label: "Researcher".to_string(),
779 facet: AgentSemanticFacet::Classification,
780 broader_iri: None,
781 },
782 AgentSemanticTag {
783 iri: "q42:AustralianHistory".to_string(),
784 label: "Australian History".to_string(),
785 facet: AgentSemanticFacet::Specialisation,
786 broader_iri: Some("q42:History".to_string()),
787 },
788 ];
789 assert_eq!(
790 agent.semantic_profile.focus_terms(),
791 vec!["researcher", "australian history"]
792 );
793 assert!(
794 agent
795 .semantic_profile
796 .briefing()
797 .contains("Australian History")
798 );
799 validate_agent(&agent).unwrap();
800 }
801
802 #[test]
803 fn has_tool_honors_membership_and_wildcard() {
804 let mut a = default_local_agent();
805 assert!(!a.has_tool("read"));
806
807 a.allowed_mcp_tools = vec!["read".to_string(), "write".to_string()];
808 assert!(a.has_tool("read"));
809 assert!(a.has_tool("write"));
810 assert!(!a.has_tool("delete"));
811
812 a.allowed_mcp_tools = vec!["*".to_string()];
813 assert!(a.has_tool("read"));
814 assert!(a.has_tool("literally-anything"));
815 }
816
817 #[test]
818 fn load_roster_seeds_when_missing_and_does_not_write() {
819 let dir = tempdir().unwrap();
820 let roster = load_roster(dir.path());
821 assert_eq!(roster.len(), 1);
822 assert_eq!(roster[0].slug, "local");
823 assert!(!roster_path(dir.path()).exists());
825 }
826
827 #[test]
828 fn load_roster_seeds_on_empty_and_empty_array_files() {
829 let dir = tempdir().unwrap();
830 save_blob(dir.path(), " \n");
832 assert_eq!(load_roster(dir.path())[0].slug, "local");
833 save_blob(dir.path(), "[]");
835 assert_eq!(load_roster(dir.path()).len(), 1);
836 assert_eq!(load_roster(dir.path())[0].slug, "local");
837 }
838
839 fn save_blob(root: &Path, blob: &str) {
840 let path = roster_path(root);
841 fs::create_dir_all(path.parent().unwrap()).unwrap();
842 fs::write(path, blob).unwrap();
843 }
844
845 #[test]
846 fn save_then_load_roundtrip_all_transports() {
847 let dir = tempdir().unwrap();
848 let roster = vec![
849 default_local_agent(),
850 remote(
851 "tcp-agent",
852 McpTransport::Tcp {
853 host: "10.0.0.1".to_string(),
854 port: 9000,
855 },
856 ),
857 remote(
858 "http-agent",
859 McpTransport::Http {
860 url: "https://x/mcp".to_string(),
861 credential_id: None,
862 },
863 ),
864 remote(
865 "stdio-agent",
866 McpTransport::Stdio {
867 command: "mcp-server".to_string(),
868 args: vec!["--flag".to_string(), "v".to_string()],
869 },
870 ),
871 ];
872 save_roster(dir.path(), &roster).unwrap();
873 let back = load_roster(dir.path());
874 assert_eq!(back, roster);
875 }
876
877 #[test]
878 fn upsert_appends_new_then_replaces_by_slug() {
879 let dir = tempdir().unwrap();
880
881 let mut a = remote(
883 "worker",
884 McpTransport::Http {
885 url: "u".to_string(),
886 credential_id: None,
887 },
888 );
889 a.created_at_unix = 100;
890 a.updated_at_unix = 100;
891 upsert_agent_at(dir.path(), a, 555).unwrap();
892
893 let roster = load_roster(dir.path());
894 assert!(roster.iter().any(|x| x.slug == "local"));
896 let stored = get_agent(dir.path(), "worker").unwrap();
897 assert_eq!(stored.created_at_unix, 100, "created preserved on append");
898 assert_eq!(stored.updated_at_unix, 555, "updated bumped to now_unix");
899
900 let mut edited = remote(
902 "worker",
903 McpTransport::Http {
904 url: "u2".to_string(),
905 credential_id: None,
906 },
907 );
908 edited.display_name = "renamed".to_string();
909 edited.created_at_unix = 9999; upsert_agent_at(dir.path(), edited, 777).unwrap();
911
912 let after = get_agent(dir.path(), "worker").unwrap();
913 assert_eq!(after.display_name, "renamed");
914 assert_eq!(after.created_at_unix, 100, "created preserved on replace");
915 assert_eq!(after.updated_at_unix, 777);
916 let count = load_roster(dir.path())
918 .iter()
919 .filter(|x| x.slug == "worker")
920 .count();
921 assert_eq!(count, 1);
922 }
923
924 #[test]
925 fn remove_agent_removes_and_is_noop_for_absent() {
926 let dir = tempdir().unwrap();
927 upsert_agent_at(
928 dir.path(),
929 remote(
930 "gone",
931 McpTransport::Http {
932 url: "u".to_string(),
933 credential_id: None,
934 },
935 ),
936 1,
937 )
938 .unwrap();
939 assert!(get_agent(dir.path(), "gone").is_some());
940
941 remove_agent(dir.path(), "gone").unwrap();
942 assert!(get_agent(dir.path(), "gone").is_none());
943
944 remove_agent(dir.path(), "never-existed").unwrap();
946 }
947
948 #[test]
949 fn removing_last_agent_reseeds_local_on_next_load() {
950 let dir = tempdir().unwrap();
951 save_roster(dir.path(), &[default_local_agent()]).unwrap();
953 remove_agent(dir.path(), "local").unwrap();
954 let roster = load_roster(dir.path());
956 assert_eq!(roster.len(), 1);
957 assert_eq!(roster[0].slug, "local");
958 }
959
960 #[test]
961 fn get_agent_found_and_missing() {
962 let dir = tempdir().unwrap();
963 assert!(get_agent(dir.path(), "local").is_some());
965 assert!(get_agent(dir.path(), "nope").is_none());
966 }
967
968 #[test]
969 fn bind_agent_did_matches_chat_layer_and_is_deterministic() {
970 let a = bind_agent_did("did:qualia:root:abc", "sess-1");
971 let b = bind_agent_did("did:qualia:root:abc", "sess-1");
972 assert_eq!(a, b);
973 assert!(a.starts_with("did:qualia:subagent:"));
974 assert_eq!(
975 a,
976 crate::chat_agents::compile_sub_agent_did("did:qualia:root:abc", "sess-1")
977 );
978 }
979}