ralph_workflow/agents/
validation.rs1use crate::agents::fallback::{AgentDrain, FallbackConfig, ResolvedDrainConfig};
11use crate::agents::opencode_api::ApiCatalog;
12use crate::agents::opencode_resolver::OpenCodeResolver;
13use std::collections::BTreeSet;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum OpenCodeValidationError {
17 InvalidReferences { messages: Vec<String> },
18}
19
20impl std::fmt::Display for OpenCodeValidationError {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 match self {
23 Self::InvalidReferences { messages } => write!(f, "{}", messages.join("\n\n")),
24 }
25 }
26}
27
28impl std::error::Error for OpenCodeValidationError {}
29
30pub fn validate_opencode_agents(
42 resolved: &ResolvedDrainConfig,
43 catalog: &ApiCatalog,
44) -> Result<(), OpenCodeValidationError> {
45 validate_opencode_agents_in_resolved_drains(resolved, catalog)
46}
47
48pub fn validate_opencode_agents_legacy(
56 fallback: &FallbackConfig,
57 catalog: &ApiCatalog,
58) -> Result<(), OpenCodeValidationError> {
59 validate_opencode_agents_in_resolved_drains(&fallback.resolve_drains(), catalog)
60}
61
62pub fn validate_opencode_agents_in_resolved_drains(
68 resolved: &ResolvedDrainConfig,
69 catalog: &ApiCatalog,
70) -> Result<(), OpenCodeValidationError> {
71 let opencode_resolver = OpenCodeResolver::new(catalog.clone());
72
73 let all_agents = get_opencode_refs_in_resolved_drains(resolved);
74
75 let errors: Vec<_> = all_agents
77 .iter()
78 .filter_map(|agent_name| {
79 parse_opencode_ref(agent_name).and_then(|(provider, model)| {
80 opencode_resolver
81 .validate(&provider, &model)
82 .err()
83 .map(|e| opencode_resolver.format_error(&e, agent_name))
84 })
85 })
86 .collect();
87
88 if errors.is_empty() {
89 Ok(())
90 } else {
91 Err(OpenCodeValidationError::InvalidReferences { messages: errors })
92 }
93}
94
95fn parse_opencode_ref(agent_name: &str) -> Option<(String, String)> {
99 if !agent_name.starts_with("opencode/") {
100 return None;
101 }
102
103 let parts: Vec<&str> = agent_name.split('/').collect();
104 if parts.len() != 3 {
105 return None;
106 }
107
108 let provider = parts.get(1)?.to_string();
109 let model = parts.get(2)?.to_string();
110
111 Some((provider, model))
112}
113
114#[must_use]
116pub fn get_opencode_refs(resolved: &ResolvedDrainConfig) -> Vec<String> {
117 get_opencode_refs_in_resolved_drains(resolved)
118}
119
120#[must_use]
122pub fn get_opencode_refs_legacy(fallback: &FallbackConfig) -> Vec<String> {
123 get_opencode_refs_in_resolved_drains(&fallback.resolve_drains())
124}
125
126#[must_use]
128pub fn get_opencode_refs_in_resolved_drains(resolved: &ResolvedDrainConfig) -> Vec<String> {
129 let unique = AgentDrain::all()
130 .into_iter()
131 .filter_map(|drain| resolved.binding(drain))
132 .flat_map(|binding| binding.agents.iter().cloned())
133 .filter(|name| name.starts_with("opencode/"))
134 .collect::<BTreeSet<_>>();
135
136 unique.into_iter().collect()
137}
138
139#[cfg(test)]
141fn count_opencode_refs(resolved: &ResolvedDrainConfig) -> usize {
142 AgentDrain::all()
143 .into_iter()
144 .filter_map(|drain| resolved.binding(drain))
145 .flat_map(|binding| binding.agents.iter())
146 .filter(|name| name.starts_with("opencode/"))
147 .cloned()
148 .collect::<BTreeSet<_>>()
149 .len()
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::agents::opencode_api::{Model, Provider};
156 use std::collections::HashMap;
157
158 fn mock_catalog() -> ApiCatalog {
159 let providers = HashMap::from([(
160 "anthropic".to_string(),
161 Provider {
162 id: "anthropic".to_string(),
163 name: "Anthropic".to_string(),
164 description: "Anthropic Claude models".to_string(),
165 },
166 )]);
167
168 let models = HashMap::from([(
169 "anthropic".to_string(),
170 vec![Model {
171 id: "claude-sonnet-4-5".to_string(),
172 name: "Claude Sonnet 4.5".to_string(),
173 description: "Latest Claude Sonnet".to_string(),
174 context_length: Some(200_000),
175 }],
176 )]);
177
178 ApiCatalog {
179 providers,
180 models,
181 cached_at: Some(chrono::Utc::now()),
182 ttl_seconds: 86400,
183 }
184 }
185
186 fn create_fallback_with_refs(refs: &[&str]) -> FallbackConfig {
187 FallbackConfig {
188 developer: refs.iter().map(|s| (*s).to_string()).collect(),
189 ..FallbackConfig::default()
190 }
191 }
192
193 #[test]
194 fn test_parse_opencode_ref_valid() {
195 let result = parse_opencode_ref("opencode/anthropic/claude-sonnet-4-5");
196 assert_eq!(
197 result,
198 Some(("anthropic".to_string(), "claude-sonnet-4-5".to_string()))
199 );
200 }
201
202 #[test]
203 fn test_parse_opencode_ref_invalid() {
204 assert_eq!(parse_opencode_ref("claude"), None);
205 assert_eq!(parse_opencode_ref("opencode"), None);
206 assert_eq!(parse_opencode_ref("opencode/anthropic"), None);
207 assert_eq!(parse_opencode_ref("ccs/glm"), None);
208 }
209
210 #[test]
211 fn test_validate_opencode_agents_valid() {
212 let catalog = mock_catalog();
213 let fallback = create_fallback_with_refs(&["opencode/anthropic/claude-sonnet-4-5"]);
214 let resolved = fallback.resolve_drains();
215
216 let result = validate_opencode_agents(&resolved, &catalog);
217 assert!(result.is_ok());
218 }
219
220 #[test]
221 fn test_validate_opencode_agents_invalid_provider() {
222 let catalog = mock_catalog();
223 let fallback = create_fallback_with_refs(&["opencode/unknown/claude-sonnet-4-5"]);
224 let resolved = fallback.resolve_drains();
225
226 let result = validate_opencode_agents(&resolved, &catalog);
227 assert!(result.is_err());
228 assert!(result
229 .expect_err("expected invalid provider error")
230 .to_string()
231 .contains("unknown"));
232 }
233
234 #[test]
235 fn test_validate_opencode_agents_returns_typed_error() {
236 let catalog = mock_catalog();
237 let fallback = create_fallback_with_refs(&["opencode/unknown/claude-sonnet-4-5"]);
238 let resolved = fallback.resolve_drains();
239
240 let error = validate_opencode_agents(&resolved, &catalog)
241 .expect_err("invalid provider should return a typed validation error");
242
243 assert!(matches!(
244 error,
245 OpenCodeValidationError::InvalidReferences { .. }
246 ));
247 }
248
249 #[test]
250 fn test_validate_opencode_agents_invalid_model() {
251 let catalog = mock_catalog();
252 let fallback = create_fallback_with_refs(&["opencode/anthropic/unknown-model"]);
253 let resolved = fallback.resolve_drains();
254
255 let result = validate_opencode_agents(&resolved, &catalog);
256 assert!(result.is_err());
257 assert!(result
258 .expect_err("expected invalid model error")
259 .to_string()
260 .contains("unknown-model"));
261 }
262
263 #[test]
264 fn test_count_opencode_refs() {
265 let fallback = create_fallback_with_refs(&[
266 "opencode/anthropic/claude-sonnet-4-5",
267 "claude",
268 "opencode/openai/gpt-4",
269 ]);
270 let resolved = fallback.resolve_drains();
271
272 let count = count_opencode_refs(&resolved);
273 assert_eq!(count, 2);
274 }
275
276 #[test]
277 fn test_get_opencode_refs() {
278 let fallback = create_fallback_with_refs(&[
279 "opencode/anthropic/claude-sonnet-4-5",
280 "claude",
281 "opencode/openai/gpt-4",
282 ]);
283 let resolved = fallback.resolve_drains();
284
285 let refs = get_opencode_refs(&resolved);
286 assert_eq!(refs.len(), 2);
287 assert!(refs.contains(&"opencode/anthropic/claude-sonnet-4-5".to_string()));
288 assert!(refs.contains(&"opencode/openai/gpt-4".to_string()));
289 }
290}