1use std::collections::HashSet;
9
10use cookie_agent_protocol::{SessionId, SessionMeta, SessionOrigin, SessionTree};
11
12use super::{Engine, EngineError, delegation::session_status_name};
13
14pub(crate) const HANDLE_HEX_LEN: usize = 8;
15pub(crate) const HANDLE_SLUG_MAX: usize = 16;
16pub(crate) const HANDLE_ATTEMPTS: usize = 16;
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum SubagentScope {
23 DirectChildren,
24 TreePeers,
25}
26
27#[must_use]
31pub(crate) fn handle_slug(agent_type: &str) -> String {
32 let mut slug = agent_type.chars().take(HANDLE_SLUG_MAX).collect::<String>();
33 while slug.ends_with('-') {
34 slug.pop();
35 }
36 if slug.is_empty() {
37 slug.push('a');
38 }
39 slug
40}
41
42#[must_use]
44pub(crate) fn is_valid_handle(value: &str) -> bool {
45 let Some((slug, hex)) = value.rsplit_once('_') else {
46 return false;
47 };
48 if hex.len() != HANDLE_HEX_LEN
49 || !hex
50 .bytes()
51 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
52 {
53 return false;
54 }
55 if slug.is_empty() || slug.len() > HANDLE_SLUG_MAX {
56 return false;
57 }
58 let bytes = slug.as_bytes();
59 if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() {
60 return false;
61 }
62 bytes.iter().enumerate().all(|(index, byte)| {
63 byte.is_ascii_lowercase() || byte.is_ascii_digit() || {
64 *byte == b'-' && bytes.get(index + 1).is_some_and(u8::is_ascii_alphanumeric)
65 }
66 })
67}
68
69pub(crate) fn generate_handle(
73 agent_type: &str,
74 existing: &HashSet<String>,
75 mut next_hex: impl FnMut() -> u32,
76) -> Result<String, EngineError> {
77 let slug = handle_slug(agent_type);
78 for _ in 0..HANDLE_ATTEMPTS {
79 let candidate = format!("{slug}_{:08x}", next_hex());
80 if !existing.contains(&candidate) {
81 return Ok(candidate);
82 }
83 }
84 Err(EngineError::ToolFailed(format!(
85 "subagent handle generation exhausted {HANDLE_ATTEMPTS} attempts; the tree uniqueness check is broken"
86 )))
87}
88
89pub(crate) fn random_hex() -> u32 {
93 use std::hash::{BuildHasher, Hasher};
94 let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
95 hasher.write_u64(0);
96 (hasher.finish() & 0xffff_ffff) as u32
97}
98
99fn push_tree(tree: &SessionTree, out: &mut Vec<SessionMeta>) {
100 out.push(tree.session.clone());
101 for child in &tree.children {
102 push_tree(child, out);
103 }
104}
105
106fn session_handles(sessions: &[SessionMeta]) -> HashSet<String> {
107 sessions
108 .iter()
109 .filter_map(|meta| meta.short_id.clone())
110 .collect()
111}
112
113fn describe(meta: &SessionMeta) -> String {
114 meta.title.as_ref().map_or_else(
115 || meta.creation_selection.agent.to_string(),
116 ToString::to_string,
117 )
118}
119
120impl Engine {
121 fn tree_sessions(&self, session: SessionId) -> Result<Vec<SessionMeta>, EngineError> {
123 let root = match self.inner.store.get(session)?.meta.origin {
124 SessionOrigin::Root => session,
125 SessionOrigin::Delegated {
126 root_session_id, ..
127 } => root_session_id,
128 };
129 let tree = self.tree(root)?;
130 let mut sessions = Vec::new();
131 push_tree(&tree, &mut sessions);
132 Ok(sessions)
133 }
134
135 pub(crate) fn session_short_id(&self, session: SessionId) -> Option<String> {
137 self.inner
138 .store
139 .get(session)
140 .ok()
141 .and_then(|projection| projection.meta.short_id)
142 }
143
144 pub(crate) fn existing_tree_handles(
147 &self,
148 root: SessionId,
149 ) -> Result<HashSet<String>, EngineError> {
150 let tree = self.tree(root)?;
151 let mut sessions = Vec::new();
152 push_tree(&tree, &mut sessions);
153 Ok(session_handles(&sessions))
154 }
155
156 pub fn resolve_subagent_target(
162 &self,
163 caller: SessionId,
164 reference: impl std::fmt::Display,
165 scope: SubagentScope,
166 ) -> Result<SessionId, EngineError> {
167 let reference = reference.to_string();
168 let sessions = self.tree_sessions(caller)?;
169 let candidates = scoped_candidates(&sessions, caller, scope);
170 match_reference(&candidates, &reference)
171 .ok_or_else(|| subagent_reference_error(&candidates, &reference))
172 }
173}
174
175pub(crate) fn scoped_candidates(
178 sessions: &[SessionMeta],
179 caller: SessionId,
180 scope: SubagentScope,
181) -> Vec<SessionMeta> {
182 sessions
183 .iter()
184 .filter(|meta| meta.session_id != caller)
185 .filter(|meta| match scope {
186 SubagentScope::DirectChildren => matches!(
187 &meta.origin,
188 SessionOrigin::Delegated {
189 parent_session_id,
190 ..
191 } if *parent_session_id == caller
192 ),
193 SubagentScope::TreePeers => true,
194 })
195 .cloned()
196 .collect()
197}
198
199pub(crate) fn match_reference(sessions: &[SessionMeta], reference: &str) -> Option<SessionId> {
203 if let Ok(id) = reference.parse::<SessionId>()
204 && sessions.iter().any(|meta| meta.session_id == id)
205 {
206 return Some(id);
207 }
208 if is_valid_handle(reference)
209 && let Some(found) = sessions
210 .iter()
211 .find(|meta| meta.short_id.as_deref() == Some(reference))
212 {
213 return Some(found.session_id);
214 }
215 None
216}
217
218fn subagent_reference_error(sessions: &[SessionMeta], reference: &str) -> EngineError {
219 let mut candidates = sessions
220 .iter()
221 .map(|meta| match &meta.short_id {
222 Some(handle) => format!(
223 "{handle} — {} — {} (session_id {})",
224 describe(meta),
225 session_status_name(meta.status),
226 meta.session_id
227 ),
228 None => format!(
231 "{} — {} — {}",
232 meta.session_id,
233 describe(meta),
234 session_status_name(meta.status),
235 ),
236 })
237 .collect::<Vec<_>>();
238 candidates.sort();
239 let list = if candidates.is_empty() {
240 "(none)".to_owned()
241 } else {
242 candidates.join("\n")
243 };
244 EngineError::ToolFailed(format!(
245 "unknown subagent reference {reference:?}; live subagents in your tree:\n{list}"
246 ))
247}
248
249#[cfg(test)]
250mod tests {
251 use std::collections::HashSet;
252
253 use cookie_agent_protocol::{
254 AgentRevision, CatalogRevision, CwdIdentity, ModelRevision, ModelSelection,
255 ModelSnapshotRevision, ProviderStateRevision, RecipeRegistryRevision, RunSelection,
256 RuntimeRevision, SessionStatus,
257 };
258
259 use super::*;
260
261 fn revision(label: char) -> String {
262 format!("sha256:{}", label.to_string().repeat(64))
263 }
264
265 fn meta(id: SessionId, short_id: Option<&str>) -> SessionMeta {
266 SessionMeta {
267 session_id: id,
268 origin: SessionOrigin::Root,
269 short_id: short_id.map(str::to_owned),
270 cwd_identity: CwdIdentity::new("workspace:test").expect("cwd"),
271 creation_selection: RunSelection {
272 agent: cookie_agent_protocol::AgentId::new("explore").expect("agent"),
273 model: ModelSelection {
274 model: "provider/model".parse().expect("model key"),
275 variant: None,
276 },
277 preset: None,
278 },
279 runtime_revision: RuntimeRevision::new(revision('1')).expect("runtime"),
280 catalog_revision: CatalogRevision::new(revision('2')).expect("catalog"),
281 provider_state_revision: ProviderStateRevision::new(revision('3')).expect("provider"),
282 model_revision: ModelRevision::new(revision('4')).expect("model"),
283 agent_revision: AgentRevision::new(revision('5')).expect("agent"),
284 recipe_registry_revision: RecipeRegistryRevision::new(revision('6')).expect("recipes"),
285 manifest_revision: ModelSnapshotRevision::new(revision('7')).expect("manifest"),
286 title: None,
287 title_updated_seq: 0,
288 last_event_seq: 1,
289 last_activity: "2026-08-06T12:00:00Z".parse().expect("timestamp"),
290 status: SessionStatus::Completed,
291 skipped_events: Vec::new(),
292 }
293 }
294
295 #[test]
296 fn handle_slug_caps_at_sixteen_and_trims_trailing_hyphens() {
297 assert_eq!(handle_slug("explore"), "explore");
298 assert_eq!(handle_slug("sub-debugger"), "sub-debugger");
299 assert_eq!(handle_slug("abcdefghijklmnopq-rst"), "abcdefghijklmnop");
300 assert_eq!(handle_slug("abc-"), "abc");
301 }
302
303 #[test]
304 fn valid_handle_grammar_accepts_complete_handles_only() {
305 assert!(is_valid_handle("explore_1a2b3c4d"));
306 assert!(is_valid_handle("a_00000000"));
307 assert!(!is_valid_handle("explore_1a2b3c4"));
308 assert!(!is_valid_handle("explore_1A2B3C4D"));
309 assert!(!is_valid_handle("explore"));
310 assert!(!is_valid_handle("-explore_1a2b3c4d"));
311 assert!(!is_valid_handle("explore-_1a2b3c4d"));
312 assert!(!is_valid_handle("abcdefghijklmnopq_1a2b3c4d"));
313 }
314
315 #[test]
316 fn generation_regenerates_on_collision_then_succeeds() {
317 let mut existing = HashSet::new();
318 existing.insert("explore_00000001".to_owned());
319 let mut values = [1_u32, 2_u32].into_iter();
320 let handle = generate_handle("explore", &existing, || values.next().unwrap())
321 .expect("second candidate is free");
322 assert_eq!(handle, "explore_00000002");
323 }
324
325 #[test]
326 fn generation_fails_loudly_after_sixteen_attempts() {
327 let existing = HashSet::from(["explore_00000001".to_owned()]);
328 let error = generate_handle("explore", &existing, || 1).expect_err("cap reached");
329 assert!(error.to_string().contains("16 attempts"));
330 }
331
332 #[test]
333 fn reference_resolution_prefers_uuid_then_exact_handle() {
334 let first = SessionId::new_v7();
335 let second = SessionId::new_v7();
336 let sessions = vec![
337 meta(first, Some("explore_1a2b3c4d")),
338 meta(second, Some("coder_9f8e7d6b")),
339 ];
340 assert_eq!(match_reference(&sessions, &first.to_string()), Some(first));
341 assert_eq!(match_reference(&sessions, "coder_9f8e7d6b"), Some(second));
342 }
343
344 #[test]
345 fn reference_resolution_rejects_prefixes_and_fabrications() {
346 let id = SessionId::new_v7();
347 let sessions = vec![meta(id, Some("explore_1a2b3c4d"))];
348 assert_eq!(match_reference(&sessions, "explore_1a2b3c4"), None);
349 assert_eq!(match_reference(&sessions, "explore"), None);
350 assert_eq!(match_reference(&sessions, "explore_00000000"), None);
351 assert_eq!(match_reference(&sessions, "not-a-handle"), None);
352 assert_eq!(
353 match_reference(&sessions, &SessionId::new_v7().to_string()),
354 None
355 );
356 }
357
358 fn delegated_meta(id: SessionId, parent: SessionId, short_id: Option<&str>) -> SessionMeta {
359 let mut meta = meta(id, short_id);
360 meta.origin = SessionOrigin::Delegated {
361 root_session_id: parent,
362 parent_session_id: parent,
363 parent_run_id: cookie_agent_protocol::RunId::new_v7(),
364 parent_tool_call_id: cookie_agent_protocol::ToolCallId::new_v7(),
365 invocation_id: cookie_agent_protocol::InvocationId::new_v7(),
366 depth: 1,
367 };
368 meta
369 }
370
371 #[test]
372 fn candidate_scope_separates_direct_children_from_tree_peers() {
373 let root = SessionId::new_v7();
375 let child = SessionId::new_v7();
376 let grandchild = SessionId::new_v7();
377 let sibling = SessionId::new_v7();
378 let sessions = vec![
379 meta(root, Some("root_00000000")),
380 delegated_meta(child, root, Some("explore_1a2b3c4d")),
381 delegated_meta(grandchild, child, Some("coder_9f8e7d6b")),
382 delegated_meta(sibling, root, Some("writer_12345678")),
383 ];
384 let direct = scoped_candidates(&sessions, root, SubagentScope::DirectChildren);
385 assert_eq!(
386 direct
387 .iter()
388 .map(|meta| meta.session_id)
389 .collect::<Vec<_>>(),
390 vec![child, sibling]
391 );
392 assert_eq!(match_reference(&direct, "coder_9f8e7d6b"), None);
394 let peers = scoped_candidates(&sessions, root, SubagentScope::TreePeers);
395 assert_eq!(peers.len(), 3);
396 assert_eq!(match_reference(&peers, "coder_9f8e7d6b"), Some(grandchild));
397 }
398
399 #[test]
400 fn old_sessions_without_handles_resolve_by_uuid_only() {
401 let id = SessionId::new_v7();
402 let sessions = vec![meta(id, None)];
403 assert_eq!(match_reference(&sessions, &id.to_string()), Some(id));
404 assert_eq!(match_reference(&sessions, "explore_1a2b3c4d"), None);
405 }
406
407 #[test]
408 fn reference_error_lists_live_candidates_with_handle_description_and_status() {
409 let child = SessionId::new_v7();
410 let mut child_meta = meta(child, Some("explore_1a2b3c4d"));
411 child_meta.title =
412 Some(cookie_agent_protocol::SessionTitle::new("Review API").expect("title"));
413 child_meta.status = SessionStatus::Running;
414 let error = subagent_reference_error(&[child_meta], "bogus");
415 let message = error.to_string();
416 assert!(message.contains("unknown subagent reference \"bogus\""));
417 assert!(message.contains("explore_1a2b3c4d — Review API — running"));
418 assert!(message.contains(&child.to_string()));
419 }
420
421 #[test]
422 fn reference_error_does_not_repeat_the_uuid_for_pre_handle_sessions() {
423 let child = SessionId::new_v7();
424 let mut child_meta = meta(child, None);
425 child_meta.status = SessionStatus::Running;
426 let message = subagent_reference_error(&[child_meta], "bogus").to_string();
427 let row = format!("{child} — explore — running");
428 assert!(message.contains(&row));
429 assert_eq!(message.matches(&child.to_string()).count(), 1);
430 }
431}