2026-03-22 19:07:07 +00:00
use crate ::agents ::PipelineStage ;
use crate ::config ::ProjectConfig ;
use crate ::http ::context ::AppContext ;
use crate ::http ::settings ::get_editor_command_from_store ;
use crate ::slog_warn ;
use crate ::worktree ;
use serde_json ::{ json , Value } ;
pub ( super ) async fn tool_start_agent ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let agent_name = args . get ( " agent_name " ) . and_then ( | v | v . as_str ( ) ) ;
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
let info = ctx
. agents
. start_agent ( & project_root , story_id , agent_name , None )
. await ? ;
// Snapshot coverage baseline from the most recent coverage report (best-effort).
if let Some ( pct ) = read_coverage_percent_from_json ( & project_root )
& & let Err ( e ) = crate ::http ::workflow ::write_coverage_baseline_to_story_file (
& project_root ,
story_id ,
pct ,
)
{
slog_warn! ( " [start_agent] Could not write coverage baseline to story file: {e} " ) ;
}
serde_json ::to_string_pretty ( & json! ( {
" story_id " : info . story_id ,
" agent_name " : info . agent_name ,
" status " : info . status . to_string ( ) ,
" session_id " : info . session_id ,
" worktree_path " : info . worktree_path ,
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
/// Try to read the overall line coverage percentage from the llvm-cov JSON report.
///
/// Expects the file at `{project_root}/.storkit/coverage/server.json`.
/// Returns `None` if the file is absent, unreadable, or cannot be parsed.
pub ( super ) fn read_coverage_percent_from_json ( project_root : & std ::path ::Path ) -> Option < f64 > {
let path = project_root
. join ( " .storkit " )
. join ( " coverage " )
. join ( " server.json " ) ;
let contents = std ::fs ::read_to_string ( & path ) . ok ( ) ? ;
let json : Value = serde_json ::from_str ( & contents ) . ok ( ) ? ;
// cargo llvm-cov --json format: data[0].totals.lines.percent
json . pointer ( " /data/0/totals/lines/percent " )
. and_then ( | v | v . as_f64 ( ) )
}
pub ( super ) async fn tool_stop_agent ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let agent_name = args
. get ( " agent_name " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: agent_name " ) ? ;
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
ctx . agents
. stop_agent ( & project_root , story_id , agent_name )
. await ? ;
Ok ( format! ( " Agent ' {agent_name} ' for story ' {story_id} ' stopped. " ) )
}
pub ( super ) fn tool_list_agents ( ctx : & AppContext ) -> Result < String , String > {
let project_root = ctx . agents . get_project_root ( & ctx . state ) . ok ( ) ;
let agents = ctx . agents . list_agents ( ) ? ;
serde_json ::to_string_pretty ( & json! ( agents
. iter ( )
. filter ( | a | {
project_root
. as_deref ( )
. map ( | root | ! crate ::http ::agents ::story_is_archived ( root , & a . story_id ) )
. unwrap_or ( true )
} )
. map ( | a | json! ( {
" story_id " : a . story_id ,
" agent_name " : a . agent_name ,
" status " : a . status . to_string ( ) ,
" session_id " : a . session_id ,
" worktree_path " : a . worktree_path ,
} ) )
. collect ::< Vec < _ > > ( ) ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
pub ( super ) async fn tool_get_agent_output_poll ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let agent_name = args
. get ( " agent_name " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: agent_name " ) ? ;
// Try draining in-memory events first.
match ctx . agents . drain_events ( story_id , agent_name ) {
Ok ( drained ) = > {
let done = drained . iter ( ) . any ( | e | {
matches! (
e ,
crate ::agents ::AgentEvent ::Done { .. }
| crate ::agents ::AgentEvent ::Error { .. }
)
} ) ;
let events : Vec < serde_json ::Value > = drained
. into_iter ( )
. filter_map ( | e | serde_json ::to_value ( & e ) . ok ( ) )
. collect ( ) ;
serde_json ::to_string_pretty ( & json! ( {
" events " : events ,
" done " : done ,
" event_count " : events . len ( ) ,
" message " : if done { " Agent stream ended. " } else if events . is_empty ( ) { " No new events. Call again to continue. " } else { " Events returned. Call again to continue. " }
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
Err ( _ ) = > {
// Agent not in memory — fall back to persistent log file.
get_agent_output_from_log ( story_id , agent_name , ctx )
}
}
}
/// Fall back to reading agent output from the persistent log file on disk.
///
/// Tries to find the log file via the agent's stored log_session_id first,
/// then falls back to `find_latest_log` scanning the log directory.
pub ( super ) fn get_agent_output_from_log (
story_id : & str ,
agent_name : & str ,
ctx : & AppContext ,
) -> Result < String , String > {
use crate ::agent_log ;
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
// Try to find the log file: first from in-memory agent info, then by scanning.
let log_path = ctx
. agents
. get_log_info ( story_id , agent_name )
. map ( | ( session_id , root ) | agent_log ::log_file_path ( & root , story_id , agent_name , & session_id ) )
. filter ( | p | p . exists ( ) )
. or_else ( | | agent_log ::find_latest_log ( & project_root , story_id , agent_name ) ) ;
let log_path = match log_path {
Some ( p ) = > p ,
None = > {
return serde_json ::to_string_pretty ( & json! ( {
" events " : [ ] ,
" done " : true ,
" event_count " : 0 ,
" message " : format ! ( " No agent '{agent_name}' for story '{story_id}' and no log file found. " ) ,
" source " : " none " ,
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) ) ;
}
} ;
match agent_log ::read_log ( & log_path ) {
Ok ( entries ) = > {
let events : Vec < serde_json ::Value > = entries
. into_iter ( )
. map ( | e | {
let mut val = e . event ;
if let serde_json ::Value ::Object ( ref mut map ) = val {
map . insert (
" timestamp " . to_string ( ) ,
serde_json ::Value ::String ( e . timestamp ) ,
) ;
}
val
} )
. collect ( ) ;
let count = events . len ( ) ;
serde_json ::to_string_pretty ( & json! ( {
" events " : events ,
" done " : true ,
" event_count " : count ,
" message " : " Events loaded from persistent log file. " ,
" source " : " log_file " ,
" log_file " : log_path . to_string_lossy ( ) ,
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
Err ( e ) = > Err ( format! ( " Failed to read log file: {e} " ) ) ,
}
}
pub ( super ) fn tool_get_agent_config ( ctx : & AppContext ) -> Result < String , String > {
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
let config = ProjectConfig ::load ( & project_root ) ? ;
// Collect available (idle) agent names across all stages so the caller can
// see at a glance which agents are free to start (story 190).
let mut available_names : std ::collections ::HashSet < String > =
std ::collections ::HashSet ::new ( ) ;
for stage in & [
PipelineStage ::Coder ,
PipelineStage ::Qa ,
PipelineStage ::Mergemaster ,
PipelineStage ::Other ,
] {
if let Ok ( names ) = ctx . agents . available_agents_for_stage ( & config , stage ) {
available_names . extend ( names ) ;
}
}
serde_json ::to_string_pretty ( & json! ( config
. agent
. iter ( )
. map ( | a | json! ( {
" name " : a . name ,
" role " : a . role ,
" model " : a . model ,
" allowed_tools " : a . allowed_tools ,
" max_turns " : a . max_turns ,
" max_budget_usd " : a . max_budget_usd ,
" available " : available_names . contains ( & a . name ) ,
} ) )
. collect ::< Vec < _ > > ( ) ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
pub ( super ) async fn tool_wait_for_agent ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let agent_name = args
. get ( " agent_name " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: agent_name " ) ? ;
let timeout_ms = args
. get ( " timeout_ms " )
. and_then ( | v | v . as_u64 ( ) )
. unwrap_or ( 300_000 ) ; // default: 5 minutes
let info = ctx
. agents
. wait_for_agent ( story_id , agent_name , timeout_ms )
. await ? ;
let commits = match ( & info . worktree_path , & info . base_branch ) {
( Some ( wt_path ) , Some ( base ) ) = > get_worktree_commits ( wt_path , base ) . await ,
_ = > None ,
} ;
let completion = info . completion . as_ref ( ) . map ( | r | json! ( {
" summary " : r . summary ,
" gates_passed " : r . gates_passed ,
" gate_output " : r . gate_output ,
} ) ) ;
serde_json ::to_string_pretty ( & json! ( {
" story_id " : info . story_id ,
" agent_name " : info . agent_name ,
" status " : info . status . to_string ( ) ,
" session_id " : info . session_id ,
" worktree_path " : info . worktree_path ,
" base_branch " : info . base_branch ,
" commits " : commits ,
" completion " : completion ,
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
pub ( super ) async fn tool_create_worktree ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
let info = ctx . agents . create_worktree ( & project_root , story_id ) . await ? ;
serde_json ::to_string_pretty ( & json! ( {
" story_id " : story_id ,
" worktree_path " : info . path . to_string_lossy ( ) ,
" branch " : info . branch ,
" base_branch " : info . base_branch ,
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
pub ( super ) fn tool_list_worktrees ( ctx : & AppContext ) -> Result < String , String > {
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
let entries = worktree ::list_worktrees ( & project_root ) ? ;
serde_json ::to_string_pretty ( & json! ( entries
. iter ( )
. map ( | e | json! ( {
" story_id " : e . story_id ,
" path " : e . path . to_string_lossy ( ) ,
} ) )
. collect ::< Vec < _ > > ( ) ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
pub ( super ) async fn tool_remove_worktree ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
let config = ProjectConfig ::load ( & project_root ) ? ;
worktree ::remove_worktree_by_story_id ( & project_root , story_id , & config ) . await ? ;
Ok ( format! ( " Worktree for story ' {story_id} ' removed. " ) )
}
pub ( super ) fn tool_get_editor_command ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let worktree_path = args
. get ( " worktree_path " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: worktree_path " ) ? ;
let editor = get_editor_command_from_store ( ctx )
. ok_or_else ( | | " No editor configured. Set one via PUT /api/settings/editor. " . to_string ( ) ) ? ;
Ok ( format! ( " {editor} {worktree_path} " ) )
}
/// Run `git log <base>..HEAD --oneline` in the worktree and return the commit
/// summaries, or `None` if git is unavailable or there are no new commits.
pub ( super ) async fn get_worktree_commits ( worktree_path : & str , base_branch : & str ) -> Option < Vec < String > > {
let wt = worktree_path . to_string ( ) ;
let base = base_branch . to_string ( ) ;
tokio ::task ::spawn_blocking ( move | | {
let output = std ::process ::Command ::new ( " git " )
. args ( [ " log " , & format! ( " {base} ..HEAD " ) , " --oneline " ] )
. current_dir ( & wt )
. output ( )
. ok ( ) ? ;
if output . status . success ( ) {
let lines : Vec < String > = String ::from_utf8 ( output . stdout )
. ok ( ) ?
. lines ( )
. filter ( | l | ! l . is_empty ( ) )
. map ( | l | l . to_string ( ) )
. collect ( ) ;
Some ( lines )
} else {
None
}
} )
. await
. ok ( )
. flatten ( )
}
#[ cfg(test) ]
mod tests {
use super ::* ;
2026-03-28 19:47:59 +00:00
use crate ::http ::test_helpers ::test_ctx ;
2026-03-22 19:07:07 +00:00
use crate ::store ::StoreOps ;
#[ test ]
fn tool_list_agents_empty ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_list_agents ( & ctx ) . unwrap ( ) ;
let parsed : Vec < Value > = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert! ( parsed . is_empty ( ) ) ;
}
#[ test ]
fn tool_get_agent_config_no_project_toml_returns_default_agent ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
// No project.toml → default config with one fallback agent
let result = tool_get_agent_config ( & ctx ) . unwrap ( ) ;
let parsed : Vec < Value > = serde_json ::from_str ( & result ) . unwrap ( ) ;
// Default config contains one agent entry with default values
assert_eq! ( parsed . len ( ) , 1 , " default config should have one fallback agent " ) ;
assert! ( parsed [ 0 ] . get ( " name " ) . is_some ( ) ) ;
assert! ( parsed [ 0 ] . get ( " role " ) . is_some ( ) ) ;
}
#[ tokio::test ]
async fn tool_get_agent_output_poll_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_get_agent_output_poll ( & json! ( { " agent_name " : " bot " } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
#[ tokio::test ]
async fn tool_get_agent_output_poll_missing_agent_name ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result =
tool_get_agent_output_poll ( & json! ( { " story_id " : " 1_test " } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " agent_name " ) ) ;
}
#[ tokio::test ]
async fn tool_get_agent_output_poll_no_agent_falls_back_to_empty_log ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
// No agent registered, no log file → returns empty response from log fallback
let result = tool_get_agent_output_poll (
& json! ( { " story_id " : " 99_nope " , " agent_name " : " bot " } ) ,
& ctx ,
)
. await
. unwrap ( ) ;
let parsed : Value = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert_eq! ( parsed [ " done " ] , true ) ;
assert_eq! ( parsed [ " event_count " ] , 0 ) ;
assert! (
parsed [ " message " ] . as_str ( ) . unwrap_or ( " " ) . contains ( " No agent " ) ,
" expected 'No agent' message: {parsed} "
) ;
}
#[ tokio::test ]
async fn tool_get_agent_output_poll_with_running_agent_returns_empty_events ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
// Inject a running agent — no events broadcast yet
ctx . agents
. inject_test_agent ( " 10_story " , " worker " , crate ::agents ::AgentStatus ::Running ) ;
let result = tool_get_agent_output_poll (
& json! ( { " story_id " : " 10_story " , " agent_name " : " worker " } ) ,
& ctx ,
)
. await
. unwrap ( ) ;
let parsed : Value = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert_eq! ( parsed [ " done " ] , false ) ;
assert_eq! ( parsed [ " event_count " ] , 0 ) ;
assert! ( parsed [ " events " ] . is_array ( ) ) ;
}
#[ tokio::test ]
async fn tool_stop_agent_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_stop_agent ( & json! ( { " agent_name " : " bot " } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
#[ tokio::test ]
async fn tool_stop_agent_missing_agent_name ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_stop_agent ( & json! ( { " story_id " : " 1_test " } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " agent_name " ) ) ;
}
#[ tokio::test ]
async fn tool_start_agent_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_start_agent ( & json! ( { } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
#[ tokio::test ]
async fn tool_start_agent_no_agent_name_no_coder_returns_clear_error ( ) {
// Config has only a supervisor — start_agent without agent_name should
// refuse rather than silently assigning supervisor.
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let sk = tmp . path ( ) . join ( " .storkit " ) ;
std ::fs ::create_dir_all ( & sk ) . unwrap ( ) ;
std ::fs ::write (
sk . join ( " project.toml " ) ,
r # "
[[agent]]
name = "supervisor"
stage = "other"
"# ,
)
. unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_start_agent ( & json! ( { " story_id " : " 42_my_story " } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
let err = result . unwrap_err ( ) ;
assert! (
err . contains ( " coder " ) ,
" error should mention 'coder', got: {err} "
) ;
}
#[ tokio::test ]
async fn tool_start_agent_no_agent_name_picks_coder_not_supervisor ( ) {
// Config has supervisor first, then coder-1. Without agent_name the
// coder should be selected, not supervisor. The call will fail due to
// missing git repo / worktree, but the error must NOT be about
// "No coder agent configured".
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let sk = tmp . path ( ) . join ( " .storkit " ) ;
std ::fs ::create_dir_all ( & sk ) . unwrap ( ) ;
std ::fs ::write (
sk . join ( " project.toml " ) ,
r # "
[[agent]]
name = "supervisor"
stage = "other"
[[agent]]
name = "coder-1"
stage = "coder"
"# ,
)
. unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_start_agent ( & json! ( { " story_id " : " 42_my_story " } ) , & ctx ) . await ;
// May succeed or fail for infrastructure reasons (no git repo), but
// must NOT fail with "No coder agent configured".
if let Err ( err ) = result {
assert! (
! err . contains ( " No coder agent configured " ) ,
" should not fail on agent selection, got: {err} "
) ;
// Should also not complain about supervisor being absent.
assert! (
! err . contains ( " supervisor " ) ,
" should not select supervisor, got: {err} "
) ;
}
}
#[ tokio::test ]
async fn tool_create_worktree_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_create_worktree ( & json! ( { } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
#[ tokio::test ]
async fn tool_remove_worktree_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_remove_worktree ( & json! ( { } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
#[ test ]
fn tool_list_worktrees_empty_dir ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_list_worktrees ( & ctx ) . unwrap ( ) ;
let parsed : Vec < Value > = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert! ( parsed . is_empty ( ) ) ;
}
// ── Editor command tool tests ─────────────────────────────────
#[ test ]
fn tool_get_editor_command_missing_worktree_path ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_get_editor_command ( & json! ( { } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " worktree_path " ) ) ;
}
#[ test ]
fn tool_get_editor_command_no_editor_configured ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_get_editor_command (
& json! ( { " worktree_path " : " /some/path " } ) ,
& ctx ,
) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " No editor configured " ) ) ;
}
#[ test ]
fn tool_get_editor_command_formats_correctly ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
ctx . store . set ( " editor_command " , json! ( " zed " ) ) ;
let result = tool_get_editor_command (
& json! ( { " worktree_path " : " /home/user/worktrees/37_my_story " } ) ,
& ctx ,
)
. unwrap ( ) ;
assert_eq! ( result , " zed /home/user/worktrees/37_my_story " ) ;
}
#[ test ]
fn tool_get_editor_command_works_with_vscode ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
ctx . store . set ( " editor_command " , json! ( " code " ) ) ;
let result = tool_get_editor_command (
& json! ( { " worktree_path " : " /path/to/worktree " } ) ,
& ctx ,
)
. unwrap ( ) ;
assert_eq! ( result , " code /path/to/worktree " ) ;
}
#[ test ]
fn get_editor_command_in_tools_list ( ) {
use super ::super ::{ handle_tools_list } ;
let resp = handle_tools_list ( Some ( json! ( 1 ) ) ) ;
let tools = resp . result . unwrap ( ) [ " tools " ] . as_array ( ) . unwrap ( ) . clone ( ) ;
let tool = tools . iter ( ) . find ( | t | t [ " name " ] = = " get_editor_command " ) ;
assert! ( tool . is_some ( ) , " get_editor_command missing from tools list " ) ;
let t = tool . unwrap ( ) ;
assert! ( t [ " description " ] . is_string ( ) ) ;
let required = t [ " inputSchema " ] [ " required " ] . as_array ( ) . unwrap ( ) ;
let req_names : Vec < & str > = required . iter ( ) . map ( | v | v . as_str ( ) . unwrap ( ) ) . collect ( ) ;
assert! ( req_names . contains ( & " worktree_path " ) ) ;
}
#[ tokio::test ]
async fn wait_for_agent_tool_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_wait_for_agent ( & json! ( { " agent_name " : " bot " } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
#[ tokio::test ]
async fn wait_for_agent_tool_missing_agent_name ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_wait_for_agent ( & json! ( { " story_id " : " 1_test " } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " agent_name " ) ) ;
}
#[ tokio::test ]
async fn wait_for_agent_tool_nonexistent_agent_returns_error ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result =
tool_wait_for_agent ( & json! ( { " story_id " : " 99_nope " , " agent_name " : " bot " , " timeout_ms " : 50 } ) , & ctx )
. await ;
// No agent registered — should error
assert! ( result . is_err ( ) ) ;
}
#[ tokio::test ]
async fn wait_for_agent_tool_returns_completed_agent ( ) {
use crate ::agents ::AgentStatus ;
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
ctx . agents
. inject_test_agent ( " 41_story " , " worker " , AgentStatus ::Completed ) ;
let result = tool_wait_for_agent (
& json! ( { " story_id " : " 41_story " , " agent_name " : " worker " } ) ,
& ctx ,
)
. await
. unwrap ( ) ;
let parsed : Value = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert_eq! ( parsed [ " status " ] , " completed " ) ;
assert_eq! ( parsed [ " story_id " ] , " 41_story " ) ;
assert_eq! ( parsed [ " agent_name " ] , " worker " ) ;
// commits key present (may be null since no real worktree)
assert! ( parsed . get ( " commits " ) . is_some ( ) ) ;
// completion key present (null for agents that didn't call report_completion)
assert! ( parsed . get ( " completion " ) . is_some ( ) ) ;
}
#[ test ]
fn wait_for_agent_tool_in_list ( ) {
use super ::super ::{ handle_tools_list } ;
let resp = handle_tools_list ( Some ( json! ( 1 ) ) ) ;
let tools = resp . result . unwrap ( ) [ " tools " ] . as_array ( ) . unwrap ( ) . clone ( ) ;
let wait_tool = tools . iter ( ) . find ( | t | t [ " name " ] = = " wait_for_agent " ) ;
assert! ( wait_tool . is_some ( ) , " wait_for_agent missing from tools list " ) ;
let t = wait_tool . unwrap ( ) ;
assert! ( t [ " description " ] . as_str ( ) . unwrap ( ) . contains ( " block " ) | | t [ " description " ] . as_str ( ) . unwrap ( ) . contains ( " Block " ) ) ;
let required = t [ " inputSchema " ] [ " required " ] . as_array ( ) . unwrap ( ) ;
let req_names : Vec < & str > = required . iter ( ) . map ( | v | v . as_str ( ) . unwrap ( ) ) . collect ( ) ;
assert! ( req_names . contains ( & " story_id " ) ) ;
assert! ( req_names . contains ( & " agent_name " ) ) ;
}
#[ test ]
fn read_coverage_percent_from_json_parses_llvm_cov_format ( ) {
use std ::fs ;
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let cov_dir = tmp . path ( ) . join ( " .storkit/coverage " ) ;
fs ::create_dir_all ( & cov_dir ) . unwrap ( ) ;
let json_content = r # "{"data":[{"totals":{"lines":{"count":100,"covered":78,"percent":78.0}}}]}"# ;
fs ::write ( cov_dir . join ( " server.json " ) , json_content ) . unwrap ( ) ;
let pct = read_coverage_percent_from_json ( tmp . path ( ) ) ;
assert_eq! ( pct , Some ( 78.0 ) ) ;
}
#[ test ]
fn read_coverage_percent_from_json_returns_none_when_absent ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let pct = read_coverage_percent_from_json ( tmp . path ( ) ) ;
assert! ( pct . is_none ( ) ) ;
}
}