feat(story-93): expose server logs to agents via get_server_logs MCP tool
- Add log_buffer module: bounded 1000-line ring buffer with push/get_recent API
- Add slog! macro: drop-in for eprintln! that also captures to ring buffer
- Replace all eprintln! calls across agents, watcher, search, chat, worktree, claude_code with slog!
- Add get_server_logs MCP tool: accepts count (1-500) and optional filter params
- 5 unit tests for log_buffer covering push/retrieve, eviction, filtering, count limits, empty buffer
- 262 tests passing, clippy clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 20:38:19 +00:00
use crate ::slog ;
2026-02-13 12:31:36 +00:00
use crate ::llm ::prompts ::SYSTEM_PROMPT ;
2026-02-20 14:09:59 +00:00
use crate ::llm ::providers ::claude_code ::ClaudeCodeResult ;
2026-02-13 12:31:36 +00:00
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 < String > ,
pub enable_tools : Option < bool > ,
2026-02-20 11:51:19 +00:00
/// Claude Code session ID for conversation resumption.
pub session_id : Option < String > ,
}
/// Result of a chat call, including messages and optional metadata.
#[ allow(dead_code) ]
2026-02-24 00:00:35 +00:00
#[ derive(Debug) ]
2026-02-20 11:51:19 +00:00
pub struct ChatResult {
pub messages : Vec < Message > ,
/// Session ID returned by Claude Code for resumption.
pub session_id : Option < String > ,
2026-02-13 12:31:36 +00:00
}
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 < String , String > {
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 ::Value , String > {
serde_json ::from_str ( args_str ) . map_err ( | e | format! ( " Error parsing arguments: {e} " ) )
}
pub fn get_tool_definitions ( ) -> Vec < ToolDefinition > {
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 < String > ) -> Result < Vec < String > , 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 < bool , String > {
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 )
}
2026-02-24 12:32:14 +00:00
#[ allow(clippy::too_many_arguments) ]
2026-02-23 18:38:15 +00:00
pub async fn chat < F , U , A > (
2026-02-13 12:31:36 +00:00
messages : Vec < Message > ,
config : ProviderConfig ,
state : & SessionState ,
store : & dyn StoreOps ,
mut on_update : F ,
mut on_token : U ,
2026-02-23 18:38:15 +00:00
mut on_activity : A ,
2026-02-20 11:51:19 +00:00
) -> Result < ChatResult , String >
2026-02-13 12:31:36 +00:00
where
F : FnMut ( & [ Message ] ) + Send ,
U : FnMut ( & str ) + Send ,
2026-02-23 18:38:15 +00:00
A : FnMut ( & str ) + Send ,
2026-02-13 12:31:36 +00:00
{
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 ( ) ) ;
feat(story-93): expose server logs to agents via get_server_logs MCP tool
- Add log_buffer module: bounded 1000-line ring buffer with push/get_recent API
- Add slog! macro: drop-in for eprintln! that also captures to ring buffer
- Replace all eprintln! calls across agents, watcher, search, chat, worktree, claude_code with slog!
- Add get_server_logs MCP tool: accepts count (1-500) and optional filter params
- 5 unit tests for log_buffer covering push/retrieve, eviction, filtering, count limits, empty buffer
- 262 tests passing, clippy clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 20:38:19 +00:00
slog! ( " [chat] provider={} model={} " , config . provider , config . model ) ;
2026-02-19 15:25:22 +00:00
let is_claude_code = config . provider = = " claude-code " ;
let is_claude = ! is_claude_code & & config . model . starts_with ( " claude- " ) ;
2026-02-13 12:31:36 +00:00
2026-02-19 15:25:22 +00:00
if ! is_claude_code & & ! is_claude & & config . provider . as_str ( ) ! = " ollama " {
2026-02-13 12:31:36 +00:00
return Err ( format! ( " Unsupported provider: {} " , config . provider ) ) ;
}
2026-02-19 15:25:22 +00:00
// Claude Code provider: bypasses our tool loop entirely.
// Claude Code has its own agent loop, tools, and context management.
2026-02-20 14:09:59 +00:00
// 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.
2026-02-19 15:25:22 +00:00
if is_claude_code {
use crate ::llm ::providers ::claude_code ::ClaudeCodeProvider ;
let user_message = messages
. iter ( )
. rev ( )
. find ( | m | m . role = = Role ::User )
. map ( | m | m . content . clone ( ) )
. ok_or_else ( | | " No user message found " . to_string ( ) ) ? ;
let project_root = state
. get_project_root ( )
. unwrap_or_else ( | _ | std ::path ::PathBuf ::from ( " . " ) ) ;
let provider = ClaudeCodeProvider ::new ( ) ;
2026-02-20 14:09:59 +00:00
let ClaudeCodeResult {
messages : cc_messages ,
session_id ,
} = provider
2026-02-19 15:25:22 +00:00
. chat_stream (
& user_message ,
& project_root . to_string_lossy ( ) ,
2026-02-20 11:51:19 +00:00
config . session_id . as_deref ( ) ,
2026-02-19 15:25:22 +00:00
& mut cancel_rx ,
| token | on_token ( token ) ,
2026-02-24 12:32:14 +00:00
| tool_name | on_activity ( tool_name ) ,
2026-02-19 15:25:22 +00:00
)
. await
. map_err ( | e | format! ( " Claude Code Error: {e} " ) ) ? ;
2026-02-20 14:09:59 +00:00
// 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.
2026-02-19 15:25:22 +00:00
let mut result = messages . clone ( ) ;
2026-02-20 14:09:59 +00:00
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 ) ;
}
2026-02-19 15:25:22 +00:00
on_update ( & result ) ;
2026-02-20 11:51:19 +00:00
return Ok ( ChatResult {
messages : result ,
2026-02-20 14:09:59 +00:00
session_id ,
2026-02-20 11:51:19 +00:00
} ) ;
2026-02-19 15:25:22 +00:00
}
2026-02-13 12:31:36 +00:00
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 ( ) ;
current_history . insert (
0 ,
Message {
role : Role ::System ,
content : SYSTEM_PROMPT . to_string ( ) ,
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 < Message > = 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 ,
& current_history ,
tools ,
& mut cancel_rx ,
| token | on_token ( token ) ,
2026-02-23 18:38:15 +00:00
| tool_name | on_activity ( tool_name ) ,
2026-02-13 12:31:36 +00:00
)
. await
. map_err ( | e | format! ( " Anthropic Error: {e} " ) ) ?
} else {
let ollama_provider = OllamaProvider ::new ( base_url . clone ( ) ) ;
ollama_provider
. chat_stream (
& config . model ,
& current_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 ( & current_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 ( & current_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 ( & current_history [ 2 .. ] ) ;
break ;
}
}
2026-02-20 11:51:19 +00:00
Ok ( ChatResult {
messages : new_messages ,
session_id : None ,
} )
2026-02-13 12:31:36 +00:00
}
async fn execute_tool ( call : & ToolCall , state : & SessionState ) -> String {
2026-02-16 16:24:21 +00:00
use crate ::io ::{ fs , search , shell } ;
2026-02-13 12:31:36 +00:00
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 < String > = 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 ( ( ) )
}
2026-02-24 00:00:35 +00:00
#[ 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 < HashMap < String , serde_json ::Value > > ,
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 < serde_json ::Value > {
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} " ) ;
}
}