2026-07-12 11:28:39 +07:00
//! The `Action` enum and its single dispatcher, `apply_action` — the
//! chokepoint through which every key input, streaming event, and async
//! background-thread result mutates `AppStateRest`.
//!
//! Flow: controllers/subagent threads construct `Action` values → the event
//! loop calls `apply_action(&mut state, action)` → for turn-producing
//! actions (`SubmitInput`), `spawn_turn` is kicked off on a background OS
//! thread which drives `run_agent_turn` (stream to the LLM, gate and
//! execute tool calls via `Harness`, archive messages to SQLite, log edits)
//! and pushes `TurnEvent`s onto a shared queue → on the next `Tick`, queued
//! `TurnEvent`s are drained back into `AppStateRest` (transcript, toasts,
//! usage counters).
//!
//! Why: keeping all state mutation behind one function means callers only
//! need to know how to *produce* actions, not how to update state safely;
//! running turns on plain OS threads (rather than blocking the main loop)
//! keeps the TUI responsive while the LLM streams.
2026-07-11 20:21:59 +07:00
use std ::collections ::VecDeque ;
use crate ::app ::harness ::Verdict ;
use sha2 ::Digest ;
use crate ::app ::review ::{ should_trigger_review , trigger_review };
use crate ::app ::state ::rest ::{ AppStateRest , ChatMessageDisplay };
use crate ::app ::state ::runtime ::TurnEvent ;
2026-07-12 03:14:52 +07:00
use crate ::app ::state ::types ::{ Origin , Overlay , Toast , ToastKind };
2026-07-11 20:21:59 +07:00
use crate ::dto ::chat ::message ::{ ChatMessage , Role };
2026-07-12 11:28:39 +07:00
/// A single, well-typed event in the app — produced by key input, the
/// streaming pipeline, or subagent threads — that mutates `AppStateRest`
/// when applied via `apply_action`.
///
/// Step bounds intentionally left unbounded (usize::MAX) so the agent can
/// continue across as many turns as needed. Each iteration still honours
/// `tc.abort_flag` and the per-call LLM timeout, so a runaway loop is
/// observable and cancellable from the UI.
2026-07-11 13:16:10 +07:00
#[derive(Debug, Clone)]
pub enum Action {
ForceQuit ,
SubmitInput ( String ),
DeleteChar ,
DeleteCharRight ,
CursorLeft ,
CursorRight ,
HistoryUp ,
HistoryDown ,
ScrollUp ,
ScrollDown ,
OpenOverlay ( Overlay ),
CloseOverlay ,
SystemNote {
kind : String ,
message : String ,
},
QuitConfirm ,
Resize ( u16 , u16 ),
Tick ,
2026-07-13 02:53:30 +07:00
2026-07-11 21:06:22 +07:00
LessonAccept {
name : String ,
},
LessonReject {
name : String ,
},
2026-07-13 02:53:30 +07:00
LessonDelete {
name : String ,
},
2026-07-11 23:45:13 +07:00
StartOAuth {
provider : String ,
},
2026-07-12 01:25:52 +07:00
OpenEditor {
path : String ,
},
McpAdd {
name : String ,
command : String ,
},
2026-07-12 02:06:46 +07:00
ModelList ,
2026-07-12 03:14:52 +07:00
AbortTurn ,
2026-07-12 13:40:58 +07:00
Compact ,
2026-07-12 17:49:34 +07:00
RunWorkflow {
script : String ,
},
2026-07-11 13:16:10 +07:00
}
2026-07-12 11:28:39 +07:00
/// Apply an `Action` to the application state.
///
/// Flow: pattern-match the variant → mutate `state` (input buffer, scroll
/// position, overlay, transcript, runtime, toasts, dirty flag, etc.) →
/// for `Tick`, also drain queued `TurnEvent`s and run periodic side jobs
/// (staleness sweep, pending-lesson commit).
///
/// Why: the single chokepoint that turns every typed key and async event
/// into a state change, so callers (controllers, subagent threads) only
/// need to know how to *produce* actions.
///
/// Return: nothing; `state` is mutated in place.
2026-07-11 13:16:10 +07:00
pub fn apply_action ( state : & mut AppStateRest , action : Action ) {
match action {
Action ::ForceQuit => {
2026-07-11 20:21:59 +07:00
save_current_session ( state );
2026-07-12 14:47:01 +07:00
state . shutdown_lsp ();
2026-07-11 13:16:10 +07:00
state . quit = true ;
}
2026-07-12 03:14:52 +07:00
2026-07-11 13:16:10 +07:00
Action ::SubmitInput ( text ) => {
2026-07-11 20:21:59 +07:00
state . input . submit ();
let text = text . trim (). to_string ();
if text . is_empty () {
state . dirty = true ;
return ;
2026-07-11 13:16:10 +07:00
}
2026-07-11 20:21:59 +07:00
state . push_transcript ( ChatMessageDisplay ::new ( Role ::User , text . clone ()));
if let Some ( ref mut rt ) = state . session_runtime {
rt . push_message ( ChatMessage ::user ( text ));
2026-07-12 12:21:46 +07:00
refresh_lesson_counters ( & state . memory_dir , rt );
} else {
let _ = std ::fs ::create_dir_all ( & state . memory_dir );
2026-07-11 20:21:59 +07:00
}
2026-07-11 23:45:13 +07:00
state . misc . thinking = true ;
2026-07-11 20:21:59 +07:00
spawn_turn ( state );
2026-07-11 13:16:10 +07:00
state . dirty = true ;
}
Action ::DeleteChar => {
state . input . delete_left ();
state . dirty = true ;
}
Action ::DeleteCharRight => {
state . input . delete_right ();
state . dirty = true ;
}
Action ::CursorLeft => {
state . input . char_left ();
}
Action ::CursorRight => {
state . input . char_right ();
}
Action ::HistoryUp => {
state . input . history_up ();
state . dirty = true ;
}
Action ::HistoryDown => {
state . input . history_down ();
state . dirty = true ;
}
Action ::ScrollUp => {
2026-07-12 03:14:52 +07:00
state . scroll . scroll_up ( 5 );
2026-07-11 13:16:10 +07:00
state . dirty = true ;
}
Action ::ScrollDown => {
2026-07-12 03:14:52 +07:00
state . scroll . scroll_down ( 5 );
2026-07-11 13:16:10 +07:00
state . dirty = true ;
}
Action ::OpenOverlay ( overlay ) => {
state . misc . overlay = overlay ;
2026-07-13 02:53:30 +07:00
if overlay == Overlay ::Learning || overlay == Overlay ::Rewind || overlay == Overlay ::ModelSelector {
state . misc . selected_index = 0 ;
}
2026-07-11 13:16:10 +07:00
state . dirty = true ;
}
2026-07-12 01:25:52 +07:00
Action ::OpenEditor { path } => {
let resolved = crate ::tool ::resolve_path ( & state . workspace_roots , & path );
match resolved {
Ok ( abs_path ) => {
let content = std ::fs ::read_to_string ( & abs_path )
. unwrap_or_default ();
let lines : Vec < String > = content . lines (). map ( | l | l . to_string ()). collect ();
let ed = crate ::app ::mode ::editor ::EditorState ::open (
abs_path . to_string_lossy (). to_string (),
Some ( lines ),
);
state . misc . editor = Some ( ed );
state . misc . overlay = Overlay ::Editor ;
state . push_toast ( Toast ::new ( ToastKind ::Info , format! ( "Editing {} " , path )));
}
Err ( e ) => {
state . push_toast ( Toast ::new ( ToastKind ::Error , format! ( "Failed to open {} : {} " , path , e )));
}
}
state . dirty = true ;
}
Action ::McpAdd { name , command } => {
let extra_args : Vec < String > = command . split_whitespace (). map ( | s | s . to_string ()). collect ();
let cmd = extra_args . first (). cloned (). unwrap_or_default ();
let args : Vec < String > = extra_args . into_iter (). skip ( 1 ). collect ();
match state . mcp_manager . connect_stdio ( & name , & cmd , & args ) {
Ok ( _ ) => {
let tool_count = state . mcp_manager . servers . last ()
. map ( | s | s . tools . len ())
. unwrap_or ( 0 );
state . push_toast ( Toast ::new ( ToastKind ::Success ,
format! ( "Connected MCP server ' {} ' ( {} tools)" , name , tool_count )));
state . dirty = true ;
}
Err ( e ) => {
state . push_toast ( Toast ::new ( ToastKind ::Error ,
format! ( "MCP connect failed: {} " , e )));
}
}
}
2026-07-12 02:06:46 +07:00
Action ::ModelList => {
state . misc . selected_index = 0 ;
state . misc . overlay = Overlay ::ModelSelector ;
state . dirty = true ;
}
2026-07-11 13:16:10 +07:00
Action ::CloseOverlay => {
2026-07-12 01:25:52 +07:00
// If the overlay is the Editor, dismiss it properly first
if state . misc . overlay == Overlay ::Editor {
crate ::app ::mode ::editor ::handle_editor_dismiss ( state );
}
2026-07-11 13:16:10 +07:00
state . misc . overlay = Overlay ::None ;
state . dirty = true ;
}
Action ::SystemNote { kind : _kind , message } => {
let toast = crate ::app ::state ::types ::Toast ::new (
crate ::app ::state ::types ::ToastKind ::Info ,
message ,
);
state . push_toast ( toast );
}
Action ::QuitConfirm => {
state . misc . overlay = Overlay ::QuitConfirm ;
state . dirty = true ;
}
Action ::Resize ( w , _h ) => {
state . scroll . set_max_visible ( w as usize );
state . dirty = true ;
}
2026-07-13 02:53:30 +07:00
2026-07-11 23:45:13 +07:00
Action ::StartOAuth { provider } => {
let turn_events = state . turn_events . clone ();
let provider_clone = provider . clone ();
std ::thread ::spawn ( move || {
let result = run_oauth_flow ( & provider_clone );
let message = match result {
Ok ( msg ) => msg ,
Err ( e ) => format! ( "OAuth login failed: {} " , e ),
};
if let Ok ( mut q ) = turn_events . lock () {
q . push_back ( TurnEvent ::SystemNote {
kind : "oauth" . to_string (),
message ,
});
}
});
let toast = Toast ::new ( ToastKind ::Info , format! ( "Opening browser for {} login..." , provider ));
state . push_toast ( toast );
state . dirty = true ;
}
2026-07-11 13:16:10 +07:00
Action ::Tick => {
2026-07-12 03:19:25 +07:00
state . misc . tick_count = state . misc . tick_count . wrapping_add ( 1 );
2026-07-11 13:16:10 +07:00
let now_ms = chrono ::Utc ::now (). timestamp_millis ();
state . misc . drain_expired_toasts ( now_ms );
2026-07-12 12:43:50 +07:00
if state . misc . tick_count . is_multiple_of ( 10 ) {
let todo_path = state . session_dir . join ( "todo.md" );
if let Ok ( content ) = std ::fs ::read_to_string ( & todo_path ) {
if content != state . misc . todo_content {
state . misc . todo_content = content ;
state . dirty = true ;
}
} else if ! state . misc . todo_content . is_empty () {
state . misc . todo_content . clear ();
state . dirty = true ;
}
}
2026-07-12 12:09:59 +07:00
// Background API connectivity check — runs on a background thread
// every ~1s while disconnected, every ~30s while connected, so the
// status bar reflects real API availability without user input.
let check_interval = if state . misc . api_connected { 600 } else { 20 };
2026-07-12 12:32:32 +07:00
if state . misc . tick_count . is_multiple_of ( check_interval ) {
2026-07-12 12:09:59 +07:00
spawn_api_connectivity_check ( state );
}
2026-07-11 21:06:22 +07:00
crate ::app ::review ::maybe_run_staleness_sweep ( state );
if let Some ( ref rt ) = state . session_runtime {
let _ = crate ::app ::review ::process_pending_lessons ( & rt . session_dir , & state . memory_dir );
}
2026-07-12 15:19:13 +07:00
// Drain LSP provision progress messages into toast notifications.
// Collect messages under the lock, then push toasts outside it to avoid
// a borrow-conflict with state.push_toast (which also accesses state).
let pending : Vec < String > = state . lsp_provision_msgs . lock ()
. ok ()
. map ( | mut q | q . drain ( .. ). collect ())
. unwrap_or_default ();
for msg in & pending {
let kind = if msg . contains ( "not available" ) || msg . contains ( "failed" ) {
ToastKind ::Warning
} else if msg . contains ( "connected" ) || msg . contains ( "✓" ) {
ToastKind ::Success
} else {
ToastKind ::Info
};
state . push_toast ( Toast ::new ( kind , msg . clone ()));
}
2026-07-11 21:06:22 +07:00
let events : Vec < TurnEvent > = {
2026-07-11 20:21:59 +07:00
if let Ok ( mut q ) = state . turn_events . lock () {
q . drain ( .. ). collect ()
} else {
Vec ::new ()
}
2026-07-11 13:16:10 +07:00
};
2026-07-11 20:21:59 +07:00
let mut turn_finished = false ;
for event in events {
match event {
TurnEvent ::AssistantMessage ( msg ) => {
2026-07-11 23:45:13 +07:00
state . misc . thinking = false ;
2026-07-12 02:26:53 +07:00
state . misc . api_connected = true ;
2026-07-11 20:21:59 +07:00
let display_content = msg . content . clone (). unwrap_or_default ();
if ! display_content . is_empty () {
2026-07-11 23:45:13 +07:00
state . push_transcript ( ChatMessageDisplay ::new ( Role ::Assistant , display_content ));
2026-07-11 20:21:59 +07:00
}
if let Some ( ref mut rt ) = state . session_runtime {
rt . push_message ( msg );
}
}
2026-07-11 22:40:45 +07:00
TurnEvent ::ToolResult { tool_call_id , tool_name , output , is_error , path } => {
2026-07-11 23:45:13 +07:00
state . misc . thinking = false ;
2026-07-11 22:40:45 +07:00
let display_path = path . unwrap_or_default ();
let display = if tool_name == "read" {
let line_count = output . lines (). count ();
if ! display_path . is_empty () {
format! ( "read: {} ( {} lines)" , display_path , line_count )
} else {
format! ( "read: {} line(s)" , line_count )
}
} else {
format! ( " {} : {} " , tool_name , output )
};
2026-07-11 20:21:59 +07:00
state . push_transcript ( ChatMessageDisplay ::new (
Role ::Tool ,
2026-07-11 22:40:45 +07:00
display ,
2026-07-11 20:21:59 +07:00
));
if let Some ( ref mut rt ) = state . session_runtime {
rt . push_message ( ChatMessage ::tool_result ( tool_call_id . clone (), output . clone ()));
rt . tool_call_results . push ( crate ::app ::state ::runtime ::ToolCallResult {
tool_call_id ,
tool_name ,
output ,
is_error ,
duration_ms : 0 ,
});
}
}
TurnEvent ::SystemNote { kind , message } => {
if kind == "edits" {
if let Some ( ref mut rt ) = state . session_runtime {
if let Ok ( count ) = message . parse ::< u32 > () {
rt . edit_count += count ;
}
}
if should_trigger_review ( state , Origin ::Main ) {
let _ = trigger_review ( state );
}
2026-07-11 21:06:22 +07:00
} else if kind == "review" {
2026-07-12 12:21:46 +07:00
let counted = if let Some ( ref mut rt ) = state . session_runtime {
refresh_lesson_counters ( & state . memory_dir , rt );
true
2026-07-11 21:06:22 +07:00
} else {
2026-07-12 12:21:46 +07:00
false
2026-07-11 21:06:22 +07:00
};
if let Some ( ref mut rt ) = state . session_runtime {
2026-07-12 12:21:46 +07:00
if counted {
2026-07-11 21:06:22 +07:00
rt . consecutive_empty_reviews = 0 ;
} else {
rt . consecutive_empty_reviews += 1 ;
}
}
state . push_toast ( Toast ::new ( ToastKind ::Info , message ));
2026-07-12 03:56:43 +07:00
} else if kind == "task_retry" {
state . push_transcript ( ChatMessageDisplay ::new (
crate ::dto ::chat ::message ::Role ::System ,
message . clone (),
));
state . push_toast ( Toast ::new ( ToastKind ::Info , "Auto-continuing unfinished tasks..." . to_string ()));
if let Some ( ref mut rt ) = state . session_runtime {
rt . push_message ( crate ::dto ::chat ::message ::ChatMessage ::system ( message . clone ()));
}
2026-07-12 12:09:59 +07:00
} else if kind == "connectivity" {
state . misc . api_connected = message == "connected" ;
2026-07-12 17:49:34 +07:00
} else if kind == "workflow_done" {
state . push_toast ( Toast {
kind : ToastKind ::Success ,
message : message . clone (),
created_at : chrono ::Utc ::now (). timestamp_millis (),
lifetime_ms : 10000 ,
});
state . push_transcript ( ChatMessageDisplay ::new (
crate ::dto ::chat ::message ::Role ::System ,
format! ( "✓ {} " , message ),
));
2026-07-12 18:19:43 +07:00
if state . misc . overlay == Overlay ::Workflow {
state . misc . overlay = Overlay ::None ;
}
2026-07-12 17:49:34 +07:00
state . dirty = true ;
} else if kind == "workflow_error" {
state . push_toast ( Toast {
kind : ToastKind ::Error ,
message : message . clone (),
created_at : chrono ::Utc ::now (). timestamp_millis (),
lifetime_ms : 12000 ,
});
state . push_transcript ( ChatMessageDisplay ::new (
crate ::dto ::chat ::message ::Role ::System ,
format! ( "✗ {} " , message ),
));
2026-07-12 18:19:43 +07:00
if state . misc . overlay == Overlay ::Workflow {
state . misc . overlay = Overlay ::None ;
}
2026-07-12 17:49:34 +07:00
state . dirty = true ;
2026-07-11 21:06:22 +07:00
} else {
state . push_toast ( Toast ::new ( ToastKind ::Info , message ));
2026-07-11 20:21:59 +07:00
}
}
2026-07-12 01:25:52 +07:00
TurnEvent ::StreamStart => {
state . misc . thinking = false ;
2026-07-12 02:26:53 +07:00
state . misc . api_connected = true ;
2026-07-12 01:25:52 +07:00
state . push_transcript ( ChatMessageDisplay ::new ( Role ::Assistant , String ::new ()));
}
TurnEvent ::StreamToken ( delta ) => {
if let Some ( last ) = state . transcript_cache . messages . last_mut () {
if last . role == Role ::Assistant {
last . content . push_str ( & delta );
state . transcript_cache . dirty = true ;
}
}
}
TurnEvent ::StreamDone ( msg ) => {
state . misc . thinking = false ;
if let Some ( ref mut rt ) = state . session_runtime {
rt . push_message ( msg );
}
}
TurnEvent ::Usage { tokens_in , tokens_out } => {
if let Some ( ref mut rt ) = state . session_runtime {
rt . usage . tokens_in += tokens_in ;
rt . usage . tokens_out += tokens_out ;
2026-07-12 13:40:58 +07:00
rt . usage . last_tokens_in = tokens_in ;
rt . usage . last_tokens_out = tokens_out ;
2026-07-12 01:25:52 +07:00
rt . usage . api_calls += 1 ;
}
}
2026-07-11 20:21:59 +07:00
TurnEvent ::Error ( msg ) => {
2026-07-12 02:26:53 +07:00
state . misc . api_connected = false ;
2026-07-11 22:10:17 +07:00
let long_toast = Toast {
kind : ToastKind ::Error ,
message : msg . clone (),
created_at : chrono ::Utc ::now (). timestamp_millis (),
lifetime_ms : 15000 ,
};
state . push_toast ( long_toast );
state . push_transcript ( ChatMessageDisplay ::new (
crate ::dto ::chat ::message ::Role ::System ,
format! ( "Error: {} " , msg ),
));
2026-07-11 20:21:59 +07:00
turn_finished = true ;
}
TurnEvent ::Done => {
2026-07-11 23:45:13 +07:00
state . misc . thinking = false ;
2026-07-11 20:21:59 +07:00
turn_finished = true ;
2026-07-11 13:16:10 +07:00
}
2026-07-12 13:40:58 +07:00
TurnEvent ::Compacted ( new_msgs ) => {
if let Some ( ref mut rt ) = state . session_runtime {
rt . messages = new_msgs ;
state . push_toast ( Toast ::new ( ToastKind ::Info , "History auto-compacted by AI." . to_string ()));
state . dirty = true ;
}
}
2026-07-12 17:49:34 +07:00
TurnEvent ::WorkflowAgentUpdate { agent_id , agent_name , status } => {
// Upsert the agent in the workflow engine roster.
// Running agents are pushed as new entries; status
// updates find the existing entry by id and replace it.
use crate ::app ::workflow ::engine ::WorkflowAgent ;
if let Some ( existing ) = state . workflow_engine . agents
. iter_mut ()
. find ( | a | a . id == agent_id )
{
existing . status = status ;
} else {
state . workflow_engine . agents . push ( WorkflowAgent {
id : agent_id ,
name : agent_name ,
status ,
});
}
2026-07-12 17:50:38 +07:00
if state . misc . overlay != Overlay ::Workflow {
state . misc . overlay = Overlay ::Workflow ;
}
2026-07-12 17:49:34 +07:00
state . dirty = true ;
}
2026-07-11 13:16:10 +07:00
}
2026-07-11 20:21:59 +07:00
}
if turn_finished {
maybe_trigger_review ( state );
}
if turn_finished || state . dirty {
2026-07-11 13:16:10 +07:00
state . dirty = true ;
}
}
2026-07-12 03:14:52 +07:00
Action ::AbortTurn => {
state . abort_flag . store ( true , std ::sync ::atomic ::Ordering ::SeqCst );
state . push_toast ( Toast ::new ( ToastKind ::Warning , "Aborting generation..." . to_string ()));
}
2026-07-12 13:40:58 +07:00
Action ::Compact => {
let max_wire_tokens = state . app_config . model_roles . values ()
. find ( | role | role . provider == state . settings . provider && role . model == state . settings . model )
. and_then ( | role | role . context_window )
. unwrap_or ( state . app_config . default_context_window ) as usize ;
if let Some ( ref mut rt ) = state . session_runtime {
let total_chars : usize = rt . messages . iter ()
. filter_map ( | m | m . content . as_deref ())
. map ( | c | c . len ())
. sum ();
2026-07-13 04:10:08 +07:00
let token_estimate = total_chars / 3 ;
2026-07-12 13:40:58 +07:00
rt . messages = crate ::app ::runtime ::shortsend ::shape_messages ( & rt . messages , token_estimate , max_wire_tokens , true , None );
state . push_toast ( Toast ::new ( ToastKind ::Success , "Conversation history compacted." . to_string ()));
state . dirty = true ;
}
}
2026-07-11 21:06:22 +07:00
Action ::LessonAccept { name } => {
if let Some ( ref rt ) = state . session_runtime {
let _ = crate ::app ::review ::resolve_pending_lesson (
& rt . session_dir , & state . memory_dir , & name , true ,
);
}
2026-07-13 02:53:30 +07:00
if let Some ( ref mut rt ) = state . session_runtime {
refresh_lesson_counters ( & state . memory_dir , rt );
}
2026-07-11 21:06:22 +07:00
state . push_toast ( Toast ::new ( ToastKind ::Success ,
format! ( "accepted lesson: {} " , name )));
state . dirty = true ;
}
Action ::LessonReject { name } => {
if let Some ( ref rt ) = state . session_runtime {
let _ = crate ::app ::review ::resolve_pending_lesson (
& rt . session_dir , & state . memory_dir , & name , false ,
);
}
2026-07-13 02:53:30 +07:00
if let Some ( ref mut rt ) = state . session_runtime {
refresh_lesson_counters ( & state . memory_dir , rt );
}
2026-07-11 21:06:22 +07:00
state . push_toast ( Toast ::new ( ToastKind ::Info ,
format! ( "rejected lesson: {} " , name )));
state . dirty = true ;
}
2026-07-13 02:53:30 +07:00
Action ::LessonDelete { name } => {
let _ = crate ::model ::memory ::Memory ::remove ( & state . memory_dir , & name );
if let Some ( ref mut rt ) = state . session_runtime {
refresh_lesson_counters ( & state . memory_dir , rt );
}
state . push_toast ( Toast ::new ( ToastKind ::Info , format! ( "deleted lesson: {} " , name )));
state . dirty = true ;
}
2026-07-12 17:49:34 +07:00
Action ::RunWorkflow { script } => {
// Open the Workflow overlay so the user can see progress.
state . misc . overlay = Overlay ::Workflow ;
state . dirty = true ;
// Reset engine state before starting.
state . workflow_engine . agents . clear ();
state . workflow_engine . findings . clear ();
let turn_events = state . turn_events . clone ();
let turn_events_live = state . turn_events . clone ();
state . push_toast ( Toast ::new (
ToastKind ::Info ,
format! ( "Starting workflow: {} …" , & script . chars (). take ( 40 ). collect ::< String > ()),
));
2026-07-12 18:09:03 +07:00
let session_dir = state . session_dir . clone ();
let workspace_roots = state . workspace_roots . clone ();
2026-07-12 17:49:34 +07:00
std ::thread ::spawn ( move || {
use std ::collections ::HashMap ;
use std ::sync ::Arc ;
use crate ::app ::workflow ::script ::{ ScriptPrimitive , ScriptOptions , WorkflowScript };
use crate ::app ::workflow ::engine ::{ LiveStateFn , AgentStatus };
// Parse the script string:
// "prompt1 | prompt2 | prompt3" → Parallel of 3 agents
// "prompt1 -> prompt2" → Pipeline of 2 stages
// "prompt" → single Agent
let parts_pipe : Vec <& str > = script . split ( '|' ). map ( | s | s . trim ()). collect ();
let parts_arrow : Vec <& str > = script . split ( "->" ). map ( | s | s . trim ()). collect ();
let primitive = if parts_pipe . len () > 1 {
ScriptPrimitive ::Parallel (
parts_pipe . iter (). map ( | p | ScriptPrimitive ::Agent ( p . to_string ())). collect ()
)
} else if parts_arrow . len () > 1 {
ScriptPrimitive ::Pipeline (
parts_arrow . iter (). map ( | p | ScriptPrimitive ::Agent ( p . to_string ())). collect ()
)
} else {
ScriptPrimitive ::Agent ( script . clone ())
};
let wf = WorkflowScript {
name : script . chars (). take ( 40 ). collect (),
description : script . clone (),
script : primitive ,
options : ScriptOptions ::default (),
};
// Build a live-state callback that pushes WorkflowAgentUpdate events
// into the turn_events queue so the TUI panel updates in real time.
let live : LiveStateFn = Arc ::new ( move | agent_id : String , status : AgentStatus | {
let name = agent_id . chars (). take ( 30 ). collect ::< String > ();
if let Ok ( mut q ) = turn_events_live . lock () {
q . push_back ( crate ::app ::state ::runtime ::TurnEvent ::WorkflowAgentUpdate {
agent_id : agent_id . clone (),
agent_name : name ,
status ,
});
}
});
let args : HashMap < String , String > = HashMap ::new ();
2026-07-12 18:09:03 +07:00
let result = crate ::app ::workflow ::engine ::run_workflow_tracked (
& wf , & args , Some ( live ), & session_dir , & workspace_roots ,
);
2026-07-12 17:49:34 +07:00
let ( kind , message ) = match result {
Ok ( summary ) => ( "workflow_done" . to_string (), summary ),
Err ( e ) => ( "workflow_error" . to_string (), format! ( "Workflow failed: {} " , e )),
};
if let Ok ( mut q ) = turn_events . lock () {
q . push_back ( crate ::app ::state ::runtime ::TurnEvent ::SystemNote {
kind ,
message ,
});
}
});
}
2026-07-11 13:16:10 +07:00
}
}
2026-07-11 20:21:59 +07:00
2026-07-12 11:28:39 +07:00
/// Spawn a background thread that runs one full LLM turn.
///
/// Flow: check that no turn is currently in-flight → bail if so →
/// collect messages and config from state → determine API key (from
/// settings, env var, or default) → resolve generation params from
/// the current effort level → collect all tools (built-in + MCP) →
/// build `TurnCtx` → spawn a thread running `run_agent_turn` →
/// on any error, push a `TurnEvent::Error` → clear the in-flight flag
/// when the thread exits.
///
/// Why: runs on a plain OS thread so the async event loop stays responsive.
///
/// Return: nothing; results flow through `state.turn_events`.
2026-07-11 20:21:59 +07:00
fn spawn_turn ( state : & AppStateRest ) {
let in_flight = if let Ok ( guard ) = state . turn_in_flight . lock () {
* guard
} else {
return ;
};
if in_flight {
return ;
}
let messages = state
. session_runtime
. as_ref ()
. map ( | rt | rt . messages . clone ())
. unwrap_or_default ();
if messages . is_empty () {
return ;
}
2026-07-12 03:14:52 +07:00
let mut api_key = state . settings . api_keys . get ( & state . settings . provider ). cloned (). unwrap_or_default ();
2026-07-11 20:21:59 +07:00
let model = state . settings . model . clone ();
2026-07-12 01:25:52 +07:00
let base_url = state . app_config . providers . get ( & state . settings . provider )
. map ( | p | p . api_base . clone ());
2026-07-12 13:40:58 +07:00
let context_window = state . app_config . model_roles . values ()
. find ( | role | role . provider == state . settings . provider && role . model == state . settings . model )
. and_then ( | role | role . context_window )
. unwrap_or ( state . app_config . default_context_window ) as usize ;
2026-07-12 02:19:08 +07:00
if api_key . is_empty () {
if let Some ( provider_cfg ) = state . app_config . providers . get ( & state . settings . provider ) {
api_key = provider_cfg . api_key_env . as_ref ()
. and_then ( | env | std ::env ::var ( env ). ok ())
. or_else ( || provider_cfg . default_api_key . clone ())
. unwrap_or_default ();
}
}
2026-07-12 02:26:53 +07:00
if api_key . is_empty () {
api_key = crate ::service ::provider ::DEFAULT_API_KEY . to_string ();
}
2026-07-12 01:25:52 +07:00
let ( temperature , max_tokens ) = crate ::app ::mode ::effort ::generation_params (
state . misc . effort_level ,
state . settings . max_tokens ,
);
2026-07-11 20:21:59 +07:00
let mut tools = crate ::tool ::all_tools ();
tools . extend ( state . mcp_manager . as_tools ());
let tool_defs = crate ::tool ::tool_defs ( & tools );
let ctx = state . tool_ctx ();
2026-07-12 03:14:52 +07:00
2026-07-11 22:10:17 +07:00
let edit_session_dir = state . session_dir . clone ();
2026-07-11 20:21:59 +07:00
let session_id = state . session_id . clone ();
let turn_events = state . turn_events . clone ();
let in_flight_flag = state . turn_in_flight . clone ();
let workspace_roots : Vec < std ::path ::PathBuf > = ctx . workspaces . clone ();
2026-07-12 03:14:52 +07:00
let abort_flag = state . abort_flag . clone ();
abort_flag . store ( false , std ::sync ::atomic ::Ordering ::SeqCst );
2026-07-11 20:21:59 +07:00
2026-07-13 04:10:08 +07:00
* in_flight_flag . lock (). unwrap_or_else ( | e | {
tracing ::error! ( "[spawn_turn] in_flight_flag mutex poisoned: {}" , e );
e . into_inner ()
}) = true ;
2026-07-11 20:21:59 +07:00
let events_q = turn_events . clone ();
std ::thread ::spawn ( move || {
2026-07-11 23:45:13 +07:00
let db = crate ::model ::msglog ::open_or_create ( & edit_session_dir )
. ok ()
. map ( | c | std ::sync ::Arc ::new ( std ::sync ::Mutex ::new ( c )));
2026-07-11 20:21:59 +07:00
let tc = TurnCtx {
2026-07-12 13:40:58 +07:00
client : crate ::service ::provider ::LlmClient ::new ( api_key , model . clone (), base_url ),
2026-07-11 20:21:59 +07:00
tdefs : tool_defs ,
tools ,
ctx ,
2026-07-12 13:40:58 +07:00
context_window ,
2026-07-12 03:14:52 +07:00
2026-07-11 20:21:59 +07:00
workspace_roots ,
2026-07-11 22:10:17 +07:00
edit_log_session_dir : edit_session_dir ,
2026-07-11 20:21:59 +07:00
session_id ,
2026-07-11 23:45:13 +07:00
db ,
2026-07-12 01:25:52 +07:00
temperature ,
max_tokens ,
2026-07-12 03:14:52 +07:00
abort_flag ,
2026-07-11 20:21:59 +07:00
};
let result = run_agent_turn ( tc , & messages , & events_q );
if let Err ( e ) = result {
if let Ok ( mut q ) = events_q . lock () {
q . push_back ( TurnEvent ::Error ( e . to_string ()));
}
}
if let Ok ( mut flag ) = in_flight_flag . lock () {
* flag = false ;
}
});
}
2026-07-12 11:28:39 +07:00
/// Context bundle passed to `run_agent_turn` on its background thread.
2026-07-11 20:21:59 +07:00
struct TurnCtx {
2026-07-11 22:10:17 +07:00
client : crate ::service ::provider ::LlmClient ,
tdefs : Vec < crate ::dto ::provider ::request ::ToolDef > ,
2026-07-11 20:21:59 +07:00
tools : Vec < Box < dyn crate ::tool ::Tool >> ,
ctx : crate ::tool ::ToolCtx ,
2026-07-12 13:40:58 +07:00
context_window : usize ,
2026-07-12 03:14:52 +07:00
2026-07-11 20:21:59 +07:00
workspace_roots : Vec < std ::path ::PathBuf > ,
2026-07-11 22:10:17 +07:00
edit_log_session_dir : std ::path ::PathBuf ,
2026-07-11 20:21:59 +07:00
session_id : String ,
2026-07-11 23:45:13 +07:00
db : Option < std ::sync ::Arc < std ::sync ::Mutex < rusqlite ::Connection >>> ,
2026-07-12 01:25:52 +07:00
temperature : f32 ,
2026-07-12 13:40:58 +07:00
max_tokens : Option < u32 > ,
2026-07-12 03:14:52 +07:00
abort_flag : std ::sync ::Arc < std ::sync ::atomic ::AtomicBool > ,
2026-07-11 23:45:13 +07:00
}
2026-07-12 11:28:39 +07:00
/// Build an ASCII tree of the workspace directory structure for the
/// system prompt, so the LLM can see the file layout.
///
/// Flow: for each root, walk using `ignore::WalkBuilder` (respecting
/// `.gitignore` and hidden files) → prefix `[DIR]` for directories →
/// truncate after 1000 entries.
///
/// Return: a formatted string with one entry per line.
2026-07-12 03:28:06 +07:00
fn generate_workspace_tree ( roots : & [ std ::path ::PathBuf ]) -> String {
let mut out = String ::new ();
out . push_str ( "Current Workspace Directory Structure: \n " );
for root in roots {
out . push_str ( & format! ( "Root: {} \n " , root . display ()));
let walker = ignore ::WalkBuilder ::new ( root )
. hidden ( true )
. git_ignore ( true )
. build ();
let mut count = 0 ;
2026-07-12 10:23:26 +07:00
for entry in walker . flatten () {
let path = entry . path ();
if let Ok ( rel ) = path . strip_prefix ( root ) {
if rel . as_os_str (). is_empty () { continue ; }
let is_dir = entry . file_type (). map ( | ft | ft . is_dir ()). unwrap_or ( false );
let prefix = if is_dir { "[DIR] " } else { " " };
out . push_str ( & format! ( " {}{} \n " , prefix , rel . display ()));
count += 1 ;
if count > 1000 {
out . push_str ( " ... (truncated) \n " );
break ;
2026-07-12 03:28:06 +07:00
}
}
}
}
out
}
2026-07-12 12:21:46 +07:00
/// Load all memory entries from `memory_dir` and format them as a compact
/// section appended to the system prompt, so the AI is always aware of
/// stored lessons and project knowledge.
///
/// Flow: list memory slugs → for each, read + parse the file → collect
/// entries whose lifecycle is not "stale" → cap total output at 3000 chars
/// to avoid dominating the prompt budget.
///
/// Why: previously, lessons existed on disk but the AI never saw them
/// unless it explicitly called `recall()`. This makes the memory system
/// actually useful by surfacing relevant knowledge automatically.
///
/// Return: a formatted string (may be empty if no memory entries exist).
fn build_memory_section ( memory_dir : & std ::path ::Path ) -> String {
let names = crate ::model ::memory ::Memory ::list ( memory_dir );
if names . is_empty () {
return String ::new ();
}
let mut section = String ::from ( " \n\n --- Persistent Memory --- \n " );
section . push_str ( & format! ( "Total entries: {} \n\n " , names . len ()));
for name in & names {
if section . len () > 3000 {
section . push_str ( "... (more entries omitted, use recall() to see all) \n " );
break ;
}
if let Ok ( mem ) = crate ::model ::memory ::Memory ::read ( memory_dir , name ) {
if mem . lifecycle == "stale" {
continue ;
}
section . push_str ( & format! ( "## [ {} ] {} \n {} \n\n " , mem . kind , mem . name , mem . content ));
}
}
section . push_str ( "---" );
section
}
/// Scan `memory_dir` and update every lesson counter in `SessionRuntime`
/// from real on-disk data.
///
/// Flow: list all memory slugs → read+parse each → increment the matching
/// kind counter (user/feedback/project/reference), lifecycle counter
/// (active/stale/contradicted), and the total. If a memory cannot be read
/// (e.g. a race with deletion) it is silently skipped.
///
/// Why: previously the UI showed all zeros because nothing ever set the
/// breakdown counters. This runs on every user submit so the dashboard
/// reflects actual memory state.
fn refresh_lesson_counters ( memory_dir : & std ::path ::Path , rt : & mut crate ::app ::state ::runtime ::SessionRuntime ) {
let names = crate ::model ::memory ::Memory ::list ( memory_dir );
rt . lesson_count = 0 ;
rt . lessons_user = 0 ;
rt . lessons_feedback = 0 ;
rt . lessons_project = 0 ;
rt . lessons_reference = 0 ;
rt . lessons_active = 0 ;
rt . lessons_stale = 0 ;
rt . lessons_contradicted = 0 ;
for name in & names {
if let Ok ( mem ) = crate ::model ::memory ::Memory ::read ( memory_dir , name ) {
rt . lesson_count += 1 ;
match mem . kind . as_str () {
"user" => rt . lessons_user += 1 ,
"feedback" => rt . lessons_feedback += 1 ,
"project" => rt . lessons_project += 1 ,
"reference" => rt . lessons_reference += 1 ,
_ => {}
}
match mem . lifecycle . as_str () {
"active" => rt . lessons_active += 1 ,
"stale" => rt . lessons_stale += 1 ,
"contradicted" => rt . lessons_contradicted += 1 ,
_ => {}
}
}
}
}
2026-07-12 11:28:39 +07:00
/// Persist a `ChatMessage` to the SQLite message log, if a database
/// connection is available.
///
/// Flow: if `db` is `Some`, lock the mutex and call `insert_message`.
/// Errors are silently ignored.
2026-07-11 23:45:13 +07:00
fn archive_message ( db : & Option < std ::sync ::Arc < std ::sync ::Mutex < rusqlite ::Connection >>> , session_id : & str , msg : & ChatMessage ) {
if let Some ( ref arc ) = db {
if let Ok ( conn ) = arc . lock () {
let _ = crate ::model ::msglog ::insert_message ( & conn , session_id , msg );
}
}
2026-07-11 20:21:59 +07:00
}
2026-07-13 03:12:37 +07:00
/// Maximum number of LLM call + tool-execution iterations per single
/// agent turn before bailing. Prevents runaway token consumption when
/// the agent gets stuck in a loop (e.g. an unachievable todo item).
const MAX_TURN_STEPS : usize = 10000 ;
2026-07-12 11:28:39 +07:00
/// Execute one full agent turn: stream the conversation to the LLM,
/// handle tool calls, and loop until the LLM produces a non-tool response
/// or runs out of unfinished todo items.
///
/// Flow: build system prompt with workspace tree → optionally shape
/// (compact) messages via `shortsend` → call `chat_with_tools_streaming`
/// with a callback that pushes `StreamStart`, `StreamToken`, `Reasoning`,
/// and `Usage` events → on streaming success, handle tool calls (gated
/// through `Harness::gate_tool_call`) or unwrap the final assistant
/// message → check for unfinished todo.md tasks (auto-retry with a
/// system message if any remain) → finalise with `Done` and an `edits`
/// SystemNote.
///
/// On streaming failure: retry once with a non-streaming call → if that
/// also fails and there are unfinished tasks, sleep 5s and loop back;
/// otherwise return the error.
///
/// Why: non-streaming fallback handles flaky connections without aborting
/// the turn; todo.md polling lets the agent self-direct toward completeness.
///
/// Return: `Ok(())` on successful completion, or an error from the LLM
/// API after retries are exhausted.
2026-07-11 20:21:59 +07:00
fn run_agent_turn (
tc : TurnCtx ,
messages : & [ ChatMessage ],
events_q : & std ::sync ::Mutex < VecDeque < TurnEvent >> ,
) -> anyhow ::Result < () > {
let mut msgs = messages . to_vec ();
let mut edits_this_turn = 0 u32 ;
2026-07-11 23:45:13 +07:00
let mut prev_shaped = false ;
2026-07-11 20:21:59 +07:00
2026-07-12 03:28:06 +07:00
let tree_info = generate_workspace_tree ( & tc . workspace_roots );
2026-07-12 12:21:46 +07:00
let memory_section = build_memory_section ( & tc . ctx . memory_dir );
2026-07-11 22:16:47 +07:00
let system_text = format! (
2026-07-12 12:21:46 +07:00
" {} \n\n {} \n\n {}{} " ,
2026-07-11 22:16:47 +07:00
crate ::resources ::SYSTEM_PROMPT ,
crate ::resources ::SYSTEM_TOOLS ,
2026-07-12 12:21:46 +07:00
tree_info ,
memory_section ,
2026-07-11 22:16:47 +07:00
);
if ! msgs . iter (). any ( | m | matches! ( m . role , crate ::dto ::chat ::message ::Role ::System )) {
2026-07-11 23:45:13 +07:00
let sys = ChatMessage ::system ( system_text );
archive_message ( & tc . db , & tc . session_id , & sys );
msgs . insert ( 0 , sys );
2026-07-11 22:16:47 +07:00
}
2026-07-13 03:12:37 +07:00
let mut turn_step = 0 usize ;
2026-07-12 10:23:26 +07:00
loop {
2026-07-13 03:12:37 +07:00
turn_step += 1 ;
if turn_step > MAX_TURN_STEPS {
anyhow ::bail! (
"turn exceeded maximum steps ({}) — possible runaway loop. \
aborting to prevent excessive token usage" ,
MAX_TURN_STEPS ,
);
}
2026-07-12 13:40:58 +07:00
let total_chars : usize = msgs . iter ()
. filter_map ( | m | m . content . as_deref ())
. map ( | c | c . len ())
. sum ();
let token_estimate = total_chars / 4 ;
let max_wire_tokens = tc . context_window ;
let wire_msgs = if crate ::app ::runtime ::shortsend ::should_shape ( token_estimate , max_wire_tokens , prev_shaped ) {
2026-07-11 23:45:13 +07:00
prev_shaped = true ;
2026-07-12 13:40:58 +07:00
let compacted = crate ::app ::runtime ::shortsend ::shape_messages ( & msgs , token_estimate , max_wire_tokens , false , Some ( & tc . client ));
// Dispatch the compacted messages to the main thread so the local session history
// is permanently compacted and doesn't trigger shaping again immediately on next turn.
if let Ok ( mut q ) = events_q . lock () {
q . push_back ( TurnEvent ::Compacted ( compacted . clone ()));
}
// Also update our local `msgs` variable so the rest of the loop operates on the compacted version
msgs = compacted . clone ();
compacted
2026-07-11 23:45:13 +07:00
} else {
prev_shaped = false ;
msgs . clone ()
};
2026-07-12 01:25:52 +07:00
let mut stream_started = false ;
2026-07-12 03:37:27 +07:00
let mut reasoning_started = false ;
let mut reasoning_ended = false ;
2026-07-12 03:14:52 +07:00
let mut usage = None ;
let result = tc . client . chat_with_tools_streaming (
2026-07-12 01:25:52 +07:00
& wire_msgs ,
2026-07-12 13:40:58 +07:00
if tc . tdefs . is_empty () { None } else { Some ( tc . tdefs . clone ()) },
2026-07-12 01:25:52 +07:00
Some ( tc . temperature ),
2026-07-12 13:40:58 +07:00
tc . max_tokens ,
2026-07-12 03:14:52 +07:00
| event | -> bool {
if tc . abort_flag . load ( std ::sync ::atomic ::Ordering ::SeqCst ) {
return false ;
}
if let Ok ( mut q ) = events_q . lock () {
match event {
crate ::app ::runtime ::stream ::StreamEvent ::Token ( tok ) => {
if ! stream_started {
q . push_back ( TurnEvent ::StreamStart );
stream_started = true ;
}
2026-07-12 03:37:27 +07:00
if reasoning_started && ! reasoning_ended {
reasoning_ended = true ;
q . push_back ( TurnEvent ::StreamToken ( " \n </think> \n\n " . to_string ()));
}
q . push_back ( TurnEvent ::StreamToken ( tok . clone ()));
}
crate ::app ::runtime ::stream ::StreamEvent ::Reasoning ( tok ) => {
if ! stream_started {
q . push_back ( TurnEvent ::StreamStart );
stream_started = true ;
}
if ! reasoning_started {
reasoning_started = true ;
q . push_back ( TurnEvent ::StreamToken ( "<think> \n " . to_string ()));
}
2026-07-12 03:14:52 +07:00
q . push_back ( TurnEvent ::StreamToken ( tok . clone ()));
2026-07-12 01:25:52 +07:00
}
2026-07-12 03:14:52 +07:00
crate ::app ::runtime ::stream ::StreamEvent ::Usage { prompt_tokens , completion_tokens , .. } => {
usage = Some (( * prompt_tokens , * completion_tokens ));
}
_ => {}
2026-07-12 01:25:52 +07:00
}
}
2026-07-12 03:14:52 +07:00
true
2026-07-12 01:25:52 +07:00
},
2026-07-12 03:14:52 +07:00
);
2026-07-12 03:37:27 +07:00
if reasoning_started && ! reasoning_ended {
if let Ok ( mut q ) = events_q . lock () {
q . push_back ( TurnEvent ::StreamToken ( " \n </think> \n\n " . to_string ()));
}
}
2026-07-12 03:14:52 +07:00
let ( response , final_usage ) = match result {
Ok (( msg , u )) => ( msg , u . or ( usage )),
Err ( e ) => {
if tc . abort_flag . load ( std ::sync ::atomic ::Ordering ::SeqCst ) || e . to_string (). contains ( "aborted" ) {
if let Ok ( mut q ) = events_q . lock () {
q . push_back ( TurnEvent ::Error ( "Generation aborted by user" . to_string ()));
}
return Ok (());
}
2026-07-12 03:56:43 +07:00
match tc . client . chat_with_tools_non_streaming ( & wire_msgs , Some ( tc . tdefs . clone ())) {
Ok (( msg , usage_fb )) => ( msg , usage_fb ),
Err ( api_err ) => {
let todo_path = tc . ctx . session_dir . join ( "todo.md" );
let mut has_unfinished = false ;
if let Ok ( todo_text ) = std ::fs ::read_to_string ( & todo_path ) {
if todo_text . lines (). any ( | l | l . trim_start (). starts_with ( "- [ ]" )) {
has_unfinished = true ;
}
}
if has_unfinished {
if let Ok ( mut q ) = events_q . lock () {
q . push_back ( TurnEvent ::SystemNote {
kind : "task_retry" . to_string (),
message : format ! ( "Network/API error: {}. Auto-retrying to finish tasks..." , api_err ),
});
}
std ::thread ::sleep ( std ::time ::Duration ::from_secs ( 5 ));
continue ;
}
return Err ( api_err );
}
}
2026-07-12 01:43:57 +07:00
}
};
2026-07-11 20:21:59 +07:00
2026-07-12 03:14:52 +07:00
if let Some (( tok_in , tok_out )) = final_usage {
if let Ok ( mut q ) = events_q . lock () {
q . push_back ( TurnEvent ::Usage { tokens_in : tok_in , tokens_out : tok_out });
}
}
2026-07-11 20:21:59 +07:00
let has_tool_calls = response . tool_calls . is_some ()
&& response . tool_calls . as_ref (). is_some_and ( | tc | ! tc . is_empty ());
let content = response . content . clone (). unwrap_or_default ();
if has_tool_calls {
let tool_calls = response . tool_calls . clone (). unwrap_or_default ();
2026-07-11 23:45:13 +07:00
archive_message ( & tc . db , & tc . session_id , & response );
2026-07-11 20:21:59 +07:00
msgs . push ( response );
for tool_call in tool_calls {
2026-07-12 03:14:52 +07:00
if tc . abort_flag . load ( std ::sync ::atomic ::Ordering ::SeqCst ) {
if let Ok ( mut q ) = events_q . lock () {
q . push_back ( TurnEvent ::Error ( "Turn aborted by user" . to_string ()));
}
return Ok (());
}
2026-07-11 20:21:59 +07:00
let tool_name = tool_call . function . name . clone ();
let args = crate ::dto ::chat ::tool ::sanitize_tool_arguments (
& tool_call . function . arguments ,
);
let ws_roots : Vec <& std ::path ::Path > =
tc . workspace_roots . iter (). map ( | p | p . as_path ()). collect ();
let verdict = crate ::app ::harness ::Harness ::gate_tool_call (
& tool_name ,
& args ,
2026-07-12 03:14:52 +07:00
2026-07-11 20:21:59 +07:00
& ws_roots ,
);
let is_edit_tool = tool_name == "write" || tool_name == "edit" ;
let ( output , is_error , is_edit ) = match verdict {
Verdict ::Allow => match execute_one_tool (
& tc . tools ,
& tc . ctx ,
& tool_name ,
2026-07-12 01:25:52 +07:00
& tool_call . id ,
2026-07-11 20:21:59 +07:00
& args ,
2026-07-11 22:10:17 +07:00
& tc . edit_log_session_dir ,
2026-07-11 20:21:59 +07:00
& tc . session_id ,
2026-07-12 01:25:52 +07:00
& tc . db ,
2026-07-11 20:21:59 +07:00
) {
Ok ( result ) => ( result , false , is_edit_tool ),
Err ( e ) => ( e . to_string (), true , false ),
},
Verdict ::Block ( reason ) => ( format! ( "Blocked: {} " , reason ), true , false ),
};
if is_edit {
edits_this_turn += 1 ;
}
2026-07-11 22:40:45 +07:00
let tool_path = args . get ( "path" ). and_then ( | v | v . as_str ()). map ( | s | s . to_string ());
2026-07-11 20:21:59 +07:00
{
if let Ok ( mut q ) = events_q . lock () {
q . push_back ( TurnEvent ::ToolResult {
tool_call_id : tool_call . id . clone (),
tool_name : tool_name . clone (),
output : output . clone (),
is_error ,
2026-07-11 22:40:45 +07:00
path : tool_path ,
2026-07-11 20:21:59 +07:00
});
}
}
let tool_msg = ChatMessage ::tool_result ( tool_call . id . clone (), output );
2026-07-11 23:45:13 +07:00
archive_message ( & tc . db , & tc . session_id , & tool_msg );
2026-07-11 20:21:59 +07:00
msgs . push ( tool_msg );
}
} else {
if ! content . is_empty () {
2026-07-11 23:45:13 +07:00
archive_message ( & tc . db , & tc . session_id , & response );
2026-07-11 20:21:59 +07:00
if let Ok ( mut q ) = events_q . lock () {
2026-07-12 01:25:52 +07:00
if stream_started {
2026-07-12 03:56:43 +07:00
q . push_back ( TurnEvent ::StreamDone ( response . clone ()));
2026-07-12 01:25:52 +07:00
} else {
2026-07-12 03:56:43 +07:00
q . push_back ( TurnEvent ::AssistantMessage ( response . clone ()));
2026-07-12 01:25:52 +07:00
}
2026-07-11 20:21:59 +07:00
}
}
2026-07-12 03:56:43 +07:00
let todo_path = tc . ctx . session_dir . join ( "todo.md" );
let mut has_unfinished = false ;
if let Ok ( todo_text ) = std ::fs ::read_to_string ( & todo_path ) {
if todo_text . lines (). any ( | l | l . trim_start (). starts_with ( "- [ ]" )) {
has_unfinished = true ;
}
}
if has_unfinished {
let sys_text = "You stopped, but you still have unfinished tasks in todo.md (marked with '- [ ]'). You MUST continue working and use tools to finish them, or edit todo.md to mark them as done if they are finished." ;
let msg = ChatMessage ::system ( sys_text );
archive_message ( & tc . db , & tc . session_id , & msg );
msgs . push ( msg );
if let Ok ( mut q ) = events_q . lock () {
q . push_back ( TurnEvent ::SystemNote {
kind : "task_retry" . to_string (),
message : sys_text . to_string (),
});
}
continue ;
}
2026-07-11 20:21:59 +07:00
break ;
}
}
if edits_this_turn > 0 {
if let Ok ( mut q ) = events_q . lock () {
q . push_back ( TurnEvent ::SystemNote {
kind : "edits" . to_string (),
message : edits_this_turn . to_string (),
});
}
}
if let Ok ( mut q ) = events_q . lock () {
q . push_back ( TurnEvent ::Done );
}
Ok (())
}
2026-07-12 11:28:39 +07:00
/// Execute a single tool call: find the tool by name, snapshot the file
/// (if write/edit) for rewind, run the tool, log an `EditLogEntry` for
/// write/edit, and return the output.
///
/// Flow: iterate tools → match by name → for write/edit, snapshot the
/// pre-existing file content into the blob store → call `tool.run()` →
/// for write/edit, compute SHA-256 of the new content and append an
/// `EditLogEntry` → return the tool output string.
///
/// Why: snapshots enable the rewind feature to restore previous content
/// after a write/edit.
///
/// Return: the tool's stdout string, or an error if no matching tool was
/// found or the tool run itself failed.
2026-07-12 01:25:52 +07:00
#[allow(clippy::too_many_arguments)]
2026-07-11 20:21:59 +07:00
fn execute_one_tool (
tools : & [ Box < dyn crate ::tool ::Tool > ],
ctx : & crate ::tool ::ToolCtx ,
name : & str ,
2026-07-12 01:25:52 +07:00
tool_call_id : & str ,
2026-07-11 20:21:59 +07:00
args : & serde_json ::Value ,
2026-07-11 22:10:17 +07:00
session_dir : & std ::path ::Path ,
2026-07-11 20:21:59 +07:00
session_id : & str ,
2026-07-12 01:25:52 +07:00
db : & Option < std ::sync ::Arc < std ::sync ::Mutex < rusqlite ::Connection >>> ,
2026-07-11 20:21:59 +07:00
) -> anyhow ::Result < String > {
for tool in tools {
if tool . name () == name {
2026-07-12 01:25:52 +07:00
// Snapshot current file content before write/edit for rewind
if ( name == "write" || name == "edit" ) && ! tool_call_id . is_empty () {
if let Some ( ref arc ) = db {
if let Ok ( conn ) = arc . lock () {
let path = args . get ( "path" ). and_then ( | v | v . as_str ()). unwrap_or ( "" );
if let Ok ( abs_path ) = crate ::tool ::resolve_path ( & ctx . workspaces , path ) {
if let Ok ( bytes ) = std ::fs ::read ( & abs_path ) {
let _ = crate ::model ::msglog ::store_blob (
& conn , session_id , tool_call_id , & bytes , None ,
);
}
}
}
}
}
2026-07-11 20:21:59 +07:00
let result = tool . run ( ctx , args ) ? ;
if name == "write" || name == "edit" {
let reason = args
. get ( "reason" )
. and_then ( | v | v . as_str ())
. unwrap_or ( "unnamed" );
let path = args
. get ( "path" )
. and_then ( | v | v . as_str ())
. unwrap_or ( "unknown" );
let content_sha256 = {
let content = args . get ( "content" ). or_else ( || args . get ( "new" ));
let hash = sha2 ::Sha256 ::digest (
content . and_then ( | v | v . as_str ()). unwrap_or ( "" ). as_bytes (),
);
format! ( " {:x} " , hash )
};
2026-07-11 22:10:17 +07:00
let bytes_delta = if name == "write" {
args . get ( "content" )
. and_then ( | v | v . as_str ())
. map ( | s | s . len () as i64 )
. unwrap_or ( 0 )
} else {
let old = args . get ( "old" ). and_then ( | v | v . as_str ()). unwrap_or ( "" );
let new = args . get ( "new" ). and_then ( | v | v . as_str ()). unwrap_or ( "" );
( new . len () as i64 - old . len () as i64 ). abs ()
};
2026-07-11 20:21:59 +07:00
let entry = crate ::model ::editlog ::EditLogEntry {
ts : chrono ::Utc ::now (). timestamp_millis (),
tool : name . to_string (),
path : path . to_string (),
reason : reason . to_string (),
content_sha256 ,
2026-07-11 22:10:17 +07:00
bytes_delta ,
2026-07-11 20:21:59 +07:00
origin : ctx . origin . tag (),
session_id : session_id . to_string (),
};
2026-07-11 22:10:17 +07:00
let mut el = crate ::model ::editlog ::EditLog ::new ( session_dir );
2026-07-11 20:21:59 +07:00
el . append ( entry ). ok ();
}
return Ok ( result );
}
}
anyhow ::bail! ( "tool not found: {}" , name )
}
2026-07-12 11:28:39 +07:00
/// Optionally push a review-available toast at the end of a turn that
/// performed edits.
///
/// Flow: skip if review is disabled → skip if `edit_count` is zero →
/// push an info toast listing the number of modified files.
///
/// Why: does not launch the review itself (that happens inside
/// `should_trigger_review` on `Tick`), only informs the user that
/// a review has material to examine.
2026-07-11 20:21:59 +07:00
fn maybe_trigger_review ( state : & mut AppStateRest ) {
if ! state . settings . review_enabled {
return ;
}
let edit_count = state
. session_runtime
. as_ref ()
. map ( | rt | rt . edit_count )
. unwrap_or ( 0 );
if edit_count == 0 {
return ;
}
state . push_toast ( Toast ::new (
ToastKind ::Info ,
2026-07-12 11:45:28 +07:00
format! ( " {} file(s) modified this session. Review available." , edit_count ),
2026-07-11 20:21:59 +07:00
));
}
2026-07-12 11:28:39 +07:00
/// Persist the current session metadata and conversation to disk.
///
/// Flow: build a `Session` object → save its metadata → write
/// `rt.messages` as JSON to the conversation file → errors are silently
/// ignored.
///
/// Why: called on `ForceQuit` so the session can be resumed later.
2026-07-11 20:21:59 +07:00
fn save_current_session ( state : & AppStateRest ) {
let base = state . store_base_dir ();
let session = crate ::model ::session ::Session ::new (
state . session_id . clone (),
"session" . to_string (),
);
let _ = session . save ( & base );
if let Some ( ref rt ) = state . session_runtime {
let conv_path = session . conversation_path ( & base );
if let Ok ( data ) = serde_json ::to_string ( & rt . messages ) {
let _ = std ::fs ::write ( & conv_path , data );
}
}
}
2026-07-11 21:06:22 +07:00
2026-07-12 11:28:39 +07:00
/// Run a browser-based OAuth PKCE flow for the given provider.
///
/// Flow: look up config by provider name ("zen"/"opencode", "openai",
/// or a custom provider via env vars) → bind a loopback server → generate
/// a PKCE code verifier and challenge → build the authorisation URL →
/// wait for the redirect code on the loopback server (with a 120s timeout)
/// → exchange the code for a token → save the token to
/// `~/.config/zesdex/oauth_{provider}.json`.
///
/// Why: the `webbrowser::open` call is currently commented out; the user
/// must open the auth URL manually until that line is reinstated.
///
/// Return: a success message on completion, or an error if the flow fails
/// at any step.
2026-07-11 23:45:13 +07:00
fn run_oauth_flow ( provider : & str ) -> anyhow ::Result < String > {
use crate ::service ::oauth ::manager ::{ OAuthConfig , OAuthManager };
use crate ::service ::oauth ::loopback ::LoopbackServer ;
use crate ::service ::oauth ::pkce ::CodeVerifier ;
let config = match provider {
"zen" | "opencode" => OAuthConfig {
auth_url : "https://opencode.ai/zen/oauth/authorize" . to_string (),
token_url : "https://opencode.ai/zen/oauth/token" . to_string (),
client_id : std ::env ::var ( "ZEN_CLIENT_ID" )
. unwrap_or_else ( | _ | "zesdex" . to_string ()),
client_secret : std ::env ::var ( "ZEN_CLIENT_SECRET" ). ok (),
scopes : vec ! [ "openid" . to_string (), "profile" . to_string (), "email" . to_string ()],
},
"openai" => OAuthConfig {
auth_url : "https://auth0.openai.com/authorize" . to_string (),
token_url : "https://auth0.openai.com/oauth/token" . to_string (),
client_id : std ::env ::var ( "OPENAI_CLIENT_ID" )
. unwrap_or_else ( | _ | "zesdex" . to_string ()),
client_secret : std ::env ::var ( "OPENAI_CLIENT_SECRET" ). ok (),
scopes : vec ! [ "openid" . to_string (), "profile" . to_string (), "email" . to_string ()],
},
other => {
let auth_url = std ::env ::var ( format! ( " {} _AUTH_URL" , other . to_uppercase ()))
. map_err ( | _ | anyhow ::anyhow! ( "unknown provider '{}'. Set {}_AUTH_URL env var." , other , other . to_uppercase ())) ? ;
let token_url = std ::env ::var ( format! ( " {} _TOKEN_URL" , other . to_uppercase ()))
. map_err ( | _ | anyhow ::anyhow! ( "{}_TOKEN_URL not set" , other . to_uppercase ())) ? ;
let client_id = std ::env ::var ( format! ( " {} _CLIENT_ID" , other . to_uppercase ()))
. unwrap_or_else ( | _ | "zesdex" . to_string ());
OAuthConfig {
auth_url ,
token_url ,
client_id ,
client_secret : std ::env ::var ( format! ( " {} _CLIENT_SECRET" , other . to_uppercase ())). ok (),
scopes : vec ! [ "openid" . to_string (), "profile" . to_string (), "email" . to_string ()],
2026-07-11 21:06:22 +07:00
}
}
2026-07-11 23:45:13 +07:00
};
let server = LoopbackServer ::bind () ? ;
let redirect_uri = server . redirect_uri ();
let verifier = CodeVerifier ::new ();
let challenge = verifier . challenge ();
let state_token = format! ( " {:x} " , sha2 ::Sha256 ::digest ( rand_bytes ( 16 )));
let mut manager = OAuthManager ::new ( config . clone ());
2026-07-12 11:45:28 +07:00
let auth_url = manager . build_auth_url ( & redirect_uri , & state_token , challenge . as_str ());
if auth_url . is_empty () {
tracing ::warn! ( "[oauth] auth_url was empty for provider '{}'" , provider );
} else if webbrowser ::open ( & auth_url ). is_err () {
tracing ::warn! (
"[oauth] could not open browser for '{}'; user must open URL manually: \n {}" ,
provider , auth_url
);
}
2026-07-11 23:45:13 +07:00
2026-07-12 11:45:28 +07:00
let code = server . wait_for_code ( 120_000 , & state_token ) ? ;
2026-07-11 23:45:13 +07:00
manager . exchange_code ( & code , & redirect_uri , verifier . as_str ())
. map_err ( | e | anyhow ::anyhow! ( "{}" , e )) ? ;
if let Some ( ref token ) = manager . token {
let token_path = dirs ::config_dir ()
. unwrap_or_else ( || std ::path ::PathBuf ::from ( "." ))
. join ( "zesdex" )
. join ( format! ( "oauth_ {} .json" , provider ));
if let Some ( parent ) = token_path . parent () {
let _ = std ::fs ::create_dir_all ( parent );
}
2026-07-12 11:55:02 +07:00
if let Err ( e ) = std ::fs ::write ( & token_path , serde_json ::to_string_pretty ( token ). unwrap_or_default ()) {
tracing ::warn! ( "[oauth] failed to persist token for '{}': {}" , provider , e );
}
2026-07-11 21:06:22 +07:00
}
2026-07-11 23:45:13 +07:00
Ok ( format! ( "Successfully authenticated with {} ." , provider ))
2026-07-11 21:06:22 +07:00
}
2026-07-11 23:45:13 +07:00
2026-07-12 12:09:59 +07:00
/// Spawn a background thread that checks API reachability via a lightweight HEAD
/// request to `<base_url>/models`, pushing the result as a `SystemNote` so the
/// next `Tick` handler updates `api_connected`.
///
/// Flow: resolve the provider's base URL → build a short-lived reqwest client
/// with 3s connect / 5s total timeout → HEAD the `/models` endpoint → push
/// a `connectivity` SystemNote with the result.
///
/// Why: runs off the event loop so a slow/TIMEOUT network does not block the TUI.
fn spawn_api_connectivity_check ( state : & AppStateRest ) {
let base_url = state
. app_config
. providers
. get ( & state . settings . provider )
. map ( | p | p . api_base . clone ())
. unwrap_or_else ( || crate ::service ::provider ::DEFAULT_BASE_URL . to_string ());
let turn_events = state . turn_events . clone ();
std ::thread ::spawn ( move || {
2026-07-13 03:12:37 +07:00
let url = format! ( " {} /chat/completions" , base_url . trim_end_matches ( '/' ));
2026-07-12 12:09:59 +07:00
let connected = match reqwest ::blocking ::Client ::builder ()
. timeout ( std ::time ::Duration ::from_secs ( 5 ))
. connect_timeout ( std ::time ::Duration ::from_secs ( 3 ))
. build ()
{
Ok ( client ) => match client . head ( & url ). send () {
Ok ( resp ) => {
let s = resp . status ();
// 401/403 means the server is reachable (just auth is wrong)
s . is_success () || s . as_u16 () == 401 || s . as_u16 () == 403
}
Err ( _ ) => false ,
},
Err ( _ ) => false ,
};
if let Ok ( mut q ) = turn_events . lock () {
q . push_back ( TurnEvent ::SystemNote {
kind : "connectivity" . to_string (),
message : if connected {
"connected" . to_string ()
} else {
"disconnected" . to_string ()
},
});
}
});
}
2026-07-12 11:45:28 +07:00
/// Generate `n` pseudo-random bytes from the system clock mixed with a monotonic
/// counter, providing sufficient unpredictability for a per-flow OAuth state
/// token without a `rand` dependency.
2026-07-12 11:28:39 +07:00
///
/// Why: avoids pulling in a full RNG crate for the OAuth state token;
2026-07-12 11:45:28 +07:00
/// the counter ensures sequential invocations produce different outputs even
/// within the same clock tick, which is sufficient for a short-lived nonce.
2026-07-11 23:45:13 +07:00
fn rand_bytes ( n : usize ) -> Vec < u8 > {
2026-07-12 11:45:28 +07:00
use std ::sync ::atomic ::{ AtomicU64 , Ordering };
2026-07-11 23:45:13 +07:00
use std ::time ::{ SystemTime , UNIX_EPOCH };
2026-07-12 11:45:28 +07:00
static COUNTER : AtomicU64 = AtomicU64 ::new ( 0 );
let counter = COUNTER . fetch_add ( 1 , Ordering ::Relaxed );
let seed = SystemTime ::now ()
. duration_since ( UNIX_EPOCH )
. unwrap_or_default ()
. as_nanos () as u64 ;
let base = seed ^ counter ;
( 0 .. n ). map ( | i | (( base >> (( i as u64 % 8 ) * 8 )) ^ ( i as u64 * 2654435761 )) as u8 ). collect ()
2026-07-11 23:45:13 +07:00
}