use crate::slog; use crate::io::onboarding; 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::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"; #[derive(Deserialize, Clone)] pub struct ProviderConfig { pub provider: String, pub model: String, pub base_url: Option, pub enable_tools: Option, /// Claude Code session ID for conversation resumption. pub session_id: Option, } /// Result of a chat call, including messages and optional metadata. #[allow(dead_code)] #[derive(Debug)] pub struct ChatResult { pub messages: Vec, /// Session ID returned by Claude Code for resumption. pub session_id: Option, } fn get_anthropic_api_key_exists_impl(store: &dyn StoreOps) -> bool { match store.get(KEY_ANTHROPIC_API_KEY) { Some(value) => value.as_str().map(|k| !k.is_empty()).unwrap_or(false), None => false, } } fn set_anthropic_api_key_impl(store: &dyn StoreOps, api_key: &str) -> Result<(), String> { store.set(KEY_ANTHROPIC_API_KEY, json!(api_key)); store.save()?; match store.get(KEY_ANTHROPIC_API_KEY) { Some(value) => { if let Some(retrieved) = value.as_str() { if retrieved != api_key { return Err("Retrieved key does not match saved key".to_string()); } } else { return Err("Stored value is not a string".to_string()); } } None => { return Err("API key was saved but cannot be retrieved".to_string()); } } Ok(()) } fn get_anthropic_api_key_impl(store: &dyn StoreOps) -> Result { match store.get(KEY_ANTHROPIC_API_KEY) { Some(value) => { if let Some(key) = value.as_str() { if key.is_empty() { Err("Anthropic API key is empty. Please set your API key.".to_string()) } else { Ok(key.to_string()) } } else { Err("Stored API key is not a string".to_string()) } } None => Err("Anthropic API key not found. Please set your API key.".to_string()), } } 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"] }), }, }, ] } 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()); OllamaProvider::get_models(&url).await } pub fn get_anthropic_api_key_exists(store: &dyn StoreOps) -> Result { Ok(get_anthropic_api_key_exists_impl(store)) } pub fn set_anthropic_api_key(store: &dyn StoreOps, api_key: String) -> Result<(), String> { set_anthropic_api_key_impl(store, &api_key) } /// Build a prompt for Claude Code that includes prior conversation history. /// /// When a Claude Code session cannot be resumed (no session_id), we embed /// the prior messages as a structured preamble so the LLM retains context. /// If there is only one user message (the current one), the content is /// returned as-is with no preamble. fn build_claude_code_context_prompt(messages: &[Message], latest_user_content: &str) -> String { // Collect prior messages (everything except the trailing user message). let prior: Vec<&Message> = messages .iter() .rev() .skip(1) // skip the latest user message .collect::>() .into_iter() .rev() .collect(); if prior.is_empty() { return latest_user_content.to_string(); } let mut parts = Vec::new(); parts.push("".to_string()); for msg in &prior { let label = match msg.role { Role::User => "User", Role::Assistant => "Assistant", Role::Tool => "Tool", Role::System => continue, }; parts.push(format!("[{}]: {}", label, msg.content)); } parts.push("".to_string()); parts.push(String::new()); parts.push(latest_user_content.to_string()); parts.join("\n") } #[allow(clippy::too_many_arguments)] pub async fn chat( messages: Vec, config: ProviderConfig, state: &SessionState, store: &dyn StoreOps, mut on_update: F, mut on_token: U, mut on_thinking: T, mut on_activity: A, ) -> Result where F: FnMut(&[Message]) + Send, U: FnMut(&str) + Send, T: FnMut(&str) + Send, A: FnMut(&str) + Send, { use crate::llm::providers::anthropic::AnthropicProvider; use crate::llm::providers::ollama::OllamaProvider; let _ = state.cancel_tx.send(false); let mut cancel_rx = state.cancel_rx.clone(); cancel_rx.borrow_and_update(); let base_url = config .base_url .clone() .unwrap_or_else(|| "http://localhost:11434".to_string()); slog!("[chat] provider={} model={}", config.provider, config.model); let is_claude_code = config.provider == "claude-code"; let is_claude = !is_claude_code && config.model.starts_with("claude-"); if !is_claude_code && !is_claude && config.provider.as_str() != "ollama" { return Err(format!("Unsupported provider: {}", config.provider)); } // Claude Code provider: bypasses our tool loop entirely. // Claude Code has its own agent loop, tools, and context management. // We pipe the user message in, stream text tokens for live display, and // collect the structured messages (assistant turns + tool results) from // the stream-json output for the final message history. if is_claude_code { use crate::llm::providers::claude_code::ClaudeCodeProvider; let latest_user_content = messages .iter() .rev() .find(|m| m.role == Role::User) .map(|m| m.content.clone()) .ok_or_else(|| "No user message found".to_string())?; // When resuming with a session_id, Claude Code loads its own transcript // from disk — the latest user message is sufficient. Without a // session_id (e.g. after a page refresh) the prior conversation context // would be lost because Claude Code only receives a single prompt // string. In that case, prepend the conversation history so the LLM // retains full context even though the session cannot be resumed. let user_message = if config.session_id.is_some() { latest_user_content } else { build_claude_code_context_prompt(&messages, &latest_user_content) }; let project_root = state .get_project_root() .unwrap_or_else(|_| std::path::PathBuf::from(".")); let provider = ClaudeCodeProvider::new(); let ClaudeCodeResult { messages: cc_messages, session_id, } = provider .chat_stream( &user_message, &project_root.to_string_lossy(), config.session_id.as_deref(), &mut cancel_rx, |token| on_token(token), |thinking| on_thinking(thinking), |tool_name| on_activity(tool_name), ) .await .map_err(|e| format!("Claude Code Error: {e}"))?; // Build the final message history: user messages + Claude Code's turns. // If the session produced no structured messages (e.g. empty response), // fall back to an empty assistant message so the UI stops loading. let mut result = messages.clone(); if cc_messages.is_empty() { result.push(Message { role: Role::Assistant, content: String::new(), tool_calls: None, tool_call_id: None, }); } else { result.extend(cc_messages); } on_update(&result); return Ok(ChatResult { messages: result, session_id, }); } let tool_defs = get_tool_definitions(); let tools = if config.enable_tools.unwrap_or(true) { tool_defs.as_slice() } else { &[] }; let mut current_history = messages.clone(); // Build the system prompt — append onboarding instructions when the // project's spec files still contain scaffold placeholders. let system_content = { let mut content = SYSTEM_PROMPT.to_string(); if let Ok(root) = state.get_project_root() { let status = onboarding::check_onboarding_status(&root); if status.needs_onboarding() { content.push_str("\n\n"); content.push_str(ONBOARDING_PROMPT); } } content }; current_history.insert( 0, Message { role: Role::System, content: system_content, tool_calls: None, tool_call_id: None, }, ); current_history.insert( 1, Message { role: Role::System, content: "REMINDER: Distinguish between showing examples (use code blocks in chat) vs implementing changes (use write_file tool). Keywords like 'show me', 'example', 'how does' = chat response. Keywords like 'create', 'add', 'implement', 'fix' = use tools." .to_string(), tool_calls: None, tool_call_id: None, }, ); let mut new_messages: Vec = Vec::new(); let mut turn_count = 0; loop { if *cancel_rx.borrow() { return Err("Chat cancelled by user".to_string()); } if turn_count >= MAX_TURNS { return Err("Max conversation turns reached.".to_string()); } turn_count += 1; let response = if is_claude { let api_key = get_anthropic_api_key_impl(store)?; let anthropic_provider = AnthropicProvider::new(api_key); anthropic_provider .chat_stream( &config.model, ¤t_history, tools, &mut cancel_rx, |token| on_token(token), |tool_name| on_activity(tool_name), ) .await .map_err(|e| format!("Anthropic Error: {e}"))? } else { let ollama_provider = OllamaProvider::new(base_url.clone()); ollama_provider .chat_stream( &config.model, ¤t_history, tools, &mut cancel_rx, |token| on_token(token), ) .await .map_err(|e| format!("Ollama Error: {e}"))? }; if let Some(tool_calls) = response.tool_calls { let assistant_msg = Message { role: Role::Assistant, content: response.content.unwrap_or_default(), tool_calls: Some(tool_calls.clone()), tool_call_id: None, }; current_history.push(assistant_msg.clone()); new_messages.push(assistant_msg); on_update(¤t_history[2..]); for call in tool_calls { if *cancel_rx.borrow() { return Err("Chat cancelled before tool execution".to_string()); } let output = execute_tool(&call, state).await; let tool_msg = Message { role: Role::Tool, content: output, tool_calls: None, tool_call_id: call.id, }; current_history.push(tool_msg.clone()); new_messages.push(tool_msg); on_update(¤t_history[2..]); } } else { let assistant_msg = Message { role: Role::Assistant, content: response.content.unwrap_or_default(), tool_calls: None, tool_call_id: None, }; new_messages.push(assistant_msg.clone()); current_history.push(assistant_msg); on_update(¤t_history[2..]); break; } } Ok(ChatResult { messages: current_history[2..].to_vec(), session_id: None, }) } /// Answer a one-off side question using the existing conversation as context. /// /// Unlike `chat`, this function: /// - Does NOT perform tool calls. /// - Does NOT modify the main conversation history. /// - Does NOT touch the shared cancel signal. /// - Performs a single LLM call and returns the response text. pub async fn side_question( context_messages: Vec, question: String, config: ProviderConfig, store: &dyn StoreOps, mut on_token: U, ) -> Result where U: FnMut(&str) + Send, { use crate::llm::providers::anthropic::AnthropicProvider; use crate::llm::providers::ollama::OllamaProvider; // Use a local cancel channel that is never cancelled, so the side question // runs to completion independently of any main chat cancel signal. // Keep `_cancel_tx` alive for the duration of the function so the channel // stays open and `changed()` inside the providers does not spuriously fire. let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); let mut cancel_rx = cancel_rx; cancel_rx.borrow_and_update(); let base_url = config .base_url .clone() .unwrap_or_else(|| "http://localhost:11434".to_string()); let is_claude_code = config.provider == "claude-code"; let is_claude = !is_claude_code && config.model.starts_with("claude-"); // Build a minimal history: existing context + the side question. let mut history = context_messages; history.push(Message { role: Role::User, content: question, tool_calls: None, tool_call_id: None, }); // No tools for side questions. let tools: &[ToolDefinition] = &[]; let response = if is_claude { let api_key = get_anthropic_api_key_impl(store)?; let provider = AnthropicProvider::new(api_key); provider .chat_stream( &config.model, &history, tools, &mut cancel_rx, |token| on_token(token), |_tool_name| {}, ) .await .map_err(|e| format!("Anthropic Error: {e}"))? } else if is_claude_code { return Err("Claude Code provider does not support side questions".to_string()); } else { let provider = OllamaProvider::new(base_url); provider .chat_stream(&config.model, &history, tools, &mut cancel_rx, |token| { on_token(token) }) .await .map_err(|e| format!("Ollama Error: {e}"))? }; 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::state::SessionState; use serde_json::json; use std::collections::HashMap; use std::sync::Mutex; // --------------------------------------------------------------------------- // Minimal in-memory StoreOps mock // --------------------------------------------------------------------------- struct MockStore { data: Mutex>, save_should_fail: bool, } impl MockStore { fn new() -> Self { Self { data: Mutex::new(HashMap::new()), save_should_fail: false, } } fn with_save_error() -> Self { Self { data: Mutex::new(HashMap::new()), save_should_fail: true, } } fn with_entry(key: &str, value: serde_json::Value) -> Self { let mut map = HashMap::new(); map.insert(key.to_string(), value); Self { data: Mutex::new(map), save_should_fail: false, } } } impl StoreOps for MockStore { fn get(&self, key: &str) -> Option { self.data.lock().ok().and_then(|m| m.get(key).cloned()) } fn set(&self, key: &str, value: serde_json::Value) { if let Ok(mut m) = self.data.lock() { m.insert(key.to_string(), value); } } fn delete(&self, key: &str) { if let Ok(mut m) = self.data.lock() { m.remove(key); } } fn save(&self) -> Result<(), String> { if self.save_should_fail { Err("mock save error".to_string()) } else { Ok(()) } } } // --------------------------------------------------------------------------- // 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_anthropic_api_key_exists_impl // --------------------------------------------------------------------------- #[test] fn api_key_exists_when_key_is_present_and_non_empty() { let store = MockStore::with_entry("anthropic_api_key", json!("sk-test-key")); assert!(get_anthropic_api_key_exists_impl(&store)); } #[test] fn api_key_exists_returns_false_when_key_is_empty_string() { let store = MockStore::with_entry("anthropic_api_key", json!("")); assert!(!get_anthropic_api_key_exists_impl(&store)); } #[test] fn api_key_exists_returns_false_when_key_absent() { let store = MockStore::new(); assert!(!get_anthropic_api_key_exists_impl(&store)); } #[test] fn api_key_exists_returns_false_when_value_is_not_string() { let store = MockStore::with_entry("anthropic_api_key", json!(42)); assert!(!get_anthropic_api_key_exists_impl(&store)); } // --------------------------------------------------------------------------- // get_anthropic_api_key_impl // --------------------------------------------------------------------------- #[test] fn get_api_key_returns_key_when_present() { let store = MockStore::with_entry("anthropic_api_key", json!("sk-test-key")); let result = get_anthropic_api_key_impl(&store); assert_eq!(result.unwrap(), "sk-test-key"); } #[test] fn get_api_key_errors_when_empty() { let store = MockStore::with_entry("anthropic_api_key", json!("")); let result = get_anthropic_api_key_impl(&store); assert!(result.is_err()); assert!(result.unwrap_err().contains("empty")); } #[test] fn get_api_key_errors_when_absent() { let store = MockStore::new(); let result = get_anthropic_api_key_impl(&store); assert!(result.is_err()); assert!(result.unwrap_err().contains("not found")); } #[test] fn get_api_key_errors_when_value_not_string() { let store = MockStore::with_entry("anthropic_api_key", json!(123)); let result = get_anthropic_api_key_impl(&store); assert!(result.is_err()); assert!(result.unwrap_err().contains("not a string")); } // --------------------------------------------------------------------------- // set_anthropic_api_key_impl // --------------------------------------------------------------------------- #[test] fn set_api_key_stores_and_returns_ok() { let store = MockStore::new(); let result = set_anthropic_api_key_impl(&store, "sk-my-key"); assert!(result.is_ok()); assert_eq!( store.get("anthropic_api_key"), Some(json!("sk-my-key")) ); } #[test] fn set_api_key_returns_error_when_save_fails() { let store = MockStore::with_save_error(); let result = set_anthropic_api_key_impl(&store, "sk-my-key"); assert!(result.is_err()); assert!(result.unwrap_err().contains("mock save error")); } // --------------------------------------------------------------------------- // Public wrappers: get_anthropic_api_key_exists / set_anthropic_api_key // --------------------------------------------------------------------------- #[test] fn public_api_key_exists_returns_ok_bool() { let store = MockStore::with_entry("anthropic_api_key", json!("sk-abc")); let result = get_anthropic_api_key_exists(&store); assert_eq!(result, Ok(true)); } #[test] fn public_api_key_exists_false_when_absent() { let store = MockStore::new(); let result = get_anthropic_api_key_exists(&store); assert_eq!(result, Ok(false)); } #[test] fn public_set_api_key_succeeds() { let store = MockStore::new(); let result = set_anthropic_api_key(&store, "sk-xyz".to_string()); assert!(result.is_ok()); } #[test] fn public_set_api_key_propagates_save_error() { let store = MockStore::with_save_error(); let result = set_anthropic_api_key(&store, "sk-xyz".to_string()); assert!(result.is_err()); } // --------------------------------------------------------------------------- // 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 // --------------------------------------------------------------------------- #[test] fn cancel_chat_sends_true_to_channel() { let state = SessionState::default(); let result = cancel_chat(&state); assert!(result.is_ok()); assert!(*state.cancel_rx.borrow()); } // --------------------------------------------------------------------------- // chat — unsupported provider early return (no network calls) // --------------------------------------------------------------------------- #[tokio::test] async fn chat_rejects_unknown_provider() { let state = SessionState::default(); let store = MockStore::new(); let config = ProviderConfig { provider: "unsupported-provider".to_string(), model: "some-model".to_string(), base_url: None, enable_tools: None, session_id: None, }; let messages = vec![Message { role: Role::User, content: "hello".to_string(), tool_calls: None, tool_call_id: None, }]; let result = chat( messages, config, &state, &store, |_| {}, |_| {}, |_| {}, |_| {}, ) .await; assert!(result.is_err()); assert!(result .unwrap_err() .contains("Unsupported provider: unsupported-provider")); } // --------------------------------------------------------------------------- // chat — ollama path exercises system prompt insertion + tool setup // (connection to a non-existent port fails fast) // --------------------------------------------------------------------------- #[tokio::test] async fn chat_ollama_bad_url_fails_with_ollama_error() { let state = SessionState::default(); let store = MockStore::new(); let config = ProviderConfig { provider: "ollama".to_string(), model: "llama3".to_string(), // Port 1 is reserved / closed — connection fails immediately. base_url: Some("http://127.0.0.1:1".to_string()), enable_tools: Some(false), session_id: None, }; let messages = vec![Message { role: Role::User, content: "ping".to_string(), tool_calls: None, tool_call_id: None, }]; let result = chat( messages, config, &state, &store, |_| {}, |_| {}, |_| {}, |_| {}, ) .await; assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.contains("Ollama Error:"), "unexpected error: {err}"); } // --------------------------------------------------------------------------- // chat — Anthropic model prefix detection (fails without API key) // --------------------------------------------------------------------------- #[tokio::test] async fn chat_claude_model_without_api_key_returns_error() { let state = SessionState::default(); // No API key in store → should fail with "API key not found" let store = MockStore::new(); let config = ProviderConfig { provider: "anthropic".to_string(), model: "claude-3-haiku-20240307".to_string(), base_url: None, enable_tools: Some(false), session_id: None, }; let messages = vec![Message { role: Role::User, content: "hello".to_string(), tool_calls: None, tool_call_id: None, }]; let result = chat( messages, config, &state, &store, |_| {}, |_| {}, |_| {}, |_| {}, ) .await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( err.contains("API key"), "expected API key error, got: {err}" ); } // --------------------------------------------------------------------------- // 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) // --------------------------------------------------------------------------- #[test] fn context_prompt_single_message_returns_content_as_is() { let messages = vec![Message { role: Role::User, content: "hello".to_string(), tool_calls: None, tool_call_id: None, }]; let result = build_claude_code_context_prompt(&messages, "hello"); assert_eq!(result, "hello"); } #[test] fn context_prompt_includes_prior_conversation() { let messages = vec![ Message { role: Role::User, content: "What is Rust?".to_string(), tool_calls: None, tool_call_id: None, }, Message { role: Role::Assistant, content: "Rust is a systems language.".to_string(), tool_calls: None, tool_call_id: None, }, Message { role: Role::User, content: "Tell me more".to_string(), tool_calls: None, tool_call_id: None, }, ]; let result = build_claude_code_context_prompt(&messages, "Tell me more"); assert!( result.contains(""), "should have history preamble" ); assert!( result.contains("[User]: What is Rust?"), "should include prior user message" ); assert!( result.contains("[Assistant]: Rust is a systems language."), "should include prior assistant message" ); assert!( result.contains(""), "should close history block" ); assert!( result.ends_with("Tell me more"), "should end with latest user message" ); } #[test] fn context_prompt_skips_system_messages() { let messages = vec![ Message { role: Role::System, content: "You are a helpful assistant.".to_string(), tool_calls: None, tool_call_id: None, }, Message { role: Role::User, content: "hi".to_string(), tool_calls: None, tool_call_id: None, }, Message { role: Role::Assistant, content: "hello".to_string(), tool_calls: None, tool_call_id: None, }, Message { role: Role::User, content: "bye".to_string(), tool_calls: None, tool_call_id: None, }, ]; let result = build_claude_code_context_prompt(&messages, "bye"); assert!( !result.contains("helpful assistant"), "should not include system messages" ); assert!(result.contains("[User]: hi")); assert!(result.contains("[Assistant]: hello")); } }