use async_trait::async_trait;
use meerkat_core::ToolDef;
use meerkat_core::types::{ToolProvenance, ToolSourceKind};
use serde::Deserialize;
use serde_json::{Value, json};
use std::sync::Arc;
use super::job_manager::JobManager;
use super::types::JobId;
use crate::builtin::{BuiltinTool, BuiltinToolError, ToolOutput};
#[derive(Debug)]
pub struct ShellJobCancelTool {
job_manager: Arc<JobManager>,
}
impl ShellJobCancelTool {
pub fn new(job_manager: Arc<JobManager>) -> Self {
Self { job_manager }
}
}
#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
struct JobCancelInput {
#[schemars(description = "The job ID to cancel")]
job_id: String,
}
#[async_trait]
impl BuiltinTool for ShellJobCancelTool {
fn name(&self) -> &'static str {
"shell_job_cancel"
}
fn def(&self) -> ToolDef {
ToolDef {
name: "shell_job_cancel".into(),
description: "Cancel a running background shell job".into(),
input_schema: crate::schema::schema_for::<JobCancelInput>(),
provenance: Some(ToolProvenance {
kind: ToolSourceKind::Shell,
source_id: "shell".into(),
}),
}
}
fn default_enabled(&self) -> bool {
false
}
async fn call(&self, args: Value) -> Result<ToolOutput, BuiltinToolError> {
let input: JobCancelInput = serde_json::from_value(args)
.map_err(|e| BuiltinToolError::invalid_args(e.to_string()))?;
let job_id = JobId::from_string(&input.job_id);
self.job_manager
.cancel_job(&job_id)
.await
.map_err(|e| BuiltinToolError::execution_failed(e.to_string()))?;
Ok(ToolOutput::Json(json!({
"job_id": input.job_id,
"status": "cancelled"
})))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::builtin::shell::config::ShellConfig;
#[cfg(feature = "integration-real-tests")]
use crate::builtin::shell::types::JobStatus;
#[cfg(feature = "integration-real-tests")]
use std::time::Duration;
#[cfg(feature = "integration-real-tests")]
use tempfile::TempDir;
#[test]
fn test_shell_job_cancel_tool_struct() {
let config = ShellConfig::default();
let manager = Arc::new(JobManager::new(config));
let _tool = ShellJobCancelTool::new(Arc::clone(&manager));
assert!(Arc::strong_count(&manager) >= 2);
}
#[test]
fn test_shell_job_cancel_tool_builtin() {
fn assert_builtin_tool<T: BuiltinTool>() {}
assert_builtin_tool::<ShellJobCancelTool>();
}
#[test]
fn test_shell_job_cancel_tool_name() {
let config = ShellConfig::default();
let manager = Arc::new(JobManager::new(config));
let tool = ShellJobCancelTool::new(manager);
assert_eq!(tool.name(), "shell_job_cancel");
}
#[test]
fn test_shell_job_cancel_tool_schema() {
let config = ShellConfig::default();
let manager = Arc::new(JobManager::new(config));
let tool = ShellJobCancelTool::new(manager);
let def = tool.def();
assert_eq!(def.name, "shell_job_cancel");
assert!(def.description.contains("Cancel") || def.description.contains("cancel"));
let schema = &def.input_schema;
assert_eq!(schema["type"], "object");
let props = &schema["properties"];
assert!(props.get("job_id").is_some());
assert_eq!(props["job_id"]["type"], "string");
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&json!("job_id")));
}
#[tokio::test]
#[cfg(feature = "integration-real-tests")]
#[ignore = "integration-real: spawns shell processes"]
async fn integration_real_shell_job_cancel_tool_output() {
let temp_dir = TempDir::new().unwrap();
let mut config = ShellConfig::with_project_root(temp_dir.path().to_path_buf());
config.shell = "sh".to_string();
let manager = Arc::new(JobManager::new(config));
let tool = ShellJobCancelTool::new(Arc::clone(&manager));
let job_id = manager.spawn_job("sleep 60", None, 120).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
let result = tool
.call(json!({
"job_id": job_id.0
}))
.await
.unwrap();
let result = result.into_json().unwrap();
assert_eq!(result["job_id"], job_id.0);
assert_eq!(result["status"], "cancelled");
}
#[tokio::test]
async fn test_shell_job_cancel_tool_not_found() {
let config = ShellConfig::default();
let manager = Arc::new(JobManager::new(config));
let tool = ShellJobCancelTool::new(manager);
let result = tool
.call(json!({
"job_id": "job_nonexistent123456789012"
}))
.await;
assert!(matches!(result, Err(BuiltinToolError::ExecutionFailed(_))));
if let Err(BuiltinToolError::ExecutionFailed(msg)) = result {
assert!(msg.contains("not found") || msg.contains("Job not found"));
}
}
#[tokio::test]
#[cfg(feature = "integration-real-tests")]
#[ignore = "integration-real: spawns shell processes"]
async fn integration_real_shell_job_cancel_tool_not_running() {
let temp_dir = TempDir::new().unwrap();
let mut config = ShellConfig::with_project_root(temp_dir.path().to_path_buf());
config.shell = "sh".to_string();
let manager = Arc::new(JobManager::new(config));
let tool = ShellJobCancelTool::new(Arc::clone(&manager));
let job_id = manager.spawn_job("echo done", None, 30).await.unwrap();
tokio::time::sleep(Duration::from_secs(1)).await;
let job = manager.get_status(&job_id).await.unwrap();
assert!(
matches!(job.status, JobStatus::Completed { .. }),
"Job should be completed for this test"
);
let result = tool
.call(json!({
"job_id": job_id.0
}))
.await;
assert!(matches!(result, Err(BuiltinToolError::ExecutionFailed(_))));
if let Err(BuiltinToolError::ExecutionFailed(msg)) = result {
assert!(
msg.contains("already completed") || msg.contains("not running"),
"Expected 'already completed' in error message, got: {msg}"
);
}
}
#[tokio::test]
async fn test_shell_job_cancel_tool_invalid_args() {
let config = ShellConfig::default();
let manager = Arc::new(JobManager::new(config));
let tool = ShellJobCancelTool::new(manager);
let result = tool.call(json!({})).await;
assert!(matches!(result, Err(BuiltinToolError::InvalidArgs(_))));
}
}