//! 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. use std::collections::VecDeque; use crate::app::harness::Verdict; use sha2::Digest; use crate::app::mode::ModeKind; use crate::app::review::{should_trigger_review, trigger_review}; use crate::app::state::rest::{AppStateRest, ChatMessageDisplay}; use crate::app::state::runtime::TurnEvent; use crate::app::state::types::{Origin, Overlay, Toast, ToastKind}; use crate::dto::chat::message::{ChatMessage, Role}; /// 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. #[derive(Debug, Clone)] pub enum Action { ForceQuit, SwitchMode(ModeKind), SubmitInput(String), InsertChar(char), DeleteChar, DeleteCharRight, CursorLeft, CursorRight, HistoryUp, HistoryDown, ScrollUp, ScrollDown, OpenOverlay(Overlay), CloseOverlay, SystemNote { kind: String, message: String, }, QuitConfirm, Resize(u16, u16), Tick, LessonExport { path: String, }, LessonImport { path: String, }, LessonAccept { name: String, }, LessonReject { name: String, }, StartOAuth { provider: String, }, OpenEditor { path: String, }, McpAdd { name: String, command: String, }, ModelList, AbortTurn, } /// 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. pub fn apply_action(state: &mut AppStateRest, action: Action) { match action { Action::ForceQuit => { save_current_session(state); state.quit = true; } Action::SwitchMode(mode) => { state.misc.overlay = match mode { ModeKind::Chat | ModeKind::Bash | ModeKind::Workflow => Overlay::None, ModeKind::Help => Overlay::Help, ModeKind::Settings => Overlay::Settings, ModeKind::QuitConfirm => Overlay::QuitConfirm, ModeKind::KeyInput => Overlay::KeyInput, ModeKind::Editor => Overlay::Editor, ModeKind::Effort => Overlay::Effort, ModeKind::Mcp => Overlay::Mcp, ModeKind::Todo => Overlay::Todo, ModeKind::Rewind => Overlay::Rewind, ModeKind::Loading => Overlay::Loading, }; state.dirty = true; } Action::SubmitInput(text) => { state.input.submit(); let text = text.trim().to_string(); if text.is_empty() { state.dirty = true; return; } state.push_transcript(ChatMessageDisplay::new(Role::User, text.clone())); if let Some(ref mut rt) = state.session_runtime { rt.push_message(ChatMessage::user(text)); } state.misc.thinking = true; spawn_turn(state); state.dirty = true; } Action::InsertChar(c) => { state.input.insert(c); 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 => { state.scroll.scroll_up(5); state.dirty = true; } Action::ScrollDown => { state.scroll.scroll_down(5); state.dirty = true; } Action::OpenOverlay(overlay) => { state.misc.overlay = overlay; state.dirty = true; } 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 = 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 = command.split_whitespace().map(|s| s.to_string()).collect(); let cmd = extra_args.first().cloned().unwrap_or_default(); let args: Vec = 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))); } } } Action::ModelList => { state.misc.selected_index = 0; state.misc.overlay = Overlay::ModelSelector; state.dirty = true; } Action::CloseOverlay => { // If the overlay is the Editor, dismiss it properly first if state.misc.overlay == Overlay::Editor { crate::app::mode::editor::handle_editor_dismiss(state); } 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; } Action::LessonExport { path } => { let dest = std::path::Path::new(&path); if let Some(parent) = dest.parent() { let _ = std::fs::create_dir_all(parent); } match crate::model::memory::export_lessons(&state.memory_dir, dest) { Ok(_) => { state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Success, format!("lessons exported to {}", path), )); } Err(e) => { state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Error, format!("export failed: {}", e), )); } } state.dirty = true; } Action::LessonImport { path } => { let src = std::path::Path::new(&path); match crate::model::memory::import_lessons(&state.memory_dir, src) { Ok(count) => { state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Success, format!("imported {} lessons from {}", count, path), )); } Err(e) => { state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Error, format!("import failed: {}", e), )); } } state.dirty = true; } 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; } Action::Tick => { state.misc.tick_count = state.misc.tick_count.wrapping_add(1); let now_ms = chrono::Utc::now().timestamp_millis(); state.misc.drain_expired_toasts(now_ms); 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); } let events: Vec = { if let Ok(mut q) = state.turn_events.lock() { q.drain(..).collect() } else { Vec::new() } }; let mut turn_finished = false; for event in events { match event { TurnEvent::AssistantMessage(msg) => { state.misc.thinking = false; state.misc.api_connected = true; let display_content = msg.content.clone().unwrap_or_default(); if !display_content.is_empty() { state.push_transcript(ChatMessageDisplay::new(Role::Assistant, display_content)); } if let Some(ref mut rt) = state.session_runtime { rt.push_message(msg); } } TurnEvent::ToolResult { tool_call_id, tool_name, output, is_error, path } => { state.misc.thinking = false; 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) }; state.push_transcript(ChatMessageDisplay::new( Role::Tool, display, )); 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::() { rt.edit_count += count; } } if should_trigger_review(state, Origin::Main) { let _ = trigger_review(state); } } else if kind == "review" { let lessons_found = if message.contains("lesson") || message.contains("Lesson") { message.rsplit(' ').next().and_then(|w| { w.trim_end_matches(')').trim_end_matches('s') .split('(').next_back() .and_then(|n| n.parse::().ok()) }).unwrap_or(0) } else { 0 }; if let Some(ref mut rt) = state.session_runtime { if lessons_found > 0 { rt.consecutive_empty_reviews = 0; rt.lesson_count += lessons_found; } else { rt.consecutive_empty_reviews += 1; } } state.push_toast(Toast::new(ToastKind::Info, message)); } 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())); } } else { state.push_toast(Toast::new(ToastKind::Info, message)); } } TurnEvent::StreamStart => { state.misc.thinking = false; state.misc.api_connected = true; 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; rt.usage.api_calls += 1; } } TurnEvent::Error(msg) => { state.misc.api_connected = false; 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), )); turn_finished = true; } TurnEvent::Done => { state.misc.thinking = false; turn_finished = true; } } } if turn_finished { maybe_trigger_review(state); } if turn_finished || state.dirty { state.dirty = true; } } Action::AbortTurn => { state.abort_flag.store(true, std::sync::atomic::Ordering::SeqCst); state.push_toast(Toast::new(ToastKind::Warning, "Aborting generation...".to_string())); } 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, ); } 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, ); } state.push_toast(Toast::new(ToastKind::Info, format!("rejected lesson: {}", name))); state.dirty = true; } } } /// 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`. 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; } let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default(); let model = state.settings.model.clone(); let base_url = state.app_config.providers.get(&state.settings.provider) .map(|p| p.api_base.clone()); 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(); } } if api_key.is_empty() { api_key = crate::service::provider::DEFAULT_API_KEY.to_string(); } let (temperature, max_tokens) = crate::app::mode::effort::generation_params( state.misc.effort_level, state.settings.max_tokens, ); 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(); let edit_session_dir = state.session_dir.clone(); 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 = ctx.workspaces.clone(); let abort_flag = state.abort_flag.clone(); abort_flag.store(false, std::sync::atomic::Ordering::SeqCst); *in_flight_flag.lock().unwrap() = true; let events_q = turn_events.clone(); std::thread::spawn(move || { let db = crate::model::msglog::open_or_create(&edit_session_dir) .ok() .map(|c| std::sync::Arc::new(std::sync::Mutex::new(c))); let tc = TurnCtx { client: crate::service::provider::LlmClient::new(api_key, model, base_url), tdefs: tool_defs, tools, ctx, workspace_roots, edit_log_session_dir: edit_session_dir, session_id, db, temperature, max_tokens, abort_flag, }; 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; } }); } /// Context bundle passed to `run_agent_turn` on its background thread. struct TurnCtx { client: crate::service::provider::LlmClient, tdefs: Vec, tools: Vec>, ctx: crate::tool::ToolCtx, workspace_roots: Vec, edit_log_session_dir: std::path::PathBuf, session_id: String, db: Option>>, temperature: f32, max_tokens: u32, abort_flag: std::sync::Arc, } /// 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. 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; 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; } } } } out } /// 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. fn archive_message(db: &Option>>, 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); } } } /// 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. fn run_agent_turn( tc: TurnCtx, messages: &[ChatMessage], events_q: &std::sync::Mutex>, ) -> anyhow::Result<()> { let mut msgs = messages.to_vec(); let mut edits_this_turn = 0u32; let mut prev_shaped = false; let tree_info = generate_workspace_tree(&tc.workspace_roots); let system_text = format!( "{}\n\n{}\n\n{}", crate::resources::SYSTEM_PROMPT, crate::resources::SYSTEM_TOOLS, tree_info ); if !msgs.iter().any(|m| matches!(m.role, crate::dto::chat::message::Role::System)) { let sys = ChatMessage::system(system_text); archive_message(&tc.db, &tc.session_id, &sys); msgs.insert(0, sys); } loop { let wire_msgs = if crate::app::runtime::shortsend::should_shape(msgs.len(), prev_shaped) { let total_chars: usize = msgs.iter() .filter_map(|m| m.content.as_deref()) .map(|c| c.len()) .sum(); let token_estimate = total_chars / 4; prev_shaped = true; crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate) } else { prev_shaped = false; msgs.clone() }; let mut stream_started = false; let mut reasoning_started = false; let mut reasoning_ended = false; let mut usage = None; let result = tc.client.chat_with_tools_streaming( &wire_msgs, Some(tc.tdefs.clone()), Some(tc.temperature), Some(tc.max_tokens), |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; } if reasoning_started && !reasoning_ended { reasoning_ended = true; q.push_back(TurnEvent::StreamToken("\n\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("\n".to_string())); } q.push_back(TurnEvent::StreamToken(tok.clone())); } crate::app::runtime::stream::StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => { usage = Some((*prompt_tokens, *completion_tokens)); } _ => {} } } true }, ); if reasoning_started && !reasoning_ended { if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::StreamToken("\n\n\n".to_string())); } } 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(()); } 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); } } } }; 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 }); } } 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(); archive_message(&tc.db, &tc.session_id, &response); msgs.push(response); for tool_call in tool_calls { 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(()); } 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, &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, &tool_call.id, &args, &tc.edit_log_session_dir, &tc.session_id, &tc.db, ) { 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; } let tool_path = args.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()); { 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, path: tool_path, }); } } let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output); archive_message(&tc.db, &tc.session_id, &tool_msg); msgs.push(tool_msg); } } else { if !content.is_empty() { archive_message(&tc.db, &tc.session_id, &response); if let Ok(mut q) = events_q.lock() { if stream_started { q.push_back(TurnEvent::StreamDone(response.clone())); } else { q.push_back(TurnEvent::AssistantMessage(response.clone())); } } } 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; } 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(()) } /// 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. #[allow(clippy::too_many_arguments)] fn execute_one_tool( tools: &[Box], ctx: &crate::tool::ToolCtx, name: &str, tool_call_id: &str, args: &serde_json::Value, session_dir: &std::path::Path, session_id: &str, db: &Option>>, ) -> anyhow::Result { for tool in tools { if tool.name() == name { // 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, ); } } } } } 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) }; 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() }; 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, bytes_delta, origin: ctx.origin.tag(), session_id: session_id.to_string(), }; let mut el = crate::model::editlog::EditLog::new(session_dir); el.append(entry).ok(); } return Ok(result); } } anyhow::bail!("tool not found: {}", name) } /// 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. 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, format!("{} file(s) modified this session. Review available.", edit_count), )); } /// 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. 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); } } } /// 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. fn run_oauth_flow(provider: &str) -> anyhow::Result { 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()], } } }; 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()); 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 ); } let code = server.wait_for_code(120_000, &state_token)?; 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); } let _ = std::fs::write(&token_path, serde_json::to_string_pretty(token).unwrap_or_default()); } Ok(format!("Successfully authenticated with {}.", provider)) } /// 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. /// /// Why: avoids pulling in a full RNG crate for the OAuth state token; /// the counter ensures sequential invocations produce different outputs even /// within the same clock tick, which is sufficient for a short-lived nonce. fn rand_bytes(n: usize) -> Vec { use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; 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() }