mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
4039 lines
142 KiB
Rust
4039 lines
142 KiB
Rust
use anyhow::anyhow;
|
|
use base64::Engine;
|
|
use etcetera::AppStrategy;
|
|
use ignore::gitignore::{Gitignore, GitignoreBuilder};
|
|
use include_dir::{include_dir, Dir};
|
|
use indoc::{formatdoc, indoc};
|
|
use rmcp::{
|
|
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
|
model::{
|
|
CallToolResult, CancelledNotificationParam, Content, ErrorCode, ErrorData,
|
|
GetPromptRequestParam, GetPromptResult, Implementation, ListPromptsResult, LoggingLevel,
|
|
LoggingMessageNotificationParam, PaginatedRequestParam, Prompt, PromptArgument,
|
|
PromptMessage, PromptMessageRole, Role, ServerCapabilities, ServerInfo,
|
|
},
|
|
schemars::JsonSchema,
|
|
service::{NotificationContext, RequestContext},
|
|
tool, tool_handler, tool_router, RoleServer, ServerHandler,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
collections::HashMap,
|
|
env::join_paths,
|
|
ffi::OsString,
|
|
future::Future,
|
|
io::Cursor,
|
|
path::{Path, PathBuf},
|
|
sync::{Arc, Mutex},
|
|
};
|
|
use xcap::{Monitor, Window};
|
|
|
|
use tokio::{
|
|
io::{AsyncBufReadExt, BufReader},
|
|
sync::RwLock,
|
|
};
|
|
use tokio_stream::{wrappers::SplitStream, StreamExt as _};
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
use crate::developer::{paths::get_shell_path_dirs, shell::ShellConfig};
|
|
|
|
use super::analyze::{types::AnalyzeParams, CodeAnalyzer};
|
|
use super::editor_models::{create_editor_model, EditorModel};
|
|
use super::shell::{configure_shell_command, expand_path, is_absolute_path, kill_process_group};
|
|
use super::text_editor::{
|
|
text_editor_insert, text_editor_replace, text_editor_undo, text_editor_view, text_editor_write,
|
|
};
|
|
|
|
/// Parameters for the screen_capture tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct ScreenCaptureParams {
|
|
/// The display number to capture (0 is main display)
|
|
#[serde(default)]
|
|
pub display: Option<u64>,
|
|
|
|
/// Optional: the exact title of the window to capture.
|
|
/// Use the list_windows tool to find the available windows.
|
|
pub window_title: Option<String>,
|
|
}
|
|
|
|
/// Parameters for the text_editor tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct TextEditorParams {
|
|
/// Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.
|
|
pub path: String,
|
|
|
|
/// The operation to perform. Allowed options are: `view`, `write`, `str_replace`, `insert`, `undo_edit`.
|
|
pub command: String,
|
|
|
|
/// Unified diff to apply. Supports editing multiple files simultaneously. Cannot create or delete files
|
|
/// Example: "--- a/file\n+++ b/file\n@@ -1,3 +1,3 @@\n context\n-old\n+new\n context"
|
|
/// Preferred edit method.
|
|
pub diff: Option<String>,
|
|
|
|
/// Optional array of two integers specifying the start and end line numbers to view.
|
|
/// Line numbers are 1-indexed, and -1 for the end line means read to the end of the file.
|
|
/// This parameter only applies when viewing files, not directories.
|
|
pub view_range: Option<Vec<i64>>,
|
|
|
|
/// The content to write to the file. Required for `write` command.
|
|
pub file_text: Option<String>,
|
|
|
|
/// The old string to replace.
|
|
pub old_str: Option<String>,
|
|
|
|
/// The new string to replace with. Required for `insert` command.
|
|
pub new_str: Option<String>,
|
|
|
|
/// The line number after which to insert text (0 for beginning). Required for `insert` command.
|
|
pub insert_line: Option<i64>,
|
|
}
|
|
|
|
/// Parameters for the shell tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct ShellParams {
|
|
/// The command string to execute in the shell
|
|
pub command: String,
|
|
}
|
|
|
|
/// Parameters for the image_processor tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct ImageProcessorParams {
|
|
/// Absolute path to the image file to process
|
|
pub path: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct SearchParams {
|
|
/// Search query (e.g., "rust tokio timeout", "react useEffect cleanup")
|
|
pub query: String,
|
|
}
|
|
|
|
/// Template structure for prompt definitions
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct PromptTemplate {
|
|
pub id: String,
|
|
pub template: String,
|
|
pub arguments: Vec<PromptArgumentTemplate>,
|
|
}
|
|
|
|
/// Template structure for prompt arguments
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct PromptArgumentTemplate {
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub required: Option<bool>,
|
|
}
|
|
|
|
// Embeds the prompts directory to the build
|
|
static PROMPTS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/developer/prompts");
|
|
|
|
/// Loads prompt files from the embedded PROMPTS_DIR and returns a HashMap of prompts.
|
|
/// Ensures that each prompt name is unique.
|
|
fn load_prompt_files() -> HashMap<String, Prompt> {
|
|
let mut prompts = HashMap::new();
|
|
|
|
for entry in PROMPTS_DIR.files() {
|
|
// Only process JSON files
|
|
if entry.path().extension().is_none_or(|ext| ext != "json") {
|
|
continue;
|
|
}
|
|
|
|
let prompt_str = String::from_utf8_lossy(entry.contents()).into_owned();
|
|
|
|
let template: PromptTemplate = match serde_json::from_str(&prompt_str) {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
eprintln!(
|
|
"Failed to parse prompt template in {}: {}",
|
|
entry.path().display(),
|
|
e
|
|
);
|
|
continue; // Skip invalid prompt file
|
|
}
|
|
};
|
|
|
|
let arguments = template
|
|
.arguments
|
|
.into_iter()
|
|
.map(|arg| PromptArgument {
|
|
name: arg.name,
|
|
description: arg.description,
|
|
required: arg.required,
|
|
title: None,
|
|
})
|
|
.collect::<Vec<PromptArgument>>();
|
|
|
|
let prompt = Prompt::new(&template.id, Some(&template.template), Some(arguments));
|
|
|
|
if prompts.contains_key(&prompt.name) {
|
|
eprintln!("Duplicate prompt name '{}' found. Skipping.", prompt.name);
|
|
continue; // Skip duplicate prompt name
|
|
}
|
|
|
|
prompts.insert(prompt.name.clone(), prompt);
|
|
}
|
|
|
|
prompts
|
|
}
|
|
|
|
/// Developer MCP Server using official RMCP SDK
|
|
#[derive(Clone)]
|
|
pub struct DeveloperServer {
|
|
tool_router: ToolRouter<Self>,
|
|
file_history: Arc<Mutex<HashMap<PathBuf, Vec<String>>>>,
|
|
ignore_patterns: Gitignore,
|
|
editor_model: Option<EditorModel>,
|
|
prompts: HashMap<String, Prompt>,
|
|
code_analyzer: CodeAnalyzer,
|
|
#[cfg(test)]
|
|
pub running_processes: Arc<RwLock<HashMap<String, CancellationToken>>>,
|
|
#[cfg(not(test))]
|
|
running_processes: Arc<RwLock<HashMap<String, CancellationToken>>>,
|
|
bash_env_file: Option<PathBuf>,
|
|
extend_path_with_shell: bool,
|
|
http_client: reqwest::Client,
|
|
}
|
|
|
|
#[tool_handler(router = self.tool_router)]
|
|
impl ServerHandler for DeveloperServer {
|
|
#[allow(clippy::too_many_lines)]
|
|
fn get_info(&self) -> ServerInfo {
|
|
// Get base instructions and working directory
|
|
let cwd = std::env::current_dir().expect("should have a current working dir");
|
|
let os = std::env::consts::OS;
|
|
let in_container = Self::is_definitely_container();
|
|
|
|
let base_instructions = match os {
|
|
"windows" => formatdoc! {r#"
|
|
The developer extension gives you the capabilities to edit code files and run shell commands,
|
|
and can be used to solve a wide range of problems.
|
|
|
|
You can use the shell tool to run Windows commands (PowerShell or CMD).
|
|
When using paths, you can use either backslashes or forward slashes.
|
|
|
|
Use the shell tool as needed to locate files or interact with the project.
|
|
|
|
Leverage `analyze` through `return_last_only=true` subagents for deep codebase understanding with lean context
|
|
- delegate analysis, retain summaries
|
|
|
|
Your windows/screen tools can be used for visual debugging. You should not use these tools unless
|
|
prompted to, but you can mention they are available if they are relevant.
|
|
|
|
operating system: {os}
|
|
current directory: {cwd}
|
|
{container_info}
|
|
"#,
|
|
os=os,
|
|
cwd=cwd.to_string_lossy(),
|
|
container_info=if in_container { "container: true" } else { "" },
|
|
},
|
|
_ => {
|
|
let shell_info = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
|
|
|
|
formatdoc! {r#"
|
|
The developer extension gives you the capabilities to edit code files and run shell commands,
|
|
and can be used to solve a wide range of problems.
|
|
|
|
You can use the shell tool to run any command that would work on the relevant operating system.
|
|
Use the shell tool as needed to locate files or interact with the project.
|
|
|
|
Leverage `analyze` through `return_last_only=true` subagents for deep codebase understanding with lean context
|
|
- delegate analysis, retain summaries
|
|
|
|
Your windows/screen tools can be used for visual debugging. You should not use these tools unless
|
|
prompted to, but you can mention they are available if they are relevant.
|
|
|
|
Always prefer ripgrep (rg -C 3) to grep.
|
|
|
|
operating system: {os}
|
|
current directory: {cwd}
|
|
shell: {shell}
|
|
{container_info}
|
|
"#,
|
|
os=os,
|
|
cwd=cwd.to_string_lossy(),
|
|
shell=shell_info,
|
|
container_info=if in_container { "container: true" } else { "" },
|
|
}
|
|
}
|
|
};
|
|
|
|
// Check if editor model exists and augment with custom llm editor tool description
|
|
let editor_description = if let Some(ref editor) = self.editor_model {
|
|
formatdoc! {r#"
|
|
|
|
Additional Text Editor Tool Instructions:
|
|
|
|
Perform text editing operations on files.
|
|
The `command` parameter specifies the operation to perform. Allowed options are:
|
|
- `view`: View the content of a file.
|
|
- `write`: Create or overwrite a file with the given content
|
|
- `str_replace`: Replace text in one or more files.
|
|
- `insert`: Insert text at a specific line location in the file.
|
|
- `undo_edit`: Undo the last edit made to a file.
|
|
|
|
To use the write command, you must specify `file_text` which will become the new content of the file. Be careful with
|
|
existing files! This is a full overwrite, so you must include everything - not just sections you are modifying.
|
|
|
|
To use the insert command, you must specify both `insert_line` (the line number after which to insert, 0 for beginning, -1 for end)
|
|
and `new_str` (the text to insert).
|
|
|
|
To use the str_replace command to edit multiple files, use the `diff` parameter with a unified diff.
|
|
To use the str_replace command to edit one file, you must specify both `old_str` and `new_str` - the `old_str` needs to exactly match one
|
|
unique section of the original file, including any whitespace. Make sure to include enough context that the match is not
|
|
ambiguous. The entire original string will be replaced with `new_str`
|
|
|
|
When possible, batch file edits together by using a multi-file unified `diff` within a single str_replace tool call.
|
|
|
|
{}
|
|
|
|
"#, editor.get_str_replace_description()}
|
|
} else {
|
|
formatdoc! {r#"
|
|
|
|
Additional Text Editor Tool Instructions:
|
|
|
|
Perform text editing operations on files.
|
|
|
|
The `command` parameter specifies the operation to perform. Allowed options are:
|
|
- `view`: View the content of a file.
|
|
- `write`: Create or overwrite a file with the given content
|
|
- `str_replace`: Replace text in one or more files.
|
|
- `insert`: Insert text at a specific line location in the file.
|
|
- `undo_edit`: Undo the last edit made to a file.
|
|
|
|
To use the write command, you must specify `file_text` which will become the new content of the file. Be careful with
|
|
existing files! This is a full overwrite, so you must include everything - not just sections you are modifying.
|
|
|
|
To use the str_replace command to edit multiple files, use the `diff` parameter with a unified diff.
|
|
To use the str_replace command to edit one file, you must specify both `old_str` and `new_str` - the `old_str` needs to exactly match one
|
|
unique section of the original file, including any whitespace. Make sure to include enough context that the match is not
|
|
ambiguous. The entire original string will be replaced with `new_str`
|
|
|
|
When possible, batch file edits together by using a multi-file unified `diff` within a single str_replace tool call.
|
|
|
|
To use the insert command, you must specify both `insert_line` (the line number after which to insert, 0 for beginning, -1 for end)
|
|
and `new_str` (the text to insert).
|
|
|
|
|
|
"#}
|
|
};
|
|
|
|
// Create comprehensive shell tool instructions
|
|
let common_shell_instructions = indoc! {r#"
|
|
Additional Shell Tool Instructions:
|
|
Execute a command in the shell.
|
|
|
|
This will return the output and error concatenated into a single string, as
|
|
you would see from running on the command line. There will also be an indication
|
|
of if the command succeeded or failed.
|
|
|
|
Avoid commands that produce a large amount of output, and consider piping those outputs to files.
|
|
|
|
**Important**: Each shell command runs in its own process. Things like directory changes or
|
|
sourcing files do not persist between tool calls. So you may need to repeat them each time by
|
|
stringing together commands.
|
|
|
|
If fetching web content, consider adding Accept: text/markdown header
|
|
"#};
|
|
|
|
let windows_specific = indoc! {r#"
|
|
**Important**: For searching files and code:
|
|
|
|
Preferred: Use ripgrep (`rg`) when available - it respects .gitignore and is fast:
|
|
- To locate a file by name: `rg --files | rg example.py`
|
|
- To locate content inside files: `rg 'class Example'`
|
|
|
|
Alternative Windows commands (if ripgrep is not installed):
|
|
- To locate a file by name: `dir /s /b example.py`
|
|
- To locate content inside files: `findstr /s /i "class Example" *.py`
|
|
|
|
Note: Alternative commands may show ignored/hidden files that should be excluded.
|
|
|
|
- Multiple commands: Use && to chain commands, avoid newlines
|
|
- Example: `cd example && dir` or `activate.bat && pip install numpy`
|
|
|
|
**Important**: Use forward slashes in paths (e.g., `C:/Users/name`) to avoid
|
|
escape character issues with backslashes, i.e. \n in a path could be
|
|
mistaken for a newline.
|
|
"#};
|
|
|
|
let unix_specific = indoc! {r#"
|
|
If you need to run a long lived command, background it - e.g. `uvicorn main:app &` so that
|
|
this tool does not run indefinitely.
|
|
|
|
**Important**: Use ripgrep - `rg` - exclusively when you need to locate a file or a code reference,
|
|
other solutions may produce too large output because of hidden files! For example *do not* use `find` or `ls -r`
|
|
- List files by name: `rg --files | rg <filename>`
|
|
- List files that contain a regex: `rg '<regex>' -l`
|
|
|
|
- Multiple commands: Use && to chain commands, avoid newlines
|
|
- Example: `cd example && ls` or `source env/bin/activate && pip install numpy`
|
|
"#};
|
|
|
|
let shell_tool_desc = match os {
|
|
"windows" => format!("{}{}", common_shell_instructions, windows_specific),
|
|
_ => format!("{}{}", common_shell_instructions, unix_specific),
|
|
};
|
|
|
|
let instructions = format!("{base_instructions}{editor_description}\n{shell_tool_desc}");
|
|
|
|
ServerInfo {
|
|
server_info: Implementation {
|
|
name: "goose-developer".to_string(),
|
|
version: env!("CARGO_PKG_VERSION").to_owned(),
|
|
title: None,
|
|
icons: None,
|
|
website_url: None,
|
|
},
|
|
capabilities: ServerCapabilities::builder()
|
|
.enable_tools()
|
|
.enable_prompts()
|
|
.build(),
|
|
instructions: Some(instructions),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
// TODO: use the rmcp prompt macros instead when SDK is updated
|
|
// Current rmcp version 0.6.0 doesn't support prompt macros yet.
|
|
// When upgrading to a newer version that supports it, replace this manual
|
|
// implementation with the macro-based approach for better maintainability.
|
|
fn list_prompts(
|
|
&self,
|
|
_request: Option<PaginatedRequestParam>,
|
|
_context: RequestContext<RoleServer>,
|
|
) -> impl Future<Output = Result<ListPromptsResult, ErrorData>> + Send + '_ {
|
|
let prompts: Vec<Prompt> = self.prompts.values().cloned().collect();
|
|
std::future::ready(Ok(ListPromptsResult {
|
|
prompts,
|
|
next_cursor: None,
|
|
meta: None,
|
|
}))
|
|
}
|
|
|
|
fn get_prompt(
|
|
&self,
|
|
request: GetPromptRequestParam,
|
|
_context: RequestContext<RoleServer>,
|
|
) -> impl Future<Output = Result<GetPromptResult, ErrorData>> + Send + '_ {
|
|
let prompt_name = request.name;
|
|
let arguments = request.arguments.unwrap_or_default();
|
|
|
|
match self.prompts.get(&prompt_name) {
|
|
Some(prompt) => {
|
|
// Get the template from the prompt description
|
|
let template = prompt.description.clone().unwrap_or_default();
|
|
|
|
// Validate template length
|
|
if template.len() > 10000 {
|
|
return std::future::ready(Err(ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
"Prompt template exceeds maximum allowed length".to_string(),
|
|
None,
|
|
)));
|
|
}
|
|
|
|
// Validate arguments for security (same checks as router)
|
|
for (key, value) in &arguments {
|
|
// Check for empty or overly long keys/values
|
|
if key.is_empty() || key.len() > 1000 {
|
|
return std::future::ready(Err(ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Argument keys must be between 1-1000 characters".to_string(),
|
|
None,
|
|
)));
|
|
}
|
|
|
|
let value_str = value.as_str().unwrap_or_default();
|
|
if value_str.len() > 1000 {
|
|
return std::future::ready(Err(ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Argument values must not exceed 1000 characters".to_string(),
|
|
None,
|
|
)));
|
|
}
|
|
|
|
// Check for potentially dangerous patterns
|
|
let dangerous_patterns = ["../", "//", "\\\\", "<script>", "{{", "}}"];
|
|
for pattern in dangerous_patterns {
|
|
if key.contains(pattern) || value_str.contains(pattern) {
|
|
return std::future::ready(Err(ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
format!(
|
|
"Arguments contain potentially unsafe pattern: {}",
|
|
pattern
|
|
),
|
|
None,
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate required arguments
|
|
if let Some(args) = &prompt.arguments {
|
|
for arg in args {
|
|
if arg.required.unwrap_or(false)
|
|
&& (!arguments.contains_key(&arg.name)
|
|
|| arguments
|
|
.get(&arg.name)
|
|
.and_then(|v| v.as_str())
|
|
.is_none_or(str::is_empty))
|
|
{
|
|
return std::future::ready(Err(ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
format!("Missing required argument: '{}'", arg.name),
|
|
None,
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create a mutable copy of the template to fill in arguments
|
|
let mut template_filled = template.clone();
|
|
|
|
// Replace each argument placeholder with its value from the arguments object
|
|
for (key, value) in &arguments {
|
|
let placeholder = format!("{{{}}}", key);
|
|
template_filled =
|
|
template_filled.replace(&placeholder, value.as_str().unwrap_or_default());
|
|
}
|
|
|
|
// Create prompt messages with the filled template
|
|
let messages = vec![PromptMessage::new_text(
|
|
PromptMessageRole::User,
|
|
template_filled.clone(),
|
|
)];
|
|
|
|
let result = GetPromptResult {
|
|
description: Some(template_filled),
|
|
messages,
|
|
};
|
|
std::future::ready(Ok(result))
|
|
}
|
|
None => std::future::ready(Err(ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Prompt '{}' not found", prompt_name),
|
|
None,
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// Called when the client cancels a specific request.
|
|
/// This method cancels the running process associated with the given request_id.
|
|
#[allow(clippy::manual_async_fn)]
|
|
fn on_cancelled(
|
|
&self,
|
|
notification: CancelledNotificationParam,
|
|
_context: NotificationContext<RoleServer>,
|
|
) -> impl Future<Output = ()> + Send + '_ {
|
|
async move {
|
|
let request_id = notification.request_id.to_string();
|
|
let processes = self.running_processes.read().await;
|
|
|
|
if let Some(token) = processes.get(&request_id) {
|
|
token.cancel();
|
|
tracing::debug!("Found process for request {}, cancelling token", request_id);
|
|
} else {
|
|
tracing::warn!("No process found for request ID: {}", request_id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for DeveloperServer {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[tool_router(router = tool_router)]
|
|
impl DeveloperServer {
|
|
pub fn new() -> Self {
|
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
|
let ignore_patterns = Self::build_ignore_patterns(&cwd);
|
|
let editor_model = create_editor_model();
|
|
|
|
Self {
|
|
tool_router: Self::tool_router(),
|
|
file_history: Arc::new(Mutex::new(HashMap::new())),
|
|
ignore_patterns,
|
|
editor_model,
|
|
prompts: load_prompt_files(),
|
|
code_analyzer: CodeAnalyzer::new(),
|
|
running_processes: Arc::new(RwLock::new(HashMap::new())),
|
|
extend_path_with_shell: false,
|
|
bash_env_file: None,
|
|
http_client: reqwest::Client::builder()
|
|
.user_agent(format!("goose-developer/{}", env!("CARGO_PKG_VERSION")))
|
|
.connect_timeout(std::time::Duration::from_secs(5))
|
|
.build()
|
|
.expect("failed to build http client"),
|
|
}
|
|
}
|
|
|
|
pub fn extend_path_with_shell(mut self, value: bool) -> Self {
|
|
self.extend_path_with_shell = value;
|
|
self
|
|
}
|
|
|
|
pub fn bash_env_file(mut self, value: Option<PathBuf>) -> Self {
|
|
self.bash_env_file = value;
|
|
self
|
|
}
|
|
|
|
/// List all available windows that can be used with screen_capture.
|
|
/// Returns a list of window titles that can be used with the window_title parameter
|
|
/// of the screen_capture tool.
|
|
#[tool(
|
|
name = "list_windows",
|
|
description = "List all available window titles that can be used with screen_capture. Returns a list of window titles that can be used with the window_title parameter of the screen_capture tool."
|
|
)]
|
|
pub async fn list_windows(&self) -> Result<CallToolResult, ErrorData> {
|
|
let windows = Window::all().map_err(|_| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
"Failed to list windows".to_string(),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
let window_titles: Vec<String> =
|
|
windows.into_iter().filter_map(|w| w.title().ok()).collect();
|
|
|
|
let content_text = format!("Available windows:\n{}", window_titles.join("\n"));
|
|
|
|
Ok(CallToolResult::success(vec![
|
|
Content::text(content_text.clone()).with_audience(vec![Role::Assistant]),
|
|
Content::text(content_text)
|
|
.with_audience(vec![Role::User])
|
|
.with_priority(0.0),
|
|
]))
|
|
}
|
|
|
|
/// Capture a screenshot of a specified display or window.
|
|
/// You can capture either:
|
|
/// 1. A full display (monitor) using the display parameter
|
|
/// 2. A specific window by its title using the window_title parameter
|
|
///
|
|
/// Only one of display or window_title should be specified.
|
|
#[tool(
|
|
name = "screen_capture",
|
|
description = "Capture a screenshot of a specified display or window. You can capture either: 1. A full display (monitor) using the display parameter 2. A specific window by its title using the window_title parameter. Only one of display or window_title should be specified."
|
|
)]
|
|
pub async fn screen_capture(
|
|
&self,
|
|
params: Parameters<ScreenCaptureParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
let params = params.0;
|
|
|
|
let mut image = if let Some(window_title) = ¶ms.window_title {
|
|
// Try to find and capture the specified window
|
|
let windows = Window::all().map_err(|_| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
"Failed to list windows".to_string(),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
let window = windows
|
|
.into_iter()
|
|
.find(|w| w.title().is_ok_and(|t| &t == window_title))
|
|
.ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("No window found with title '{}'", window_title),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
window.capture_image().map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to capture window '{}': {}", window_title, e),
|
|
None,
|
|
)
|
|
})?
|
|
} else {
|
|
// Default to display capture if no window title is specified
|
|
let display = params.display.unwrap_or(0) as usize;
|
|
|
|
let monitors = Monitor::all().map_err(|_| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
"Failed to access monitors".to_string(),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
let monitor = monitors.get(display).ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!(
|
|
"{} was not an available monitor, {} found.",
|
|
display,
|
|
monitors.len()
|
|
),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
monitor.capture_image().map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to capture display {}: {}", display, e),
|
|
None,
|
|
)
|
|
})?
|
|
};
|
|
|
|
// Resize the image to a reasonable width while maintaining aspect ratio
|
|
let max_width = 768;
|
|
if image.width() > max_width {
|
|
let scale = max_width as f32 / image.width() as f32;
|
|
let new_height = (image.height() as f32 * scale) as u32;
|
|
image = xcap::image::imageops::resize(
|
|
&image,
|
|
max_width,
|
|
new_height,
|
|
xcap::image::imageops::FilterType::Lanczos3,
|
|
);
|
|
}
|
|
|
|
let mut bytes: Vec<u8> = Vec::new();
|
|
image
|
|
.write_to(&mut Cursor::new(&mut bytes), xcap::image::ImageFormat::Png)
|
|
.map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to write image buffer {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
// Convert to base64
|
|
let data = base64::prelude::BASE64_STANDARD.encode(bytes);
|
|
|
|
// Return two Content objects like the old implementation:
|
|
// one text for Assistant, one image with priority 0.0
|
|
Ok(CallToolResult::success(vec![
|
|
Content::text("Screenshot captured").with_audience(vec![Role::Assistant]),
|
|
Content::image(data, "image/png").with_priority(0.0),
|
|
]))
|
|
}
|
|
|
|
/// Perform text editing operations on files.
|
|
///
|
|
/// The `command` parameter specifies the operation to perform. Allowed options are:
|
|
/// - `view`: View the content of a file.
|
|
/// - `write`: Create or overwrite a file with the given content
|
|
/// - `str_replace`: Replace old_str with new_str in the file.
|
|
/// - `insert`: Insert text at a specific line location in the file.
|
|
/// - `undo_edit`: Undo the last edit made to a file.
|
|
#[tool(
|
|
name = "text_editor",
|
|
description = "Perform text editing operations on files. Commands: view (show file content), write (create/overwrite file), str_replace (edit file), insert (insert at line), undo_edit (undo last change)."
|
|
)]
|
|
pub async fn text_editor(
|
|
&self,
|
|
params: Parameters<TextEditorParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
let params = params.0;
|
|
let path = self.resolve_path(¶ms.path)?;
|
|
|
|
// Check if file is ignored before proceeding with any text editor operation
|
|
if self.is_ignored(&path) {
|
|
return Err(ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!(
|
|
"Access to '{}' is restricted by .gooseignore",
|
|
path.display()
|
|
),
|
|
None,
|
|
));
|
|
}
|
|
|
|
match params.command.as_str() {
|
|
"view" => {
|
|
let view_range = params.view_range.as_ref().and_then(|vr| {
|
|
if vr.len() == 2 {
|
|
Some((vr[0] as usize, vr[1]))
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
let content = text_editor_view(&path, view_range).await?;
|
|
Ok(CallToolResult::success(content))
|
|
}
|
|
"write" => {
|
|
let file_text = params.file_text.ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Missing 'file_text' parameter for write command".to_string(),
|
|
None,
|
|
)
|
|
})?;
|
|
let content = text_editor_write(&path, &file_text).await?;
|
|
Ok(CallToolResult::success(content))
|
|
}
|
|
"str_replace" => {
|
|
// Check if diff parameter is provided
|
|
if let Some(ref diff) = params.diff {
|
|
// When diff is provided, old_str and new_str are not required
|
|
let content = text_editor_replace(
|
|
&path,
|
|
"", // old_str not used with diff
|
|
"", // new_str not used with diff
|
|
Some(diff),
|
|
&self.editor_model,
|
|
&self.file_history,
|
|
)
|
|
.await?;
|
|
Ok(CallToolResult::success(content))
|
|
} else {
|
|
// Traditional str_replace with old_str and new_str
|
|
let old_str = params.old_str.ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Missing 'old_str' parameter for str_replace command".to_string(),
|
|
None,
|
|
)
|
|
})?;
|
|
let new_str = params.new_str.ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Missing 'new_str' parameter for str_replace command".to_string(),
|
|
None,
|
|
)
|
|
})?;
|
|
let content = text_editor_replace(
|
|
&path,
|
|
&old_str,
|
|
&new_str,
|
|
None,
|
|
&self.editor_model,
|
|
&self.file_history,
|
|
)
|
|
.await?;
|
|
Ok(CallToolResult::success(content))
|
|
}
|
|
}
|
|
"insert" => {
|
|
let insert_line = params.insert_line.ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Missing 'insert_line' parameter for insert command".to_string(),
|
|
None,
|
|
)
|
|
})? as usize;
|
|
let new_str = params.new_str.ok_or_else(|| {
|
|
ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Missing 'new_str' parameter for insert command".to_string(),
|
|
None,
|
|
)
|
|
})?;
|
|
let content =
|
|
text_editor_insert(&path, insert_line as i64, &new_str, &self.file_history)
|
|
.await?;
|
|
Ok(CallToolResult::success(content))
|
|
}
|
|
"undo_edit" => {
|
|
let content = text_editor_undo(&path, &self.file_history).await?;
|
|
Ok(CallToolResult::success(content))
|
|
}
|
|
_ => Err(ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
format!("Unknown command '{}'", params.command),
|
|
None,
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Execute a command in the shell.
|
|
///
|
|
/// This will return the output and error concatenated into a single string, as
|
|
/// you would see from running on the command line. There will also be an indication
|
|
/// of if the command succeeded or failed.
|
|
///
|
|
/// Avoid commands that produce a large amount of output, and consider piping those outputs to files.
|
|
/// If you need to run a long lived command, background it - e.g. `uvicorn main:app &` so that
|
|
/// this tool does not run indefinitely.
|
|
#[tool(
|
|
name = "shell",
|
|
description = "Execute a command in the shell.This will return the output and error concatenated into a single string, as you would see from running on the command line. There will also be an indication of if the command succeeded or failed. Avoid commands that produce a large amount of output, and consider piping those outputs to files. If you need to run a long lived command, background it - e.g. `uvicorn main:app &` so that this tool does not run indefinitely."
|
|
)]
|
|
pub async fn shell(
|
|
&self,
|
|
params: Parameters<ShellParams>,
|
|
context: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
let params = params.0;
|
|
let command = ¶ms.command;
|
|
let peer = context.peer;
|
|
let request_id = context.id;
|
|
|
|
// Validate the shell command
|
|
self.validate_shell_command(command)?;
|
|
|
|
let cancellation_token = CancellationToken::new();
|
|
// Track the process using the request ID
|
|
{
|
|
let mut processes = self.running_processes.write().await;
|
|
let request_id_str = request_id.to_string();
|
|
processes.insert(request_id_str.clone(), cancellation_token.clone());
|
|
}
|
|
|
|
// Execute the command and capture output
|
|
let output_result = self
|
|
.execute_shell_command(command, &peer, cancellation_token.clone())
|
|
.await;
|
|
|
|
// Clean up the process from tracking
|
|
{
|
|
let mut processes = self.running_processes.write().await;
|
|
let request_id_str = request_id.to_string();
|
|
let was_present = processes.remove(&request_id_str).is_some();
|
|
if !was_present {
|
|
tracing::warn!(
|
|
"Process for request_id {} was not in tracking map when trying to remove",
|
|
request_id
|
|
);
|
|
}
|
|
}
|
|
|
|
let output_str = output_result?;
|
|
|
|
// Validate output size
|
|
self.validate_shell_output_size(command, &output_str)?;
|
|
|
|
// Process and format the output
|
|
let (final_output, user_output) = self.process_shell_output(&output_str)?;
|
|
|
|
Ok(CallToolResult::success(vec![
|
|
Content::text(final_output).with_audience(vec![Role::Assistant]),
|
|
Content::text(user_output)
|
|
.with_audience(vec![Role::User])
|
|
.with_priority(0.0),
|
|
]))
|
|
}
|
|
|
|
/// Validate a shell command before execution.
|
|
///
|
|
/// Checks for empty commands and ensures the command doesn't attempt to access
|
|
/// files that are restricted by ignore patterns.
|
|
fn validate_shell_command(&self, command: &str) -> Result<(), ErrorData> {
|
|
// Check for empty commands
|
|
if command.trim().is_empty() {
|
|
return Err(ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Shell command cannot be empty".to_string(),
|
|
None,
|
|
));
|
|
}
|
|
|
|
let cmd_parts: Vec<&str> = command.split_whitespace().collect();
|
|
|
|
// Check if command arguments reference ignored files
|
|
for arg in &cmd_parts[1..] {
|
|
// Skip command flags
|
|
if arg.starts_with('-') {
|
|
continue;
|
|
}
|
|
|
|
// Skip invalid paths
|
|
let path = Path::new(arg);
|
|
if !path.exists() {
|
|
continue;
|
|
}
|
|
|
|
if self.is_ignored(path) {
|
|
return Err(ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!(
|
|
"The command attempts to access '{}' which is restricted by .gooseignore",
|
|
arg
|
|
),
|
|
None,
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Execute a shell command and return the combined output.
|
|
///
|
|
/// Streams output in real-time to the client using logging notifications.
|
|
async fn execute_shell_command(
|
|
&self,
|
|
command: &str,
|
|
peer: &rmcp::service::Peer<RoleServer>,
|
|
cancellation_token: CancellationToken,
|
|
) -> Result<String, ErrorData> {
|
|
let mut shell_config = ShellConfig::default();
|
|
let shell_name = std::path::Path::new(&shell_config.executable)
|
|
.file_name()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("bash");
|
|
|
|
let working_dir = std::env::var("GOOSE_WORKING_DIR")
|
|
.ok()
|
|
.map(std::path::PathBuf::from);
|
|
|
|
if let Some(ref env_file) = self.bash_env_file {
|
|
if shell_name == "bash" {
|
|
shell_config.envs.push((
|
|
OsString::from("BASH_ENV"),
|
|
env_file.clone().into_os_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
let mut command = configure_shell_command(&shell_config, command, working_dir.as_deref());
|
|
|
|
if self.extend_path_with_shell {
|
|
if let Err(e) = get_shell_path_dirs()
|
|
.await
|
|
.and_then(|dirs| join_paths(dirs).map_err(|e| anyhow!(e)))
|
|
.map(|path| command.env("PATH", path))
|
|
{
|
|
tracing::error!("Failed to extend PATH with shell directories: {}", e)
|
|
}
|
|
}
|
|
|
|
let mut child = command
|
|
.spawn()
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
|
|
let pid = child.id();
|
|
if let Some(pid) = pid {
|
|
tracing::debug!("Shell process spawned with PID: {}", pid);
|
|
} else {
|
|
tracing::warn!("Shell process spawned but PID not available");
|
|
}
|
|
|
|
// Stream the output and wait for completion with cancellation support
|
|
let output_task = self.stream_shell_output(
|
|
child.stdout.take().unwrap(),
|
|
child.stderr.take().unwrap(),
|
|
peer.clone(),
|
|
);
|
|
|
|
tokio::select! {
|
|
output_result = output_task => {
|
|
// Wait for the process to complete
|
|
let _exit_status = child.wait().await.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
output_result
|
|
}
|
|
_ = cancellation_token.cancelled() => {
|
|
tracing::info!("Cancellation token triggered! Attempting to kill process and all child processes");
|
|
|
|
// Kill the process and its children using platform-specific approach
|
|
match kill_process_group(&mut child, pid).await {
|
|
Ok(_) => {
|
|
tracing::debug!("Successfully killed shell process and child processes");
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Failed to kill shell process and child processes: {}", e);
|
|
}
|
|
}
|
|
|
|
Err(ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
"Shell command was cancelled by user".to_string(),
|
|
None,
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stream shell output in real-time and return the combined output.
|
|
///
|
|
/// Merges stdout and stderr streams and sends each line as a logging notification.
|
|
async fn stream_shell_output(
|
|
&self,
|
|
stdout: tokio::process::ChildStdout,
|
|
stderr: tokio::process::ChildStderr,
|
|
peer: rmcp::service::Peer<RoleServer>,
|
|
) -> Result<String, ErrorData> {
|
|
let stdout = BufReader::new(stdout);
|
|
let stderr = BufReader::new(stderr);
|
|
|
|
let output_task = tokio::spawn(async move {
|
|
let mut combined_output = String::new();
|
|
|
|
// Merge stdout and stderr streams
|
|
// ref https://blog.yoshuawuyts.com/futures-concurrency-3
|
|
let stdout = SplitStream::new(stdout.split(b'\n')).map(|v| ("stdout", v));
|
|
let stderr = SplitStream::new(stderr.split(b'\n')).map(|v| ("stderr", v));
|
|
let mut merged = stdout.merge(stderr);
|
|
|
|
while let Some((stream_type, line)) = merged.next().await {
|
|
let mut line = line?;
|
|
// Re-add newline as clients expect it
|
|
line.push(b'\n');
|
|
// Convert to UTF-8 to avoid corrupted output
|
|
let line_str = String::from_utf8_lossy(&line);
|
|
|
|
combined_output.push_str(&line_str);
|
|
|
|
// Stream each line back to the client in real-time
|
|
let trimmed_line = line_str.trim();
|
|
if !trimmed_line.is_empty() {
|
|
// Send the output line as a structured logging message
|
|
if let Err(e) = peer
|
|
.notify_logging_message(LoggingMessageNotificationParam {
|
|
level: LoggingLevel::Info,
|
|
data: serde_json::json!({
|
|
"type": "shell_output",
|
|
"stream": stream_type,
|
|
"output": trimmed_line
|
|
}),
|
|
logger: Some("shell_tool".to_string()),
|
|
})
|
|
.await
|
|
{
|
|
// Don't break execution if streaming fails, just log it
|
|
eprintln!("Failed to stream output line: {}", e);
|
|
}
|
|
}
|
|
}
|
|
Ok::<_, std::io::Error>(combined_output)
|
|
});
|
|
|
|
match output_task.await {
|
|
Ok(result) => {
|
|
result.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))
|
|
}
|
|
Err(e) => Err(ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
e.to_string(),
|
|
None,
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Validate that shell output doesn't exceed size limits.
|
|
fn validate_shell_output_size(&self, command: &str, output: &str) -> Result<(), ErrorData> {
|
|
const MAX_CHAR_COUNT: usize = 400_000; // 400KB
|
|
let char_count = output.chars().count();
|
|
|
|
if char_count > MAX_CHAR_COUNT {
|
|
return Err(ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!(
|
|
"Shell output from command '{}' has too many characters ({}). Maximum character count is {}.",
|
|
command,
|
|
char_count,
|
|
MAX_CHAR_COUNT
|
|
),
|
|
None,
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Analyze code structure and relationships.
|
|
///
|
|
/// Automatically selects the appropriate analysis:
|
|
/// - Files: Semantic analysis with call graphs
|
|
/// - Directories: Structure overview with metrics
|
|
/// - With focus parameter: Track symbol across files
|
|
///
|
|
/// Examples:
|
|
/// analyze(path="file.py") -> semantic analysis
|
|
/// analyze(path="src/") -> structure overview down to max_depth subdirs
|
|
/// analyze(path="src/", focus="main") -> track main() across files in src/ down to max_depth subdirs
|
|
#[tool(
|
|
name = "analyze",
|
|
description = "Analyze code structure in 3 modes: 1) Directory overview - file tree with LOC/function/class counts to max_depth. 2) File details - functions, classes, imports. 3) Symbol focus - call graphs across directory to max_depth (requires directory path, case-sensitive). Typical flow: directory → files → symbols. Functions called >3x show •N."
|
|
)]
|
|
pub async fn analyze(
|
|
&self,
|
|
params: Parameters<AnalyzeParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
let params = params.0;
|
|
let path = self.resolve_path(¶ms.path)?;
|
|
self.code_analyzer
|
|
.analyze(params, path, &self.ignore_patterns)
|
|
}
|
|
|
|
/// Process an image file from disk.
|
|
///
|
|
/// The image will be:
|
|
/// 1. Resized if larger than max width while maintaining aspect ratio
|
|
/// 2. Converted to PNG format
|
|
/// 3. Returned as base64 encoded data
|
|
///
|
|
/// This allows processing image files for use in the conversation.
|
|
#[tool(
|
|
name = "image_processor",
|
|
description = "Process an image file from disk. Resizes if needed, converts to PNG, and returns as base64 data."
|
|
)]
|
|
pub async fn image_processor(
|
|
&self,
|
|
params: Parameters<ImageProcessorParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
let params = params.0;
|
|
let path_str = ¶ms.path;
|
|
|
|
let path = {
|
|
let p = self.resolve_path(path_str)?;
|
|
if cfg!(target_os = "macos") {
|
|
self.normalize_mac_screenshot_path(&p)
|
|
} else {
|
|
p
|
|
}
|
|
};
|
|
|
|
// Check if file is ignored before proceeding
|
|
if self.is_ignored(&path) {
|
|
return Err(ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!(
|
|
"Access to '{}' is restricted by .gooseignore",
|
|
path.display()
|
|
),
|
|
None,
|
|
));
|
|
}
|
|
|
|
// Check if file exists
|
|
if !path.exists() {
|
|
return Err(ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("File '{}' does not exist", path.display()),
|
|
None,
|
|
));
|
|
}
|
|
|
|
// Check file size (10MB limit for image files)
|
|
const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; // 10MB in bytes
|
|
let file_size = std::fs::metadata(&path)
|
|
.map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to get file metadata: {}", e),
|
|
None,
|
|
)
|
|
})?
|
|
.len();
|
|
|
|
if file_size > MAX_FILE_SIZE {
|
|
return Err(ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!(
|
|
"File '{}' is too large ({:.2}MB). Maximum size is 10MB.",
|
|
path.display(),
|
|
file_size as f64 / (1024.0 * 1024.0)
|
|
),
|
|
None,
|
|
));
|
|
}
|
|
|
|
// Open and decode the image
|
|
let image = xcap::image::open(&path).map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to open image file: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
// Resize if necessary (same logic as screen_capture)
|
|
let mut processed_image = image;
|
|
let max_width = 768;
|
|
if processed_image.width() > max_width {
|
|
let scale = max_width as f32 / processed_image.width() as f32;
|
|
let new_height = (processed_image.height() as f32 * scale) as u32;
|
|
processed_image = xcap::image::DynamicImage::ImageRgba8(xcap::image::imageops::resize(
|
|
&processed_image,
|
|
max_width,
|
|
new_height,
|
|
xcap::image::imageops::FilterType::Lanczos3,
|
|
));
|
|
}
|
|
|
|
// Convert to PNG and encode as base64
|
|
let mut bytes: Vec<u8> = Vec::new();
|
|
processed_image
|
|
.write_to(&mut Cursor::new(&mut bytes), xcap::image::ImageFormat::Png)
|
|
.map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to write image buffer: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
let data = base64::prelude::BASE64_STANDARD.encode(bytes);
|
|
|
|
Ok(CallToolResult::success(vec![
|
|
Content::text(format!(
|
|
"Successfully processed image from {}",
|
|
path.display()
|
|
))
|
|
.with_audience(vec![Role::Assistant]),
|
|
Content::image(data, "image/png").with_priority(0.0),
|
|
]))
|
|
}
|
|
|
|
#[tool(
|
|
name = "search",
|
|
description = "Search Sourcegraph (code), GitHub Issues, and Reddit. Best: 2-3 keywords (\"redis timeout\" not \"how to fix redis timeout errors\")."
|
|
)]
|
|
pub async fn search(
|
|
&self,
|
|
params: Parameters<SearchParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
const SEARCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
|
|
const SEARCH_LIMIT: usize = 5;
|
|
|
|
let query = params.0.query.trim();
|
|
|
|
if query.is_empty() {
|
|
return Err(ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Search query cannot be empty".to_string(),
|
|
None,
|
|
));
|
|
}
|
|
|
|
let (sg, gh, rd) = tokio::join!(
|
|
self.search_sourcegraph(query, SEARCH_TIMEOUT, SEARCH_LIMIT),
|
|
self.search_github_issues(query, SEARCH_TIMEOUT, SEARCH_LIMIT),
|
|
self.search_reddit(query, SEARCH_TIMEOUT, SEARCH_LIMIT)
|
|
);
|
|
|
|
let all_failed = sg.is_err() && gh.is_err() && rd.is_err();
|
|
let sources = [
|
|
(sg, SearchSource::Sourcegraph, "sourcegraph"),
|
|
(gh, SearchSource::GitHubIssues, "github"),
|
|
(rd, SearchSource::Reddit, "reddit"),
|
|
];
|
|
|
|
let mut results = Vec::new();
|
|
let mut active_sources = Vec::new();
|
|
for (result, source, name) in sources {
|
|
let items = result.unwrap_or_else(|e| {
|
|
tracing::warn!("{}: {}", name, e);
|
|
vec![]
|
|
});
|
|
if !items.is_empty() {
|
|
active_sources.push(source);
|
|
}
|
|
results.extend(items);
|
|
}
|
|
|
|
let text = if all_failed {
|
|
"Search unavailable.".to_string()
|
|
} else if results.is_empty() {
|
|
"No results found.".to_string()
|
|
} else {
|
|
format!(
|
|
"{}{}",
|
|
format_search_results(&results),
|
|
build_fetch_hints(&active_sources)
|
|
)
|
|
};
|
|
|
|
Ok(CallToolResult::success(vec![
|
|
Content::text(text).with_audience(vec![Role::Assistant])
|
|
]))
|
|
}
|
|
|
|
async fn search_sourcegraph(
|
|
&self,
|
|
query: &str,
|
|
timeout: std::time::Duration,
|
|
limit: usize,
|
|
) -> Result<Vec<SearchResult>, String> {
|
|
let sg_query = format!(
|
|
"{} -file:\\.md$ -file:\\.json$ -file:\\.yaml$ -file:\\.yml$ \
|
|
-file:_test -file:test_ -file:/tests/ -file:__tests__ \
|
|
-file:vendor/ -file:node_modules/ -file:third_party/ \
|
|
fork:yes count:{}",
|
|
query, limit
|
|
);
|
|
|
|
let url = reqwest::Url::parse_with_params(
|
|
"https://sourcegraph.com/.api/search/stream",
|
|
&[
|
|
("q", sg_query.as_str()),
|
|
("v", "V3"),
|
|
("t", "literal"),
|
|
("display", "100"),
|
|
],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let resp = self
|
|
.http_client
|
|
.get(url)
|
|
.timeout(timeout)
|
|
.send()
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(format!("HTTP {}", resp.status()));
|
|
}
|
|
|
|
let text = resp.text().await.map_err(|e| e.to_string())?;
|
|
|
|
let mut results = Vec::new();
|
|
let mut in_matches = false;
|
|
|
|
for line in text.lines() {
|
|
if line == "event: matches" {
|
|
in_matches = true;
|
|
continue;
|
|
}
|
|
if line.starts_with("event:") {
|
|
in_matches = false;
|
|
continue;
|
|
}
|
|
if !in_matches || !line.starts_with("data:") {
|
|
continue;
|
|
}
|
|
|
|
let json_str = line.strip_prefix("data:").unwrap_or(line).trim();
|
|
let matches: Vec<serde_json::Value> = match serde_json::from_str(json_str) {
|
|
Ok(m) => m,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
for m in matches.iter().take(limit.saturating_sub(results.len())) {
|
|
let repo = m.get("repository").and_then(|r| r.as_str()).unwrap_or("");
|
|
let path = m.get("path").and_then(|p| p.as_str()).unwrap_or("");
|
|
if repo.is_empty() || path.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let repo_clean = repo.strip_prefix("github.com/").unwrap_or(repo);
|
|
let stars = m.get("repoStars").and_then(|s| s.as_u64()).unwrap_or(0);
|
|
let meta = if stars > 0 {
|
|
format!(" ({}★)", stars)
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let snippet = m
|
|
.get("lineMatches")
|
|
.and_then(|lm| lm.as_array())
|
|
.and_then(|arr| {
|
|
arr.iter()
|
|
.filter_map(|lm| lm.get("line").and_then(|l| l.as_str()))
|
|
.next()
|
|
.map(|s| s.trim().to_string())
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let raw_url = format!(
|
|
"https://raw.githubusercontent.com/{}/HEAD/{}",
|
|
repo_clean, path
|
|
);
|
|
|
|
results.push(SearchResult {
|
|
source: SearchSource::Sourcegraph,
|
|
title: format!("{}/{}{}", repo_clean, path, meta),
|
|
url: format!("https://github.com/{}/blob/HEAD/{}", repo_clean, path),
|
|
snippet: truncate(&snippet, 150),
|
|
fetch_url: Some(raw_url),
|
|
});
|
|
}
|
|
|
|
if results.len() >= limit {
|
|
break;
|
|
}
|
|
}
|
|
Ok(results)
|
|
}
|
|
|
|
async fn search_github_issues(
|
|
&self,
|
|
query: &str,
|
|
timeout: std::time::Duration,
|
|
limit: usize,
|
|
) -> Result<Vec<SearchResult>, String> {
|
|
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
|
|
|
|
let gh_query = format!("{} is:issue", query);
|
|
let encoded_query = utf8_percent_encode(&gh_query, NON_ALPHANUMERIC).to_string();
|
|
let url = format!(
|
|
"https://api.github.com/search/issues?q={}&per_page=20",
|
|
encoded_query
|
|
);
|
|
|
|
let mut req = self
|
|
.http_client
|
|
.get(&url)
|
|
.timeout(timeout)
|
|
.header("Accept", "application/vnd.github+json");
|
|
|
|
if let Ok(token) = std::env::var("GITHUB_TOKEN") {
|
|
req = req.header("Authorization", format!("Bearer {}", token));
|
|
}
|
|
|
|
let resp = req.send().await.map_err(|e| e.to_string())?;
|
|
if !resp.status().is_success() {
|
|
return Err(format!("HTTP {}", resp.status()));
|
|
}
|
|
|
|
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
|
let mut results = Vec::new();
|
|
|
|
if let Some(items) = json.get("items").and_then(|i| i.as_array()) {
|
|
for item in items {
|
|
if results.len() >= limit {
|
|
break;
|
|
}
|
|
|
|
let title = item.get("title").and_then(|t| t.as_str()).unwrap_or("");
|
|
if title.is_empty() || is_bot_issue(title) {
|
|
continue;
|
|
}
|
|
|
|
let html_url = item.get("html_url").and_then(|u| u.as_str()).unwrap_or("");
|
|
let state = item.get("state").and_then(|s| s.as_str()).unwrap_or("open");
|
|
let comments = item.get("comments").and_then(|c| c.as_u64()).unwrap_or(0);
|
|
let reactions = item
|
|
.pointer("/reactions/total_count")
|
|
.and_then(|c| c.as_u64())
|
|
.unwrap_or(0);
|
|
let date = item
|
|
.get("created_at")
|
|
.and_then(|d| d.as_str())
|
|
.and_then(|s| s.get(..7))
|
|
.unwrap_or("");
|
|
let repo = item
|
|
.get("repository_url")
|
|
.and_then(|u| u.as_str())
|
|
.and_then(|u| u.strip_prefix("https://api.github.com/repos/"))
|
|
.unwrap_or("");
|
|
|
|
let mut meta = state.to_string();
|
|
if reactions > 0 {
|
|
meta.push_str(&format!(", {}👍", reactions));
|
|
}
|
|
if comments > 0 {
|
|
meta.push_str(&format!(", {} comments", comments));
|
|
}
|
|
if !date.is_empty() {
|
|
meta.push_str(&format!(", {}", date));
|
|
}
|
|
if !repo.is_empty() {
|
|
meta.push_str(&format!(" [{}]", repo));
|
|
}
|
|
|
|
let body = item.get("body").and_then(|b| b.as_str()).unwrap_or("");
|
|
|
|
let api_url = item.get("url").and_then(|u| u.as_str()).map(String::from);
|
|
|
|
results.push(SearchResult {
|
|
source: SearchSource::GitHubIssues,
|
|
title: format!("{} ({})", title, meta),
|
|
url: html_url.to_string(),
|
|
snippet: truncate(body, 150),
|
|
fetch_url: api_url,
|
|
});
|
|
}
|
|
}
|
|
Ok(results)
|
|
}
|
|
|
|
async fn search_reddit(
|
|
&self,
|
|
query: &str,
|
|
timeout: std::time::Duration,
|
|
limit: usize,
|
|
) -> Result<Vec<SearchResult>, String> {
|
|
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
|
|
|
|
let subreddits = TECH_SUBREDDITS.join("+");
|
|
let encoded_query = utf8_percent_encode(query, NON_ALPHANUMERIC).to_string();
|
|
let url = format!(
|
|
"https://www.reddit.com/r/{}/search.json?q={}&restrict_sr=1&limit={}&sort=relevance",
|
|
subreddits, encoded_query, limit
|
|
);
|
|
|
|
let resp = self
|
|
.http_client
|
|
.get(&url)
|
|
.timeout(timeout)
|
|
.send()
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(format!("HTTP {}", resp.status()));
|
|
}
|
|
|
|
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
|
let mut results = Vec::new();
|
|
|
|
if let Some(posts) = json.pointer("/data/children").and_then(|c| c.as_array()) {
|
|
for post in posts.iter().take(limit) {
|
|
let data = match post.get("data") {
|
|
Some(d) => d,
|
|
None => continue,
|
|
};
|
|
|
|
let title = data.get("title").and_then(|t| t.as_str()).unwrap_or("");
|
|
if title.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let subreddit = data.get("subreddit").and_then(|s| s.as_str()).unwrap_or("");
|
|
let permalink = data.get("permalink").and_then(|p| p.as_str()).unwrap_or("");
|
|
let score = data.get("score").and_then(|s| s.as_i64()).unwrap_or(0);
|
|
let num_comments = data
|
|
.get("num_comments")
|
|
.and_then(|n| n.as_i64())
|
|
.unwrap_or(0);
|
|
let selftext = data.get("selftext").and_then(|s| s.as_str()).unwrap_or("");
|
|
|
|
let meta = format!("r/{}, {}↑, {} comments", subreddit, score, num_comments);
|
|
|
|
results.push(SearchResult {
|
|
source: SearchSource::Reddit,
|
|
title: format!("{} ({})", title, meta),
|
|
url: format!("https://www.reddit.com{}", permalink),
|
|
snippet: truncate(selftext, 150),
|
|
fetch_url: Some(format!("https://www.reddit.com{}.json", permalink)),
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
fn resolve_path(&self, path_str: &str) -> Result<PathBuf, ErrorData> {
|
|
let cwd = std::env::current_dir().expect("should have a current working dir");
|
|
let expanded = expand_path(path_str);
|
|
let path = Path::new(&expanded);
|
|
|
|
// If the path is absolute, return it as-is
|
|
if is_absolute_path(&expanded) {
|
|
Ok(path.to_path_buf())
|
|
} else {
|
|
// For relative paths, resolve them relative to the current working directory
|
|
Ok(cwd.join(path))
|
|
}
|
|
}
|
|
|
|
fn build_ignore_patterns(cwd: &PathBuf) -> Gitignore {
|
|
let mut builder = GitignoreBuilder::new(cwd);
|
|
let local_ignore_path = cwd.join(".gooseignore");
|
|
|
|
let global_ignore_path = etcetera::choose_app_strategy(crate::APP_STRATEGY.clone())
|
|
.map(|strategy| strategy.config_dir().join(".gooseignore"))
|
|
.ok();
|
|
|
|
let has_local_ignore = local_ignore_path.is_file();
|
|
let has_global_ignore = global_ignore_path
|
|
.as_ref()
|
|
.map(|p| p.is_file())
|
|
.unwrap_or(false);
|
|
|
|
if has_global_ignore {
|
|
let _ = builder.add(global_ignore_path.as_ref().unwrap());
|
|
}
|
|
|
|
if has_local_ignore {
|
|
let _ = builder.add(&local_ignore_path);
|
|
}
|
|
|
|
if !has_local_ignore && !has_global_ignore {
|
|
let _ = builder.add_line(None, "**/.env");
|
|
let _ = builder.add_line(None, "**/.env.*");
|
|
let _ = builder.add_line(None, "**/secrets.*");
|
|
}
|
|
|
|
builder.build().expect("Failed to build ignore patterns")
|
|
}
|
|
|
|
// Helper method to check if a path should be ignored
|
|
fn is_ignored(&self, path: &Path) -> bool {
|
|
self.ignore_patterns.matched(path, false).is_ignore()
|
|
}
|
|
|
|
// Only returns true when 100% certain (checks /proc/1/cgroup for container markers)
|
|
fn is_definitely_container() -> bool {
|
|
let Ok(content) = std::fs::read_to_string("/proc/1/cgroup") else {
|
|
// If the file doesn't exist, we're definitely not in a Linux container
|
|
return false;
|
|
};
|
|
|
|
// Check for definitive container markers in cgroup paths
|
|
for line in content.lines() {
|
|
if line.contains("/docker/")
|
|
|| line.contains("/docker-")
|
|
|| line.contains("/kubepods/")
|
|
|| line.contains("/libpod-")
|
|
|| line.contains("/lxc/")
|
|
|| line.contains("/containerd/")
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Check for cgroups v2 unified hierarchy in containers
|
|
// In Docker with cgroups v2, we typically see just "0::/"
|
|
// This is a strong signal when it's the only line
|
|
if content.trim() == "0::/" {
|
|
return true;
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
// Helper function to handle Mac screenshot filenames that contain U+202F (narrow no-break space)
|
|
fn normalize_mac_screenshot_path(&self, path: &Path) -> PathBuf {
|
|
// Only process if the path has a filename
|
|
if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
|
|
// Check if this matches Mac screenshot pattern:
|
|
// "Screenshot YYYY-MM-DD at H.MM.SS AM/PM.png"
|
|
if let Some(captures) = regex::Regex::new(r"^Screenshot \d{4}-\d{2}-\d{2} at \d{1,2}\.\d{2}\.\d{2} (AM|PM|am|pm)(?: \(\d+\))?\.png$")
|
|
.ok()
|
|
.and_then(|re| re.captures(filename))
|
|
{
|
|
// Get the AM/PM part
|
|
let meridian = captures.get(1).unwrap().as_str();
|
|
|
|
// Find the last space before AM/PM and replace it with U+202F
|
|
let space_pos = filename.rfind(meridian)
|
|
.and_then(|pos| filename.get(..pos).map(|s| s.trim_end().len()))
|
|
.unwrap_or(0);
|
|
|
|
if space_pos > 0 {
|
|
let parent = path.parent().unwrap_or(Path::new(""));
|
|
if let (Some(before), Some(after)) = (filename.get(..space_pos), filename.get(space_pos+1..)) {
|
|
let new_filename = format!(
|
|
"{}{}{}",
|
|
before,
|
|
'\u{202F}',
|
|
after
|
|
);
|
|
let new_path = parent.join(new_filename);
|
|
|
|
return new_path;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return the original path if it doesn't match or couldn't be processed
|
|
path.to_path_buf()
|
|
}
|
|
|
|
// shell output can be large, this will help manage that
|
|
fn process_shell_output(&self, output_str: &str) -> Result<(String, String), ErrorData> {
|
|
let lines: Vec<&str> = output_str.lines().collect();
|
|
let line_count = lines.len();
|
|
|
|
let start = lines.len().saturating_sub(100);
|
|
let last_100_lines_str = lines[start..].join("\n");
|
|
|
|
let final_output = if line_count > 100 {
|
|
let tmp_file = tempfile::NamedTempFile::new().map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to create temporary file: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
std::fs::write(tmp_file.path(), output_str).map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to write to temporary file: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
let (_, path) = tmp_file.keep().map_err(|e| {
|
|
ErrorData::new(
|
|
ErrorCode::INTERNAL_ERROR,
|
|
format!("Failed to persist temporary file: {}", e),
|
|
None,
|
|
)
|
|
})?;
|
|
|
|
format!(
|
|
"private note: output was {} lines and we are only showing the most recent lines, remainder of lines in {} do not show tmp file to user, that file can be searched if extra context needed to fulfill request. truncated output: \n{}",
|
|
line_count,
|
|
path.display(),
|
|
last_100_lines_str
|
|
)
|
|
} else {
|
|
output_str.to_string()
|
|
};
|
|
|
|
let user_output = if line_count > 100 {
|
|
format!(
|
|
"NOTE: Output was {} lines, showing only the last 100 lines.\n\n{}",
|
|
line_count, last_100_lines_str
|
|
)
|
|
} else {
|
|
output_str.to_string()
|
|
};
|
|
|
|
Ok((final_output, user_output))
|
|
}
|
|
}
|
|
|
|
const TECH_SUBREDDITS: &[&str] = &[
|
|
"rust",
|
|
"golang",
|
|
"python",
|
|
"javascript",
|
|
"typescript",
|
|
"programming",
|
|
"coding",
|
|
"learnprogramming",
|
|
"webdev",
|
|
"frontend",
|
|
"backend",
|
|
"devops",
|
|
"kubernetes",
|
|
"docker",
|
|
"machinelearning",
|
|
"datascience",
|
|
"linux",
|
|
"commandline",
|
|
"vim",
|
|
"neovim",
|
|
"opensource",
|
|
"selfhosted",
|
|
"node",
|
|
"reactjs",
|
|
"django",
|
|
"flask",
|
|
"aws",
|
|
"azure",
|
|
"googlecloud",
|
|
"databases",
|
|
"sql",
|
|
"mongodb",
|
|
"redis",
|
|
"elasticsearch",
|
|
"netsec",
|
|
"cybersecurity",
|
|
"algorithms",
|
|
"compsci",
|
|
"cpp",
|
|
"java",
|
|
"csharp",
|
|
"swift",
|
|
"kotlin",
|
|
"scala",
|
|
"haskell",
|
|
"elixir",
|
|
"erlang",
|
|
"clojure",
|
|
];
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum SearchSource {
|
|
Sourcegraph,
|
|
GitHubIssues,
|
|
Reddit,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct SearchResult {
|
|
source: SearchSource,
|
|
title: String,
|
|
url: String,
|
|
snippet: String,
|
|
fetch_url: Option<String>,
|
|
}
|
|
|
|
fn is_bot_issue(title: &str) -> bool {
|
|
let t = title.to_lowercase();
|
|
["bump ", "dependabot", "renovate", "chore(deps)"]
|
|
.iter()
|
|
.any(|p| t.contains(p))
|
|
}
|
|
|
|
fn truncate(s: &str, max: usize) -> String {
|
|
let s = s.trim();
|
|
if s.chars().count() <= max {
|
|
return s.to_string();
|
|
}
|
|
format!("{}...", s.chars().take(max).collect::<String>().trim_end())
|
|
}
|
|
|
|
fn format_search_results(results: &[SearchResult]) -> String {
|
|
let mut lines = Vec::new();
|
|
|
|
for r in results {
|
|
let tag = match r.source {
|
|
SearchSource::Sourcegraph => "code",
|
|
SearchSource::GitHubIssues => "issue",
|
|
SearchSource::Reddit => "reddit",
|
|
};
|
|
|
|
let fetch_hint = r
|
|
.fetch_url
|
|
.as_ref()
|
|
.map(|u| format!("\n fetch: {}", u))
|
|
.unwrap_or_default();
|
|
lines.push(format!(
|
|
"[{}] {} - {}\n {}{}",
|
|
tag, r.title, r.url, r.snippet, fetch_hint
|
|
));
|
|
}
|
|
|
|
lines.join("\n\n")
|
|
}
|
|
|
|
fn build_fetch_hints(sources: &[SearchSource]) -> String {
|
|
let mut hints = Vec::new();
|
|
|
|
if sources.contains(&SearchSource::Sourcegraph) {
|
|
hints.push("Code: `shell curl -sH 'Accept: text/plain' '<fetch_url>'`");
|
|
}
|
|
if sources.contains(&SearchSource::GitHubIssues) {
|
|
hints.push("Issues: `shell curl -sH 'Accept: application/vnd.github+json' '<fetch_url>'` (use GITHUB_TOKEN if set)");
|
|
}
|
|
if sources.contains(&SearchSource::Reddit) {
|
|
hints.push("Reddit: `shell curl -s '<fetch_url>'` (JSON format)");
|
|
}
|
|
|
|
if hints.is_empty() {
|
|
return String::new();
|
|
}
|
|
|
|
format!("\n\n---\nTo fetch full content:\n{}", hints.join("\n"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use rmcp::handler::server::wrapper::Parameters;
|
|
use rmcp::model::{CancelledNotificationParam, NumberOrString};
|
|
use rmcp::service::{serve_directly, NotificationContext};
|
|
use rmcp::ServerHandler;
|
|
use serial_test::serial;
|
|
use std::{
|
|
fs,
|
|
time::{Duration, Instant},
|
|
};
|
|
use tempfile::TempDir;
|
|
use tokio::time::timeout;
|
|
|
|
fn create_test_server() -> DeveloperServer {
|
|
DeveloperServer::new()
|
|
}
|
|
|
|
/// Creates a test transport using in-memory streams instead of stdio
|
|
/// This avoids the hanging issues caused by multiple tests competing for stdio
|
|
fn create_test_transport() -> impl rmcp::transport::IntoTransport<
|
|
RoleServer,
|
|
std::io::Error,
|
|
rmcp::transport::async_rw::TransportAdapterAsyncCombinedRW,
|
|
> {
|
|
let (_client, server) = tokio::io::duplex(1024);
|
|
server
|
|
}
|
|
|
|
/// Helper function to run shell tests with proper runtime management
|
|
/// This ensures clean shutdown and prevents hanging tests
|
|
fn run_shell_test<F, Fut, T>(test_fn: F) -> T
|
|
where
|
|
F: FnOnce() -> Fut,
|
|
Fut: std::future::Future<Output = T>,
|
|
{
|
|
// Create a separate runtime for this test to ensure clean shutdown
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
let result = rt.block_on(test_fn());
|
|
|
|
// Force shutdown the runtime to kill ALL spawned tasks
|
|
// This terminates the fire-and-forget tasks that rmcp doesn't track
|
|
rt.shutdown_timeout(std::time::Duration::from_millis(100));
|
|
|
|
// Return the test result
|
|
result
|
|
}
|
|
|
|
/// Helper function to clean up test services and prevent hanging tests
|
|
/// This should be called at the end of tests that create running services
|
|
fn cleanup_test_service(
|
|
running_service: rmcp::service::RunningService<RoleServer, DeveloperServer>,
|
|
peer: rmcp::service::Peer<RoleServer>,
|
|
) {
|
|
let cancellation_token = running_service.cancellation_token();
|
|
cancellation_token.cancel();
|
|
drop(peer);
|
|
drop(running_service);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn test_shell_missing_parameters() {
|
|
run_shell_test(|| async {
|
|
let server = create_test_server();
|
|
let running_service = serve_directly(server.clone(), create_test_transport(), None);
|
|
let peer = running_service.peer().clone();
|
|
|
|
// Test directly on the server instead of using peer.call_tool
|
|
let result = server
|
|
.shell(
|
|
Parameters(ShellParams {
|
|
command: "".to_string(),
|
|
}),
|
|
RequestContext {
|
|
ct: Default::default(),
|
|
id: NumberOrString::Number(1),
|
|
meta: Default::default(),
|
|
extensions: Default::default(),
|
|
peer: peer.clone(),
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert!(result.is_err());
|
|
let err = result.err().unwrap();
|
|
assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
|
|
|
|
// Force cleanup before runtime shutdown
|
|
cleanup_test_service(running_service, peer);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
#[cfg(windows)]
|
|
fn test_windows_specific_commands() {
|
|
run_shell_test(|| async {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
let running_service = serve_directly(server.clone(), create_test_transport(), None);
|
|
let peer = running_service.peer().clone();
|
|
|
|
// Test PowerShell command
|
|
let shell_params = Parameters(ShellParams {
|
|
command: "Get-ChildItem".to_string(),
|
|
});
|
|
|
|
let result = server
|
|
.shell(
|
|
shell_params,
|
|
RequestContext {
|
|
ct: Default::default(),
|
|
id: NumberOrString::Number(1),
|
|
meta: Default::default(),
|
|
extensions: Default::default(),
|
|
peer: peer.clone(),
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert!(result.is_err());
|
|
|
|
// Test that resolve_path works with Windows paths
|
|
let windows_path = r"C:\Windows\System32";
|
|
if Path::new(windows_path).exists() {
|
|
let resolved = server.resolve_path(windows_path);
|
|
assert!(resolved.is_ok());
|
|
}
|
|
|
|
// Force cleanup before runtime shutdown
|
|
cleanup_test_service(running_service, peer);
|
|
});
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_size_limits() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
let server = create_test_server();
|
|
|
|
// Test file size limit
|
|
{
|
|
let large_file_path = temp_dir.path().join("large.txt");
|
|
|
|
// Create a file larger than 2MB
|
|
let content = "x".repeat(3 * 1024 * 1024); // 3MB
|
|
fs::write(&large_file_path, content).unwrap();
|
|
|
|
let view_params = Parameters(TextEditorParams {
|
|
path: large_file_path.to_str().unwrap().to_string(),
|
|
command: "view".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let result = server.text_editor(view_params).await;
|
|
|
|
assert!(result.is_err());
|
|
let err = result.err().unwrap();
|
|
assert_eq!(err.code, ErrorCode::INTERNAL_ERROR);
|
|
assert!(err.to_string().contains("too large"));
|
|
}
|
|
|
|
// Test character count limit
|
|
{
|
|
let many_chars_path = temp_dir.path().join("many_chars.txt");
|
|
|
|
// This is above MAX_FILE_SIZE
|
|
let content = "x".repeat(500_000);
|
|
fs::write(&many_chars_path, content).unwrap();
|
|
|
|
let view_params = Parameters(TextEditorParams {
|
|
path: many_chars_path.to_str().unwrap().to_string(),
|
|
command: "view".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let result = server.text_editor(view_params).await;
|
|
|
|
assert!(result.is_err());
|
|
let err = result.err().unwrap();
|
|
assert_eq!(err.code, ErrorCode::INTERNAL_ERROR);
|
|
assert!(err.to_string().contains("is too large"));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_write_and_view_file() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("test.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Create a new file
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some("Hello, world!".to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(write_params).await.unwrap();
|
|
|
|
// View the file
|
|
let view_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "view".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let view_result = server.text_editor(view_params).await.unwrap();
|
|
|
|
assert!(!view_result.content.is_empty());
|
|
let user_content = view_result
|
|
.content
|
|
.iter()
|
|
.find(|c| {
|
|
c.audience()
|
|
.is_some_and(|roles| roles.contains(&Role::User))
|
|
})
|
|
.unwrap()
|
|
.as_text()
|
|
.unwrap();
|
|
assert!(user_content.text.contains("Hello, world!"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_str_replace() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("test.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Create a new file
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some("Hello, world!".to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(write_params).await.unwrap();
|
|
|
|
// Replace string
|
|
let replace_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "str_replace".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: Some("world".to_string()),
|
|
new_str: Some("Rust".to_string()),
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let replace_result = server.text_editor(replace_params).await.unwrap();
|
|
|
|
let assistant_content = replace_result
|
|
.content
|
|
.iter()
|
|
.find(|c| {
|
|
c.audience()
|
|
.is_some_and(|roles| roles.contains(&Role::Assistant))
|
|
})
|
|
.unwrap()
|
|
.as_text()
|
|
.unwrap();
|
|
|
|
assert!(
|
|
assistant_content.text.contains("The file")
|
|
&& assistant_content.text.contains("has been edited")
|
|
);
|
|
|
|
// Verify the file contents changed
|
|
let content = fs::read_to_string(&file_path).unwrap();
|
|
assert!(content.contains("Hello, Rust!"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_undo_edit() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("test.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Create a file
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some("Original content".to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(write_params).await.unwrap();
|
|
|
|
// Make an edit
|
|
let replace_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "str_replace".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: Some("Original".to_string()),
|
|
new_str: Some("Modified".to_string()),
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(replace_params).await.unwrap();
|
|
|
|
// Verify the edit was made
|
|
let content = fs::read_to_string(&file_path).unwrap();
|
|
assert!(content.contains("Modified content"));
|
|
|
|
// Undo the edit
|
|
let undo_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "undo_edit".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let undo_result = server.text_editor(undo_params).await.unwrap();
|
|
|
|
// Verify undo worked
|
|
let content = fs::read_to_string(&file_path).unwrap();
|
|
assert!(content.contains("Original content"));
|
|
|
|
let undo_content = undo_result
|
|
.content
|
|
.iter()
|
|
.find(|c| c.as_text().is_some())
|
|
.unwrap()
|
|
.as_text()
|
|
.unwrap();
|
|
assert!(undo_content.text.contains("Undid the last edit"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_goose_ignore_basic_patterns() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
// Create .gooseignore file with patterns
|
|
fs::write(".gooseignore", "secret.txt\n*.env").unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Test basic file matching
|
|
assert!(
|
|
server.is_ignored(Path::new("secret.txt")),
|
|
"secret.txt should be ignored"
|
|
);
|
|
assert!(
|
|
server.is_ignored(Path::new("./secret.txt")),
|
|
"./secret.txt should be ignored"
|
|
);
|
|
assert!(
|
|
!server.is_ignored(Path::new("not_secret.txt")),
|
|
"not_secret.txt should not be ignored"
|
|
);
|
|
|
|
// Test pattern matching
|
|
assert!(
|
|
server.is_ignored(Path::new("test.env")),
|
|
"*.env pattern should match test.env"
|
|
);
|
|
assert!(
|
|
server.is_ignored(Path::new("./test.env")),
|
|
"*.env pattern should match ./test.env"
|
|
);
|
|
assert!(
|
|
!server.is_ignored(Path::new("test.txt")),
|
|
"*.env pattern should not match test.txt"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_respects_ignore_patterns() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
// Create .gooseignore file
|
|
fs::write(".gooseignore", "secret.txt").unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Try to write to an ignored file
|
|
let secret_path = temp_dir.path().join("secret.txt");
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: secret_path.to_str().unwrap().to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some("test content".to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let result = server.text_editor(write_params).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Should not be able to write to ignored file"
|
|
);
|
|
assert_eq!(result.unwrap_err().code, ErrorCode::INTERNAL_ERROR);
|
|
|
|
// Try to write to a non-ignored file
|
|
let allowed_path = temp_dir.path().join("allowed.txt");
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: allowed_path.to_str().unwrap().to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some("test content".to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let result = server.text_editor(write_params).await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"Should be able to write to non-ignored file"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn test_shell_respects_ignore_patterns() {
|
|
run_shell_test(|| async {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
let running_service = serve_directly(server.clone(), create_test_transport(), None);
|
|
let peer = running_service.peer().clone();
|
|
|
|
// Create an ignored file
|
|
let secret_file_path = temp_dir.path().join("secrets.txt");
|
|
fs::write(&secret_file_path, "secret content").unwrap();
|
|
|
|
// try to cat the ignored file
|
|
let result = server
|
|
.shell(
|
|
Parameters(ShellParams {
|
|
command: format!("cat {}", secret_file_path.to_str().unwrap()),
|
|
}),
|
|
RequestContext {
|
|
ct: Default::default(),
|
|
id: NumberOrString::Number(1),
|
|
meta: Default::default(),
|
|
extensions: Default::default(),
|
|
peer: peer.clone(),
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert!(result.is_err(), "Should not be able to cat ignored file");
|
|
assert_eq!(result.unwrap_err().code, ErrorCode::INTERNAL_ERROR);
|
|
|
|
// Try to cat a non-ignored file
|
|
let allowed_file_path = temp_dir.path().join("allowed.txt");
|
|
fs::write(&allowed_file_path, "allowed content").unwrap();
|
|
|
|
let result = server
|
|
.shell(
|
|
Parameters(ShellParams {
|
|
command: format!("cat {}", allowed_file_path.to_str().unwrap()),
|
|
}),
|
|
RequestContext {
|
|
ct: Default::default(),
|
|
id: NumberOrString::Number(1),
|
|
meta: Default::default(),
|
|
extensions: Default::default(),
|
|
peer: peer.clone(),
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert!(result.is_ok(), "Should be able to cat non-ignored file");
|
|
|
|
// Clean up
|
|
let cancellation_token = running_service.cancellation_token();
|
|
cancellation_token.cancel();
|
|
drop(peer);
|
|
drop(running_service);
|
|
});
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_descriptions() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
// Test without editor API configured (should be the case in tests due to cfg!(test))
|
|
let server = create_test_server();
|
|
|
|
// Get server info which contains tool descriptions
|
|
let server_info = server.get_info();
|
|
let instructions = server_info.instructions.unwrap_or_default();
|
|
|
|
// Should use traditional description with str_replace command
|
|
assert!(instructions.contains("Replace text in one or more files"));
|
|
assert!(instructions.contains("str_replace"));
|
|
|
|
// Should not contain editor API description or edit_file command
|
|
assert!(!instructions.contains("Edit the file with the new content"));
|
|
assert!(!instructions.contains("edit_file"));
|
|
assert!(!instructions.contains("work out how to place old_str with it intelligently"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_view_range() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("test.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Create a multi-line file
|
|
let content =
|
|
"Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10";
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some(content.to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(write_params).await.unwrap();
|
|
|
|
// Test viewing specific range
|
|
let view_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "view".to_string(),
|
|
view_range: Some(vec![3, 6]),
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let view_result = server.text_editor(view_params).await.unwrap();
|
|
|
|
let text = view_result
|
|
.content
|
|
.iter()
|
|
.find(|c| {
|
|
c.audience()
|
|
.is_some_and(|roles| roles.contains(&Role::User))
|
|
})
|
|
.unwrap()
|
|
.as_text()
|
|
.unwrap();
|
|
|
|
// Should contain lines 3-6 with line numbers
|
|
assert!(text.text.contains("3: Line 3"));
|
|
assert!(text.text.contains("4: Line 4"));
|
|
assert!(text.text.contains("5: Line 5"));
|
|
assert!(text.text.contains("6: Line 6"));
|
|
assert!(text.text.contains("(lines 3-6)"));
|
|
// Should not contain other lines
|
|
assert!(!text.text.contains("1: Line 1"));
|
|
assert!(!text.text.contains("7: Line 7"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_view_range_to_end() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("test.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Create a multi-line file
|
|
let content = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some(content.to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(write_params).await.unwrap();
|
|
|
|
// Test viewing from line 3 to end using -1
|
|
let view_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "view".to_string(),
|
|
view_range: Some(vec![3, -1]),
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let view_result = server.text_editor(view_params).await.unwrap();
|
|
|
|
let text = view_result
|
|
.content
|
|
.iter()
|
|
.find(|c| {
|
|
c.audience()
|
|
.is_some_and(|roles| roles.contains(&Role::User))
|
|
})
|
|
.unwrap()
|
|
.as_text()
|
|
.unwrap();
|
|
|
|
// Should contain lines 3-5
|
|
assert!(text.text.contains("3: Line 3"));
|
|
assert!(text.text.contains("4: Line 4"));
|
|
assert!(text.text.contains("5: Line 5"));
|
|
assert!(text.text.contains("(lines 3-end)"));
|
|
// Should not contain lines 1-2
|
|
assert!(!text.text.contains("1: Line 1"));
|
|
assert!(!text.text.contains("2: Line 2"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_view_range_invalid() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("test.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Create a small file
|
|
let content = "Line 1\nLine 2\nLine 3";
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some(content.to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(write_params).await.unwrap();
|
|
|
|
// Test invalid range - start line beyond file
|
|
let view_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "view".to_string(),
|
|
view_range: Some(vec![10, 15]),
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let result = server.text_editor(view_params).await;
|
|
assert!(result.is_err());
|
|
let error = result.unwrap_err();
|
|
assert_eq!(error.code, ErrorCode::INVALID_PARAMS);
|
|
assert!(error.message.contains("beyond the end of the file"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_insert_at_beginning() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("test.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Create a file with some content
|
|
let content = "Line 2\nLine 3\nLine 4";
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some(content.to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(write_params).await.unwrap();
|
|
|
|
// Insert at the beginning (line 0)
|
|
let insert_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "insert".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: Some("Line 1".to_string()),
|
|
insert_line: Some(0),
|
|
diff: None,
|
|
});
|
|
|
|
let insert_result = server.text_editor(insert_params).await.unwrap();
|
|
|
|
let text = insert_result
|
|
.content
|
|
.iter()
|
|
.find(|c| {
|
|
c.audience()
|
|
.is_some_and(|roles| roles.contains(&Role::Assistant))
|
|
})
|
|
.unwrap()
|
|
.as_text()
|
|
.unwrap();
|
|
|
|
assert!(text.text.contains("Text has been inserted at line 1"));
|
|
|
|
// Verify the file content by reading it directly
|
|
let file_content = fs::read_to_string(&file_path).unwrap();
|
|
assert!(file_content.contains("Line 1\nLine 2\nLine 3\nLine 4"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_insert_in_middle() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("test.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Create a file with some content
|
|
let content = "Line 1\nLine 2\nLine 4\nLine 5";
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some(content.to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(write_params).await.unwrap();
|
|
|
|
// Insert after line 2
|
|
let insert_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "insert".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: Some("Line 3".to_string()),
|
|
insert_line: Some(2),
|
|
diff: None,
|
|
});
|
|
|
|
let insert_result = server.text_editor(insert_params).await.unwrap();
|
|
|
|
let text = insert_result
|
|
.content
|
|
.iter()
|
|
.find(|c| {
|
|
c.audience()
|
|
.is_some_and(|roles| roles.contains(&Role::Assistant))
|
|
})
|
|
.unwrap()
|
|
.as_text()
|
|
.unwrap();
|
|
|
|
assert!(text.text.contains("Text has been inserted at line 3"));
|
|
|
|
// Verify the file content by reading it directly
|
|
let file_content = fs::read_to_string(&file_path).unwrap();
|
|
let lines: Vec<&str> = file_content.lines().collect();
|
|
assert_eq!(lines[0], "Line 1");
|
|
assert_eq!(lines[1], "Line 2");
|
|
assert_eq!(lines[2], "Line 3");
|
|
assert_eq!(lines[3], "Line 4");
|
|
assert_eq!(lines[4], "Line 5");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_insert_at_end() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("test.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Create a file with some content
|
|
let content = "Line 1\nLine 2\nLine 3";
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some(content.to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(write_params).await.unwrap();
|
|
|
|
// Insert at the end (after line 3)
|
|
let insert_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "insert".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: Some("Line 4".to_string()),
|
|
insert_line: Some(3),
|
|
diff: None,
|
|
});
|
|
|
|
let insert_result = server.text_editor(insert_params).await.unwrap();
|
|
|
|
let text = insert_result
|
|
.content
|
|
.iter()
|
|
.find(|c| {
|
|
c.audience()
|
|
.is_some_and(|roles| roles.contains(&Role::Assistant))
|
|
})
|
|
.unwrap()
|
|
.as_text()
|
|
.unwrap();
|
|
|
|
assert!(text.text.contains("Text has been inserted at line 4"));
|
|
|
|
// Verify the file content by reading it directly
|
|
let file_content = fs::read_to_string(&file_path).unwrap();
|
|
assert!(file_content.contains("Line 1\nLine 2\nLine 3\nLine 4"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_insert_at_end_negative() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("test.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Create a file with some content
|
|
let content = "Line 1\nLine 2\nLine 3";
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some(content.to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(write_params).await.unwrap();
|
|
|
|
// Insert at the end using -1
|
|
let insert_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "insert".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: Some("Line 4".to_string()),
|
|
insert_line: Some(-1),
|
|
diff: None,
|
|
});
|
|
|
|
let insert_result = server.text_editor(insert_params).await.unwrap();
|
|
|
|
let text = insert_result
|
|
.content
|
|
.iter()
|
|
.find(|c| {
|
|
c.audience()
|
|
.is_some_and(|roles| roles.contains(&Role::Assistant))
|
|
})
|
|
.unwrap()
|
|
.as_text()
|
|
.unwrap();
|
|
|
|
assert!(text.text.contains("Text has been inserted at line 4"));
|
|
|
|
// Verify the file content by reading it directly
|
|
let file_content = fs::read_to_string(&file_path).unwrap();
|
|
assert!(file_content.contains("Line 1\nLine 2\nLine 3\nLine 4"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_insert_invalid_line() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("test.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Create a file with some content
|
|
let content = "Line 1\nLine 2\nLine 3";
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some(content.to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(write_params).await.unwrap();
|
|
|
|
// Try to insert beyond the end of the file
|
|
let insert_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "insert".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: Some("Line 11".to_string()),
|
|
insert_line: Some(10),
|
|
diff: None,
|
|
});
|
|
|
|
let result = server.text_editor(insert_params).await;
|
|
|
|
assert!(result.is_err());
|
|
let err = result.err().unwrap();
|
|
assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
|
|
assert!(err.message.contains("beyond the end of the file"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_insert_missing_parameters() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("test.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Create a file first
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some("Initial content".to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(write_params).await.unwrap();
|
|
|
|
// Test insert without new_str parameter
|
|
let insert_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "insert".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None, // Missing required parameter
|
|
insert_line: Some(1),
|
|
diff: None,
|
|
});
|
|
|
|
let result = server.text_editor(insert_params).await;
|
|
assert!(result.is_err());
|
|
let error = result.unwrap_err();
|
|
assert_eq!(error.code, ErrorCode::INVALID_PARAMS);
|
|
assert!(error.message.contains("Missing 'new_str' parameter"));
|
|
|
|
// Test insert without insert_line parameter
|
|
let insert_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "insert".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: Some("New text".to_string()),
|
|
insert_line: None, // Missing required parameter
|
|
diff: None,
|
|
});
|
|
|
|
let result = server.text_editor(insert_params).await;
|
|
assert!(result.is_err());
|
|
let error = result.unwrap_err();
|
|
assert_eq!(error.code, ErrorCode::INVALID_PARAMS);
|
|
assert!(error.message.contains("Missing 'insert_line' parameter"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_insert_with_undo() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("test.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Create a file with some content
|
|
let content = "Line 1\nLine 2";
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some(content.to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(write_params).await.unwrap();
|
|
|
|
// Insert a line
|
|
let insert_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "insert".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: Some("Inserted Line".to_string()),
|
|
insert_line: Some(1),
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(insert_params).await.unwrap();
|
|
|
|
// Undo the insert
|
|
let undo_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "undo_edit".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let undo_result = server.text_editor(undo_params).await.unwrap();
|
|
|
|
let text = undo_result
|
|
.content
|
|
.iter()
|
|
.find(|c| c.as_text().is_some())
|
|
.unwrap()
|
|
.as_text()
|
|
.unwrap();
|
|
assert!(text.text.contains("Undid the last edit"));
|
|
|
|
// Verify the file is back to original content
|
|
let file_content = fs::read_to_string(&file_path).unwrap();
|
|
assert!(file_content.contains("Line 1\nLine 2"));
|
|
assert!(!file_content.contains("Inserted Line"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_insert_nonexistent_file() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("nonexistent.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Try to insert into a nonexistent file
|
|
let insert_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "insert".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: Some("New line".to_string()),
|
|
insert_line: Some(0),
|
|
diff: None,
|
|
});
|
|
|
|
let result = server.text_editor(insert_params).await;
|
|
|
|
assert!(result.is_err());
|
|
let err = result.err().unwrap();
|
|
assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
|
|
assert!(err.message.contains("does not exist"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_view_large_file_without_range() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("large_file.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Create a file with more than 2000 lines (LINE_READ_LIMIT)
|
|
let mut content = String::new();
|
|
for i in 1..=2001 {
|
|
content.push_str(&format!("Line {}\n", i));
|
|
}
|
|
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some(content),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(write_params).await.unwrap();
|
|
|
|
// Test viewing without view_range - should trigger the error
|
|
let view_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "view".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let result = server.text_editor(view_params).await;
|
|
|
|
assert!(result.is_err());
|
|
let err = result.err().unwrap();
|
|
assert_eq!(err.code, ErrorCode::INTERNAL_ERROR);
|
|
assert!(err.message.contains("2001 lines long"));
|
|
assert!(err
|
|
.message
|
|
.contains("recommended to read in with view_range"));
|
|
assert!(err
|
|
.message
|
|
.contains("please pass in view_range with [1, 2001]"));
|
|
|
|
// Test viewing with view_range - should work
|
|
let view_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "view".to_string(),
|
|
view_range: Some(vec![1, 100]),
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let result = server.text_editor(view_params).await;
|
|
assert!(result.is_ok());
|
|
|
|
let view_result = result.unwrap();
|
|
let text = view_result
|
|
.content
|
|
.iter()
|
|
.find(|c| {
|
|
c.audience()
|
|
.is_some_and(|roles| roles.contains(&Role::User))
|
|
})
|
|
.unwrap()
|
|
.as_text()
|
|
.unwrap();
|
|
|
|
// Should contain lines 1-100
|
|
assert!(text.text.contains("1: Line 1"));
|
|
assert!(text.text.contains("100: Line 100"));
|
|
assert!(!text.text.contains("101: Line 101"));
|
|
|
|
// Test viewing with explicit full range - should work
|
|
let view_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "view".to_string(),
|
|
view_range: Some(vec![1, 2001]),
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let result = server.text_editor(view_params).await;
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_view_file_with_exactly_2000_lines() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("file_2000.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Create a file with exactly 2000 lines (should not trigger the check)
|
|
let mut content = String::new();
|
|
for i in 1..=2000 {
|
|
content.push_str(&format!("Line {}\n", i));
|
|
}
|
|
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some(content),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(write_params).await.unwrap();
|
|
|
|
// Test viewing without view_range - should work since it's exactly 2000 lines
|
|
let view_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "view".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let result = server.text_editor(view_params).await;
|
|
|
|
assert!(result.is_ok());
|
|
let view_result = result.unwrap();
|
|
let text = view_result
|
|
.content
|
|
.iter()
|
|
.find(|c| {
|
|
c.audience()
|
|
.is_some_and(|roles| roles.contains(&Role::User))
|
|
})
|
|
.unwrap()
|
|
.as_text()
|
|
.unwrap();
|
|
|
|
// Should contain all lines
|
|
assert!(text.text.contains("1: Line 1"));
|
|
assert!(text.text.contains("2000: Line 2000"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_view_small_file_without_range() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("small_file.txt");
|
|
let file_path_str = file_path.to_str().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Create a file with less than 2000 lines
|
|
let mut content = String::new();
|
|
for i in 1..=100 {
|
|
content.push_str(&format!("Line {}\n", i));
|
|
}
|
|
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some(content),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
server.text_editor(write_params).await.unwrap();
|
|
|
|
// Test viewing without view_range - should work fine
|
|
let view_params = Parameters(TextEditorParams {
|
|
path: file_path_str.to_string(),
|
|
command: "view".to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let result = server.text_editor(view_params).await;
|
|
|
|
assert!(result.is_ok());
|
|
let view_result = result.unwrap();
|
|
let text = view_result
|
|
.content
|
|
.iter()
|
|
.find(|c| {
|
|
c.audience()
|
|
.is_some_and(|roles| roles.contains(&Role::User))
|
|
})
|
|
.unwrap()
|
|
.as_text()
|
|
.unwrap();
|
|
|
|
// Should contain all lines
|
|
assert!(text.text.contains("1: Line 1"));
|
|
assert!(text.text.contains("100: Line 100"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_view_directory() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let temp_path = temp_dir.path();
|
|
|
|
// Set the current directory before creating the server
|
|
std::env::set_current_dir(temp_path).unwrap();
|
|
|
|
// Create some test files and directories
|
|
fs::create_dir(temp_path.join("subdir1")).unwrap();
|
|
fs::create_dir(temp_path.join("subdir2")).unwrap();
|
|
fs::create_dir(temp_path.join("another_dir")).unwrap();
|
|
|
|
fs::write(temp_path.join("file1.txt"), "content1").unwrap();
|
|
fs::write(temp_path.join("file2.rs"), "content2").unwrap();
|
|
fs::write(temp_path.join("README.md"), "content3").unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Test viewing a directory
|
|
let result = server
|
|
.text_editor(Parameters(TextEditorParams {
|
|
command: "view".to_string(),
|
|
path: temp_path.to_str().unwrap().to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
}))
|
|
.await;
|
|
|
|
assert!(result.is_ok());
|
|
let content = result.unwrap().content;
|
|
assert_eq!(content.len(), 1);
|
|
|
|
// Check the content is a text message with directory listing
|
|
let text_content = content[0].as_text().expect("Expected text content");
|
|
let output = &text_content.text;
|
|
|
|
// Check that it identifies as a directory
|
|
assert!(output.contains("is a directory"));
|
|
assert!(output.contains("Contents:"));
|
|
|
|
// Check directories are listed with trailing slash
|
|
assert!(output.contains("Directories:"));
|
|
assert!(output.contains("another_dir/"));
|
|
assert!(output.contains("subdir1/"));
|
|
assert!(output.contains("subdir2/"));
|
|
|
|
// Check files are listed
|
|
assert!(output.contains("Files:"));
|
|
assert!(output.contains("file1.txt"));
|
|
assert!(output.contains("file2.rs"));
|
|
assert!(output.contains("README.md"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_view_directory_with_many_files() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let temp_path = temp_dir.path();
|
|
|
|
// Set the current directory before creating the server
|
|
std::env::set_current_dir(temp_path).unwrap();
|
|
|
|
// Create more than 50 files to test the limit
|
|
for i in 0..60 {
|
|
fs::write(
|
|
temp_path.join(format!("file{:03}.txt", i)),
|
|
format!("content{}", i),
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
// Create some directories too
|
|
for i in 0..10 {
|
|
fs::create_dir(temp_path.join(format!("dir{:02}", i))).unwrap();
|
|
}
|
|
|
|
let server = create_test_server();
|
|
|
|
let result = server
|
|
.text_editor(Parameters(TextEditorParams {
|
|
command: "view".to_string(),
|
|
path: temp_path.to_str().unwrap().to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
}))
|
|
.await;
|
|
|
|
assert!(result.is_ok());
|
|
let content = result.unwrap().content;
|
|
assert_eq!(content.len(), 1);
|
|
|
|
let text_content = content[0].as_text().expect("Expected text content");
|
|
let output = &text_content.text;
|
|
|
|
// Check that it shows the limit message
|
|
assert!(output.contains("... and"));
|
|
assert!(output.contains("more items"));
|
|
assert!(output.contains("(showing first 50 items)"));
|
|
|
|
// Count the actual number of items shown (should be 50)
|
|
let dir_count = output.matches("/\n").count(); // directories end with /
|
|
let file_count = output.matches(".txt\n").count(); // only counting .txt files for simplicity
|
|
assert!(dir_count + file_count <= 50);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_view_empty_directory() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let temp_path = temp_dir.path();
|
|
|
|
// Set the current directory before creating the server
|
|
std::env::set_current_dir(temp_path).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
let result = server
|
|
.text_editor(Parameters(TextEditorParams {
|
|
command: "view".to_string(),
|
|
path: temp_path.to_str().unwrap().to_string(),
|
|
view_range: None,
|
|
file_text: None,
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
}))
|
|
.await;
|
|
|
|
assert!(result.is_ok());
|
|
let content = result.unwrap().content;
|
|
assert_eq!(content.len(), 1);
|
|
|
|
let text_content = content[0].as_text().expect("Expected text content");
|
|
let output = &text_content.text;
|
|
|
|
// Check that it shows empty directory message
|
|
assert!(output.contains("is a directory"));
|
|
assert!(output.contains("(empty directory)"));
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn test_shell_output_truncation() {
|
|
run_shell_test(|| async {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
let running_service = serve_directly(server.clone(), create_test_transport(), None);
|
|
let peer = running_service.peer().clone();
|
|
|
|
// Create a command that generates > 100 lines of output
|
|
let command = if cfg!(windows) {
|
|
"for /L %i in (1,1,150) do @echo Line %i"
|
|
} else {
|
|
"for i in {1..150}; do echo \"Line $i\"; done"
|
|
};
|
|
|
|
let result = server
|
|
.shell(
|
|
Parameters(ShellParams {
|
|
command: command.to_string(),
|
|
}),
|
|
RequestContext {
|
|
ct: Default::default(),
|
|
id: NumberOrString::Number(1),
|
|
meta: Default::default(),
|
|
extensions: Default::default(),
|
|
peer: peer.clone(),
|
|
},
|
|
)
|
|
.await;
|
|
|
|
// Should have two Content items
|
|
assert_eq!(result.clone().unwrap().content.len(), 2);
|
|
|
|
let content = result.clone().unwrap().content;
|
|
|
|
// Find the Assistant and User content
|
|
let assistant_content = content
|
|
.iter()
|
|
.find(|c| {
|
|
c.audience()
|
|
.is_some_and(|roles| roles.contains(&Role::Assistant))
|
|
})
|
|
.unwrap()
|
|
.as_text()
|
|
.unwrap();
|
|
|
|
let user_content = content
|
|
.iter()
|
|
.find(|c| {
|
|
c.audience()
|
|
.is_some_and(|roles| roles.contains(&Role::User))
|
|
})
|
|
.unwrap()
|
|
.as_text()
|
|
.unwrap();
|
|
|
|
// Assistant should get the full message with temp file info
|
|
assert!(assistant_content
|
|
.text
|
|
.contains("private note: output was 150 lines"));
|
|
|
|
// User should only get the truncated output with prefix
|
|
assert!(user_content
|
|
.text
|
|
.starts_with("NOTE: Output was 150 lines, showing only the last 100 lines"));
|
|
assert!(!user_content.text.contains("private note: output was"));
|
|
|
|
// User output should contain lines 51-150 (last 100 lines)
|
|
assert!(user_content.text.contains("Line 51"));
|
|
assert!(user_content.text.contains("Line 150"));
|
|
assert!(!user_content.text.contains("Line 50"));
|
|
|
|
let start_tag = "remainder of lines in";
|
|
let end_tag = "do not show tmp file to user";
|
|
|
|
if let (Some(start), Some(end)) = (
|
|
assistant_content.text.find(start_tag),
|
|
assistant_content.text.find(end_tag),
|
|
) {
|
|
let start_idx = start + start_tag.len();
|
|
if start_idx < end {
|
|
let Some(path) = assistant_content.text.get(start_idx..end).map(|s| s.trim())
|
|
else {
|
|
panic!("Failed to extract path from assistant content");
|
|
};
|
|
println!("Extracted path: {}", path);
|
|
|
|
let file_contents =
|
|
std::fs::read_to_string(path).expect("Failed to read extracted temp file");
|
|
|
|
let lines: Vec<&str> = file_contents.lines().collect();
|
|
|
|
// Ensure we have exactly 150 lines
|
|
assert_eq!(lines.len(), 150, "Expected 150 lines in temp file");
|
|
|
|
// Ensure the first and last lines are correct
|
|
assert_eq!(lines.first(), Some(&"Line 1"), "First line mismatch");
|
|
assert_eq!(lines.last(), Some(&"Line 150"), "Last line mismatch");
|
|
} else {
|
|
panic!("No path found in bash output truncation output");
|
|
}
|
|
} else {
|
|
panic!("Failed to find start or end tag in bash output truncation output");
|
|
}
|
|
|
|
// Force cleanup before runtime shutdown
|
|
cleanup_test_service(running_service, peer);
|
|
|
|
temp_dir.close().unwrap();
|
|
});
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_process_shell_output_short() {
|
|
let dir = TempDir::new().unwrap();
|
|
std::env::set_current_dir(dir.path()).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Test with short output (< 100 lines)
|
|
let short_output = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
|
|
let result = server.process_shell_output(short_output).unwrap();
|
|
|
|
// Both outputs should be the same for short outputs
|
|
assert_eq!(result.0, short_output);
|
|
assert_eq!(result.1, short_output);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_process_shell_output_empty() {
|
|
let dir = TempDir::new().unwrap();
|
|
std::env::set_current_dir(dir.path()).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Test with empty output
|
|
let empty_output = "";
|
|
let result = server.process_shell_output(empty_output).unwrap();
|
|
|
|
// Both outputs should be empty
|
|
assert_eq!(result.0, "");
|
|
assert_eq!(result.1, "");
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn test_shell_output_without_trailing_newline() {
|
|
run_shell_test(|| async {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
let running_service = serve_directly(server.clone(), create_test_transport(), None);
|
|
let peer = running_service.peer().clone();
|
|
|
|
// Test command that outputs content without a trailing newline
|
|
let command = if cfg!(windows) {
|
|
"echo|set /p=\"Content without newline\""
|
|
} else {
|
|
"printf 'Content without newline'"
|
|
};
|
|
|
|
let result = server
|
|
.shell(
|
|
Parameters(ShellParams {
|
|
command: command.to_string(),
|
|
}),
|
|
RequestContext {
|
|
ct: Default::default(),
|
|
id: NumberOrString::Number(1),
|
|
meta: Default::default(),
|
|
extensions: Default::default(),
|
|
peer: peer.clone(),
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert!(result.is_ok());
|
|
|
|
// Test the output processing logic that would be used by shell method
|
|
let output_without_newline = "Content without newline";
|
|
let result = server.process_shell_output(output_without_newline).unwrap();
|
|
|
|
// The output should contain the content even without a trailing newline
|
|
assert!(
|
|
result.0.contains("Content without newline"),
|
|
"Output should contain content even without trailing newline, but got: {}",
|
|
result.0
|
|
);
|
|
assert!(
|
|
result.1.contains("Content without newline"),
|
|
"User output should contain content even without trailing newline, but got: {}",
|
|
result.1
|
|
);
|
|
|
|
// Both should be the same for short output
|
|
assert_eq!(result.0, output_without_newline);
|
|
assert_eq!(result.1, output_without_newline);
|
|
|
|
// Force cleanup before runtime shutdown
|
|
cleanup_test_service(running_service, peer);
|
|
});
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_shell_output_handling_logic() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
|
|
// Test output truncation logic with content without trailing newlines
|
|
let content_without_newline = "Content without newline";
|
|
let result = server
|
|
.process_shell_output(content_without_newline)
|
|
.unwrap();
|
|
|
|
assert_eq!(result.0, content_without_newline);
|
|
assert_eq!(result.1, content_without_newline);
|
|
assert!(
|
|
result.0.contains("Content without newline"),
|
|
"Output processing should preserve content without trailing newlines"
|
|
);
|
|
|
|
// Test with content that has trailing newlines
|
|
let content_with_newline = "Content with newline\n";
|
|
let result = server.process_shell_output(content_with_newline).unwrap();
|
|
assert_eq!(result.0, content_with_newline);
|
|
assert_eq!(result.1, content_with_newline);
|
|
|
|
// Test empty output handling
|
|
let empty_output = "";
|
|
let result = server.process_shell_output(empty_output).unwrap();
|
|
assert_eq!(result.0, "");
|
|
assert_eq!(result.1, "");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_default_patterns_when_no_ignore_files() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
// Don't create any ignore files
|
|
let server = create_test_server();
|
|
|
|
// Default patterns should be used
|
|
assert!(
|
|
server.is_ignored(Path::new(".env")),
|
|
".env should be ignored by default patterns"
|
|
);
|
|
assert!(
|
|
server.is_ignored(Path::new(".env.local")),
|
|
".env.local should be ignored by default patterns"
|
|
);
|
|
assert!(
|
|
server.is_ignored(Path::new("secrets.txt")),
|
|
"secrets.txt should be ignored by default patterns"
|
|
);
|
|
assert!(
|
|
!server.is_ignored(Path::new("normal.txt")),
|
|
"normal.txt should not be ignored"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn test_resolve_path_absolute() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
let absolute_path = temp_dir.path().join("test.txt");
|
|
let absolute_path_str = absolute_path.to_str().unwrap();
|
|
|
|
let resolved = server.resolve_path(absolute_path_str).unwrap();
|
|
assert_eq!(resolved, absolute_path);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_resolve_path_relative() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
let relative_path = "subdir/test.txt";
|
|
|
|
let resolved = server.resolve_path(relative_path).unwrap();
|
|
let expected = std::env::current_dir().unwrap().join("subdir/test.txt");
|
|
assert_eq!(resolved, expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_with_absolute_path() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
let absolute_path = temp_dir.path().join("absolute_test.txt");
|
|
let absolute_path_str = absolute_path.to_str().unwrap();
|
|
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: absolute_path_str.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some("Absolute path test".to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let result = server.text_editor(write_params).await;
|
|
assert!(result.is_ok());
|
|
|
|
let content = fs::read_to_string(&absolute_path).unwrap();
|
|
assert_eq!(content.trim(), "Absolute path test");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_text_editor_with_relative_path() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
let server = create_test_server();
|
|
let relative_path = "relative_test.txt";
|
|
|
|
let write_params = Parameters(TextEditorParams {
|
|
path: relative_path.to_string(),
|
|
command: "write".to_string(),
|
|
view_range: None,
|
|
file_text: Some("Relative path test".to_string()),
|
|
old_str: None,
|
|
new_str: None,
|
|
insert_line: None,
|
|
diff: None,
|
|
});
|
|
|
|
let result = server.text_editor(write_params).await;
|
|
assert!(result.is_ok());
|
|
|
|
let absolute_path = temp_dir.path().join(relative_path);
|
|
let content = fs::read_to_string(&absolute_path).unwrap();
|
|
assert_eq!(content.trim(), "Relative path test");
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
#[cfg(unix)] // Unix-specific test using sleep command
|
|
fn test_shell_command_cancellation() {
|
|
run_shell_test(|| async {
|
|
let server = create_test_server();
|
|
let running_service = serve_directly(server.clone(), create_test_transport(), None);
|
|
let peer = running_service.peer().clone();
|
|
|
|
let request_id = NumberOrString::Number(123);
|
|
|
|
let context = RequestContext {
|
|
ct: Default::default(),
|
|
id: request_id.clone(),
|
|
meta: Default::default(),
|
|
extensions: Default::default(),
|
|
peer: peer.clone(),
|
|
};
|
|
|
|
// Start a long-running shell command in the background
|
|
let server_clone = server.clone();
|
|
let shell_task = tokio::spawn(async move {
|
|
server_clone
|
|
.shell(
|
|
Parameters(ShellParams {
|
|
command: "sleep 30".to_string(),
|
|
}),
|
|
context,
|
|
)
|
|
.await
|
|
});
|
|
|
|
// Give the command a moment to start
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
|
|
// Verify the process is tracked
|
|
{
|
|
let processes = server.running_processes.read().await;
|
|
assert!(processes.contains_key("123"), "Process should be tracked");
|
|
}
|
|
|
|
let start_time = Instant::now();
|
|
|
|
// Cancel the command
|
|
let cancel_params = CancelledNotificationParam {
|
|
request_id,
|
|
reason: Some("test cancellation".to_string()),
|
|
};
|
|
|
|
let notification_context = NotificationContext {
|
|
peer: peer.clone(),
|
|
meta: Default::default(),
|
|
extensions: Default::default(),
|
|
};
|
|
|
|
server
|
|
.on_cancelled(cancel_params, notification_context)
|
|
.await;
|
|
|
|
// Wait for the shell task to complete
|
|
let result = timeout(Duration::from_secs(5), shell_task).await;
|
|
let elapsed = start_time.elapsed();
|
|
|
|
// Verify the task completed due to cancellation (not timeout)
|
|
assert!(result.is_ok(), "Shell task should complete within timeout");
|
|
let task_result = result.unwrap();
|
|
assert!(task_result.is_ok(), "Shell task should not panic");
|
|
|
|
// Verify the command was cancelled quickly (much less than 30 seconds)
|
|
assert!(
|
|
elapsed < Duration::from_secs(5),
|
|
"Command should be cancelled quickly, took {:?}",
|
|
elapsed
|
|
);
|
|
|
|
// Verify the process is no longer tracked
|
|
{
|
|
let processes = server.running_processes.read().await;
|
|
assert!(
|
|
!processes.contains_key("123"),
|
|
"Process should be removed from tracking"
|
|
);
|
|
}
|
|
|
|
cleanup_test_service(running_service, peer);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
#[cfg(unix)] // Unix-specific test using shell commands
|
|
fn test_child_process_cancellation() {
|
|
run_shell_test(|| async {
|
|
let server = create_test_server();
|
|
let running_service = serve_directly(server.clone(), create_test_transport(), None);
|
|
let peer = running_service.peer().clone();
|
|
|
|
let request_id = NumberOrString::Number(456);
|
|
|
|
let context = RequestContext {
|
|
ct: Default::default(),
|
|
id: request_id.clone(),
|
|
meta: Default::default(),
|
|
extensions: Default::default(),
|
|
peer: peer.clone(),
|
|
};
|
|
|
|
// Start a command that spawns child processes
|
|
let server_clone = server.clone();
|
|
let shell_task = tokio::spawn(async move {
|
|
server_clone
|
|
.shell(
|
|
Parameters(ShellParams {
|
|
command: "bash -c 'sleep 60 & wait'".to_string(),
|
|
}),
|
|
context,
|
|
)
|
|
.await
|
|
});
|
|
|
|
// Give the command time to start and spawn child processes
|
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
|
|
let start_time = Instant::now();
|
|
|
|
// Cancel the command
|
|
let cancel_params = CancelledNotificationParam {
|
|
request_id,
|
|
reason: Some("test cancellation".to_string()),
|
|
};
|
|
|
|
let notification_context = NotificationContext {
|
|
peer: peer.clone(),
|
|
meta: Default::default(),
|
|
extensions: Default::default(),
|
|
};
|
|
|
|
server
|
|
.on_cancelled(cancel_params, notification_context)
|
|
.await;
|
|
|
|
// Wait for completion
|
|
let result = timeout(Duration::from_secs(5), shell_task).await;
|
|
let elapsed = start_time.elapsed();
|
|
|
|
assert!(result.is_ok(), "Shell task should complete within timeout");
|
|
assert!(
|
|
elapsed < Duration::from_secs(5),
|
|
"Command with child processes should be cancelled quickly, took {:?}",
|
|
elapsed
|
|
);
|
|
|
|
cleanup_test_service(running_service, peer);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn test_cancel_nonexistent_process() {
|
|
run_shell_test(|| async {
|
|
let server = create_test_server();
|
|
let running_service = serve_directly(server.clone(), create_test_transport(), None);
|
|
let peer = running_service.peer().clone();
|
|
|
|
// Try to cancel a process that doesn't exist
|
|
let cancel_params = CancelledNotificationParam {
|
|
request_id: NumberOrString::Number(999),
|
|
reason: Some("test cancellation".to_string()),
|
|
};
|
|
|
|
let notification_context = NotificationContext {
|
|
peer: peer.clone(),
|
|
meta: Default::default(),
|
|
extensions: Default::default(),
|
|
};
|
|
|
|
// This should not panic or cause issues
|
|
server
|
|
.on_cancelled(cancel_params, notification_context)
|
|
.await;
|
|
|
|
// Verify no processes are tracked
|
|
let processes = server.running_processes.read().await;
|
|
assert!(processes.is_empty(), "No processes should be tracked");
|
|
|
|
cleanup_test_service(running_service, peer);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
#[cfg(unix)]
|
|
fn test_successful_shell_command_completion() {
|
|
run_shell_test(|| async {
|
|
let server = create_test_server();
|
|
let running_service = serve_directly(server.clone(), create_test_transport(), None);
|
|
let peer = running_service.peer().clone();
|
|
|
|
let context = RequestContext {
|
|
ct: Default::default(),
|
|
id: NumberOrString::Number(789),
|
|
meta: Default::default(),
|
|
extensions: Default::default(),
|
|
peer: peer.clone(),
|
|
};
|
|
|
|
// Run a quick command that should complete successfully
|
|
let result = server
|
|
.shell(
|
|
Parameters(ShellParams {
|
|
command: "echo 'Hello, World!'".to_string(),
|
|
}),
|
|
context,
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
result.is_ok(),
|
|
"Simple shell command should succeed: {:?}",
|
|
result
|
|
);
|
|
|
|
// Verify no processes are left tracked after completion
|
|
let processes = server.running_processes.read().await;
|
|
assert!(
|
|
!processes.contains_key("789"),
|
|
"Process should be cleaned up after completion"
|
|
);
|
|
|
|
cleanup_test_service(running_service, peer);
|
|
});
|
|
}
|
|
}
|