From f426edfc171fe80fc298e8b65d41c88fb9fa8cdc Mon Sep 17 00:00:00 2001 From: harald Date: Sat, 21 Feb 2026 07:39:50 +0100 Subject: [PATCH] feat(agent): emit tool status events from run_tool_call_loop Add ToolStatusEvent enum (Thinking, ToolStart) and extract_tool_detail helper to the agent loop. run_tool_call_loop now accepts an optional on_tool_status sender and emits events before LLM calls and tool executions. CLI callers pass None; the channel orchestrator uses it for real-time draft updates. Includes unit tests for extract_tool_detail covering all tool types. Co-Authored-By: Claude Opus 4.6 --- src/agent/loop_.rs | 150 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index caa7e53..a19d271 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -15,6 +15,45 @@ use std::sync::{Arc, LazyLock}; use std::time::Instant; use uuid::Uuid; +/// Events emitted during tool execution for real-time status display in channels. +#[derive(Debug, Clone)] +pub enum ToolStatusEvent { + /// LLM request started (thinking). + Thinking, + /// A tool is about to execute. + ToolStart { + name: String, + detail: Option, + }, +} + +/// Extract a short display summary from tool arguments for status display. +pub fn extract_tool_detail(tool_name: &str, args: &serde_json::Value) -> Option { + match tool_name { + "shell" => args.get("command").and_then(|v| v.as_str()).map(|s| { + if s.len() > 60 { + format!("{}...", &s[..57]) + } else { + s.to_string() + } + }), + "file_read" | "file_write" => args.get("path").and_then(|v| v.as_str()).map(String::from), + "memory_recall" | "web_search_tool" => args + .get("query") + .and_then(|v| v.as_str()) + .map(|s| format!("\"{s}\"")), + "http_request" | "browser_open" => { + args.get("url").and_then(|v| v.as_str()).map(String::from) + } + "git_operations" => args + .get("operation") + .and_then(|v| v.as_str()) + .map(String::from), + "memory_store" => args.get("key").and_then(|v| v.as_str()).map(String::from), + _ => None, + } +} + /// Minimum characters per chunk when relaying LLM text to a streaming draft. const STREAM_CHUNK_MIN_CHARS: usize = 80; @@ -841,6 +880,7 @@ pub(crate) async fn agent_turn( "channel", max_tool_iterations, None, + None, ) .await } @@ -861,6 +901,7 @@ pub(crate) async fn run_tool_call_loop( channel_name: &str, max_tool_iterations: usize, on_delta: Option>, + on_tool_status: Option>, ) -> Result { let max_iterations = if max_tool_iterations == 0 { DEFAULT_MAX_TOOL_ITERATIONS @@ -873,6 +914,10 @@ pub(crate) async fn run_tool_call_loop( let use_native_tools = provider.supports_native_tools() && !tool_specs.is_empty(); for _iteration in 0..max_iterations { + if let Some(ref tx) = on_tool_status { + let _ = tx.send(ToolStatusEvent::Thinking).await; + } + observer.record_event(&ObserverEvent::LlmRequest { provider: provider_name.to_string(), model: model.to_string(), @@ -1026,6 +1071,15 @@ pub(crate) async fn run_tool_call_loop( observer.record_event(&ObserverEvent::ToolCallStart { tool: call.name.clone(), }); + if let Some(ref tx) = on_tool_status { + let detail = extract_tool_detail(&call.name, &call.arguments); + let _ = tx + .send(ToolStatusEvent::ToolStart { + name: call.name.clone(), + detail, + }) + .await; + } let start = Instant::now(); let result = if let Some(tool) = find_tool(tools_registry, &call.name) { match tool.execute(call.arguments.clone()).await { @@ -1398,6 +1452,7 @@ pub async fn run( "cli", config.agent.max_tool_iterations, None, + None, ) .await?; final_output = response.clone(); @@ -1524,6 +1579,7 @@ pub async fn run( "cli", config.agent.max_tool_iterations, None, + None, ) .await { @@ -2511,4 +2567,98 @@ browser_open/url>https://example.com"#; assert_eq!(calls[0].arguments["command"], "pwd"); assert_eq!(text, "Done"); } + + // ═══════════════════════════════════════════════════════════════════════ + // Tool Status Display - extract_tool_detail + // ═══════════════════════════════════════════════════════════════════════ + + #[test] + fn extract_tool_detail_shell_short() { + let args = serde_json::json!({"command": "ls -la"}); + assert_eq!(extract_tool_detail("shell", &args), Some("ls -la".into())); + } + + #[test] + fn extract_tool_detail_shell_truncates_long_command() { + let long = "a".repeat(80); + let args = serde_json::json!({"command": long}); + let detail = extract_tool_detail("shell", &args).unwrap(); + assert_eq!(detail.len(), 60); // 57 chars + "..." + assert!(detail.ends_with("...")); + } + + #[test] + fn extract_tool_detail_file_read() { + let args = serde_json::json!({"path": "src/main.rs"}); + assert_eq!( + extract_tool_detail("file_read", &args), + Some("src/main.rs".into()) + ); + } + + #[test] + fn extract_tool_detail_file_write() { + let args = serde_json::json!({"path": "/tmp/out.txt", "content": "data"}); + assert_eq!( + extract_tool_detail("file_write", &args), + Some("/tmp/out.txt".into()) + ); + } + + #[test] + fn extract_tool_detail_memory_recall() { + let args = serde_json::json!({"query": "project goals"}); + assert_eq!( + extract_tool_detail("memory_recall", &args), + Some("\"project goals\"".into()) + ); + } + + #[test] + fn extract_tool_detail_web_search() { + let args = serde_json::json!({"query": "rust async"}); + assert_eq!( + extract_tool_detail("web_search_tool", &args), + Some("\"rust async\"".into()) + ); + } + + #[test] + fn extract_tool_detail_http_request() { + let args = serde_json::json!({"url": "https://example.com/api", "method": "GET"}); + assert_eq!( + extract_tool_detail("http_request", &args), + Some("https://example.com/api".into()) + ); + } + + #[test] + fn extract_tool_detail_git_operations() { + let args = serde_json::json!({"operation": "status"}); + assert_eq!( + extract_tool_detail("git_operations", &args), + Some("status".into()) + ); + } + + #[test] + fn extract_tool_detail_memory_store() { + let args = serde_json::json!({"key": "user_pref", "value": "dark mode"}); + assert_eq!( + extract_tool_detail("memory_store", &args), + Some("user_pref".into()) + ); + } + + #[test] + fn extract_tool_detail_unknown_tool_returns_none() { + let args = serde_json::json!({"foo": "bar"}); + assert_eq!(extract_tool_detail("unknown_tool", &args), None); + } + + #[test] + fn extract_tool_detail_missing_key_returns_none() { + let args = serde_json::json!({"other": "value"}); + assert_eq!(extract_tool_detail("shell", &args), None); + } }