refactor: DRY cleanup — extract shared helpers, remove duplication across tools, LSP, overlays, and runtime

Eliminate ~500 lines of duplicate code across 31 files by extracting
shared functions, helpers, and consolidating repeated patterns.

Highlights:
- Toast helpers (toast_info/success/warning/error) on AppStateRest
- push_event() helper for turn-event queue (19 callers consolidated)
- log_write_edit_tool() shared fn (turn.rs + engine.rs ~50 lines saved)
- resolve_api_key() shared fn (spawn.rs + provider.rs)
- LSP call_positional() helper on LspClient
- lsp_cursor_params() shared schema for 4 tool files
-overlay_block() helper for consistent overlay title/border styling
- cycle_selected_index(), path_not_found/a_directory() helpers
- mark_dirty(), save_settings() on AppStateRest
- Remove redundant Err(e) => Err(e) arms in LSP tools
- Consolidate generate_workspace_tree (turn.rs → workspace.rs)
- Simplify background-review wrapper args in auto/mod.rs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
asepharyana
2026-07-18 03:18:27 +07:00
co-authored by Claude Opus 4.8
parent b02754acd2
commit 9a6ab62562
31 changed files with 628 additions and 870 deletions
+42 -30
View File
@@ -303,24 +303,35 @@ impl LspClient {
)
}
/// Call a textDocument/positional method (hover, completion, definition, references).
///
/// Builds the standard `{ textDocument: { uri }, position: { line, character } }` body
/// and delegates to `self.call`. `extra` is merged into the body when present (used by
/// `references` to include the `context` block).
fn call_positional(
&mut self,
method: &str,
uri: &str,
line: u32,
character: u32,
extra: Option<serde_json::Value>,
) -> anyhow::Result<Value> {
let mut body = json!({
"textDocument": { "uri": uri },
"position": { "line": line, "character": character },
});
if let Some(ref extra) = extra {
merge_json(&mut body, extra);
}
self.call(method, &body)
}
pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call(
"textDocument/hover",
&json!({
"textDocument": { "uri": uri },
"position": { "line": line, "character": character }
}),
)
self.call_positional("textDocument/hover", uri, line, character, None)
}
pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call(
"textDocument/completion",
&json!({
"textDocument": { "uri": uri },
"position": { "line": line, "character": character }
}),
)
self.call_positional("textDocument/completion", uri, line, character, None)
}
pub fn goto_definition(
@@ -329,25 +340,13 @@ impl LspClient {
line: u32,
character: u32,
) -> anyhow::Result<Value> {
self.call(
"textDocument/definition",
&json!({
"textDocument": { "uri": uri },
"position": { "line": line, "character": character }
}),
)
self.call_positional("textDocument/definition", uri, line, character, None)
}
pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call(
"textDocument/references",
&json!({
"textDocument": { "uri": uri },
"position": { "line": line, "character": character },
"context": {
"includeDeclaration": true
}
}),
self.call_positional(
"textDocument/references", uri, line, character,
Some(json!({"context": { "includeDeclaration": true }})),
)
}
@@ -384,6 +383,19 @@ impl Drop for LspClient {
}
}
/// Merge the fields of `b` into the object `a` (mutating `a` in place).
///
/// Used by `LspClient::call_positional` to layer extra fields (e.g. `context`)
/// onto the standard positional-query body. When `a` is not an object or `b`
/// is not an object this is a no-op.
fn merge_json(a: &mut serde_json::Value, b: &serde_json::Value) {
if let (Some(map), Some(extra)) = (a.as_object_mut(), b.as_object()) {
for (k, v) in extra {
map.insert(k.clone(), v.clone());
}
}
}
pub fn path_to_lsp_uri(path: &str) -> String {
file_path_to_uri(path)
}
+1 -4
View File
@@ -47,9 +47,6 @@ pub fn cycle_effort(state: &mut AppStateRest) {
let current = current_effort(state);
state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len();
let label = current_effort_str(state);
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Info,
format!("Effort: {label}"),
));
state.toast_info(format!("Effort: {label}"));
state.dirty = true;
}
+19
View File
@@ -11,3 +11,22 @@ pub mod quit_confirm;
pub mod rewind;
pub mod settings;
pub mod todo;
/// Cycle `current` in the range `[0, len)`.
///
/// * `forward = true` — increment (wrap at len)
/// * `forward = false` — decrement (wrap at 0), saturating at 0 when len is 0
///
/// Return: `0` when `len == 0`, otherwise the wrapped index.
pub fn cycle_selected_index(current: usize, len: usize, forward: bool) -> usize {
if len == 0 {
return 0;
}
if forward {
(current + 1) % len
} else if current == 0 {
len.saturating_sub(1)
} else {
current - 1
}
}
+7 -28
View File
@@ -27,10 +27,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
let conn = match open_session_db(&state.session_dir) {
Ok(c) => c,
Err(e) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("Failed to open session DB: {e}"),
));
state.toast_error(format!("Failed to open session DB: {e}"));
state.dirty = true;
return;
}
@@ -39,20 +36,14 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
let keys = match crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id) {
Ok(k) => k,
Err(e) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("Failed to list snapshots: {e}"),
));
state.toast_error(format!("Failed to list snapshots: {e}"));
state.dirty = true;
return;
}
};
if keys.is_empty() || index >= keys.len() {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Warning,
"No snapshot available at that index".to_string(),
));
state.toast_warning("No snapshot available at that index".to_string());
state.dirty = true;
return;
}
@@ -62,18 +53,12 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
{
Ok(Some(b)) => b,
Ok(None) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
"Snapshot data not found".to_string(),
));
state.toast_error("Snapshot data not found".to_string());
state.dirty = true;
return;
}
Err(e) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("Failed to retrieve snapshot: {e}"),
));
state.toast_error(format!("Failed to retrieve snapshot: {e}"));
state.dirty = true;
return;
}
@@ -87,16 +72,10 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
match std::fs::write(&restore_path, &bytes) {
Ok(()) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Success,
format!("Restored {} from snapshot", restore_path.display()),
));
state.toast_success(format!("Restored {} from snapshot", restore_path.display()));
}
Err(e) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("Failed to write restored file: {e}"),
));
state.toast_error(format!("Failed to write restored file: {e}"));
}
}
@@ -57,12 +57,9 @@ pub(super) fn spawn_turn(state: &AppStateRest) {
if messages.is_empty() {
return;
}
let mut api_key = state
.settings
.api_keys
.get(&state.settings.provider)
.cloned()
.unwrap_or_default();
let mut api_key = crate::service::provider::resolve_api_key(
&state.settings, &state.app_config,
);
let model = state.settings.model.clone();
let base_url = state
.app_config
@@ -95,16 +92,6 @@ pub(super) fn spawn_turn(state: &AppStateRest) {
}
return;
}
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();
}
@@ -271,6 +271,19 @@ pub(super) fn handle_tick(state: &mut AppStateRest) {
}
TurnEvent::StreamDone(msg) => {
state.misc.thinking = false;
// Replace the partial streaming transcript with the complete
// message content. In the normal streaming path this is a
// no-op (the accumulated tokens already match), but when the
// non-streaming fallback fires the response is a completely
// new generation — the partial SSE text must be overwritten.
if let Some(content) = &msg.content {
if let Some(last) = state.transcript_cache.messages.last_mut() {
if last.role == Role::Assistant {
last.content.clone_from(content);
state.transcript_cache.dirty = true;
}
}
}
if let Some(ref mut rt) = state.session_runtime {
rt.push_message(msg);
}
@@ -3,18 +3,17 @@
//! messages, and manages auto-retry for unfinished tasks.
//!
//! Also contains the smaller helpers that the loop depends on:
//! `execute_one_tool`, `generate_workspace_tree`, `build_memory_section`,
//! and `archive_message`.
//! `execute_one_tool`, `build_memory_section`, and `archive_message`.
use std::collections::VecDeque;
use std::fmt::Write;
use sha2::Digest;
use zesdex_cms::domain::repository::EditLogRepository;
use crate::app::guard::Verdict;
use crate::app::runtime::context::tokens::count_tokens;
use crate::app::runtime::push_event;
use crate::app::state::runtime::TurnEvent;
use zesdex_cms::domain::repository::EditLogRepository;
use zesdex_cms::domain::repository::MemoryRepository;
use crate::dto::chat::message::ChatMessage;
@@ -66,17 +65,15 @@ pub(super) fn run_agent_turn(
const MAX_TODO_RETRIES: usize = 5;
let mut msgs = messages.to_vec();
let mut edited_paths: Vec<String> = Vec::new();
let initial_edits = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new()
.open(&tc.edit_log_session_dir)
.map(|el| el.len())
.unwrap_or(0);
let initial_edit_log = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new()
.open(&tc.edit_log_session_dir).ok();
let mut inline_reviews_count: usize = 0;
let mut prev_shaped = false;
// Build system prompt components once and cache them for the entire turn
// instead of regenerating on every loop iteration (which walks the full
// workspace tree and reads all memory files each time).
let tree_info = generate_workspace_tree(&tc.workspace_roots);
let tree_info = crate::app::subagent::workspace::generate_workspace_tree(&tc.workspace_roots);
let memory_section = build_memory_section(&tc.ctx.memory_dir);
let system_text = format!(
"{}\n\n{}\n\n{}{}",
@@ -142,12 +139,10 @@ pub(super) fn run_agent_turn(
"[hive-mind] the Hive stirs — Core Intelligence compiling a cognitive cycle plan"
);
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "pipeline".to_string(),
message: HIVE_MIND_KICKOFF_NOTE.to_string(),
});
}
push_event(&events_q, TurnEvent::SystemNote {
kind: "pipeline".to_string(),
message: HIVE_MIND_KICKOFF_NOTE.to_string(),
});
let pipeline_abort = Some(tc.abort_flag.clone());
@@ -194,8 +189,13 @@ pub(super) fn run_agent_turn(
let planner_prompt_chars = system_msg.content.as_deref().map_or(0, str::len)
+ user_msg.content.as_deref().map_or(0, str::len);
let planner_result =
tc.client.chat_with_tools_non_streaming(&[system_msg, user_msg], None);
let planner_result = tc.client.chat_with_tools_non_streaming(
&[system_msg, user_msg],
None,
None,
None,
Some(&tc.abort_flag),
);
let pipeline_result = match planner_result {
Ok((reply, usage_opt)) => {
let (mut tok_in, mut tok_out) = usage_opt.unwrap_or((0, 0));
@@ -206,12 +206,10 @@ pub(super) fn run_agent_turn(
let response_chars = reply.content.as_deref().map_or(0, str::len);
tok_out = (response_chars / 4).max(1) as u64;
}
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Usage {
tokens_in: tok_in,
tokens_out: tok_out,
});
}
push_event(&events_q, TurnEvent::Usage {
tokens_in: tok_in,
tokens_out: tok_out,
});
let reply_text = reply.content.as_deref().unwrap_or("").trim();
let clean_json = if reply_text.starts_with("```") {
let mut lines = reply_text.lines();
@@ -238,15 +236,13 @@ pub(super) fn run_agent_turn(
.collect::<Vec<String>>()
.join(", ");
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "pipeline".to_string(),
message: format!(
"The Hive compiled {} cycle(s) — {cycle_desc}. Deploying nodes...",
plan.cycles.len()
),
});
}
push_event(&events_q, TurnEvent::SystemNote {
kind: "pipeline".to_string(),
message: format!(
"The Hive compiled {} cycle(s) — {cycle_desc}. Deploying nodes...",
plan.cycles.len()
),
});
crate::app::workflow::hive_mind::run_hive_mind(
user_request,
@@ -283,20 +279,16 @@ pub(super) fn run_agent_turn(
archive_message(tc.db.as_ref(), &tc.session_id, &pipeline_msg);
msgs.push(pipeline_msg);
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "pipeline".to_string(),
message:
"The Hive's convergence is complete. Core Intelligence reviewing consensus for LO..."
.to_string(),
});
}
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "hive_mind_converged".to_string(),
message: String::new(),
});
}
push_event(&events_q, TurnEvent::SystemNote {
kind: "pipeline".to_string(),
message:
"The Hive's convergence is complete. Core Intelligence reviewing consensus for LO..."
.to_string(),
});
push_event(&events_q, TurnEvent::SystemNote {
kind: "hive_mind_converged".to_string(),
message: String::new(),
});
}
Err(e) => {
tracing::warn!("[hive-mind] convergence fractured: {}", e);
@@ -318,9 +310,7 @@ pub(super) fn run_agent_turn(
.abort_flag
.load(std::sync::atomic::Ordering::SeqCst)
{
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Error("Generation aborted by user".to_string()));
}
push_event(&events_q, TurnEvent::Error("Generation aborted by user".to_string()));
return Ok(());
}
@@ -357,9 +347,7 @@ pub(super) fn run_agent_turn(
// 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()));
}
push_event(&events_q, TurnEvent::Compacted(compacted.clone()));
// Also update our local `msgs` variable so the rest of the loop operates on the compacted version
msgs.clone_from(&compacted);
@@ -424,14 +412,13 @@ pub(super) fn run_agent_turn(
}
true
},
Some(&tc.abort_flag),
);
if reasoning_started && !reasoning_ended {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::StreamToken(
"\n</think>\n\n".to_string(),
));
}
push_event(&events_q, TurnEvent::StreamToken(
"\n</think>\n\n".to_string(),
));
}
let (response, final_usage) = match result {
@@ -441,11 +428,9 @@ pub(super) fn run_agent_turn(
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(),
));
}
push_event(&events_q, TurnEvent::Error(
"Generation aborted by user".to_string(),
));
return Ok(());
}
// Streaming-only: no non-streaming fallback.
@@ -472,14 +457,12 @@ pub(super) fn run_agent_turn(
Edit todo.md manually or ask me to focus on specific items.",
);
}
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "task_retry".to_string(),
message: format!(
"Network/API error: {api_err}. Auto-retrying to finish tasks... (retry {todo_retry_count}/{MAX_TODO_RETRIES})"
),
});
}
push_event(&events_q, TurnEvent::SystemNote {
kind: "task_retry".to_string(),
message: format!(
"Network/API error: {api_err}. Auto-retrying to finish tasks... (retry {todo_retry_count}/{MAX_TODO_RETRIES})"
),
});
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
@@ -500,12 +483,10 @@ pub(super) fn run_agent_turn(
let response_chars = response.content.as_deref().map_or(0, str::len);
tok_out = (response_chars / 4).max(1) as u64;
}
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Usage {
tokens_in: tok_in,
tokens_out: tok_out,
});
}
push_event(&events_q, 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());
@@ -575,11 +556,9 @@ pub(super) fn run_agent_turn(
.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(),
));
}
push_event(&events_q, TurnEvent::Error(
"Turn aborted by user".to_string(),
));
return Ok(());
}
@@ -648,17 +627,13 @@ pub(super) fn run_agent_turn(
.and_then(|v| v.as_str())
.map(std::string::ToString::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,
});
}
}
push_event(&events_q, 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);
@@ -668,12 +643,10 @@ pub(super) fn run_agent_turn(
} else {
if !content.is_empty() {
archive_message(tc.db.as_ref(), &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()));
}
if stream_started {
push_event(&events_q, TurnEvent::StreamDone(response.clone()));
} else {
push_event(&events_q, TurnEvent::AssistantMessage(response.clone()));
}
}
@@ -691,12 +664,10 @@ pub(super) fn run_agent_turn(
if has_unfinished {
todo_retry_count += 1;
if todo_retry_count > MAX_TODO_RETRIES {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "task_retry".to_string(),
message: format!("Giving up after {MAX_TODO_RETRIES} retries — some todo items remain unfinished. Edit todo.md manually or ask again."),
});
}
push_event(&events_q, TurnEvent::SystemNote {
kind: "task_retry".to_string(),
message: format!("Giving up after {MAX_TODO_RETRIES} retries — some todo items remain unfinished. Edit todo.md manually or ask again."),
});
break;
}
let sys_text = format!("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. (Retry {todo_retry_count}/{MAX_TODO_RETRIES})");
@@ -704,12 +675,10 @@ pub(super) fn run_agent_turn(
let msg = ChatMessage::system(sys_text);
archive_message(tc.db.as_ref(), &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_clone,
});
}
push_event(&events_q, TurnEvent::SystemNote {
kind: "task_retry".to_string(),
message: sys_text_clone,
});
continue;
}
@@ -717,49 +686,52 @@ pub(super) fn run_agent_turn(
}
}
let el = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new()
.open(&tc.edit_log_session_dir)
.unwrap_or_else(|_| zesdex_cms::domain::edit_log::EditLog::new());
let final_edits = el.len();
let total_edits_this_turn = final_edits.saturating_sub(initial_edits);
let total_edits_this_turn = initial_edit_log.as_ref().and_then(|initial_el| {
let initial_count = initial_el.len();
zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new()
.open(&tc.edit_log_session_dir)
.ok()
.map(|final_el| {
let count = final_el.len().saturating_sub(initial_count);
(count, initial_count, final_el)
})
});
if total_edits_this_turn > 0 {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote {
if let Some((total_edits_this_turn, prev_edits, el)) = &total_edits_this_turn {
if *total_edits_this_turn > 0 {
push_event(&events_q, TurnEvent::SystemNote {
kind: "edits".to_string(),
message: total_edits_this_turn.to_string(),
});
}
// Collect edited paths from the new edit log entries
let mut bg_paths = Vec::new();
for entry in el.entries.iter().skip(initial_edits) {
bg_paths.push(entry.path.clone());
}
bg_paths.sort();
bg_paths.dedup();
// Collect edited paths from the new edit log entries
let mut bg_paths = Vec::new();
for entry in el.entries.iter().skip(*prev_edits) {
bg_paths.push(entry.path.clone());
}
bg_paths.sort();
bg_paths.dedup();
// ── Background auto-subagents ──
if !bg_paths.is_empty() {
let bg_session_dir = tc.edit_log_session_dir.clone();
let bg_workspaces = tc.workspace_roots.clone();
let bg_events = events_q.clone();
let bg_abort = tc.abort_flag.clone();
std::thread::spawn(move || {
crate::app::subagent::auto::spawn_all_background(
&bg_paths,
&bg_session_dir,
&bg_workspaces,
&bg_events,
bg_abort,
);
});
// ── Background auto-subagents ──
if !bg_paths.is_empty() {
let bg_session_dir = tc.edit_log_session_dir.clone();
let bg_workspaces = tc.workspace_roots.clone();
let bg_events = events_q.clone();
let bg_abort = tc.abort_flag.clone();
std::thread::spawn(move || {
crate::app::subagent::auto::spawn_all_background(
&bg_paths,
&bg_session_dir,
&bg_workspaces,
&bg_events,
bg_abort,
);
});
}
}
}
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Done);
}
push_event(&events_q, TurnEvent::Done);
Ok(())
}
@@ -820,55 +792,9 @@ fn execute_one_tool(
}
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(),
);
hex::encode(hash)
};
let bytes_delta = if name == "write" {
args.get("content")
.and_then(|v| v.as_str())
.map_or(0, |s| s.len() as i64)
} 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 = zesdex_cms::domain::edit_log::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: sess.id.to_string(),
};
let repo =
zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
if let Ok(mut el) = repo.open(sess.dir) {
let _ = repo.append(sess.dir, &mut el, entry);
}
crate::tool::log_write_edit_tool(
args, name, &ctx.origin.tag(), sess.dir, sess.id,
);
}
return Ok(result);
}
@@ -876,44 +802,6 @@ fn execute_one_tool(
anyhow::bail!("tool not found: {name}")
}
/// 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 {
writeln!(out, "Root: {}", root.display()).unwrap();
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().is_some_and(|ft| ft.is_dir());
let prefix = if is_dir { "[DIR] " } else { " " };
writeln!(out, " {}{}", prefix, rel.display()).unwrap();
count += 1;
if count > 1000 {
out.push_str(" ... (truncated)\n");
break;
}
}
}
}
out
}
/// 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.
@@ -315,7 +315,7 @@ pub fn shape_messages(
let mut result: Option<String> = None;
let mut last_err: Option<anyhow::Error> = None;
for attempt in 0..2 {
match llm.chat_with_tools_non_streaming(&req_msgs, None) {
match llm.chat_with_tools_non_streaming(&req_msgs, None, None, None, abort_flag) {
Ok(resp) => {
if let Some(content) = resp.0.content {
result = Some(format!(
@@ -1,6 +1,26 @@
//! Runtime layer: action dispatch, slash commands, short-send handling,
//! and the LLM streaming pipeline.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use super::state::runtime::TurnEvent;
pub mod actions;
pub mod action_dispatch;
pub mod context;
pub mod stream;
/// Acquire the mutex on a turn-events queue and push one event onto it.
///
/// Silently ignores a poisoned mutex so callers never have to handle lock
/// errors inline. Used by the 20+ locations in `actions/turn.rs` that push
/// events and want to skip the boilerplate.
pub fn push_event(
q: &Arc<Mutex<VecDeque<TurnEvent>>>,
event: TurnEvent,
) {
if let Ok(mut guard) = q.lock() {
guard.push_back(event);
}
}
+37 -1
View File
@@ -347,10 +347,35 @@ impl AppStateRest {
self.dirty = true;
}
/// Mark the app state as dirty, triggering a TUI re-render on the next frame.
pub fn mark_dirty(&mut self) {
self.dirty = true;
}
/// Queue a toast notification for display and mark the app dirty.
pub fn push_toast(&mut self, toast: Toast) {
self.misc.push_toast(toast);
self.dirty = true;
self.mark_dirty();
}
/// Push an info toast with the given message.
pub fn toast_info(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(super::types::ToastKind::Info, msg.into()));
}
/// Push a success toast with the given message.
pub fn toast_success(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(super::types::ToastKind::Success, msg.into()));
}
/// Push a warning toast with the given message.
pub fn toast_warning(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(super::types::ToastKind::Warning, msg.into()));
}
/// Push an error toast with the given message.
pub fn toast_error(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(super::types::ToastKind::Error, msg.into()));
}
/// Resolve the base directory that stores this session (grandparent of
@@ -385,6 +410,17 @@ impl AppStateRest {
)
}
/// Persist the current settings to the store and swallow any error.
///
/// Inline usage of `JsonSettingsRepository::new().save(...)` was
/// duplicated twice in `controller/input.rs` — this helper centralises
/// the call site.
pub fn save_settings(&self) {
use zesdex_cms::domain::repository::SettingsRepository;
let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
.save(&self.store_base_dir(), &self.settings);
}
/// Build a `ToolCtx` for tool calls originating from the main agent.
pub fn tool_ctx(&self) -> crate::tool::ToolCtx {
self.tool_ctx_for(Origin::Main)
@@ -248,16 +248,24 @@ fn spawn_background_review(
});
}
/// Collect the trailing arguments shared by all background-review spawners.
fn review_args<'a>(
file_paths: &'a [String],
session_dir: &'a Path,
workspaces: &'a [std::path::PathBuf],
turn_events: &'a Arc<Mutex<VecDeque<TurnEvent>>>,
abort_flag: Arc<AtomicBool>,
) -> (Vec<String>, std::path::PathBuf, Vec<std::path::PathBuf>, Arc<Mutex<VecDeque<TurnEvent>>>, Arc<AtomicBool>) {
(
file_paths.to_vec(),
session_dir.to_path_buf(),
workspaces.to_vec(),
turn_events.clone(),
abort_flag,
)
}
/// Spawn a background subagent that generates tests for modified files.
///
/// Uses the test-generator prompt and has read-write access so it can
/// create test files. Runs in a separate OS thread and reports completion
/// via `TurnEvent::SystemNote { kind: "bg-test-gen" }`.
///
/// Skipped (no-op) if a test-gen run is already in flight (guarded by
/// `TEST_GEN_RUNNING`) — prevents a chatty multi-turn edit session from
/// stacking overlapping runs. `abort_flag` is forwarded to the generic
/// spawner so the run can be cancelled if the turn aborts.
pub fn spawn_background_test_gen(
file_paths: &[String],
session_dir: &Path,
@@ -265,29 +273,15 @@ pub fn spawn_background_test_gen(
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
abort_flag: Arc<AtomicBool>,
) {
let (fps, sd, ws, te, af) = review_args(file_paths, session_dir, workspaces, turn_events, abort_flag);
spawn_background_review(
"bg-test-gen",
&TEST_GEN_RUNNING,
crate::prompts::TEST_GENERATOR_PROMPT,
"test-generator",
"coder",
file_paths.to_vec(),
session_dir.to_path_buf(),
workspaces.to_vec(),
turn_events.clone(),
abort_flag,
"bg-test-gen", &TEST_GEN_RUNNING,
crate::prompts::TEST_GENERATOR_PROMPT, "test-generator", "coder",
fps, sd, ws, te, af,
);
}
/// Spawn a background architecture-review subagent.
///
/// Inspects the modified files for architectural consistency (layering,
/// coupling, module boundaries). Reports via
/// `TurnEvent::SystemNote { kind: "bg-arch-review" }`.
///
/// Skipped (no-op) if an arch-review run is already in flight (guarded by
/// `ARCH_REVIEW_RUNNING`). `abort_flag` is forwarded to the generic
/// spawner so the run can be cancelled if the turn aborts.
pub fn spawn_background_arch_review(
file_paths: &[String],
session_dir: &Path,
@@ -295,31 +289,18 @@ pub fn spawn_background_arch_review(
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
abort_flag: Arc<AtomicBool>,
) {
let (fps, sd, ws, te, af) = review_args(file_paths, session_dir, workspaces, turn_events, abort_flag);
spawn_background_review(
"bg-arch-review",
&ARCH_REVIEW_RUNNING,
crate::prompts::ARCH_REVIEWER_PROMPT,
"arch-reviewer",
"reviewer",
file_paths.to_vec(),
session_dir.to_path_buf(),
workspaces.to_vec(),
turn_events.clone(),
abort_flag,
"bg-arch-review", &ARCH_REVIEW_RUNNING,
crate::prompts::ARCH_REVIEWER_PROMPT, "arch-reviewer", "reviewer",
fps, sd, ws, te, af,
);
}
/// Spawn a background security-review subagent.
///
/// Checks modified files for security vulnerabilities. Reports via
/// `TurnEvent::SystemNote { kind: "bg-security-review" }`.
///
/// Only reviews production code files for security — test files and
/// config files are out of scope for security review.
///
/// Skipped (no-op) if a security-review run is already in flight (guarded by
/// `SECURITY_REVIEW_RUNNING`). `abort_flag` is forwarded to the generic
/// spawner so the run can be cancelled if the turn aborts.
pub fn spawn_background_security_review(
file_paths: &[String],
session_dir: &Path,
@@ -327,25 +308,16 @@ pub fn spawn_background_security_review(
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
abort_flag: Arc<AtomicBool>,
) {
// Only review production code files for security — test files and
// config files are out of scope for security review.
let (_, sd, ws, te, af) = review_args(file_paths, session_dir, workspaces, turn_events, abort_flag);
let prod_paths: Vec<String> = file_paths
.iter()
.filter(|p| is_production_code(p))
.cloned()
.collect();
spawn_background_review(
"bg-security-review",
&SECURITY_REVIEW_RUNNING,
crate::prompts::SECURITY_REVIEWER_PROMPT,
"security-reviewer",
"reviewer",
prod_paths,
session_dir.to_path_buf(),
workspaces.to_vec(),
turn_events.clone(),
abort_flag,
"bg-security-review", &SECURITY_REVIEW_RUNNING,
crate::prompts::SECURITY_REVIEWER_PROMPT, "security-reviewer", "reviewer",
prod_paths, sd, ws, te, af,
);
}
@@ -14,10 +14,8 @@ use super::workspace::generate_workspace_tree;
use crate::dto::chat::message::ChatMessage;
use crate::dto::provider::request::ToolDef;
use crate::tool::tool_is_risky;
use sha2::Digest;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::mpsc;
use zesdex_cms::domain::repository::EditLogRepository;
/// Tiny jitter helper so retry backoffs don't arrive in lockstep.
fn retry_jitter_ns(range_ns: u64) -> u64 {
@@ -208,6 +206,7 @@ pub fn run_subagent(
}
true
},
ctx.abort_flag.as_deref(),
);
match stream_result {
@@ -346,49 +345,14 @@ pub fn run_subagent(
let run_res = tool.run(tool_ctx_ref, &args);
if is_edit && run_res.is_ok() {
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(),
);
hex::encode(hash)
};
let bytes_delta = if tool_name == "write" {
args.get("content")
.and_then(|v| v.as_str())
.map_or(0, |s| s.len() as i64)
} 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 session_id = ctx.session_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let entry = zesdex_cms::domain::edit_log::EditLogEntry {
ts: chrono::Utc::now().timestamp_millis(),
tool: tool_name.clone(),
path: path.to_string(),
reason: reason.to_string(),
content_sha256,
bytes_delta,
origin: tool_ctx_ref.origin.tag(),
session_id,
};
let repo = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
if let Ok(mut el) = repo.open(&ctx.session_dir) {
let _ = repo.append(&ctx.session_dir, &mut el, entry);
}
.unwrap_or("unknown");
crate::tool::log_write_edit_tool(
&args, tool_name, &tool_ctx_ref.origin.tag(),
&ctx.session_dir, session_id,
);
}
run_res
}
@@ -27,40 +27,19 @@ pub(crate) fn resolve_provider_config() -> (String, String, Option<String>, Stri
.load(&store_base_dir)
.unwrap_or_default();
let mut api_key = settings
.api_keys
.get(&settings.provider)
.cloned()
.unwrap_or_else(|| {
tracing::warn!(
"[subagent] no API key for provider '{}' in settings, trying env/default",
settings.provider
);
String::new()
});
let api_key = crate::service::provider::resolve_api_key(&settings, &app_config);
if api_key.is_empty() {
tracing::warn!(
"[subagent] all API key resolution paths exhausted for '{}'",
settings.provider
);
}
let model = settings.model.clone();
let base_url = app_config
.providers
.get(&settings.provider)
.map(|p| p.api_base.clone());
if api_key.is_empty() {
if let Some(provider_cfg) = app_config.providers.get(&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_else(|| {
tracing::warn!(
"[subagent] all API key resolution paths exhausted for '{}'",
settings.provider
);
String::new()
});
}
}
(api_key, model, base_url, settings.provider)
}