@@ -112,7 +112,47 @@ fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Resul
// --- tool mapping -----------------------------------------------------------------------
/// Claude Code tool → (our tool name, input with our key names), so the chat renders it like ours.
/// Claude Code's `AskUserQuestion` input → our questions (it writes `multiSelect`).
fn cc_questions ( input : & Value ) -> Vec < crate ::types ::Question > {
input [ " questions " ]
. as_array ( )
. into_iter ( )
. flatten ( )
. filter_map ( | q | {
let question = q [ " question " ] . as_str ( ) ? . to_string ( ) ;
let options = q [ " options " ]
. as_array ( )
. into_iter ( )
. flatten ( )
. filter_map ( | o | Some ( crate ::types ::QuestionOption { label : o [ " label " ] . as_str ( ) ? . to_string ( ) , description : o [ " description " ] . as_str ( ) . unwrap_or ( " " ) . to_string ( ) } ) )
. collect ( ) ;
Some ( crate ::types ::Question { question , header : q [ " header " ] . as_str ( ) . unwrap_or ( " " ) . to_string ( ) , options , multi_select : q [ " multiSelect " ] . as_bool ( ) . or ( q [ " multi_select " ] . as_bool ( ) ) . unwrap_or ( false ) } )
} )
. collect ( )
}
/// Our answers (question → text or list) as the CLI expects them: question → text, lists joined by ", ".
fn cc_answers ( answers : & serde_json ::Map < String , Value > ) -> Value {
Value ::Object (
answers
. iter ( )
. map ( | ( q , a ) | {
let text = match a {
Value ::String ( s ) = > s . clone ( ) ,
Value ::Array ( l ) = > l . iter ( ) . filter_map ( | x | x . as_str ( ) ) . collect ::< Vec < _ > > ( ) . join ( " , " ) ,
other = > other . to_string ( ) ,
} ;
( q . clone ( ) , Value ::String ( text ) )
} )
. collect ( ) ,
)
}
fn map_tool ( name : & str , input : & Value ) -> ( String , Value ) {
// Questions to the user show up as our question card / ask_user call.
if name = = " AskUserQuestion " {
return ( " ask_user " . to_string ( ) , json! ( { " questions " : cc_questions ( input ) } ) ) ;
}
// Our own tools reach the CLI over MCP: shown and judged under their usual names.
if let Some ( ours ) = name . strip_prefix ( crate ::mcp_bridge ::PREFIX ) {
return ( ours . to_string ( ) , input . clone ( ) ) ;
@@ -194,9 +234,6 @@ fn title_of(name: &str, input: &Value, claude_input: &Value) -> String {
/// Ask our permission system whether a tool call of Claude Code may run: (allow, message for a rejection).
async fn decide ( app : & Arc < App > , session : & Session , claude_name : & str , claude_input : & Value , cancel : & CancellationToken ) -> ( bool , Option < String > ) {
let ( name , input ) = map_tool ( claude_name , claude_input ) ;
if claude_name = = " AskUserQuestion " {
return ( false , Some ( " Questions to the user are not available in this mode – decide yourself or state your assumption. " . into ( ) ) ) ;
}
let info = CcTool { name : name . clone ( ) } ;
let cfg = app . config ( ) ;
let root = app . root_session_id ( & session . id ) ;
@@ -324,7 +361,12 @@ fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> {
let mut out = Vec ::new ( ) ;
for ( i , p ) in parts . iter ( ) . enumerate ( ) {
let Part ::Image { image } = p else { continue } ;
// An image without bytes (an unresolved reference) would become an empty file.
let Ok ( bytes ) = base64 ::engine ::general_purpose ::STANDARD . decode ( & image . data ) else { continue } ;
if bytes . is_empty ( ) {
tracing ::warn! ( " claude code: image {i} of the message has no data – not saved " ) ;
continue ;
}
let ext = image . media_type . split ( '/' ) . nth ( 1 ) . unwrap_or ( " png " ) . split ( '+' ) . next ( ) . unwrap_or ( " png " ) ;
let path = dir . join ( format! ( " chat- {} - {i} . {ext} " , now_ms ( ) ) ) ;
if std ::fs ::create_dir_all ( & dir ) . is_ok ( ) {
@@ -342,7 +384,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
let session = app . session ( session_id ) ? ;
let owner = session . owner_id . clone ( ) ;
let model_id = session . model . strip_prefix ( " claude-code/ " ) . unwrap_or ( " default " ) . to_string ( ) ;
let history = crate ::runner ::active_history ( app . store . messages ( session_id ) ? ) ;
// With the bytes of the images filled in (they are stored as references).
let history = crate ::runner ::active_history ( app . store . messages_for_model ( session_id ) ? ) ;
let Some ( last_user ) = history . iter ( ) . rposition ( | m | m . role = = Role ::User & & m . parts . iter ( ) . any ( | p | matches! ( p , Part ::Text { .. } | Part ::Image { .. } ) ) ) else {
bail! ( " nothing to do: no user message " ) ;
} ;
@@ -417,6 +460,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
let mut cur : Option < Cur > = None ;
let mut calls : HashMap < String , String > = HashMap ::new ( ) ;
let mut diffs : HashMap < String , String > = HashMap ::new ( ) ;
// Answers to AskUserQuestion calls, kept as the result's data for the question card.
let mut asked : HashMap < String , Value > = HashMap ::new ( ) ;
let mut final_text = String ::new ( ) ;
let mut got_result = false ;
let mut failure : Option < String > = None ;
@@ -498,7 +543,7 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
}
let id = b [ " tool_use_id " ] . as_str ( ) . unwrap_or ( " " ) . to_string ( ) ;
let is_error = b [ " is_error " ] . as_bool ( ) . unwrap_or ( false ) ;
let meta = if is_error { None } else { diffs . remove ( & id ) . map ( | d | json! ( { " diff " : d } ) ) } ;
let meta = if is_error { None } else { diffs . remove ( & id ) . map ( | d | json! ( { " diff " : d } ) ) . or_else ( | | asked . remove ( & id ) . map ( | a | json! ( { " answers " : a } ) ) ) } ;
parts . push ( Part ::ToolResult { name : calls . get ( & id ) . cloned ( ) . unwrap_or_default ( ) , call_id : id , content : result_text ( & b [ " content " ] ) , images : result_images ( & b [ " content " ] ) , is_error , meta } ) ;
}
if ! parts . is_empty ( ) {
@@ -511,6 +556,30 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
" control_request " if ev [ " request " ] [ " subtype " ] = = " can_use_tool " = > {
finalize ( & app , session_id , & mut cur , & model_id ) ;
let req = & ev [ " request " ] ;
// A question for the user: shown as a question card; the answers go back to the CLI.
if req [ " tool_name " ] = = " AskUserQuestion " {
let questions = cc_questions ( & req [ " input " ] ) ;
let response = if questions . is_empty ( ) {
json! ( { " behavior " : " deny " , " message " : " No usable question was given. " } )
} else {
match app . ask_user ( session_id , " agent " , questions , cancel . clone ( ) ) . await {
Ok ( Some ( answers ) ) = > {
let mut input = req [ " input " ] . clone ( ) ;
input [ " answers " ] = cc_answers ( & answers ) ;
if let Some ( id ) = req [ " tool_use_id " ] . as_str ( ) {
asked . insert ( id . to_string ( ) , Value ::Object ( answers ) ) ;
}
json! ( { " behavior " : " allow " , " updatedInput " : input } )
}
_ = > json! ( { " behavior " : " deny " , " message " : " The user dismissed the questions without answering. " } ) ,
}
} ;
let reply = json! ( { " type " : " control_response " , " response " : { " subtype " : " success " , " request_id " : ev [ " request_id " ] , " response " : response } } ) ;
if stdin . write_all ( format! ( " {reply} \n " ) . as_bytes ( ) ) . await . is_err ( ) {
break ;
}
continue ;
}
let ( allow , message ) = decide ( & app , & session , req [ " tool_name " ] . as_str ( ) . unwrap_or ( " " ) , & req [ " input " ] , & cancel ) . await ;
if allow {
let ( name , input ) = map_tool ( req [ " tool_name " ] . as_str ( ) . unwrap_or ( " " ) , & req [ " input " ] ) ;
@@ -893,6 +962,43 @@ pub async fn update() -> Result<String> {
#[ cfg(test) ]
mod tests {
#[ test ]
fn uploaded_images_are_saved_with_their_bytes_only ( ) {
let dir = std ::env ::temp_dir ( ) . join ( format! ( " cc-uploads- {} " , std ::process ::id ( ) ) ) ;
std ::fs ::create_dir_all ( & dir ) . unwrap ( ) ;
let parts = vec! [
Part ::Image { image : ImageData { media_type : " image/png " . into ( ) , data : " QUJD " . into ( ) , blob : None , size : None } } ,
// A reference whose bytes were not filled in: must not become an empty file.
Part ::Image { image : ImageData { media_type : " image/png " . into ( ) , data : String ::new ( ) , blob : Some ( " x " . into ( ) ) , size : None } } ,
] ;
let saved = save_images ( & dir . display ( ) . to_string ( ) , & parts ) ;
assert_eq! ( saved . len ( ) , 1 ) ;
assert_eq! ( std ::fs ::read ( & saved [ 0 ] ) . unwrap ( ) , b " ABC " ) ;
std ::fs ::remove_dir_all ( & dir ) . unwrap ( ) ;
}
#[ test ]
fn questions_roundtrip_with_the_cli_format ( ) {
let input = json! ( { " questions " : [
{ " question " : " Which database? " , " header " : " DB " , " multiSelect " : false , " options " : [ { " label " : " SQLite " , " description " : " file " } , { " label " : " Postgres " } ] } ,
{ " question " : " Which extras? " , " header " : " Extras " , " multiSelect " : true , " options " : [ { " label " : " Auth " } , { " label " : " Docs " } ] } ,
{ " nonsense " : true }
] } ) ;
let qs = cc_questions ( & input ) ;
assert_eq! ( qs . len ( ) , 2 ) ;
assert_eq! ( ( qs [ 0 ] . question . as_str ( ) , qs [ 0 ] . options . len ( ) , qs [ 0 ] . multi_select ) , ( " Which database? " , 2 , false ) ) ;
assert! ( qs [ 1 ] . multi_select ) ;
// The tool card shows it as our ask_user.
let ( name , shown ) = map_tool ( " AskUserQuestion " , & input ) ;
assert_eq! ( name , " ask_user " ) ;
assert_eq! ( shown [ " questions " ] [ 1 ] [ " multi_select " ] , true ) ;
// Answers: text stays, a multiple choice is joined.
let answers = json! ( { " Which database? " : " SQLite " , " Which extras? " : [ " Auth " , " Docs " ] } ) ;
let out = cc_answers ( answers . as_object ( ) . unwrap ( ) ) ;
assert_eq! ( out [ " Which database? " ] , " SQLite " ) ;
assert_eq! ( out [ " Which extras? " ] , " Auth, Docs " ) ;
}
#[ test ]
fn tool_result_images_are_kept ( ) {
let content = json! ( [ { " type " : " text " , " text " : " ok " } , { " type " : " image " , " source " : { " type " : " base64 " , " media_type " : " image/jpeg " , " data " : " QUJD " } } ] ) ;