diff --git a/server/src/llm/chat/mod.rs b/server/src/llm/chat/mod.rs new file mode 100644 index 00000000..e23cf445 --- /dev/null +++ b/server/src/llm/chat/mod.rs @@ -0,0 +1,10 @@ +//! LLM chat — orchestrates multi-turn conversations with tool-calling LLM providers. + +mod run; +mod tools; + +#[allow(unused_imports)] +pub use run::ChatResult; +pub use run::{ProviderConfig, cancel_chat, chat, get_ollama_models, side_question}; +#[allow(unused_imports)] +pub use tools::get_tool_definitions; diff --git a/server/src/llm/chat.rs b/server/src/llm/chat/run.rs similarity index 61% rename from server/src/llm/chat.rs rename to server/src/llm/chat/run.rs index b5870a16..7b69e9d1 100644 --- a/server/src/llm/chat.rs +++ b/server/src/llm/chat/run.rs @@ -1,17 +1,18 @@ -//! LLM chat — orchestrates multi-turn conversations with tool-calling LLM providers. +//! Chat orchestration: multi-turn tool loop, side questions, and cancellation. use crate::io::onboarding; +use crate::llm::chat::tools::{execute_tool, get_tool_definitions}; use crate::llm::prompts::{ONBOARDING_PROMPT, SYSTEM_PROMPT}; use crate::llm::providers::claude_code::ClaudeCodeResult; -use crate::llm::types::{Message, Role, ToolCall, ToolDefinition, ToolFunctionDefinition}; +use crate::llm::types::{Message, Role, ToolDefinition}; use crate::slog; use crate::state::SessionState; use crate::store::StoreOps; use serde::Deserialize; -use serde_json::json; const MAX_TURNS: usize = 30; const KEY_ANTHROPIC_API_KEY: &str = "anthropic_api_key"; +/// Configuration for the LLM provider used during a chat session. #[derive(Deserialize, Clone)] pub struct ProviderConfig { pub provider: String, @@ -48,95 +49,7 @@ fn get_anthropic_api_key_impl(store: &dyn StoreOps) -> Result { } } -fn parse_tool_arguments(args_str: &str) -> Result { - serde_json::from_str(args_str).map_err(|e| format!("Error parsing arguments: {e}")) -} - -pub fn get_tool_definitions() -> Vec { - vec![ - ToolDefinition { - kind: "function".to_string(), - function: ToolFunctionDefinition { - name: "read_file".to_string(), - description: "Reads the complete content of a file from the project. Use this to understand existing code before making changes.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Relative path to the file from project root" } - }, - "required": ["path"] - }), - }, - }, - ToolDefinition { - kind: "function".to_string(), - function: ToolFunctionDefinition { - name: "write_file".to_string(), - description: "Creates or completely overwrites a file with new content. YOU MUST USE THIS to implement code changes - do not suggest code to the user. The content parameter must contain the COMPLETE file including all imports, functions, and unchanged code.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Relative path to the file from project root" }, - "content": { "type": "string", "description": "The complete file content to write (not a diff or partial code)" } - }, - "required": ["path", "content"] - }), - }, - }, - ToolDefinition { - kind: "function".to_string(), - function: ToolFunctionDefinition { - name: "list_directory".to_string(), - description: "Lists all files and directories at a given path. Use this to explore the project structure.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Relative path to list (use '.' for project root)" } - }, - "required": ["path"] - }), - }, - }, - ToolDefinition { - kind: "function".to_string(), - function: ToolFunctionDefinition { - name: "search_files".to_string(), - description: "Searches for text patterns across all files in the project. Use this to find functions, variables, or code patterns when you don't know which file they're in." - .to_string(), - parameters: json!({ - "type": "object", - "properties": { - "query": { "type": "string", "description": "The text pattern to search for across all files" } - }, - "required": ["query"] - }), - }, - }, - ToolDefinition { - kind: "function".to_string(), - function: ToolFunctionDefinition { - name: "exec_shell".to_string(), - description: "Executes a shell command in the project root directory. Use this to run tests, build commands, git operations, or any command-line tool. Examples: cargo check, npm test, git status.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The command binary to execute (e.g., 'git', 'cargo', 'npm', 'ls')" - }, - "args": { - "type": "array", - "items": { "type": "string" }, - "description": "Array of arguments to pass to the command (e.g., ['status'] for git status)" - } - }, - "required": ["command", "args"] - }), - }, - }, - ] -} - +/// Returns the list of available Ollama models from the given base URL. pub async fn get_ollama_models(base_url: Option) -> Result, String> { use crate::llm::providers::ollama::OllamaProvider; let url = base_url.unwrap_or_else(|| "http://localhost:11434".to_string()); @@ -181,6 +94,13 @@ fn build_claude_code_context_prompt(messages: &[Message], latest_user_content: & parts.join("\n") } +/// Sends a signal to cancel the active chat for the given session. +pub fn cancel_chat(state: &SessionState) -> Result<(), String> { + state.cancel_tx.send(true).map_err(|e| e.to_string())?; + Ok(()) +} + +/// Run a multi-turn chat with tool calling against the configured provider. #[allow(clippy::too_many_arguments)] pub async fn chat( messages: Vec, @@ -289,7 +209,7 @@ where } let tool_defs = get_tool_definitions(); - let tools = if config.enable_tools.unwrap_or(true) { + let tools: &[ToolDefinition] = if config.enable_tools.unwrap_or(true) { tool_defs.as_slice() } else { &[] @@ -501,76 +421,12 @@ where Ok(response.content.unwrap_or_default()) } -async fn execute_tool(call: &ToolCall, state: &SessionState) -> String { - use crate::io::{fs, search, shell}; - - let name = call.function.name.as_str(); - let args: serde_json::Value = match parse_tool_arguments(&call.function.arguments) { - Ok(v) => v, - Err(e) => return e, - }; - - match name { - "read_file" => { - let path = args["path"].as_str().unwrap_or("").to_string(); - match fs::read_file(path, state).await { - Ok(content) => content, - Err(e) => format!("Error: {e}"), - } - } - "write_file" => { - let path = args["path"].as_str().unwrap_or("").to_string(); - let content = args["content"].as_str().unwrap_or("").to_string(); - match fs::write_file(path, content, state).await { - Ok(()) => "File written successfully.".to_string(), - Err(e) => format!("Error: {e}"), - } - } - "list_directory" => { - let path = args["path"].as_str().unwrap_or("").to_string(); - match fs::list_directory(path, state).await { - Ok(entries) => serde_json::to_string(&entries).unwrap_or_default(), - Err(e) => format!("Error: {e}"), - } - } - "search_files" => { - let query = args["query"].as_str().unwrap_or("").to_string(); - match search::search_files(query, state).await { - Ok(results) => serde_json::to_string(&results).unwrap_or_default(), - Err(e) => format!("Error: {e}"), - } - } - "exec_shell" => { - let command = args["command"].as_str().unwrap_or("").to_string(); - let args_vec: Vec = args["args"] - .as_array() - .map(|arr| { - arr.iter() - .map(|v| v.as_str().unwrap_or("").to_string()) - .collect() - }) - .unwrap_or_default(); - - match shell::exec_shell(command, args_vec, state).await { - Ok(output) => serde_json::to_string(&output).unwrap_or_default(), - Err(e) => format!("Error: {e}"), - } - } - _ => format!("Unknown tool: {name}"), - } -} - -pub fn cancel_chat(state: &SessionState) -> Result<(), String> { - state.cancel_tx.send(true).map_err(|e| e.to_string())?; - Ok(()) -} - #[cfg(test)] mod tests { use super::*; - use crate::llm::types::{FunctionCall, ToolCall}; + use crate::llm::types::Message; use crate::state::SessionState; - use serde_json::json; + use crate::store::StoreOps; use std::collections::HashMap; use std::sync::Mutex; @@ -618,104 +474,6 @@ mod tests { } } - // --------------------------------------------------------------------------- - // parse_tool_arguments - // --------------------------------------------------------------------------- - - #[test] - fn parse_tool_arguments_valid_json() { - let result = parse_tool_arguments(r#"{"path": "src/main.rs"}"#); - assert!(result.is_ok()); - assert_eq!(result.unwrap()["path"], json!("src/main.rs")); - } - - #[test] - fn parse_tool_arguments_invalid_json() { - let result = parse_tool_arguments("not json {{{"); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Error parsing arguments:")); - } - - #[test] - fn parse_tool_arguments_empty_object() { - let result = parse_tool_arguments("{}"); - assert!(result.is_ok()); - } - - // --------------------------------------------------------------------------- - // get_tool_definitions - // --------------------------------------------------------------------------- - - #[test] - fn tool_definitions_returns_five_tools() { - let tools = get_tool_definitions(); - assert_eq!(tools.len(), 5); - } - - #[test] - fn tool_definitions_has_expected_names() { - let tools = get_tool_definitions(); - let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect(); - assert!(names.contains(&"read_file")); - assert!(names.contains(&"write_file")); - assert!(names.contains(&"list_directory")); - assert!(names.contains(&"search_files")); - assert!(names.contains(&"exec_shell")); - } - - #[test] - fn tool_definitions_each_has_function_kind() { - let tools = get_tool_definitions(); - for tool in &tools { - assert_eq!(tool.kind, "function"); - } - } - - #[test] - fn tool_definitions_each_has_non_empty_description() { - let tools = get_tool_definitions(); - for tool in &tools { - assert!(!tool.function.description.is_empty()); - } - } - - #[test] - fn tool_definitions_parameters_have_object_type() { - let tools = get_tool_definitions(); - for tool in &tools { - assert_eq!(tool.function.parameters["type"], json!("object")); - } - } - - #[test] - fn read_file_tool_requires_path_parameter() { - let tools = get_tool_definitions(); - let read_file = tools - .iter() - .find(|t| t.function.name == "read_file") - .unwrap(); - let required = read_file.function.parameters["required"] - .as_array() - .unwrap(); - let required_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); - assert!(required_names.contains(&"path")); - } - - #[test] - fn exec_shell_tool_requires_command_and_args() { - let tools = get_tool_definitions(); - let exec_shell = tools - .iter() - .find(|t| t.function.name == "exec_shell") - .unwrap(); - let required = exec_shell.function.parameters["required"] - .as_array() - .unwrap(); - let required_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); - assert!(required_names.contains(&"command")); - assert!(required_names.contains(&"args")); - } - // --------------------------------------------------------------------------- // cancel_chat // --------------------------------------------------------------------------- @@ -854,129 +612,6 @@ mod tests { ); } - // --------------------------------------------------------------------------- - // execute_tool — unknown tool name (no I/O, no network) - // --------------------------------------------------------------------------- - - #[tokio::test] - async fn execute_tool_returns_error_for_unknown_tool() { - let state = SessionState::default(); - let call = ToolCall { - id: Some("call-1".to_string()), - kind: "function".to_string(), - function: FunctionCall { - name: "nonexistent_tool".to_string(), - arguments: "{}".to_string(), - }, - }; - - let result = execute_tool(&call, &state).await; - assert_eq!(result, "Unknown tool: nonexistent_tool"); - } - - #[tokio::test] - async fn execute_tool_returns_parse_error_for_invalid_json_args() { - let state = SessionState::default(); - let call = ToolCall { - id: Some("call-2".to_string()), - kind: "function".to_string(), - function: FunctionCall { - name: "read_file".to_string(), - arguments: "INVALID JSON".to_string(), - }, - }; - - let result = execute_tool(&call, &state).await; - assert!( - result.contains("Error parsing arguments:"), - "unexpected result: {result}" - ); - } - - // --------------------------------------------------------------------------- - // execute_tool — tools that use SessionState (no project root → errors) - // --------------------------------------------------------------------------- - - #[tokio::test] - async fn execute_tool_read_file_no_project_root_returns_error() { - let state = SessionState::default(); // no project_root set - let call = ToolCall { - id: None, - kind: "function".to_string(), - function: FunctionCall { - name: "read_file".to_string(), - arguments: r#"{"path": "some_file.txt"}"#.to_string(), - }, - }; - - let result = execute_tool(&call, &state).await; - assert!(result.starts_with("Error:"), "unexpected result: {result}"); - } - - #[tokio::test] - async fn execute_tool_write_file_no_project_root_returns_error() { - let state = SessionState::default(); - let call = ToolCall { - id: None, - kind: "function".to_string(), - function: FunctionCall { - name: "write_file".to_string(), - arguments: r#"{"path": "out.txt", "content": "hello"}"#.to_string(), - }, - }; - - let result = execute_tool(&call, &state).await; - assert!(result.starts_with("Error:"), "unexpected result: {result}"); - } - - #[tokio::test] - async fn execute_tool_list_directory_no_project_root_returns_error() { - let state = SessionState::default(); - let call = ToolCall { - id: None, - kind: "function".to_string(), - function: FunctionCall { - name: "list_directory".to_string(), - arguments: r#"{"path": "."}"#.to_string(), - }, - }; - - let result = execute_tool(&call, &state).await; - assert!(result.starts_with("Error:"), "unexpected result: {result}"); - } - - #[tokio::test] - async fn execute_tool_search_files_no_project_root_returns_error() { - let state = SessionState::default(); - let call = ToolCall { - id: None, - kind: "function".to_string(), - function: FunctionCall { - name: "search_files".to_string(), - arguments: r#"{"query": "fn main"}"#.to_string(), - }, - }; - - let result = execute_tool(&call, &state).await; - assert!(result.starts_with("Error:"), "unexpected result: {result}"); - } - - #[tokio::test] - async fn execute_tool_exec_shell_no_project_root_returns_error() { - let state = SessionState::default(); - let call = ToolCall { - id: None, - kind: "function".to_string(), - function: FunctionCall { - name: "exec_shell".to_string(), - arguments: r#"{"command": "ls", "args": []}"#.to_string(), - }, - }; - - let result = execute_tool(&call, &state).await; - assert!(result.starts_with("Error:"), "unexpected result: {result}"); - } - // --------------------------------------------------------------------------- // build_claude_code_context_prompt (Bug 245) // --------------------------------------------------------------------------- diff --git a/server/src/llm/chat/tools.rs b/server/src/llm/chat/tools.rs new file mode 100644 index 00000000..907ab2cc --- /dev/null +++ b/server/src/llm/chat/tools.rs @@ -0,0 +1,382 @@ +//! Tool definitions and execution for the LLM chat agent loop. +use crate::io::{fs, search, shell}; +use crate::llm::types::{ToolCall, ToolDefinition, ToolFunctionDefinition}; +use crate::state::SessionState; +use serde_json::json; + +fn parse_tool_arguments(args_str: &str) -> Result { + serde_json::from_str(args_str).map_err(|e| format!("Error parsing arguments: {e}")) +} + +/// Returns the tool definitions available to the LLM. +pub fn get_tool_definitions() -> Vec { + vec![ + ToolDefinition { + kind: "function".to_string(), + function: ToolFunctionDefinition { + name: "read_file".to_string(), + description: "Reads the complete content of a file from the project. Use this to understand existing code before making changes.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Relative path to the file from project root" } + }, + "required": ["path"] + }), + }, + }, + ToolDefinition { + kind: "function".to_string(), + function: ToolFunctionDefinition { + name: "write_file".to_string(), + description: "Creates or completely overwrites a file with new content. YOU MUST USE THIS to implement code changes - do not suggest code to the user. The content parameter must contain the COMPLETE file including all imports, functions, and unchanged code.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Relative path to the file from project root" }, + "content": { "type": "string", "description": "The complete file content to write (not a diff or partial code)" } + }, + "required": ["path", "content"] + }), + }, + }, + ToolDefinition { + kind: "function".to_string(), + function: ToolFunctionDefinition { + name: "list_directory".to_string(), + description: "Lists all files and directories at a given path. Use this to explore the project structure.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Relative path to list (use '.' for project root)" } + }, + "required": ["path"] + }), + }, + }, + ToolDefinition { + kind: "function".to_string(), + function: ToolFunctionDefinition { + name: "search_files".to_string(), + description: "Searches for text patterns across all files in the project. Use this to find functions, variables, or code patterns when you don't know which file they're in." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "The text pattern to search for across all files" } + }, + "required": ["query"] + }), + }, + }, + ToolDefinition { + kind: "function".to_string(), + function: ToolFunctionDefinition { + name: "exec_shell".to_string(), + description: "Executes a shell command in the project root directory. Use this to run tests, build commands, git operations, or any command-line tool. Examples: cargo check, npm test, git status.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The command binary to execute (e.g., 'git', 'cargo', 'npm', 'ls')" + }, + "args": { + "type": "array", + "items": { "type": "string" }, + "description": "Array of arguments to pass to the command (e.g., ['status'] for git status)" + } + }, + "required": ["command", "args"] + }), + }, + }, + ] +} + +/// Executes a tool call and returns the string result. +pub(crate) async fn execute_tool(call: &ToolCall, state: &SessionState) -> String { + let name = call.function.name.as_str(); + let args: serde_json::Value = match parse_tool_arguments(&call.function.arguments) { + Ok(v) => v, + Err(e) => return e, + }; + + match name { + "read_file" => { + let path = args["path"].as_str().unwrap_or("").to_string(); + match fs::read_file(path, state).await { + Ok(content) => content, + Err(e) => format!("Error: {e}"), + } + } + "write_file" => { + let path = args["path"].as_str().unwrap_or("").to_string(); + let content = args["content"].as_str().unwrap_or("").to_string(); + match fs::write_file(path, content, state).await { + Ok(()) => "File written successfully.".to_string(), + Err(e) => format!("Error: {e}"), + } + } + "list_directory" => { + let path = args["path"].as_str().unwrap_or("").to_string(); + match fs::list_directory(path, state).await { + Ok(entries) => serde_json::to_string(&entries).unwrap_or_default(), + Err(e) => format!("Error: {e}"), + } + } + "search_files" => { + let query = args["query"].as_str().unwrap_or("").to_string(); + match search::search_files(query, state).await { + Ok(results) => serde_json::to_string(&results).unwrap_or_default(), + Err(e) => format!("Error: {e}"), + } + } + "exec_shell" => { + let command = args["command"].as_str().unwrap_or("").to_string(); + let args_vec: Vec = args["args"] + .as_array() + .map(|arr| { + arr.iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect() + }) + .unwrap_or_default(); + + match shell::exec_shell(command, args_vec, state).await { + Ok(output) => serde_json::to_string(&output).unwrap_or_default(), + Err(e) => format!("Error: {e}"), + } + } + _ => format!("Unknown tool: {name}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::llm::types::FunctionCall; + use crate::state::SessionState; + use serde_json::json; + + // --------------------------------------------------------------------------- + // parse_tool_arguments + // --------------------------------------------------------------------------- + + #[test] + fn parse_tool_arguments_valid_json() { + let result = parse_tool_arguments(r#"{"path": "src/main.rs"}"#); + assert!(result.is_ok()); + assert_eq!(result.unwrap()["path"], json!("src/main.rs")); + } + + #[test] + fn parse_tool_arguments_invalid_json() { + let result = parse_tool_arguments("not json {{{"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Error parsing arguments:")); + } + + #[test] + fn parse_tool_arguments_empty_object() { + let result = parse_tool_arguments("{}"); + assert!(result.is_ok()); + } + + // --------------------------------------------------------------------------- + // get_tool_definitions + // --------------------------------------------------------------------------- + + #[test] + fn tool_definitions_returns_five_tools() { + let tools = get_tool_definitions(); + assert_eq!(tools.len(), 5); + } + + #[test] + fn tool_definitions_has_expected_names() { + let tools = get_tool_definitions(); + let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect(); + assert!(names.contains(&"read_file")); + assert!(names.contains(&"write_file")); + assert!(names.contains(&"list_directory")); + assert!(names.contains(&"search_files")); + assert!(names.contains(&"exec_shell")); + } + + #[test] + fn tool_definitions_each_has_function_kind() { + let tools = get_tool_definitions(); + for tool in &tools { + assert_eq!(tool.kind, "function"); + } + } + + #[test] + fn tool_definitions_each_has_non_empty_description() { + let tools = get_tool_definitions(); + for tool in &tools { + assert!(!tool.function.description.is_empty()); + } + } + + #[test] + fn tool_definitions_parameters_have_object_type() { + let tools = get_tool_definitions(); + for tool in &tools { + assert_eq!(tool.function.parameters["type"], json!("object")); + } + } + + #[test] + fn read_file_tool_requires_path_parameter() { + let tools = get_tool_definitions(); + let read_file = tools + .iter() + .find(|t| t.function.name == "read_file") + .unwrap(); + let required = read_file.function.parameters["required"] + .as_array() + .unwrap(); + let required_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(required_names.contains(&"path")); + } + + #[test] + fn exec_shell_tool_requires_command_and_args() { + let tools = get_tool_definitions(); + let exec_shell = tools + .iter() + .find(|t| t.function.name == "exec_shell") + .unwrap(); + let required = exec_shell.function.parameters["required"] + .as_array() + .unwrap(); + let required_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(required_names.contains(&"command")); + assert!(required_names.contains(&"args")); + } + + // --------------------------------------------------------------------------- + // execute_tool — unknown tool name (no I/O, no network) + // --------------------------------------------------------------------------- + + #[tokio::test] + async fn execute_tool_returns_error_for_unknown_tool() { + let state = SessionState::default(); + let call = ToolCall { + id: Some("call-1".to_string()), + kind: "function".to_string(), + function: FunctionCall { + name: "nonexistent_tool".to_string(), + arguments: "{}".to_string(), + }, + }; + + let result = execute_tool(&call, &state).await; + assert_eq!(result, "Unknown tool: nonexistent_tool"); + } + + #[tokio::test] + async fn execute_tool_returns_parse_error_for_invalid_json_args() { + let state = SessionState::default(); + let call = ToolCall { + id: Some("call-2".to_string()), + kind: "function".to_string(), + function: FunctionCall { + name: "read_file".to_string(), + arguments: "INVALID JSON".to_string(), + }, + }; + + let result = execute_tool(&call, &state).await; + assert!( + result.contains("Error parsing arguments:"), + "unexpected result: {result}" + ); + } + + // --------------------------------------------------------------------------- + // execute_tool — tools that use SessionState (no project root → errors) + // --------------------------------------------------------------------------- + + #[tokio::test] + async fn execute_tool_read_file_no_project_root_returns_error() { + let state = SessionState::default(); // no project_root set + let call = ToolCall { + id: None, + kind: "function".to_string(), + function: FunctionCall { + name: "read_file".to_string(), + arguments: r#"{"path": "some_file.txt"}"#.to_string(), + }, + }; + + let result = execute_tool(&call, &state).await; + assert!(result.starts_with("Error:"), "unexpected result: {result}"); + } + + #[tokio::test] + async fn execute_tool_write_file_no_project_root_returns_error() { + let state = SessionState::default(); + let call = ToolCall { + id: None, + kind: "function".to_string(), + function: FunctionCall { + name: "write_file".to_string(), + arguments: r#"{"path": "out.txt", "content": "hello"}"#.to_string(), + }, + }; + + let result = execute_tool(&call, &state).await; + assert!(result.starts_with("Error:"), "unexpected result: {result}"); + } + + #[tokio::test] + async fn execute_tool_list_directory_no_project_root_returns_error() { + let state = SessionState::default(); + let call = ToolCall { + id: None, + kind: "function".to_string(), + function: FunctionCall { + name: "list_directory".to_string(), + arguments: r#"{"path": "."}"#.to_string(), + }, + }; + + let result = execute_tool(&call, &state).await; + assert!(result.starts_with("Error:"), "unexpected result: {result}"); + } + + #[tokio::test] + async fn execute_tool_search_files_no_project_root_returns_error() { + let state = SessionState::default(); + let call = ToolCall { + id: None, + kind: "function".to_string(), + function: FunctionCall { + name: "search_files".to_string(), + arguments: r#"{"query": "fn main"}"#.to_string(), + }, + }; + + let result = execute_tool(&call, &state).await; + assert!(result.starts_with("Error:"), "unexpected result: {result}"); + } + + #[tokio::test] + async fn execute_tool_exec_shell_no_project_root_returns_error() { + let state = SessionState::default(); + let call = ToolCall { + id: None, + kind: "function".to_string(), + function: FunctionCall { + name: "exec_shell".to_string(), + arguments: r#"{"command": "ls", "args": []}"#.to_string(), + }, + }; + + let result = execute_tool(&call, &state).await; + assert!(result.starts_with("Error:"), "unexpected result: {result}"); + } +}