1use std::collections::HashMap;
14
15use crate::types::{MemoryConfiguration, SectionOverride, SystemMessageConfig};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum ClientMode {
29 #[default]
31 CopilotCli,
32 Empty,
34}
35
36pub(crate) fn resolve_custom_agents_local_only(
38 mode: ClientMode,
39 custom_agents_local_only: Option<bool>,
40) -> Option<bool> {
41 custom_agents_local_only.or_else(|| (mode == ClientMode::Empty).then_some(true))
42}
43
44fn is_valid_tool_name(name: &str) -> bool {
47 !name.is_empty()
48 && name
49 .chars()
50 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
51}
52
53fn validate_name(kind: &str, name: &str) -> Result<(), crate::Error> {
54 if name == "*" {
55 return Ok(());
56 }
57 if !is_valid_tool_name(name) {
58 return Err(crate::Error::with_message(
59 crate::ErrorKind::InvalidConfig,
60 format!(
61 "Invalid {kind} tool name '{name}': tool names must match \
62 /^[a-zA-Z0-9_-]+$/ or be the wildcard '*'."
63 ),
64 ));
65 }
66 Ok(())
67}
68
69#[derive(Debug, Clone, Default)]
90pub struct ToolSet {
91 items: Vec<String>,
92}
93
94impl ToolSet {
95 pub fn new() -> Self {
97 Self::default()
98 }
99
100 pub fn add_builtin(mut self, name: &str) -> Result<Self, crate::Error> {
103 validate_name("builtin", name)?;
104 self.items.push(format!("builtin:{name}"));
105 Ok(self)
106 }
107
108 pub fn add_builtin_many<I, S>(mut self, names: I) -> Result<Self, crate::Error>
110 where
111 I: IntoIterator<Item = S>,
112 S: AsRef<str>,
113 {
114 for name in names {
115 let name = name.as_ref();
116 validate_name("builtin", name)?;
117 self.items.push(format!("builtin:{name}"));
118 }
119 Ok(self)
120 }
121
122 pub fn add_custom(mut self, name: &str) -> Result<Self, crate::Error> {
125 validate_name("custom", name)?;
126 self.items.push(format!("custom:{name}"));
127 Ok(self)
128 }
129
130 pub fn add_mcp(mut self, tool_name: &str) -> Result<Self, crate::Error> {
133 validate_name("mcp", tool_name)?;
134 self.items.push(format!("mcp:{tool_name}"));
135 Ok(self)
136 }
137
138 pub fn to_vec(&self) -> Vec<String> {
140 self.items.clone()
141 }
142
143 pub fn into_vec(self) -> Vec<String> {
145 self.items
146 }
147
148 pub fn len(&self) -> usize {
150 self.items.len()
151 }
152
153 pub fn is_empty(&self) -> bool {
155 self.items.is_empty()
156 }
157}
158
159impl From<ToolSet> for Vec<String> {
160 fn from(value: ToolSet) -> Self {
161 value.into_vec()
162 }
163}
164
165pub const BUILTIN_TOOLS_ISOLATED: &[&str] = &[
177 "ask_user",
178 "task_complete",
179 "exit_plan_mode",
180 "task",
181 "read_agent",
182 "write_agent",
183 "list_agents",
184 "send_inbox",
185 "context_board",
186 "skill",
187];
188
189pub(crate) fn validate_tool_filter_list(
193 field: &str,
194 list: Option<&[String]>,
195) -> Result<(), crate::Error> {
196 let Some(list) = list else { return Ok(()) };
197 for item in list {
198 if item == "*" {
199 return Err(crate::Error::with_message(
200 crate::ErrorKind::InvalidConfig,
201 format!(
202 "{field} contains a bare '*' which matches no tool. Use \
203 source-qualified wildcards instead: \
204 ToolSet::new().add_builtin(\"*\").add_mcp(\"*\").add_custom(\"*\")."
205 ),
206 ));
207 }
208 }
209 Ok(())
210}
211
212pub(crate) fn system_message_for_mode(
216 mode: ClientMode,
217 supplied: Option<SystemMessageConfig>,
218) -> Option<SystemMessageConfig> {
219 if mode != ClientMode::Empty {
220 return supplied;
221 }
222 let strip_env = || {
223 let mut sections = HashMap::new();
224 sections.insert(
225 "environment_context".to_string(),
226 SectionOverride {
227 action: Some("remove".to_string()),
228 content: None,
229 },
230 );
231 sections
232 };
233 let Some(supplied) = supplied else {
234 return Some(SystemMessageConfig {
235 mode: Some("customize".to_string()),
236 content: None,
237 sections: Some(strip_env()),
238 });
239 };
240 let mode_str = supplied.mode.as_deref().unwrap_or("append");
241 match mode_str {
242 "replace" => Some(supplied),
243 "customize" => {
244 if supplied
245 .sections
246 .as_ref()
247 .is_some_and(|s| s.contains_key("environment_context"))
248 {
249 Some(supplied)
250 } else {
251 let mut sections = supplied.sections.unwrap_or_default();
252 sections.insert(
253 "environment_context".to_string(),
254 SectionOverride {
255 action: Some("remove".to_string()),
256 content: None,
257 },
258 );
259 Some(SystemMessageConfig {
260 mode: Some("customize".to_string()),
261 content: supplied.content,
262 sections: Some(sections),
263 })
264 }
265 }
266 _ => Some(SystemMessageConfig {
270 mode: Some("customize".to_string()),
271 content: supplied.content,
272 sections: Some(strip_env()),
273 }),
274 }
275}
276
277pub(crate) fn memory_for_mode(
284 mode: ClientMode,
285 supplied: Option<MemoryConfiguration>,
286) -> Option<MemoryConfiguration> {
287 match supplied {
288 Some(config) => Some(config),
289 None if mode == ClientMode::Empty => Some(MemoryConfiguration::disabled()),
290 None => None,
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn custom_agents_local_only_respects_mode_and_caller_value() {
300 assert_eq!(
301 resolve_custom_agents_local_only(ClientMode::Empty, None),
302 Some(true)
303 );
304 assert_eq!(
305 resolve_custom_agents_local_only(ClientMode::Empty, Some(false)),
306 Some(false)
307 );
308 assert_eq!(
309 resolve_custom_agents_local_only(ClientMode::CopilotCli, None),
310 None
311 );
312 }
313
314 #[test]
315 fn tool_set_emits_source_qualified_patterns() {
316 let v = ToolSet::new()
317 .add_builtin("bash")
318 .unwrap()
319 .add_builtin("*")
320 .unwrap()
321 .add_custom("foo")
322 .unwrap()
323 .add_custom("*")
324 .unwrap()
325 .add_mcp("github-list_issues")
326 .unwrap()
327 .add_mcp("*")
328 .unwrap()
329 .to_vec();
330 assert_eq!(
331 v,
332 vec![
333 "builtin:bash",
334 "builtin:*",
335 "custom:foo",
336 "custom:*",
337 "mcp:github-list_issues",
338 "mcp:*",
339 ]
340 );
341 }
342
343 #[test]
344 fn tool_set_add_builtin_many() {
345 let v = ToolSet::new()
346 .add_builtin_many(BUILTIN_TOOLS_ISOLATED)
347 .unwrap()
348 .into_vec();
349 assert_eq!(v.len(), BUILTIN_TOOLS_ISOLATED.len());
350 assert_eq!(v[0], format!("builtin:{}", BUILTIN_TOOLS_ISOLATED[0]));
351 }
352
353 #[test]
354 fn tool_set_rejects_invalid_names() {
355 for bad in ["bash!", "with space", "colon:name", "", "wild*card"] {
356 assert!(
357 ToolSet::new().add_builtin(bad).is_err(),
358 "expected '{bad}' to be rejected"
359 );
360 assert!(ToolSet::new().add_custom(bad).is_err());
361 assert!(ToolSet::new().add_mcp(bad).is_err());
362 }
363 }
364
365 #[test]
366 fn tool_set_accepts_wildcard_and_underscores_and_dashes() {
367 assert!(ToolSet::new().add_builtin("*").is_ok());
368 assert!(ToolSet::new().add_mcp("github-list_issues").is_ok());
369 assert!(ToolSet::new().add_custom("A_b-9").is_ok());
370 }
371
372 #[test]
373 fn into_vec_is_idempotent_with_to_vec() {
374 let ts = ToolSet::new().add_builtin("bash").unwrap();
375 assert_eq!(ts.to_vec(), vec!["builtin:bash"]);
376 assert_eq!(ts.into_vec(), vec!["builtin:bash"]);
377 }
378
379 #[test]
380 fn into_vec_string_conversion() {
381 let v: Vec<String> = ToolSet::new().add_mcp("*").unwrap().into();
382 assert_eq!(v, vec!["mcp:*"]);
383 }
384
385 #[test]
386 fn validate_tool_filter_list_rejects_bare_star() {
387 let bad = vec!["*".to_string()];
388 assert!(validate_tool_filter_list("availableTools", Some(&bad)).is_err());
389 }
390
391 #[test]
392 fn validate_tool_filter_list_allows_qualified_star() {
393 let ok = vec!["builtin:*".to_string(), "mcp:*".to_string()];
394 assert!(validate_tool_filter_list("availableTools", Some(&ok)).is_ok());
395 }
396
397 #[test]
398 fn validate_tool_filter_list_none_is_ok() {
399 assert!(validate_tool_filter_list("availableTools", None).is_ok());
400 }
401
402 #[test]
403 fn builtin_tools_isolated_contents() {
404 assert!(BUILTIN_TOOLS_ISOLATED.contains(&"ask_user"));
405 assert!(BUILTIN_TOOLS_ISOLATED.contains(&"task_complete"));
406 assert!(BUILTIN_TOOLS_ISOLATED.contains(&"skill"));
407 assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"bash"));
408 assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"edit"));
409 assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"web_fetch"));
410 }
411
412 #[test]
413 fn client_mode_default_is_copilot_cli() {
414 assert_eq!(ClientMode::default(), ClientMode::CopilotCli);
415 }
416
417 #[test]
418 fn system_message_copilot_cli_passes_through_unchanged() {
419 let cfg = SystemMessageConfig {
420 mode: Some("append".to_string()),
421 content: Some("hello".to_string()),
422 sections: None,
423 };
424 let out = system_message_for_mode(ClientMode::CopilotCli, Some(cfg.clone()));
425 let out = out.unwrap();
426 assert_eq!(out.mode.as_deref(), Some("append"));
427 assert_eq!(out.content.as_deref(), Some("hello"));
428 }
429
430 #[test]
431 fn system_message_empty_none_injects_strip() {
432 let out = system_message_for_mode(ClientMode::Empty, None).unwrap();
433 assert_eq!(out.mode.as_deref(), Some("customize"));
434 let sections = out.sections.unwrap();
435 let env = sections.get("environment_context").unwrap();
436 assert_eq!(env.action.as_deref(), Some("remove"));
437 }
438
439 #[test]
440 fn system_message_empty_append_promoted_to_customize() {
441 let cfg = SystemMessageConfig {
442 mode: Some("append".to_string()),
443 content: Some("hi".to_string()),
444 sections: None,
445 };
446 let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap();
447 assert_eq!(out.mode.as_deref(), Some("customize"));
448 assert_eq!(out.content.as_deref(), Some("hi"));
449 let sections = out.sections.unwrap();
450 assert!(sections.contains_key("environment_context"));
451 }
452
453 #[test]
454 fn system_message_empty_replace_passes_through() {
455 let cfg = SystemMessageConfig {
456 mode: Some("replace".to_string()),
457 content: Some("verbatim".to_string()),
458 sections: None,
459 };
460 let out = system_message_for_mode(ClientMode::Empty, Some(cfg.clone())).unwrap();
461 assert_eq!(out.mode.as_deref(), Some("replace"));
462 assert_eq!(out.content.as_deref(), Some("verbatim"));
463 assert!(out.sections.is_none());
464 }
465
466 #[test]
467 fn system_message_empty_customize_with_env_context_preserved() {
468 let mut sections = HashMap::new();
469 sections.insert(
470 "environment_context".to_string(),
471 SectionOverride {
472 action: Some("replace".to_string()),
473 content: Some("custom env".to_string()),
474 },
475 );
476 let cfg = SystemMessageConfig {
477 mode: Some("customize".to_string()),
478 content: None,
479 sections: Some(sections),
480 };
481 let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap();
482 let env = out.sections.unwrap().remove("environment_context").unwrap();
483 assert_eq!(env.action.as_deref(), Some("replace"));
484 assert_eq!(env.content.as_deref(), Some("custom env"));
485 }
486
487 #[test]
488 fn system_message_empty_customize_without_env_context_gets_strip() {
489 let mut sections = HashMap::new();
490 sections.insert(
491 "other_section".to_string(),
492 SectionOverride {
493 action: Some("replace".to_string()),
494 content: Some("body".to_string()),
495 },
496 );
497 let cfg = SystemMessageConfig {
498 mode: Some("customize".to_string()),
499 content: None,
500 sections: Some(sections),
501 };
502 let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap();
503 let secs = out.sections.unwrap();
504 assert!(secs.contains_key("other_section"));
505 let env = secs.get("environment_context").unwrap();
506 assert_eq!(env.action.as_deref(), Some("remove"));
507 }
508
509 #[test]
510 fn memory_copilot_cli_leaves_unset_when_not_supplied() {
511 assert_eq!(memory_for_mode(ClientMode::CopilotCli, None), None);
512 }
513
514 #[test]
515 fn memory_copilot_cli_preserves_supplied() {
516 assert_eq!(
517 memory_for_mode(ClientMode::CopilotCli, Some(MemoryConfiguration::enabled())),
518 Some(MemoryConfiguration::enabled())
519 );
520 }
521
522 #[test]
523 fn memory_empty_defaults_to_disabled() {
524 assert_eq!(
525 memory_for_mode(ClientMode::Empty, None),
526 Some(MemoryConfiguration::disabled())
527 );
528 }
529
530 #[test]
531 fn memory_empty_preserves_supplied() {
532 assert_eq!(
533 memory_for_mode(ClientMode::Empty, Some(MemoryConfiguration::enabled())),
534 Some(MemoryConfiguration::enabled())
535 );
536 }
537}