2026-04-29 04:47:16 +02:00
use crate ::ai_agents ::{ AiAgentPermissionMode , AiAgentStreamEvent } ;
use serde ::Deserialize ;
2026-05-02 01:08:09 +02:00
use std ::ffi ::OsString ;
2026-04-29 04:47:16 +02:00
use std ::io ::BufRead ;
2026-04-30 15:01:35 +02:00
use std ::path ::{ Path , PathBuf } ;
2026-04-29 04:47:16 +02:00
use std ::process ::{ Command , ExitStatus } ;
#[ derive(Debug, Clone, Deserialize) ]
pub struct AgentStreamRequest {
pub message : String ,
pub system_prompt : Option < String > ,
pub vault_path : String ,
pub permission_mode : AiAgentPermissionMode ,
}
pub ( crate ) struct JsonLineRun {
pub session_id : String ,
pub stderr_output : String ,
pub status : ExitStatus ,
}
pub ( crate ) fn build_prompt ( message : & str , system_prompt : Option < & str > ) -> String {
match system_prompt
. map ( str ::trim )
. filter ( | prompt | ! prompt . is_empty ( ) )
{
Some ( system_prompt ) = > {
format! ( " System instructions: \n {system_prompt} \n \n User request: \n {message} " )
}
None = > message . to_string ( ) ,
}
}
pub ( crate ) fn mcp_server_path_string ( ) -> Result < String , String > {
Ok ( crate ::mcp ::mcp_server_dir ( ) ?
. join ( " index.js " )
. to_str ( )
. ok_or ( " Invalid MCP server path " ) ?
. to_string ( ) )
}
pub ( crate ) fn version_for_binary ( binary : & PathBuf ) -> Option < String > {
2026-05-02 01:08:09 +02:00
let mut command = crate ::hidden_command ( binary ) ;
configure_agent_command_environment ( & mut command , binary ) ;
command
2026-04-29 04:47:16 +02:00
. arg ( " --version " )
. output ( )
. ok ( )
. filter ( | output | output . status . success ( ) )
. map ( | output | String ::from_utf8_lossy ( & output . stdout ) . trim ( ) . to_string ( ) )
}
2026-05-02 01:08:09 +02:00
pub ( crate ) fn configure_agent_command_environment ( command : & mut Command , binary : & Path ) {
if let Some ( path ) = expanded_agent_path ( binary ) {
command . env ( " PATH " , path ) ;
}
}
fn expanded_agent_path ( binary : & Path ) -> Option < OsString > {
let mut paths = std ::env ::var_os ( " PATH " )
. map ( | path | std ::env ::split_paths ( & path ) . collect ::< Vec < _ > > ( ) )
. unwrap_or_default ( ) ;
for candidate in agent_path_candidates ( binary ) {
push_unique_path ( & mut paths , candidate ) ;
}
std ::env ::join_paths ( paths ) . ok ( )
}
fn agent_path_candidates ( binary : & Path ) -> Vec < PathBuf > {
let mut candidates = Vec ::new ( ) ;
if let Some ( parent ) = binary
. parent ( )
. filter ( | parent | ! parent . as_os_str ( ) . is_empty ( ) )
{
candidates . push ( parent . to_path_buf ( ) ) ;
}
if let Some ( home ) = dirs ::home_dir ( ) {
candidates . extend ( [
home . join ( " .local/bin " ) ,
home . join ( " .local/share/mise/shims " ) ,
home . join ( " .asdf/shims " ) ,
home . join ( " .npm-global/bin " ) ,
home . join ( " .npm/bin " ) ,
home . join ( " .bun/bin " ) ,
home . join ( " .linuxbrew/bin " ) ,
home . join ( " AppData/Roaming/npm " ) ,
home . join ( " AppData/Local/pnpm " ) ,
home . join ( " scoop/shims " ) ,
] ) ;
}
candidates . extend ( [
PathBuf ::from ( " /opt/homebrew/bin " ) ,
PathBuf ::from ( " /usr/local/bin " ) ,
PathBuf ::from ( " /home/linuxbrew/.linuxbrew/bin " ) ,
] ) ;
candidates
}
fn push_unique_path ( paths : & mut Vec < PathBuf > , candidate : PathBuf ) {
if candidate . as_os_str ( ) . is_empty ( ) {
return ;
}
if paths . iter ( ) . any ( | path | path = = & candidate ) {
return ;
}
paths . push ( candidate ) ;
}
2026-04-30 15:01:35 +02:00
pub ( crate ) fn find_executable_binary_candidate (
candidates : Vec < PathBuf > ,
agent_label : & str ,
) -> Result < Option < PathBuf > , String > {
let mut first_unusable_candidate = None ;
for candidate in candidates {
if ! candidate . exists ( ) {
continue ;
}
if is_executable_file ( & candidate ) {
return Ok ( Some ( candidate ) ) ;
}
if first_unusable_candidate . is_none ( ) {
first_unusable_candidate = Some ( candidate ) ;
}
}
match first_unusable_candidate {
Some ( candidate ) = > Err ( format! (
" {agent_label} binary found at {} but it is not executable. Fix the file permissions or reinstall the CLI. " ,
candidate . display ( )
) ) ,
None = > Ok ( None ) ,
}
}
fn is_executable_file ( path : & Path ) -> bool {
#[ cfg(unix) ]
{
use std ::os ::unix ::fs ::PermissionsExt ;
std ::fs ::metadata ( path )
. map ( | metadata | metadata . is_file ( ) & & metadata . permissions ( ) . mode ( ) & 0o111 ! = 0 )
. unwrap_or ( false )
}
#[ cfg(not(unix)) ]
{
path . is_file ( )
}
}
2026-04-29 04:47:16 +02:00
pub ( crate ) fn parse_json_line (
line : Result < String , std ::io ::Error > ,
) -> Result < Option < serde_json ::Value > , String > {
let line = line . map_err ( | error | format! ( " Read error: {error} " ) ) ? ;
let trimmed = line . trim ( ) ;
if trimmed . is_empty ( ) {
return Ok ( None ) ;
}
Ok ( serde_json ::from_str ::< serde_json ::Value > ( trimmed ) . ok ( ) )
}
#[ cfg(test) ]
pub ( crate ) fn parse_ai_agent_json_line < F > (
line : Result < String , std ::io ::Error > ,
emit : & mut F ,
) -> Option < serde_json ::Value >
where
F : FnMut ( AiAgentStreamEvent ) ,
{
match parse_json_line ( line ) {
Ok ( json ) = > json ,
Err ( message ) = > {
emit ( AiAgentStreamEvent ::Error { message } ) ;
None
}
}
}
pub ( crate ) fn run_json_line_process < Event , F , H > (
mut command : Command ,
process_name : & 'static str ,
emit : & mut F ,
error_event : impl Fn ( String ) -> Event ,
mut handle_json : H ,
) -> Result < JsonLineRun , String >
where
F : FnMut ( Event ) ,
H : FnMut ( & serde_json ::Value , & mut F , & mut String ) ,
{
let mut child = command
. spawn ( )
2026-05-02 01:08:09 +02:00
. map_err ( | error | format_spawn_error ( process_name , & error ) ) ? ;
2026-04-29 04:47:16 +02:00
let stdout = child . stdout . take ( ) . ok_or ( " No stdout handle " ) ? ;
let reader = std ::io ::BufReader ::new ( stdout ) ;
let mut session_id = String ::new ( ) ;
for line in reader . lines ( ) {
match parse_json_line ( line ) {
Ok ( Some ( json ) ) = > handle_json ( & json , emit , & mut session_id ) ,
Ok ( None ) = > { }
Err ( message ) = > {
emit ( error_event ( message ) ) ;
break ;
}
}
}
let stderr_output = child
. stderr
. take ( )
. and_then ( | stderr | std ::io ::read_to_string ( stderr ) . ok ( ) )
. unwrap_or_default ( ) ;
let status = child
. wait ( )
. map_err ( | error | format! ( " Wait failed: {error} " ) ) ? ;
Ok ( JsonLineRun {
session_id ,
stderr_output ,
status ,
} )
}
2026-05-02 01:08:09 +02:00
fn format_spawn_error ( process_name : & str , error : & std ::io ::Error ) -> String {
if error . kind ( ) = = std ::io ::ErrorKind ::NotFound {
return format! (
" Failed to start {process_name}: the CLI or one of its runtime dependencies was not found. If it was installed with Homebrew, make sure /opt/homebrew/bin or /usr/local/bin contains the CLI and Node.js, then restart Tolaria. Details: {error} "
) ;
}
format! ( " Failed to spawn {process_name} : {error} " )
}
2026-04-29 04:47:16 +02:00
pub ( crate ) fn run_ai_agent_json_stream < F > (
command : Command ,
process_name : & 'static str ,
mut emit : F ,
session_id : impl Fn ( & serde_json ::Value ) -> Option < & str > ,
dispatch_event : impl Fn ( & serde_json ::Value , & mut F ) ,
format_error : impl Fn ( String , String ) -> String ,
) -> Result < String , String >
where
F : FnMut ( AiAgentStreamEvent ) ,
{
let run = run_json_line_process (
command ,
process_name ,
& mut emit ,
| message | AiAgentStreamEvent ::Error { message } ,
| json , emit , active_session_id | {
if let Some ( id ) = session_id ( json ) {
* active_session_id = id . to_string ( ) ;
}
dispatch_event ( json , emit ) ;
} ,
) ? ;
if ! run . status . success ( ) {
emit ( AiAgentStreamEvent ::Error {
message : format_error ( run . stderr_output , run . status . to_string ( ) ) ,
} ) ;
}
emit ( AiAgentStreamEvent ::Done ) ;
Ok ( run . session_id )
}
#[ cfg(test) ]
mod tests {
use super ::* ;
#[ test ]
fn build_prompt_keeps_system_prompt_first ( ) {
let prompt = build_prompt ( " Rename the note " , Some ( " Be concise " ) ) ;
assert! ( prompt . starts_with ( " System instructions: \n Be concise " ) ) ;
assert! ( prompt . contains ( " User request: \n Rename the note " ) ) ;
}
#[ test ]
fn build_prompt_skips_blank_system_prompt ( ) {
assert_eq! (
build_prompt ( " Rename the note " , Some ( " " ) ) ,
" Rename the note "
) ;
}
#[ test ]
fn parse_json_line_reports_read_errors_and_skips_blank_or_invalid_lines ( ) {
assert! ( parse_json_line ( Ok ( " " . into ( ) ) ) . unwrap ( ) . is_none ( ) ) ;
assert! ( parse_json_line ( Ok ( " not json " . into ( ) ) ) . unwrap ( ) . is_none ( ) ) ;
let error = parse_json_line ( Err ( std ::io ::Error ::other ( " broken pipe " ) ) ) . unwrap_err ( ) ;
assert! ( error . contains ( " broken pipe " ) ) ;
}
2026-04-30 15:01:35 +02:00
2026-05-02 01:08:09 +02:00
#[ test ]
fn agent_command_environment_keeps_homebrew_shims_available ( ) {
let mut command = Command ::new ( " /opt/homebrew/bin/codex " ) ;
configure_agent_command_environment ( & mut command , Path ::new ( " /opt/homebrew/bin/codex " ) ) ;
let path = command
. get_envs ( )
. find ( | ( key , _ ) | * key = = std ::ffi ::OsStr ::new ( " PATH " ) )
. and_then ( | ( _ , value ) | value )
. expect ( " PATH should be set " ) ;
let paths = std ::env ::split_paths ( path ) . collect ::< Vec < _ > > ( ) ;
assert! ( paths . contains ( & PathBuf ::from ( " /opt/homebrew/bin " ) ) ) ;
assert! ( paths . contains ( & PathBuf ::from ( " /usr/local/bin " ) ) ) ;
}
#[ test ]
fn spawn_not_found_errors_explain_gui_path_runtime_dependencies ( ) {
let error = std ::io ::Error ::new ( std ::io ::ErrorKind ::NotFound , " No such file or directory " ) ;
let message = format_spawn_error ( " codex " , & error ) ;
assert! ( message . contains ( " Failed to start codex " ) ) ;
assert! ( message . contains ( " /opt/homebrew/bin " ) ) ;
assert! ( message . contains ( " Node.js " ) ) ;
}
2026-04-30 15:01:35 +02:00
#[ cfg(unix) ]
#[ test ]
fn executable_binary_candidate_skips_unusable_file_when_later_candidate_works ( ) {
use std ::os ::unix ::fs ::PermissionsExt ;
let dir = tempfile ::tempdir ( ) . unwrap ( ) ;
let unusable = dir . path ( ) . join ( " codex-unusable " ) ;
let executable = dir . path ( ) . join ( " codex " ) ;
std ::fs ::write ( & unusable , " #!/bin/sh \n " ) . unwrap ( ) ;
std ::fs ::write ( & executable , " #!/bin/sh \n " ) . unwrap ( ) ;
std ::fs ::set_permissions ( & unusable , std ::fs ::Permissions ::from_mode ( 0o644 ) ) . unwrap ( ) ;
std ::fs ::set_permissions ( & executable , std ::fs ::Permissions ::from_mode ( 0o755 ) ) . unwrap ( ) ;
let found =
find_executable_binary_candidate ( vec! [ unusable , executable . clone ( ) ] , " Codex CLI " )
. unwrap ( ) ;
assert_eq! ( found , Some ( executable ) ) ;
}
#[ cfg(unix) ]
#[ test ]
fn executable_binary_candidate_reports_unusable_file_when_no_candidate_works ( ) {
use std ::os ::unix ::fs ::PermissionsExt ;
let dir = tempfile ::tempdir ( ) . unwrap ( ) ;
let unusable = dir . path ( ) . join ( " opencode " ) ;
std ::fs ::write ( & unusable , " #!/bin/sh \n " ) . unwrap ( ) ;
std ::fs ::set_permissions ( & unusable , std ::fs ::Permissions ::from_mode ( 0o644 ) ) . unwrap ( ) ;
let error =
find_executable_binary_candidate ( vec! [ unusable . clone ( ) ] , " OpenCode CLI " ) . unwrap_err ( ) ;
assert! ( error . contains ( " OpenCode CLI binary found " ) ) ;
assert! ( error . contains ( & unusable . display ( ) . to_string ( ) ) ) ;
assert! ( error . contains ( " not executable " ) ) ;
}
2026-04-29 04:47:16 +02:00
}