diff --git a/apps/domain/src/auth/session_lock.rs b/apps/domain/src/auth/session_lock.rs index c42246f..c29e979 100644 --- a/apps/domain/src/auth/session_lock.rs +++ b/apps/domain/src/auth/session_lock.rs @@ -149,10 +149,7 @@ impl SessionLock { // Phase 3: re-check /proc//exe to detect PID reuse between // Phase 1 and Phase 2. - match std::fs::read_link(&proc_exe) { - Ok(recheck) if recheck == self_exe => true, - _ => false, - } + matches!(std::fs::read_link(&proc_exe), Ok(recheck) if recheck == self_exe) } #[cfg(not(unix))] diff --git a/apps/infrastructure/src/agent/runner.rs b/apps/infrastructure/src/agent/runner.rs index 8a8f0b1..c8b45d5 100644 --- a/apps/infrastructure/src/agent/runner.rs +++ b/apps/infrastructure/src/agent/runner.rs @@ -11,7 +11,7 @@ use tracing::{debug, info, warn}; use zesdex_domain::core::tool_call::sanitize_tool_arguments; use zesdex_domain::core::ChatMessage; use crate::llm::provider::LlmClient; -use crate::subagent::auto::engine::trigger_auto_review; +use crate::subagent::auto::engine::spawn_background_review; use crate::tools::{all_tools, tool_defs, ToolCtx}; use crate::TurnEvent; @@ -26,8 +26,6 @@ pub struct AgentTurnParams { pub api_key: String, pub model: String, pub api_base: Option, - pub edit_count: u32, - pub consecutive_empty_reviews: u32, } /// Spawns an agent turn on a background OS thread. @@ -201,23 +199,6 @@ fn run_turn(params: &mut AgentTurnParams) { params .messages .push(ChatMessage::tool(tc.id.clone(), output.clone())); - - // Trigger auto-review after write/edit tool execution - if name == "write" || name == "edit" { - params.edit_count = params.edit_count.saturating_add(1); - - // If we have a workspace root, trigger review - if let Some(root) = params.workspace_roots.first() { - let _ = trigger_auto_review( - root, - params.edit_count, - &mut params.consecutive_empty_reviews, - 3, // max_skip: skip after 3 consecutive empty reviews - ¶ms.turn_events, - Some(&client), - ); - } - } } } Err(e) => { @@ -231,6 +212,26 @@ fn run_turn(params: &mut AgentTurnParams) { } } + // If edits were made, spawn a background auto-review after the turn ends. + // This runs asynchronously — findings arrive as TurnEvent::SystemNote events. + let had_edits = params + .messages + .iter() + .any(|m| { + m.role == zesdex_domain::core::Role::Tool + && m.content.as_deref().unwrap_or("").contains("Written") + }); + if had_edits { + info!("edits detected, spawning background auto-review"); + spawn_background_review( + params.workspace_roots.clone(), + params.turn_events.clone(), + params.api_key.clone(), + params.model.clone(), + params.api_base.clone(), + ); + } + // Propagate accumulated messages back to caller so the next turn starts // with full history (assistant replies + tool results). push_event( diff --git a/apps/infrastructure/src/persistence/iam/session_lock_repo.rs b/apps/infrastructure/src/persistence/iam/session_lock_repo.rs index 32647db..739d73e 100644 --- a/apps/infrastructure/src/persistence/iam/session_lock_repo.rs +++ b/apps/infrastructure/src/persistence/iam/session_lock_repo.rs @@ -102,9 +102,6 @@ impl SessionLockRepository for FileSystemSessionLockRepository { // Phase 3: re-check /proc//exe to detect PID reuse between // Phase 1 and Phase 2. - match std::fs::read_link(&proc_exe) { - Ok(recheck) if recheck == self_exe => true, - _ => false, - } + matches!(std::fs::read_link(&proc_exe), Ok(recheck) if recheck == self_exe) } } diff --git a/apps/infrastructure/src/subagent/auto/engine.rs b/apps/infrastructure/src/subagent/auto/engine.rs index 195aa30..a619dcd 100644 --- a/apps/infrastructure/src/subagent/auto/engine.rs +++ b/apps/infrastructure/src/subagent/auto/engine.rs @@ -1,280 +1,305 @@ -//! Auto-review engine — automatically checks git diff after file edits -//! using an LLM subagent. +//! Auto-review engine — after edits, spawns a subagent that reviews AND +//! automatically fixes issues using tool access (edit/write/grep). //! -//! Flow: after each write/edit tool execution in the agent turn, the runner -//! calls `trigger_auto_review` which: -//! 1. Runs `git diff --cached` and `git diff` to get working-tree changes -//! 2. Sends the diff to a lightweight LLM call for quick review -//! 3. Emits findings as `TurnEvent::SystemNote` on the event queue +//! Flow: after an agent turn with edits completes: +//! 1. Emit `WorkflowAgentUpdate(Running)` → visible in workflow sidebar +//! 2. Run `git diff` to get the changed files +//! 3. Spawn the subagent engine with Write-tier tools + directive to +//! review & fix +//! 4. The subagent finds issues and applies fixes using edit/write tools +//! 5. Results stream as `TurnEvent::SystemNote` events +//! 6. Emit `WorkflowAgentUpdate(Completed)` when done use std::collections::VecDeque; -use std::path::Path; +use std::path::PathBuf; use std::process::Command; use std::sync::{Arc, Mutex}; -use anyhow::Result; use tracing::{debug, info, instrument, warn}; -use crate::llm::provider::LlmClient; -use crate::subagent::gating::should_review; -use crate::TurnEvent; +use crate::subagent::context::SubagentContext; +use crate::subagent::division::AccessTier; +use crate::subagent::engine::run_agent; +use crate::tools::ToolCtx; +use crate::{AgentStatus, TurnEvent}; -/// Trigger an auto-review of recent git changes. +const REVIEW_AGENT_ID: &str = "auto-review"; + +/// Spawn a review subagent that reviews changes and auto-fixes issues. /// -/// Flow: -/// 1. Check gating conditions (edit count, consecutive empty reviews) -/// 2. Run `git diff --cached` to get staged changes -/// 3. Run `git diff` to get unstaged changes -/// 4. If there are changes, call LLM for a quick review -/// 5. Emit findings as TurnEvent::SystemNote +/// The subagent runs inline on the current background thread (no extra +/// thread spawn) with its own tokio runtime and Write-tier tool access +/// (edit, write, grep, read, glob). It receives the git diff as context +/// and is directed to: +/// 1. Read changed files +/// 2. Check for typos, missing imports, syntax errors, logic bugs +/// 3. Fix any issues found using edit/write tools +/// 4. Report what was fixed /// -/// Returns `(had_findings, total_findings)` tuple. -#[instrument(skip(turn_events, llm_client))] -pub fn trigger_auto_review( - workspace_root: &Path, - edit_count: u32, - consecutive_empty_reviews: &mut u32, - max_skip: u32, - turn_events: &Arc>>, - llm_client: Option<&LlmClient>, -) -> Result<(bool, usize)> { - // Gating check - if !should_review(edit_count, *consecutive_empty_reviews, max_skip) { - debug!("auto-review skipped by gating"); - return Ok((false, 0)); - } +/// All findings stream as TurnEvent events consumed by the TUI event loop. +#[instrument(skip(turn_events))] +pub fn spawn_background_review( + workspace_roots: Vec, + turn_events: Arc>>, + api_key: String, + model: String, + api_base: Option, +) { + let agent_id = REVIEW_AGENT_ID.to_string(); + let agent_name = "Auto-Review".to_string(); - info!("triggering auto-review"); + std::thread::spawn(move || { + let root = match workspace_roots.first() { + Some(r) => r.clone(), + None => { + debug!("auto-review: no workspace root, skipping"); + return; + } + }; - // Run git diff to get changes - let diff = match get_git_diff(workspace_root) { - Ok(d) => d, - Err(e) => { - debug!(error = %e, "auto-review: git diff failed (not a git repo?)"); - return Ok((false, 0)); - } - }; + info!("auto-review: starting"); - if diff.is_empty() { - debug!("auto-review: no changes to review"); - *consecutive_empty_reviews = consecutive_empty_reviews.saturating_add(1); - return Ok((false, 0)); - } + // Mark Running in workflow panel + push_event( + &turn_events, + TurnEvent::WorkflowAgentUpdate { + agent_id: agent_id.clone(), + agent_name: agent_name.clone(), + status: AgentStatus::Running, + }, + ); - // If we have an LLM client, do a real review - let review_result = if let Some(client) = llm_client { - perform_llm_review(client, &diff)? - } else { - // Fallback: simple heuristic review without LLM - perform_heuristic_review(&diff) - }; + // 1. Get the git diff to know what changed + let diff = match get_git_diff(&root) { + Ok(d) if !d.is_empty() => d, + Ok(_) => { + debug!("auto-review: no changes detected"); + push_event( + &turn_events, + TurnEvent::WorkflowAgentUpdate { + agent_id, + agent_name, + status: AgentStatus::Completed, + }, + ); + return; + } + Err(e) => { + debug!(error = %e, "auto-review: git diff failed"); + push_event( + &turn_events, + TurnEvent::WorkflowAgentUpdate { + agent_id, + agent_name, + status: AgentStatus::Failed(e), + }, + ); + return; + } + }; - let had_findings = !review_result.is_empty(); - let finding_count = review_result.len(); + // Truncate very large diffs for the prompt + const MAX_DIFF_CHARS: usize = 5000; + let truncated_diff = if diff.len() > MAX_DIFF_CHARS { + push_event( + &turn_events, + TurnEvent::SystemNote { + kind: "review".into(), + message: format!( + "📐 Diff is large ({} chars), reviewing first {} chars...", + diff.len(), + MAX_DIFF_CHARS + ), + }, + ); + format!( + "{}...\n[diff truncated at {} characters]", + &diff[..MAX_DIFF_CHARS], + MAX_DIFF_CHARS + ) + } else { + diff.to_string() + }; - if had_findings { - *consecutive_empty_reviews = 0; - info!(finding_count, "auto-review produced findings"); + // 2. Build directive: review AND fix issues using tools + let directive = format!( + "You are an auto-review subagent. Complete the following:\n\n\ + 1. Review this git diff for:\n\ + - Typos and spelling errors\n\ + - Missing imports or undefined references\n\ + - Syntax errors or type mismatches\n\ + - Logic bugs or off-by-one errors\n\ + - Missing error handling\n\ + - Security issues\n\n\ + 2. FIX any issues you find using the available tools:\n\ + - `read` to check file contents\n\ + - `edit` to fix specific text blocks\n\ + - `write` to replace files if needed\n\ + - `grep` to find related patterns\n\n\ + 3. Be conservative: only fix CLEAR, CONFIRMED issues. \ + Don't change logic, style, or formatting.\n\ + 4. Report what you fixed at the end.\n\n\ + Git diff of changes:\n\n```diff\n{truncated_diff}\n```" + ); - // Emit findings as SystemNote events - for finding in &review_result { - let note = TurnEvent::SystemNote { - kind: "info".to_string(), - message: format!("🔍 Auto-Review: {finding}"), - }; - if let Ok(mut q) = turn_events.lock() { - q.push_back(note); + // 3. Emit progress note + push_event( + &turn_events, + TurnEvent::SystemNote { + kind: "review".into(), + message: "🔍 Auto-review: examining and fixing issues...".into(), + }, + ); + + // 4. Build minimal ToolCtx + let tool_ctx = ToolCtx::builder() + .session_dir(root.join(".zesdex").join("sessions").join("auto-review")) + .workspaces(workspace_roots.clone()) + .turn_events(turn_events.clone()) + .build(); + + // 5. Determine base URL + let base_url = api_base.unwrap_or_else(|| { + std::env::var("OPENAI_API_BASE") + .unwrap_or_else(|_| "https://opencode.ai/zen/v1".to_string()) + }); + + // 6. Build SubagentContext + let subagent_ctx = SubagentContext::new( + directive, + tool_ctx.clone(), + "write".to_string(), + base_url, + api_key, + model, + ); + + // 7. Run the subagent engine directly on this thread + // (creates its own tokio runtime, calls run_agent with Write tools) + let rt = match tokio::runtime::Runtime::new() { + Ok(r) => r, + Err(e) => { + warn!(error = %e, "auto-review: failed to create tokio runtime"); + push_event( + &turn_events, + TurnEvent::WorkflowAgentUpdate { + agent_id, + agent_name, + status: AgentStatus::Failed(format!("runtime error: {e}")), + }, + ); + return; + } + }; + + let result = rt.block_on(run_agent( + subagent_ctx, + "Auto-review and fix issues in the changed files", + AccessTier::Write, + tool_ctx, + )); + + // 8. Report results + match result { + Ok(report) => { + let trimmed = report.trim(); + if trimmed.is_empty() || trimmed.contains("no issues") { + push_event( + &turn_events, + TurnEvent::SystemNote { + kind: "review".into(), + message: "✅ Auto-review: no issues found.".into(), + }, + ); + info!("auto-review: no issues found"); + } else { + push_event( + &turn_events, + TurnEvent::SystemNote { + kind: "review_finding".into(), + message: format!("📋 Auto-review complete:\n{}", trimmed), + }, + ); + info!("auto-review: completed with findings"); + } + push_event( + &turn_events, + TurnEvent::WorkflowAgentUpdate { + agent_id, + agent_name, + status: AgentStatus::Completed, + }, + ); + } + Err(e) => { + warn!(error = %e, "auto-review subagent failed"); + push_event( + &turn_events, + TurnEvent::SystemNote { + kind: "review".into(), + message: format!("⚠️ Auto-review encountered an error: {e}"), + }, + ); + push_event( + &turn_events, + TurnEvent::WorkflowAgentUpdate { + agent_id, + agent_name, + status: AgentStatus::Failed(e.to_string()), + }, + ); } } - } else { - *consecutive_empty_reviews = consecutive_empty_reviews.saturating_add(1); - info!("auto-review: no issues found"); - } - - Ok((had_findings, finding_count)) + }); } /// Run `git diff` to get workspace changes (both staged and unstaged). -fn get_git_diff(workspace_root: &Path) -> Result { - // Check if this is a git repo +fn get_git_diff(workspace_root: &PathBuf) -> Result { let git_dir = workspace_root.join(".git"); if !git_dir.exists() { - return Ok(String::new()); + return Err("not a git repository".to_string()); } - // Get unstaged diff - let unstaged = Command::new("git") - .arg("diff") - .current_dir(workspace_root) - .output() - .map_err(|e| anyhow::anyhow!("git diff failed: {e}"))?; - - // Get staged diff - let staged = Command::new("git") - .arg("diff") - .arg("--cached") - .current_dir(workspace_root) - .output() - .map_err(|e| anyhow::anyhow!("git diff --cached failed: {e}"))?; - let mut combined = String::new(); - let staged_out = String::from_utf8_lossy(&staged.stdout).trim().to_string(); - if !staged_out.is_empty() { - combined.push_str("=== Staged Changes ===\n"); - combined.push_str(&staged_out); - combined.push('\n'); - } - - let unstaged_out = String::from_utf8_lossy(&unstaged.stdout).trim().to_string(); - if !unstaged_out.is_empty() { - combined.push_str("=== Unstaged Changes ===\n"); - combined.push_str(&unstaged_out); - combined.push('\n'); - } - - // Run `git diff --stat` for summary - let stat = Command::new("git") - .arg("diff") - .arg("--stat") + // Unstaged diff + if let Ok(out) = Command::new("git") + .args(["diff"]) .current_dir(workspace_root) .output() - .map_err(|e| anyhow::anyhow!("git diff --stat failed: {e}"))?; - let stat_out = String::from_utf8_lossy(&stat.stdout).trim().to_string(); - if !stat_out.is_empty() { - combined.push_str("=== Summary ===\n"); - combined.push_str(&stat_out); - combined.push('\n'); + { + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !stdout.is_empty() { + combined.push_str("=== Unstaged Changes ===\n"); + combined.push_str(&stdout); + combined.push('\n'); + } + } + + // Staged diff + if let Ok(out) = Command::new("git") + .args(["diff", "--cached"]) + .current_dir(workspace_root) + .output() + { + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !stdout.is_empty() { + combined.push_str("=== Staged Changes ===\n"); + combined.push_str(&stdout); + combined.push('\n'); + } + } + + if combined.is_empty() { + return Err("no changes".to_string()); } Ok(combined) } -/// Perform an LLM-based review of the git diff. -/// -/// Sends the diff to the model with a focused prompt asking it to find -/// typos, missing imports, syntax errors, and other issues. -fn perform_llm_review(client: &LlmClient, diff: &str) -> Result> { - // Truncate very large diffs to avoid token limits - const MAX_DIFF_CHARS: usize = 8000; - let truncated_diff = if diff.len() > MAX_DIFF_CHARS { - format!("{}...\n[diff truncated at {} characters]", - &diff[..MAX_DIFF_CHARS], MAX_DIFF_CHARS) - } else { - diff.to_string() - }; - - let messages = vec![ - zesdex_domain::core::ChatMessage::system( - "You are a focused code reviewer. Review the following git diff for:\n\ - 1. Typos and spelling errors\n\ - 2. Missing imports or undefined references\n\ - 3. Syntax errors or type mismatches\n\ - 4. Logic bugs or off-by-one errors\n\ - 5. Missing error handling\n\ - 6. Security issues\n\n\ - Be concise. List each issue on a new line with severity [HIGH], [MEDIUM], or [LOW].\n\ - If no issues are found, reply with exactly: NO_ISSUES_FOUND" - .to_string(), - ), - zesdex_domain::core::ChatMessage::user(format!( - "Review this git diff:\n\n```diff\n{truncated_diff}\n```" - )), - ]; - - match client.chat_with_tools_non_streaming(&messages, None, Some(1024), Some(0.3), None) { - Ok((response, _usage)) => { - let text = response.content.unwrap_or_default().trim().to_string(); - - if text.contains("NO_ISSUES_FOUND") { - return Ok(Vec::new()); - } - - // Parse findings line by line - let findings: Vec = text - .lines() - .map(|l| l.trim().to_string()) - .filter(|l| { - !l.is_empty() - && !l.starts_with("Here") - && !l.starts_with("Let me") - && !l.starts_with("I've") - && !l.starts_with("The diff") - }) - .collect(); - - Ok(findings) - } - Err(e) => { - warn!(error = %e, "auto-review LLM call failed"); - Ok(Vec::new()) - } +/// Push a TurnEvent onto the shared event queue. +fn push_event(queue: &Arc>>, event: TurnEvent) { + if let Ok(mut q) = queue.lock() { + q.push_back(event); } } - -/// Perform a simple heuristic-based review without an LLM call. -/// -/// This is a fallback when no LLM client is available. It checks for: -/// - Missing semicolons -/// - Unclosed brackets -/// - `todo!()` or `unimplemented!()` macros left in code -/// - Debug print statements -/// - Extremely long functions -fn perform_heuristic_review(diff: &str) -> Vec { - let mut findings = Vec::new(); - - // Check for added lines (lines starting with +) - let added_lines: Vec<&str> = diff - .lines() - .filter(|l| l.starts_with('+') && !l.starts_with("+++")) - .collect(); - - let added_content: String = added_lines - .iter() - .map(|l| &l[1..]) // Strip leading + - .collect::>() - .join("\n"); - - // Check for todo! and unimplemented! - if added_content.contains("todo!()") { - findings.push("[MEDIUM] `todo!()` found in new code — replace with implementation".to_string()); - } - if added_content.contains("unimplemented!()") { - findings.push("[MEDIUM] `unimplemented!()` found in new code — replace with implementation".to_string()); - } - - // Check for debug print statements - if added_content.contains("println!") || added_content.contains("dbg!") { - findings.push("[LOW] Debug print statements (println!/dbg!) found — consider removing before finalizing".to_string()); - } - if added_content.contains("eprintln!") { - findings.push("[LOW] Debug eprintln! found — consider removing before finalizing".to_string()); - } - - // Check for unreachable or panic statements - if added_content.contains("panic!(\"reached") || added_content.contains("panic!(\"not implemented") { - findings.push("[HIGH] Unreachable code / panic found — implement the missing logic".to_string()); - } - - // Check for very long lines (>120 chars) - for (i, line) in added_lines.iter().enumerate() { - let content = &line[1..]; // Strip leading + - if content.len() > 120 && !content.trim_start().starts_with("//") { - let preview: String = content.chars().take(80).collect(); - findings.push(format!( - "[LOW] Very long line ({} chars, line {} in diff) — consider breaking up:\n `{}…`", - content.len(), - i + 1, - preview - )); - } - } - - // Count new functions to detect very long additions - let fn_count = added_content.matches("fn ").count(); - if fn_count > 5 { - findings.push("[INFO] Large number of new functions ({fn_count}) — consider whether this should be split into separate modules".to_string()); - } - - findings -} diff --git a/apps/infrastructure/src/subagent/auto/paths.rs b/apps/infrastructure/src/subagent/auto/paths.rs index 48afc3a..961c920 100644 --- a/apps/infrastructure/src/subagent/auto/paths.rs +++ b/apps/infrastructure/src/subagent/auto/paths.rs @@ -1,8 +1,8 @@ //! Auto-subagent path resolution. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Resolve paths for auto-subagent scripts. -pub fn auto_subagent_dir(base_dir: &PathBuf) -> PathBuf { +pub fn auto_subagent_dir(base_dir: &Path) -> PathBuf { base_dir.join("auto-agents") } diff --git a/apps/infrastructure/src/tools/parallel_delegate.rs b/apps/infrastructure/src/tools/parallel_delegate.rs index 707a52a..ca140a9 100644 --- a/apps/infrastructure/src/tools/parallel_delegate.rs +++ b/apps/infrastructure/src/tools/parallel_delegate.rs @@ -165,7 +165,7 @@ impl Tool for ParallelDelegate { let handle = spawn_subagent( subagent_ctx, directive.clone(), - access.clone(), + *access, ctx.clone(), ); handles.push((i, handle)); diff --git a/apps/infrastructure/src/tools/semantic_search.rs b/apps/infrastructure/src/tools/semantic_search.rs index 626ea9b..6b5641b 100644 --- a/apps/infrastructure/src/tools/semantic_search.rs +++ b/apps/infrastructure/src/tools/semantic_search.rs @@ -468,18 +468,13 @@ fn extract_doc_comments(lines: &[&str]) -> HashMap { /// Find the next line that looks like a declaration (not doc, not attr). fn find_next_declaration_line(lines: &[&str], start: usize) -> Option { - for i in start..lines.len() { - let trimmed = lines[i].trim(); - if trimmed.is_empty() - || trimmed.starts_with("///") - || trimmed.starts_with("//!") - || trimmed.starts_with('#') - { - continue; - } - return Some(i); - } - None + lines[start..].iter().position(|line| { + let trimmed = line.trim(); + !trimmed.is_empty() + && !trimmed.starts_with("///") + && !trimmed.starts_with("//!") + && !trimmed.starts_with('#') + }).map(|pos| start + pos) } // --------------------------------------------------------------------------- diff --git a/apps/infrastructure/src/utils.rs b/apps/infrastructure/src/utils.rs index 44aed29..eb981bc 100644 --- a/apps/infrastructure/src/utils.rs +++ b/apps/infrastructure/src/utils.rs @@ -163,28 +163,26 @@ pub fn build_workspace_tree(root: &Path, max_files: usize) -> String { // hidden(false) allows files like .github to be seen, but it still respects .gitignore // and ignores .git directories by default. - for result in WalkBuilder::new(root).hidden(false).build() { - if let Ok(entry) = result { - if count >= max_files { - tree.push_str("\n... (truncated)"); - break; - } - - let path = entry.path(); - if let Ok(rel) = path.strip_prefix(root) { - let name = rel.to_string_lossy(); - if name.is_empty() { - tree.push_str(".\n"); - } else { - let depth = entry.depth(); - let indent = " ".repeat(depth.saturating_sub(1)); - let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); - let suffix = if is_dir { "/" } else { "" }; - - let filename = entry.file_name().to_string_lossy(); - tree.push_str(&format!("{indent}{filename}{suffix}\n")); - count += 1; - } + for entry in WalkBuilder::new(root).hidden(false).build().flatten() { + if count >= max_files { + tree.push_str("\n... (truncated)"); + break; + } + + let path = entry.path(); + if let Ok(rel) = path.strip_prefix(root) { + let name = rel.to_string_lossy(); + if name.is_empty() { + tree.push_str(".\n"); + } else { + let depth = entry.depth(); + let indent = " ".repeat(depth.saturating_sub(1)); + let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); + let suffix = if is_dir { "/" } else { "" }; + + let filename = entry.file_name().to_string_lossy(); + tree.push_str(&format!("{indent}{filename}{suffix}\n")); + count += 1; } } } diff --git a/apps/interfaces/daemon/src/handler.rs b/apps/interfaces/daemon/src/handler.rs index ff28cf2..d65044f 100644 --- a/apps/interfaces/daemon/src/handler.rs +++ b/apps/interfaces/daemon/src/handler.rs @@ -330,8 +330,6 @@ fn handle_submit_input(state: &mut AppStateRest, text: String) { api_key, model: state.settings.model.clone(), api_base: provider_cfg.map(|cfg| cfg.api_base.clone()), - edit_count: 0, - consecutive_empty_reviews: 0, }; zesdex_infrastructure::agent::spawn_agent_turn(params); diff --git a/apps/interfaces/tui/src/action.rs b/apps/interfaces/tui/src/action.rs index 5c898b4..d1a82f6 100644 --- a/apps/interfaces/tui/src/action.rs +++ b/apps/interfaces/tui/src/action.rs @@ -203,6 +203,76 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) { // Mark dirty so spinner disappears state.dirty = true; } + zesdex_infrastructure::TurnEvent::WorkflowAgentUpdate { + agent_id, + agent_name, + status, + } => { + // Find existing agent by ID, or create new one + let idx = state + .workflow_engine + .agents + .iter() + .position(|a| a.name == agent_id); + + match status { + zesdex_infrastructure::AgentStatus::Pending => { + if idx.is_none() { + state.workflow_engine.agents.push( + crate::state::SimpleAgent::with_display( + agent_id, + agent_name, + ), + ); + } + } + zesdex_infrastructure::AgentStatus::Running => { + if let Some(i) = idx { + state.workflow_engine.agents[i].state = + crate::state::AgentState::Running; + state.workflow_engine.agents[i].display_name = + agent_name; + state.workflow_engine.agents[i].started_at = + Some(chrono::Utc::now().timestamp_millis()); + } else { + let mut agent = + crate::state::SimpleAgent::with_display( + agent_id, + agent_name, + ); + agent.state = crate::state::AgentState::Running; + agent.started_at = + Some(chrono::Utc::now().timestamp_millis()); + state.workflow_engine.agents.push(agent); + } + } + zesdex_infrastructure::AgentStatus::Completed => { + if let Some(i) = idx { + state.workflow_engine.agents[i].state = + crate::state::AgentState::Completed; + state.workflow_engine.agents[i].display_name = + agent_name; + state.workflow_engine.agents[i].completed_at = + Some(chrono::Utc::now().timestamp_millis()); + } + } + zesdex_infrastructure::AgentStatus::Failed(msg) => { + if let Some(i) = idx { + state.workflow_engine.agents[i].state = + crate::state::AgentState::Failed; + state.workflow_engine.agents[i].display_name = + agent_name; + state.workflow_engine.agents[i].error = Some(msg); + } + } + zesdex_infrastructure::AgentStatus::Cancelled => { + if let Some(i) = idx { + state.workflow_engine.agents.remove(i); + } + } + } + state.dirty = true; + } _ => { debug!("unhandled turn event variant"); state.dirty = true; diff --git a/apps/interfaces/tui/src/state.rs b/apps/interfaces/tui/src/state.rs index 50028ad..18b6416 100644 --- a/apps/interfaces/tui/src/state.rs +++ b/apps/interfaces/tui/src/state.rs @@ -616,8 +616,10 @@ pub enum AgentState { /// A single agent entry in the workflow sidebar. #[derive(Debug, Clone)] pub struct SimpleAgent { - /// Agent display name. + /// Agent unique ID (e.g. "auto-review", "Node-0-1"). pub name: String, + /// Human-readable display label (e.g. "Auto-Review", "Backend API Agent"). + pub display_name: String, /// Current lifecycle state. pub state: AgentState, /// Millisecond timestamp when the agent started. @@ -631,9 +633,10 @@ pub struct SimpleAgent { } impl SimpleAgent { - /// Create a new agent with the given name. + /// Create a new agent with the given name (used as both ID and display name). pub fn new(name: String) -> Self { SimpleAgent { + display_name: name.clone(), name, state: AgentState::Idle, started_at: None, @@ -642,6 +645,19 @@ impl SimpleAgent { progress: None, } } + + /// Create a new agent with separate ID and display label. + pub fn with_display(name: String, display_name: String) -> Self { + SimpleAgent { + name, + display_name, + state: AgentState::Idle, + started_at: None, + completed_at: None, + error: None, + progress: None, + } + } } /// Simplified workflow engine state for TUI display. diff --git a/apps/interfaces/tui/src/turn.rs b/apps/interfaces/tui/src/turn.rs index d75395b..c17593b 100644 --- a/apps/interfaces/tui/src/turn.rs +++ b/apps/interfaces/tui/src/turn.rs @@ -61,17 +61,6 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) { info!("delegating agent turn to infrastructure engine (model: {})", model); - let edit_count = state - .session_runtime - .as_ref() - .map(|rt| rt.edit_count) - .unwrap_or(0); - let consecutive_empty_reviews = state - .session_runtime - .as_ref() - .map(|rt| rt.consecutive_empty_reviews) - .unwrap_or(0); - let params = AgentTurnParams { messages, session_dir, @@ -82,8 +71,6 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) { api_key, model, api_base, - edit_count, - consecutive_empty_reviews, }; backend_spawn_agent_turn(params); diff --git a/apps/interfaces/tui/src/view/workflow.rs b/apps/interfaces/tui/src/view/workflow.rs index 4b1944c..d15b64e 100644 --- a/apps/interfaces/tui/src/view/workflow.rs +++ b/apps/interfaces/tui/src/view/workflow.rs @@ -130,7 +130,7 @@ pub fn draw_workflow_panel( Style::default().fg(color).add_modifier(Modifier::BOLD), ), Span::styled( - format!(" {}", agent.name), + format!(" {}", agent.display_name), Style::default() .fg(Theme::TEXT) .add_modifier(Modifier::BOLD), diff --git a/apps/interfaces/ws/src/lib.rs b/apps/interfaces/ws/src/lib.rs index c2c2a56..a477e93 100644 --- a/apps/interfaces/ws/src/lib.rs +++ b/apps/interfaces/ws/src/lib.rs @@ -92,8 +92,6 @@ async fn handle_socket(mut socket: WebSocket, state: Arc) { api_key, model, api_base: None, - edit_count: 0, - consecutive_empty_reviews: 0, }; zesdex_infrastructure::agent::spawn_agent_turn(params);