1use serde::Serialize;
34
35use crate::backend::reactive::handler::{LookupOutcome, ReactiveHandler};
36use crate::backend::storage::store::Store;
37
38#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
40pub struct AgentCandidate {
41 pub uid: Option<String>,
44 pub name: String,
46 #[serde(skip_serializing_if = "String::is_empty")]
47 pub slug: String,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub block_id: Option<String>,
51 pub live: bool,
52}
53
54#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
56#[serde(tag = "resolution", rename_all = "snake_case")]
57pub enum NameResolution {
58 One {
60 uid: String,
61 candidate: AgentCandidate,
62 },
63 Ambiguous {
65 candidates: Vec<AgentCandidate>,
66 },
67 Unidentified {
73 candidate: AgentCandidate,
74 },
75 None,
76}
77
78pub(crate) fn resolve_name_to_uid(
89 store: &Store,
90 registry: &ReactiveHandler,
91 typed: &str,
92) -> Result<NameResolution, String> {
93 let typed = typed.trim();
94 if typed.is_empty() {
95 return Ok(NameResolution::None);
96 }
97
98 let as_uid = store
100 .agent_def_get_strict(typed)
101 .map_err(|e| store_fault(typed, e))?;
102 if let Some(def) = as_uid {
103 if def.is_seeded == 0 {
104 let candidate = AgentCandidate {
105 uid: Some(def.id.clone()),
106 name: if def.name.is_empty() {
107 def.slug.clone()
108 } else {
109 def.name.clone()
110 },
111 slug: def.slug.clone(),
112 block_id: registry.get_agent(typed).map(|r| r.block_id),
113 live: registry.get_agent(typed).is_some(),
114 };
115 return Ok(NameResolution::One {
116 uid: def.id,
117 candidate,
118 });
119 }
120 }
121
122 let mut candidates: Vec<AgentCandidate> = Vec::new();
123
124 let live: Vec<crate::backend::reactive::AgentRegistration> =
126 match registry.lookup_by_name(typed) {
127 LookupOutcome::One(reg) => vec![reg],
128 LookupOutcome::Ambiguous(regs) => regs,
129 LookupOutcome::NotFound => Vec::new(),
130 };
131 for reg in live {
132 let uid = reg
137 .uid
138 .clone()
139 .or_else(|| crate::backend::agent_resolve::uid_for_block(store, ®.block_id));
140 candidates.push(AgentCandidate {
141 uid,
142 name: reg.agent_id.clone(),
143 slug: String::new(),
144 block_id: Some(reg.block_id.clone()),
145 live: true,
146 });
147 }
148
149 let rows = store
151 .agents_matching_name(typed)
152 .map_err(|e| store_fault(typed, e))?;
153 for row in rows {
154 if let Some(existing) = candidates
155 .iter_mut()
156 .find(|c| c.uid.as_deref() == Some(row.id.as_str()))
157 {
158 if existing.slug.is_empty() {
160 existing.slug = row.slug.clone();
161 }
162 continue;
163 }
164 candidates.push(AgentCandidate {
165 uid: Some(row.id),
166 name: if row.instance_name.is_empty() {
167 row.name
168 } else {
169 row.instance_name
170 },
171 slug: row.slug,
172 block_id: None,
173 live: false,
174 });
175 }
176
177 match candidates.len() {
178 0 => Ok(NameResolution::None),
182 1 => {
183 let candidate = candidates.remove(0);
184 match candidate.uid.clone() {
185 Some(uid) => Ok(NameResolution::One { uid, candidate }),
186 None => {
187 record("resolve.unidentified");
188 Ok(NameResolution::Unidentified { candidate })
189 }
190 }
191 }
192 _ => Ok(NameResolution::Ambiguous { candidates }),
193 }
194}
195
196fn store_fault(typed: &str, e: impl std::fmt::Display) -> String {
197 tracing::warn!(name = %typed, error = %e, "resolve_name_to_uid: store read failed — refusing to resolve");
198 record("resolve.store_error");
199 format!("agent store unavailable: {e}")
200}
201
202fn record(site: &'static str) {
203 crate::backend::agent_resolve::record_uid_fallback(site);
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use crate::backend::storage::agents::test_agent_def;
210
211 const UID_Y: &str = "4f3c0000-0000-4000-8000-000000000a91";
212 const UID_UP: &str = "9b2e0000-0000-4000-8000-0000000007d4";
213
214 fn store_with(rows: &[(&str, &str, &str)]) -> Store {
215 let store = Store::open_in_memory().unwrap();
216 for (id, name, slug) in rows {
217 let mut def = test_agent_def(id, name, "claude", "agent", 1, "");
218 def.slug = slug.to_string();
219 store.agent_def_insert(&mut def).unwrap();
220 }
221 store
222 }
223
224 fn registry() -> ReactiveHandler {
225 ReactiveHandler::new()
226 }
227
228 #[test]
229 fn a_uid_resolves_to_itself() {
230 let store = store_with(&[(UID_Y, "AgentY", "agenty")]);
231 let r = resolve_name_to_uid(&store, ®istry(), UID_Y).unwrap();
232 assert!(
233 matches!(r, NameResolution::One { ref uid, .. } if uid == UID_Y),
234 "{r:?}"
235 );
236 }
237
238 #[test]
242 fn a_unique_display_name_resolves_to_the_rows_uid() {
243 let store = store_with(&[(UID_Y, "AgentY", "agenty")]);
244 for typed in ["AgentY", "agenty", "AGENTY"] {
245 let r = resolve_name_to_uid(&store, ®istry(), typed).unwrap();
246 assert!(
247 matches!(r, NameResolution::One { ref uid, .. } if uid == UID_Y),
248 "{typed}: {r:?}"
249 );
250 }
251 }
252
253 #[test]
255 fn colliding_names_are_ambiguous_with_both_candidates_listed() {
256 let store = store_with(&[(UID_Y, "AgentY", "agenty"), (UID_UP, "AGENTY", "agenty-2")]);
257 let r = resolve_name_to_uid(&store, ®istry(), "agenty").unwrap();
258 let NameResolution::Ambiguous { candidates } = r else {
259 panic!("expected Ambiguous, got {r:?}");
260 };
261 let uids: Vec<&str> = candidates.iter().filter_map(|c| c.uid.as_deref()).collect();
262 assert!(
263 uids.contains(&UID_Y) && uids.contains(&UID_UP),
264 "{candidates:?}"
265 );
266 let r = resolve_name_to_uid(&store, ®istry(), "agenty-2").unwrap();
268 assert!(
269 matches!(r, NameResolution::One { ref uid, .. } if uid == UID_UP),
270 "{r:?}"
271 );
272 }
273
274 #[test]
277 fn a_live_agent_and_its_row_merge_into_one_live_candidate() {
278 let store = store_with(&[(UID_Y, "AgentY", "agenty")]);
279 let reg = registry();
280 reg.register_agent_full("AgentY", "block-y", None, 0, None, Some(UID_Y), "test.m3")
281 .unwrap();
282 let r = resolve_name_to_uid(&store, ®, "AgentY").unwrap();
283 let NameResolution::One { uid, candidate } = r else {
284 panic!("expected One, got {r:?}");
285 };
286 assert_eq!(uid, UID_Y);
287 assert!(candidate.live);
288 assert_eq!(candidate.block_id.as_deref(), Some("block-y"));
289 assert_eq!(candidate.slug, "agenty");
290 }
291
292 fn bind_block(store: &Store, block_id: &str, agent_uid: &str) {
293 let mut block = crate::backend::obj::Block {
294 oid: block_id.to_string(),
295 parentoref: String::new(),
296 version: 0,
297 runtimeopts: None,
298 stickers: None,
299 meta: {
300 let mut m = crate::backend::obj::MetaMapType::new();
301 m.insert("view".to_string(), serde_json::json!("agent"));
302 m.insert("agentId".to_string(), serde_json::json!(agent_uid));
303 m
304 },
305 subblockids: None,
306 };
307 store.insert(&mut block).unwrap();
308 }
309
310 #[test]
315 fn a_live_agent_registered_without_a_uid_merges_with_its_own_row_by_block() {
316 let store = store_with(&[(UID_Y, "AgentY", "agenty")]);
317 bind_block(&store, "block-y", UID_Y);
318 let reg = registry();
319 reg.register_agent_full("AgentY", "block-y", None, 0, None, None, "test.m3")
320 .unwrap();
321 let r = resolve_name_to_uid(&store, ®, "AgentY").unwrap();
322 let NameResolution::One { uid, candidate } = r else {
323 panic!("expected One, got {r:?}");
324 };
325 assert_eq!(uid, UID_Y);
326 assert!(candidate.live);
327 assert_eq!(candidate.block_id.as_deref(), Some("block-y"));
328 }
329
330 #[test]
334 fn a_same_named_pane_on_another_block_is_still_ambiguous_with_the_row() {
335 let store = store_with(&[(UID_Y, "AgentY", "agenty")]);
336 let reg = registry();
337 reg.register_agent_full("AgentY", "block-pane", None, 0, None, None, "test.m3")
338 .unwrap();
339 let r = resolve_name_to_uid(&store, ®, "AgentY").unwrap();
340 let NameResolution::Ambiguous { candidates } = r else {
341 panic!("expected Ambiguous, got {r:?}");
342 };
343 assert_eq!(candidates.len(), 2, "{candidates:?}");
344 }
345
346 #[test]
349 fn a_live_block_without_a_row_is_unidentified_not_none() {
350 let store = store_with(&[]);
351 let reg = registry();
352 reg.register_agent_full("Scratch", "block-s", None, 0, None, None, "test.m3")
353 .unwrap();
354 let r = resolve_name_to_uid(&store, ®, "scratch").unwrap();
355 assert!(
356 matches!(r, NameResolution::Unidentified { ref candidate } if candidate.block_id.as_deref() == Some("block-s")),
357 "{r:?}"
358 );
359 }
360
361 #[test]
362 fn unknown_and_empty_names_are_none() {
363 let store = store_with(&[(UID_Y, "AgentY", "agenty")]);
364 assert_eq!(
365 resolve_name_to_uid(&store, ®istry(), "nobody").unwrap(),
366 NameResolution::None
367 );
368 assert_eq!(
369 resolve_name_to_uid(&store, ®istry(), " ").unwrap(),
370 NameResolution::None
371 );
372 }
373
374 #[test]
378 fn a_store_fault_is_refused_not_answered_as_none() {
379 let store = store_with(&[(UID_Y, "AgentY", "agenty")]);
380 let reg = registry();
381 reg.register_agent_full("AgentY", "block-other", None, 0, None, None, "test.m3")
382 .unwrap();
383 store.test_break_agents_table().unwrap();
384 for typed in ["AgentY", UID_Y] {
385 let r = resolve_name_to_uid(&store, ®, typed);
386 assert!(r.is_err(), "{typed}: {r:?}");
387 }
388 }
389
390 #[test]
394 fn an_unreadable_global_definition_is_refused_not_answered_as_none() {
395 let store = store_with(&[]);
396 let tmp = tempfile::tempdir().unwrap();
397 let defs = crate::registry::DefinitionStore::open(tmp.path().to_path_buf()).unwrap();
398 std::fs::write(tmp.path().join(format!("{UID_UP}.json")), b"{ not json").unwrap();
399 store.set_def_registry(std::sync::Arc::new(defs));
400 let r = resolve_name_to_uid(&store, ®istry(), UID_UP);
401 assert!(r.is_err(), "{r:?}");
402 }
403
404 #[test]
407 fn a_template_row_is_not_a_candidate() {
408 let store = Store::open_in_memory().unwrap();
409 let mut tpl = test_agent_def("claude", "Claude", "claude", "agent", 1, "");
410 tpl.is_seeded = 1;
411 store.agent_def_insert(&mut tpl).unwrap();
412 assert_eq!(
413 resolve_name_to_uid(&store, ®istry(), "claude").unwrap(),
414 NameResolution::None
415 );
416 }
417}