use crate::daemon::DaemonCommand;
use crate::tools::context::ToolContext;
use crate::tools::{AllowedCaller, Tool, ToolExecError, groups_enum_schema, unknown_group_names};
use choreo_keystore::ServiceCredential;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
use std::sync::{Weak, mpsc};
use tracing::info;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub(crate) struct LoadToolsArgs {
pub(crate) groups: Vec<String>,
}
pub(crate) fn apply_load_tools(
active_tool_groups: &mut HashSet<String>,
groups: &[String],
) -> String {
let mut loaded = Vec::new();
for g in groups {
if active_tool_groups.insert(g.clone()) {
loaded.push(g.clone());
}
}
if loaded.is_empty() {
"All specified groups were already active.".to_string()
} else {
format!("Activated tool groups: {}", humfmt::list(&loaded))
}
}
fn execute_load_tools(
args: &LoadToolsArgs,
_working_dir: Option<&Path>,
ctx: Option<&ToolContext>,
) -> Result<String, ToolExecError> {
let ctx = ctx.ok_or_else(|| ToolExecError("no session context".into()))?;
if args.groups.is_empty() {
return Err(ToolExecError("missing required argument: groups".into()));
}
info!(
session_id = ctx.session_id,
groups = ?args.groups,
"activating tool groups",
);
let (reply, rx) = mpsc::channel();
ctx.daemon_tx
.send(DaemonCommand::LoadTools {
session_id: ctx.session_id,
groups: args.groups.clone(),
reply,
})
.map_err(|e| ToolExecError(format!("daemon communication failed: {e}")))?;
let outcome = rx
.recv()
.map_err(|e| ToolExecError(format!("daemon did not respond: {e}")))?;
outcome.map_err(ToolExecError)
}
pub fn describe_invocation(args: &LoadToolsArgs) -> String {
format!("Activating tool groups: {}.", args.groups.join(", "))
}
pub(crate) struct LoadTools {
registry: Weak<crate::tools::ToolRegistry>,
}
impl LoadTools {
pub fn new(registry: Weak<crate::tools::ToolRegistry>) -> Self {
LoadTools { registry }
}
fn group_names(&self) -> Vec<String> {
self.registry
.upgrade()
.map(|r| r.group_names())
.unwrap_or_default()
}
}
impl Tool for LoadTools {
type Args = LoadToolsArgs;
type Return = String;
type Error = ToolExecError;
fn name(&self) -> &'static str {
"load_tools"
}
fn group(&self) -> &'static str {
"core"
}
fn description(&self) -> &'static str {
"Activate one or more tool groups for use in this session. \
Tools belonging to inactive groups will not be available. \
The 'core' group is always active and cannot be unloaded."
}
fn describe_invocation(&self, args: &Self::Args) -> String {
describe_invocation(args)
}
fn return_string(ret: &Self::Return) -> String {
ret.clone()
}
fn allowed_callers(&self) -> Vec<AllowedCaller> {
vec![AllowedCaller::Direct]
}
fn schema(&self) -> serde_json::Value {
groups_enum_schema(self.group_names(), "Tool groups to activate")
}
fn execute(
&self,
args: Self::Args,
_x_credentials: Option<&ServiceCredential>,
working_dir: Option<&Path>,
ctx: Option<&ToolContext>,
) -> Result<Self::Return, Self::Error> {
if let Some(known) = self.registry.upgrade().map(|r| r.known_group_names())
&& let Some(unknown) = unknown_group_names(&args.groups, &known)
{
return Err(ToolExecError(format!(
"Unknown tool group(s): {}",
unknown.join(", ")
)));
}
execute_load_tools(&args, working_dir, ctx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::ToolRegistry;
use crate::tools::context::ToolContext;
use std::sync::Arc;
fn test_context() -> (
ToolContext,
std::sync::mpsc::Sender<DaemonCommand>,
std::sync::mpsc::Receiver<DaemonCommand>,
) {
let (daemon_tx, daemon_rx) = std::sync::mpsc::channel::<DaemonCommand>();
let dir = tempfile::tempdir().unwrap();
let db_path = dir.keep(); let db = Arc::new(redb::Database::create(db_path.join("test.redb")).unwrap());
let ctx = ToolContext::new(42, db, daemon_tx.clone());
(ctx, daemon_tx, daemon_rx)
}
fn run_with_daemon_reply(
args: LoadToolsArgs,
reply: Result<String, String>,
) -> (Result<String, ToolExecError>, DaemonCommand) {
let (ctx, _daemon_tx, daemon_rx) = test_context();
let handle = std::thread::spawn(move || execute_load_tools(&args, None, Some(&ctx)));
let cmd = daemon_rx.recv().unwrap();
match &cmd {
DaemonCommand::LoadTools { reply: tx, .. } => {
tx.send(reply).unwrap();
}
other => panic!(
"expected LoadTools, got {:?}",
std::mem::discriminant(other)
),
}
(handle.join().unwrap(), cmd)
}
#[test]
fn apply_loads_new_groups() {
let mut active: HashSet<String> = ["core".into(), "git".into()].into_iter().collect();
let result = apply_load_tools(&mut active, &["shell".into(), "x".into()]);
assert_eq!(result, "Activated tool groups: shell and x");
assert!(active.contains("shell"));
assert!(active.contains("x"));
assert!(active.contains("core"));
}
#[test]
fn apply_skips_already_active() {
let mut active: HashSet<String> = ["core".into(), "git".into(), "shell".into()]
.into_iter()
.collect();
let result = apply_load_tools(&mut active, &["shell".into()]);
assert_eq!(result, "All specified groups were already active.");
}
#[test]
fn execute_sends_daemon_command_and_returns_reply() {
let args = LoadToolsArgs {
groups: vec!["shell".into(), "x".into()],
};
let (result, cmd) =
run_with_daemon_reply(args, Ok("Activated tool groups: shell and x".into()));
assert_eq!(result.unwrap(), "Activated tool groups: shell and x");
match cmd {
DaemonCommand::LoadTools {
session_id, groups, ..
} => {
assert_eq!(session_id, 42);
assert_eq!(groups, vec!["shell", "x"]);
}
_ => panic!("expected LoadTools command"),
}
}
#[test]
fn execute_forwards_daemon_error() {
let args = LoadToolsArgs {
groups: vec!["shell".into()],
};
let (result, _cmd) = run_with_daemon_reply(args, Err("session is not active".into()));
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("session is not active")
);
}
#[test]
fn execute_no_context_returns_error() {
let args = LoadToolsArgs {
groups: vec!["shell".into()],
};
let result = execute_load_tools(&args, None, None);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("no session context")
);
}
#[test]
fn execute_empty_groups_returns_error() {
let (ctx, _daemon_tx, _daemon_rx) = test_context();
let args = LoadToolsArgs { groups: vec![] };
let result = execute_load_tools(&args, None, Some(&ctx));
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("missing required argument: groups")
);
}
#[test]
fn schema_has_groups_enum_excluding_core() {
let registry = ToolRegistry::new().build();
let tool = LoadTools::new(Arc::downgrade(®istry));
let schema = tool.schema();
let items = schema["properties"]["groups"]["items"].as_object().unwrap();
let enum_vals = items["enum"].as_array().unwrap();
let names: Vec<&str> = enum_vals.iter().filter_map(|v| v.as_str()).collect();
assert!(names.contains(&"git"), "enum should include git: {names:?}");
assert!(
!names.contains(&"core"),
"core must not appear in load_tools enum: {names:?}"
);
let required = schema["required"].as_array().unwrap();
assert!(required.iter().any(|v| v == "groups"));
}
#[test]
fn tool_restricted_to_direct_callers() {
let registry = ToolRegistry::new().build();
let tool = LoadTools::new(Arc::downgrade(®istry));
let callers = tool.allowed_callers();
assert_eq!(callers, vec![AllowedCaller::Direct]);
assert!(!callers.contains(&AllowedCaller::Programmatic));
}
#[test]
fn execute_rejects_unknown_group() {
let registry = ToolRegistry::new().build();
let tool = LoadTools::new(Arc::downgrade(®istry));
let args = LoadToolsArgs {
groups: vec!["not-a-real-group".into()],
};
let result = tool.execute(args, None, None, None);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Unknown tool group(s): not-a-real-group")
);
}
#[test]
fn execute_accepts_core_and_known_groups() {
let registry = ToolRegistry::new().build();
let tool = LoadTools::new(Arc::downgrade(®istry));
let args = LoadToolsArgs {
groups: vec!["core".into(), "git".into()],
};
let result = tool.execute(args, None, None, None);
assert!(
result
.err()
.map(|e| e.to_string())
.is_some_and(|e| e.contains("no session context"))
);
}
#[test]
fn describe_invocation_includes_groups() {
let args = LoadToolsArgs {
groups: vec!["git".into(), "shell".into()],
};
let desc = describe_invocation(&args);
assert_eq!(desc, "Activating tool groups: git, shell.");
}
#[test]
fn execute_postcard_args_round_trip() {
let args = LoadToolsArgs {
groups: vec!["git".into()],
};
let args_bytes = postcard::to_allocvec(&args).unwrap();
let decoded: LoadToolsArgs = postcard::from_bytes(&args_bytes).unwrap();
assert_eq!(decoded.groups, vec!["git"]);
}
}