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
294pub(crate) fn experimental_mode_for_mode(mode: ClientMode, supplied: Option<bool>) -> Option<bool> {
296 if mode == ClientMode::Empty {
297 Some(supplied.unwrap_or(false))
298 } else {
299 supplied
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn custom_agents_local_only_respects_mode_and_caller_value() {
309 assert_eq!(
310 resolve_custom_agents_local_only(ClientMode::Empty, None),
311 Some(true)
312 );
313 assert_eq!(
314 resolve_custom_agents_local_only(ClientMode::Empty, Some(false)),
315 Some(false)
316 );
317 assert_eq!(
318 resolve_custom_agents_local_only(ClientMode::CopilotCli, None),
319 None
320 );
321 }
322
323 #[test]
324 fn tool_set_emits_source_qualified_patterns() {
325 let v = ToolSet::new()
326 .add_builtin("bash")
327 .unwrap()
328 .add_builtin("*")
329 .unwrap()
330 .add_custom("foo")
331 .unwrap()
332 .add_custom("*")
333 .unwrap()
334 .add_mcp("github-list_issues")
335 .unwrap()
336 .add_mcp("*")
337 .unwrap()
338 .to_vec();
339 assert_eq!(
340 v,
341 vec![
342 "builtin:bash",
343 "builtin:*",
344 "custom:foo",
345 "custom:*",
346 "mcp:github-list_issues",
347 "mcp:*",
348 ]
349 );
350 }
351
352 #[test]
353 fn tool_set_add_builtin_many() {
354 let v = ToolSet::new()
355 .add_builtin_many(BUILTIN_TOOLS_ISOLATED)
356 .unwrap()
357 .into_vec();
358 assert_eq!(v.len(), BUILTIN_TOOLS_ISOLATED.len());
359 assert_eq!(v[0], format!("builtin:{}", BUILTIN_TOOLS_ISOLATED[0]));
360 }
361
362 #[test]
363 fn tool_set_rejects_invalid_names() {
364 for bad in ["bash!", "with space", "colon:name", "", "wild*card"] {
365 assert!(
366 ToolSet::new().add_builtin(bad).is_err(),
367 "expected '{bad}' to be rejected"
368 );
369 assert!(ToolSet::new().add_custom(bad).is_err());
370 assert!(ToolSet::new().add_mcp(bad).is_err());
371 }
372 }
373
374 #[test]
375 fn tool_set_accepts_wildcard_and_underscores_and_dashes() {
376 assert!(ToolSet::new().add_builtin("*").is_ok());
377 assert!(ToolSet::new().add_mcp("github-list_issues").is_ok());
378 assert!(ToolSet::new().add_custom("A_b-9").is_ok());
379 }
380
381 #[test]
382 fn into_vec_is_idempotent_with_to_vec() {
383 let ts = ToolSet::new().add_builtin("bash").unwrap();
384 assert_eq!(ts.to_vec(), vec!["builtin:bash"]);
385 assert_eq!(ts.into_vec(), vec!["builtin:bash"]);
386 }
387
388 #[test]
389 fn into_vec_string_conversion() {
390 let v: Vec<String> = ToolSet::new().add_mcp("*").unwrap().into();
391 assert_eq!(v, vec!["mcp:*"]);
392 }
393
394 #[test]
395 fn validate_tool_filter_list_rejects_bare_star() {
396 let bad = vec!["*".to_string()];
397 assert!(validate_tool_filter_list("availableTools", Some(&bad)).is_err());
398 }
399
400 #[test]
401 fn validate_tool_filter_list_allows_qualified_star() {
402 let ok = vec!["builtin:*".to_string(), "mcp:*".to_string()];
403 assert!(validate_tool_filter_list("availableTools", Some(&ok)).is_ok());
404 }
405
406 #[test]
407 fn validate_tool_filter_list_none_is_ok() {
408 assert!(validate_tool_filter_list("availableTools", None).is_ok());
409 }
410
411 #[test]
412 fn builtin_tools_isolated_contents() {
413 assert!(BUILTIN_TOOLS_ISOLATED.contains(&"ask_user"));
414 assert!(BUILTIN_TOOLS_ISOLATED.contains(&"task_complete"));
415 assert!(BUILTIN_TOOLS_ISOLATED.contains(&"skill"));
416 assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"bash"));
417 assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"edit"));
418 assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"web_fetch"));
419 }
420
421 #[test]
422 fn client_mode_default_is_copilot_cli() {
423 assert_eq!(ClientMode::default(), ClientMode::CopilotCli);
424 }
425
426 #[test]
427 fn system_message_copilot_cli_passes_through_unchanged() {
428 let cfg = SystemMessageConfig {
429 mode: Some("append".to_string()),
430 content: Some("hello".to_string()),
431 sections: None,
432 };
433 let out = system_message_for_mode(ClientMode::CopilotCli, Some(cfg.clone()));
434 let out = out.unwrap();
435 assert_eq!(out.mode.as_deref(), Some("append"));
436 assert_eq!(out.content.as_deref(), Some("hello"));
437 }
438
439 #[test]
440 fn system_message_empty_none_injects_strip() {
441 let out = system_message_for_mode(ClientMode::Empty, None).unwrap();
442 assert_eq!(out.mode.as_deref(), Some("customize"));
443 let sections = out.sections.unwrap();
444 let env = sections.get("environment_context").unwrap();
445 assert_eq!(env.action.as_deref(), Some("remove"));
446 }
447
448 #[test]
449 fn system_message_empty_append_promoted_to_customize() {
450 let cfg = SystemMessageConfig {
451 mode: Some("append".to_string()),
452 content: Some("hi".to_string()),
453 sections: None,
454 };
455 let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap();
456 assert_eq!(out.mode.as_deref(), Some("customize"));
457 assert_eq!(out.content.as_deref(), Some("hi"));
458 let sections = out.sections.unwrap();
459 assert!(sections.contains_key("environment_context"));
460 }
461
462 #[test]
463 fn system_message_empty_replace_passes_through() {
464 let cfg = SystemMessageConfig {
465 mode: Some("replace".to_string()),
466 content: Some("verbatim".to_string()),
467 sections: None,
468 };
469 let out = system_message_for_mode(ClientMode::Empty, Some(cfg.clone())).unwrap();
470 assert_eq!(out.mode.as_deref(), Some("replace"));
471 assert_eq!(out.content.as_deref(), Some("verbatim"));
472 assert!(out.sections.is_none());
473 }
474
475 #[test]
476 fn system_message_empty_customize_with_env_context_preserved() {
477 let mut sections = HashMap::new();
478 sections.insert(
479 "environment_context".to_string(),
480 SectionOverride {
481 action: Some("replace".to_string()),
482 content: Some("custom env".to_string()),
483 },
484 );
485 let cfg = SystemMessageConfig {
486 mode: Some("customize".to_string()),
487 content: None,
488 sections: Some(sections),
489 };
490 let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap();
491 let env = out.sections.unwrap().remove("environment_context").unwrap();
492 assert_eq!(env.action.as_deref(), Some("replace"));
493 assert_eq!(env.content.as_deref(), Some("custom env"));
494 }
495
496 #[test]
497 fn system_message_empty_customize_without_env_context_gets_strip() {
498 let mut sections = HashMap::new();
499 sections.insert(
500 "other_section".to_string(),
501 SectionOverride {
502 action: Some("replace".to_string()),
503 content: Some("body".to_string()),
504 },
505 );
506 let cfg = SystemMessageConfig {
507 mode: Some("customize".to_string()),
508 content: None,
509 sections: Some(sections),
510 };
511 let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap();
512 let secs = out.sections.unwrap();
513 assert!(secs.contains_key("other_section"));
514 let env = secs.get("environment_context").unwrap();
515 assert_eq!(env.action.as_deref(), Some("remove"));
516 }
517
518 #[test]
519 fn memory_copilot_cli_leaves_unset_when_not_supplied() {
520 assert_eq!(memory_for_mode(ClientMode::CopilotCli, None), None);
521 }
522
523 #[test]
524 fn memory_copilot_cli_preserves_supplied() {
525 assert_eq!(
526 memory_for_mode(ClientMode::CopilotCli, Some(MemoryConfiguration::enabled())),
527 Some(MemoryConfiguration::enabled())
528 );
529 }
530
531 #[test]
532 fn memory_empty_defaults_to_disabled() {
533 assert_eq!(
534 memory_for_mode(ClientMode::Empty, None),
535 Some(MemoryConfiguration::disabled())
536 );
537 }
538
539 #[test]
540 fn memory_empty_preserves_supplied() {
541 assert_eq!(
542 memory_for_mode(ClientMode::Empty, Some(MemoryConfiguration::enabled())),
543 Some(MemoryConfiguration::enabled())
544 );
545 }
546
547 #[test]
548 fn experimental_mode_defaults_false_in_empty_mode() {
549 assert_eq!(
550 experimental_mode_for_mode(ClientMode::Empty, None),
551 Some(false)
552 );
553 assert_eq!(
554 experimental_mode_for_mode(ClientMode::Empty, Some(true)),
555 Some(true)
556 );
557 assert_eq!(
558 experimental_mode_for_mode(ClientMode::Empty, Some(false)),
559 Some(false)
560 );
561 }
562
563 #[test]
564 fn experimental_mode_remains_runtime_controlled_in_copilot_cli_mode() {
565 assert_eq!(
566 experimental_mode_for_mode(ClientMode::CopilotCli, None),
567 None
568 );
569 }
570}