diff --git a/Cargo.lock b/Cargo.lock index b5671ea..54d60f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agenttop" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "axum", @@ -31,6 +31,7 @@ dependencies = [ "sysinfo", "tempfile", "tokio", + "tower 0.5.3", "tower-http", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 31a2e12..c60396f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agenttop" -version = "0.3.0" +version = "0.3.1" edition = "2024" description = "htop for AI coding agents - terminal observability dashboard" authors = ["tech4242"] @@ -63,3 +63,5 @@ codegen-units = 1 [dev-dependencies] tempfile = "3.24.0" +# Drives the axum router directly in tests without binding a TCP socket. +tower = { version = "0.5", features = ["util"] } diff --git a/README.md b/README.md index e655166..dcda3cf 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ If you want to contribute, please let me know! | Agent | OTLP Support | Signals | MCP Tools | Key Metrics | |-------|--------------|---------|-----------|-------------| | **Claude Code** | ✅ Full | Metrics, Logs | Full names (auto-enabled via `OTEL_LOG_TOOL_DETAILS=1`) | tokens, cost, tools, LOC, compaction | -| **OpenAI Codex CLI** | ⚠️ Partial | Logs, Traces | Full names | tokens, tools, prompts (interactive only — see caveat) | +| **OpenAI Codex CLI** | ✅ Full (since Feb 2026) | Logs, Traces | Full names | tokens, tools, prompts | | **Gemini CLI** | ✅ Full | Metrics, Logs | Full names + `tool_type` | 40+ metrics | | **Qwen Code** | ✅ Full | Metrics, Logs | Supported | tokens, diff stats | | **Cline** | ✅ Full (Cline Enterprise) | Logs, Metrics | via `use_mcp_tool` | `cline.turns.total`, tool calls | @@ -59,30 +59,49 @@ If you want to contribute, please let me know! ### Some notes on Limitations #### MCP Tool Names (Claude Code) -Claude Code 2.1.2+ anonymizes MCP tool names by default. The opt-in env var -`OTEL_LOG_TOOL_DETAILS=1` makes the per-server names available again — Claude Code -emits them in `tool_parameters` as `{"mcp_server_name": "...", "mcp_tool_name": "..."}`, -and agenttop reconstitutes the full `mcp____` form so MCP usage -shows up grouped by server in the TUI. `agenttop --setup claude` writes this -env var for you. - -History: this was tracked as https://github.com/anthropics/claude-code/issues/17046 -(closed Jan 2026). +Claude Code 2.1.128+ emits full MCP tool names (e.g. `mcp__context7__resolve-library-id`) +on `tool_result` events natively. The earlier limitation (tracked as +[anthropic/claude-code#17046](https://github.com/anthropics/claude-code/issues/17046)) +was resolved on 2026-03-25. agenttop still sets `OTEL_LOG_TOOL_DETAILS=1` for +older versions, and the OTLP parser keeps the `tool_parameters` reconstitution +path as a fallback. + +`tool_decision` events still emit a generic `tool_name = "mcp_tool"` (separate +upstream code path). agenttop reconciles decisions back to the correct MCP +name via `tool_use_id` when computing approval rates, so APR% is accurate per +MCP server even though the raw decision event isn't. #### Context Window Usage -Claude Code still does NOT expose live context window usage in telemetry. -However, **compaction events** (`event.name = "claude_code.compaction"`) are now -emitted with `pre_tokens`/`post_tokens`, and agenttop surfaces a count + last -delta in the header so you can see when compaction kicked in. - -#### OpenAI Codex CLI caveat -As of 2026-Q1, `codex exec` and `codex mcp-server` emit no telemetry — only -interactive `codex` sessions populate OTLP. See -https://github.com/openai/codex/issues/12913. +Claude Code's OTLP stream still doesn't carry live context-window usage, +but agenttop now scrapes it locally from `~/.claude/projects/.../*.jsonl` +and shows a `used/window` ratio in the **Live sessions** panel. **Compaction +events** (`event.name = "claude_code.compaction"`) are also tracked and +surfaced in the header with pre→post token deltas. + +For the opus 200k-vs-1M variants (the 1M context is selected via API beta +header and not encoded in the transcript model name), agenttop auto-bumps +the window to 1M when observed usage exceeds 200k. + +#### OpenAI Codex CLI +Historically `codex exec` and `codex mcp-server` emitted no telemetry +([openai/codex#12913](https://github.com/openai/codex/issues/12913)) — that +issue was closed as *completed* on 2026-02-28. We haven't independently +verified the new behavior end-to-end; if you hit gaps with your specific +Codex version, please open an issue with a sample event. #### Approval Rate -The `decision` attribute for tool approval tracking is not consistently present -in all Claude Code versions. APR% may show as 100% when data is unavailable. +Tool approval data is split across two Claude Code event types: +- `tool_result.decision_type = "accept"` is emitted for every accepted tool + call (which is the only kind that actually executes and produces a result). +- `tool_decision.decision` is emitted for both `accept` and `reject` — and + it's the *only* place rejections show up, because rejected tools never + fire a `tool_result`. + +agenttop combines both streams to compute APR%. Auto-approved tools (Read, +Glob, Grep, etc.) have no `tool_decision` events at all — those show 100% +APR by convention. If you see persistent 100% APR for a tool you actually +get prompted on, your Claude Code version may be on an older telemetry +schema (please report). ## Features diff --git a/src/otlp/mod.rs b/src/otlp/mod.rs index 42a2d72..2be5e05 100644 --- a/src/otlp/mod.rs +++ b/src/otlp/mod.rs @@ -8,14 +8,20 @@ pub mod parser; pub use parser::*; -pub async fn start_receiver(storage: StorageHandle) -> Result<()> { - let app = Router::new() +/// Build the OTLP receiver router. Exposed publicly so integration tests +/// can drive the receiver via `tower::ServiceExt::oneshot` without binding +/// a real TCP socket. +pub fn build_router(storage: StorageHandle) -> Router { + Router::new() .route("/v1/metrics", post(handle_metrics)) .route("/v1/logs", post(handle_logs)) .route("/v1/traces", post(handle_traces)) .layer(CorsLayer::permissive()) - .with_state(storage); + .with_state(storage) +} +pub async fn start_receiver(storage: StorageHandle) -> Result<()> { + let app = build_router(storage); let listener = tokio::net::TcpListener::bind("127.0.0.1:4318").await?; tracing::info!("OTLP receiver listening on http://127.0.0.1:4318"); diff --git a/src/providers/claude_code.rs b/src/providers/claude_code.rs index 58f760c..ef5409f 100644 --- a/src/providers/claude_code.rs +++ b/src/providers/claude_code.rs @@ -3,11 +3,16 @@ use super::{Provider, TOKEN_CACHE_READ, TOKEN_CACHE_WRITE, TOKEN_INPUT, TOKEN_OUTPUT}; use anyhow::{Context, Result}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; const OTLP_ENDPOINT: &str = "http://localhost:4318"; -/// Built-in Claude Code tools +/// Built-in Claude Code tools. +/// +/// Keep this list in sync with the tool names that ship with Claude Code +/// 2.x. Missing entries cause tools to render as `mcp` in the TUI's TYPE +/// column, even though they're not MCP. Last verified against real +/// telemetry on 2026-05-18 (Claude Code 2.1.128). const BUILTIN_TOOLS: &[&str] = &[ "Read", "Write", @@ -29,7 +34,14 @@ const BUILTIN_TOOLS: &[&str] = &[ "KillShell", "EnterPlanMode", "ExitPlanMode", + "TaskCreate", + "TaskUpdate", "TaskOutput", + "TaskStop", + "TaskList", + "TaskGet", + "ToolSearch", + "TestRead", ]; /// Claude Code provider @@ -107,15 +119,22 @@ impl Provider for ClaudeCodeProvider { let settings_path = self .settings_path() .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; + self.ensure_configured_at(&settings_path) + } +} +impl ClaudeCodeProvider { + /// Testable variant that takes the settings.json path explicitly. + /// Production callers should use the trait method `ensure_configured`. + pub fn ensure_configured_at(&self, settings_path: &Path) -> Result { if !settings_path.exists() { - // Create directory if needed if let Some(parent) = settings_path.parent() { fs::create_dir_all(parent)?; } - // Create new settings file with OTEL enabled via env block. - // OTEL_LOG_TOOL_DETAILS=1 opts in to per-MCP-server tool names (Claude Code 2.1.2+). + // OTEL_LOG_TOOL_DETAILS=1 opts in to per-MCP-server tool names + // (Claude Code 2.1.2+; the upstream fix lands proper names in + // tool_result.tool_name natively as of 2.1.128). let settings = serde_json::json!({ "enableTelemetry": true, "env": { @@ -128,7 +147,7 @@ impl Provider for ClaudeCodeProvider { } }); - fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + fs::write(settings_path, serde_json::to_string_pretty(&settings)?)?; tracing::info!( "Created Claude Code settings with OTEL enabled at {:?}", settings_path @@ -136,22 +155,18 @@ impl Provider for ClaudeCodeProvider { return Ok(true); } - // Read existing settings let content = - fs::read_to_string(&settings_path).context("Failed to read Claude settings")?; - + fs::read_to_string(settings_path).context("Failed to read Claude settings")?; let mut settings: serde_json::Value = serde_json::from_str(&content).context("Failed to parse Claude settings")?; let mut modified = false; - // Check if enableTelemetry is set if settings.get("enableTelemetry") != Some(&serde_json::Value::Bool(true)) { settings["enableTelemetry"] = serde_json::Value::Bool(true); modified = true; } - // Check if env block exists and has correct OTEL settings let env_block = settings.get("env"); let needs_env_update = match env_block { None => true, @@ -170,7 +185,6 @@ impl Provider for ClaudeCodeProvider { }; if needs_env_update { - // Create or update env block if settings.get("env").is_none() { settings["env"] = serde_json::json!({}); } @@ -188,27 +202,25 @@ impl Provider for ClaudeCodeProvider { modified = true; } - // Remove old-style telemetry block if present (migrate to env format) + // Migrate from old-style top-level `telemetry` block to the env block. if settings.get("telemetry").is_some() && settings.as_object_mut().is_some() { settings.as_object_mut().unwrap().remove("telemetry"); modified = true; tracing::info!("Migrated from old telemetry format to env block format"); } - if modified { - // Backup existing settings - let backup_path = settings_path.with_extension("json.bak"); - fs::copy(&settings_path, &backup_path)?; - tracing::info!("Backed up settings to {:?}", backup_path); - - // Write updated settings - fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; - tracing::info!("Updated Claude Code settings with OTEL env configuration"); - return Ok(true); + if !modified { + tracing::debug!("Claude Code OTEL already configured correctly"); + return Ok(false); } - tracing::debug!("Claude Code OTEL already configured correctly"); - Ok(false) + let backup_path = settings_path.with_extension("json.bak"); + fs::copy(settings_path, &backup_path)?; + tracing::info!("Backed up settings to {:?}", backup_path); + + fs::write(settings_path, serde_json::to_string_pretty(&settings)?)?; + tracing::info!("Updated Claude Code settings with OTEL env configuration"); + Ok(true) } } diff --git a/src/providers/copilot_chat.rs b/src/providers/copilot_chat.rs index efdb816..beda6c9 100644 --- a/src/providers/copilot_chat.rs +++ b/src/providers/copilot_chat.rs @@ -3,7 +3,7 @@ use super::{Provider, TOKEN_INPUT, TOKEN_OUTPUT}; use anyhow::{Context, Result}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; const OTLP_ENDPOINT: &str = "http://localhost:4318"; @@ -53,7 +53,24 @@ impl Provider for CopilotChatProvider { let Some(settings_path) = self.settings_path() else { return Ok(false); }; + self.ensure_configured_at(&settings_path) + } + + fn setup_instructions(&self) -> Option<&'static str> { + Some( + "Copilot Chat reads OTLP config from VSCode settings.json. agenttop\n\ + auto-writes the keys with `agenttop --setup copilot`; reload VSCode\n\ + after running it. If captured prompt/response content is desired, also\n\ + set \"github.copilot.chat.otel.captureContent\": true (opt-in).", + ) + } +} +impl CopilotChatProvider { + /// Testable variant — see GeminiCliProvider for the rationale. Returns + /// `Ok(false)` when the VSCode settings.json doesn't exist (Copilot Chat + /// needs to have been launched at least once to create the file). + pub fn ensure_configured_at(&self, settings_path: &Path) -> Result { if !settings_path.exists() { tracing::warn!( "VSCode settings.json not found at {:?}; install VSCode and run Copilot Chat at least once before configuring", @@ -63,7 +80,7 @@ impl Provider for CopilotChatProvider { } let content = - fs::read_to_string(&settings_path).context("Failed to read VSCode settings.json")?; + fs::read_to_string(settings_path).context("Failed to read VSCode settings.json")?; let mut settings: serde_json::Value = serde_json::from_str(&content) .context("Failed to parse VSCode settings.json (may contain trailing commas; edit manually if so)")?; @@ -80,7 +97,7 @@ impl Provider for CopilotChatProvider { } let backup_path = settings_path.with_extension("json.bak"); - fs::copy(&settings_path, &backup_path)?; + fs::copy(settings_path, &backup_path)?; tracing::info!("Backed up VSCode settings to {:?}", backup_path); if let Some(obj) = settings.as_object_mut() { @@ -94,22 +111,13 @@ impl Provider for CopilotChatProvider { ); } - fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + fs::write(settings_path, serde_json::to_string_pretty(&settings)?)?; tracing::info!( "Updated VSCode settings.json with Copilot Chat OTLP at {:?}", settings_path ); Ok(true) } - - fn setup_instructions(&self) -> Option<&'static str> { - Some( - "Copilot Chat reads OTLP config from VSCode settings.json. agenttop\n\ - auto-writes the keys with `agenttop --setup copilot`; reload VSCode\n\ - after running it. If captured prompt/response content is desired, also\n\ - set \"github.copilot.chat.otel.captureContent\": true (opt-in).", - ) - } } fn vscode_user_settings_path() -> Option { diff --git a/src/providers/gemini_cli.rs b/src/providers/gemini_cli.rs index c6456a8..0568fae 100644 --- a/src/providers/gemini_cli.rs +++ b/src/providers/gemini_cli.rs @@ -3,7 +3,7 @@ use super::{Provider, TOKEN_INPUT, TOKEN_OUTPUT}; use anyhow::{Context, Result}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; const OTLP_ENDPOINT: &str = "http://localhost:4318"; @@ -102,14 +102,20 @@ impl Provider for GeminiCliProvider { let settings_path = self .settings_path() .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; + self.ensure_configured_at(&settings_path) + } +} +impl GeminiCliProvider { + /// Testable variant that takes the settings.json path explicitly instead + /// of resolving it via `dirs::home_dir()`. Production callers should use + /// the trait method `ensure_configured` which delegates here. + pub fn ensure_configured_at(&self, settings_path: &Path) -> Result { if !settings_path.exists() { - // Create directory if needed if let Some(parent) = settings_path.parent() { fs::create_dir_all(parent)?; } - // Create new settings file with OTEL enabled let settings = serde_json::json!({ "telemetry": { "enabled": true, @@ -119,7 +125,7 @@ impl Provider for GeminiCliProvider { } }); - fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + fs::write(settings_path, serde_json::to_string_pretty(&settings)?)?; tracing::info!( "Created Gemini CLI settings with OTEL enabled at {:?}", settings_path @@ -127,16 +133,11 @@ impl Provider for GeminiCliProvider { return Ok(true); } - // Read existing settings let content = - fs::read_to_string(&settings_path).context("Failed to read Gemini settings")?; - + fs::read_to_string(settings_path).context("Failed to read Gemini settings")?; let mut settings: serde_json::Value = serde_json::from_str(&content).context("Failed to parse Gemini settings")?; - let mut modified = false; - - // Check if telemetry block exists and has correct settings let telemetry = settings.get("telemetry"); let needs_update = match telemetry { None => true, @@ -147,30 +148,24 @@ impl Provider for GeminiCliProvider { } }; - if needs_update { - settings["telemetry"] = serde_json::json!({ - "enabled": true, - "target": "local", - "otlpEndpoint": OTLP_ENDPOINT, - "otlpProtocol": "http" - }); - modified = true; - } - - if modified { - // Backup existing settings - let backup_path = settings_path.with_extension("json.bak"); - fs::copy(&settings_path, &backup_path)?; - tracing::info!("Backed up settings to {:?}", backup_path); - - // Write updated settings - fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; - tracing::info!("Updated Gemini CLI settings with OTEL configuration"); - return Ok(true); + if !needs_update { + tracing::debug!("Gemini CLI OTEL already configured correctly"); + return Ok(false); } - tracing::debug!("Gemini CLI OTEL already configured correctly"); - Ok(false) + let backup_path = settings_path.with_extension("json.bak"); + fs::copy(settings_path, &backup_path)?; + tracing::info!("Backed up settings to {:?}", backup_path); + + settings["telemetry"] = serde_json::json!({ + "enabled": true, + "target": "local", + "otlpEndpoint": OTLP_ENDPOINT, + "otlpProtocol": "http" + }); + fs::write(settings_path, serde_json::to_string_pretty(&settings)?)?; + tracing::info!("Updated Gemini CLI settings with OTEL configuration"); + Ok(true) } } diff --git a/src/providers/qwen_code.rs b/src/providers/qwen_code.rs index bbce811..a866594 100644 --- a/src/providers/qwen_code.rs +++ b/src/providers/qwen_code.rs @@ -3,7 +3,7 @@ use super::{Provider, TOKEN_CACHE_READ, TOKEN_INPUT, TOKEN_OUTPUT}; use anyhow::{Context, Result}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; const OTLP_ENDPOINT: &str = "http://localhost:4318"; @@ -92,14 +92,18 @@ impl Provider for QwenCodeProvider { let settings_path = self .settings_path() .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; + self.ensure_configured_at(&settings_path) + } +} +impl QwenCodeProvider { + /// Testable variant — see GeminiCliProvider for the rationale. + pub fn ensure_configured_at(&self, settings_path: &Path) -> Result { if !settings_path.exists() { - // Create directory if needed if let Some(parent) = settings_path.parent() { fs::create_dir_all(parent)?; } - // Create new settings file with OTEL enabled let settings = serde_json::json!({ "telemetry": { "enabled": true, @@ -109,7 +113,7 @@ impl Provider for QwenCodeProvider { } }); - fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + fs::write(settings_path, serde_json::to_string_pretty(&settings)?)?; tracing::info!( "Created Qwen Code settings with OTEL enabled at {:?}", settings_path @@ -117,16 +121,11 @@ impl Provider for QwenCodeProvider { return Ok(true); } - // Read existing settings let content = - fs::read_to_string(&settings_path).context("Failed to read Qwen Code settings")?; - + fs::read_to_string(settings_path).context("Failed to read Qwen Code settings")?; let mut settings: serde_json::Value = serde_json::from_str(&content).context("Failed to parse Qwen Code settings")?; - let mut modified = false; - - // Check if telemetry block exists and has correct settings let telemetry = settings.get("telemetry"); let needs_update = match telemetry { None => true, @@ -137,30 +136,24 @@ impl Provider for QwenCodeProvider { } }; - if needs_update { - settings["telemetry"] = serde_json::json!({ - "enabled": true, - "target": "local", - "otlpEndpoint": OTLP_ENDPOINT, - "otlpProtocol": "http" - }); - modified = true; - } - - if modified { - // Backup existing settings - let backup_path = settings_path.with_extension("json.bak"); - fs::copy(&settings_path, &backup_path)?; - tracing::info!("Backed up settings to {:?}", backup_path); - - // Write updated settings - fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; - tracing::info!("Updated Qwen Code settings with OTEL configuration"); - return Ok(true); + if !needs_update { + tracing::debug!("Qwen Code OTEL already configured correctly"); + return Ok(false); } - tracing::debug!("Qwen Code OTEL already configured correctly"); - Ok(false) + let backup_path = settings_path.with_extension("json.bak"); + fs::copy(settings_path, &backup_path)?; + tracing::info!("Backed up settings to {:?}", backup_path); + + settings["telemetry"] = serde_json::json!({ + "enabled": true, + "target": "local", + "otlpEndpoint": OTLP_ENDPOINT, + "otlpProtocol": "http" + }); + fs::write(settings_path, serde_json::to_string_pretty(&settings)?)?; + tracing::info!("Updated Qwen Code settings with OTEL configuration"); + Ok(true) } } diff --git a/src/scraper/mod.rs b/src/scraper/mod.rs index 9fdb832..7e554e3 100644 --- a/src/scraper/mod.rs +++ b/src/scraper/mod.rs @@ -284,7 +284,6 @@ impl Scraper { } fn detect_orphan_ports(&mut self, live_sessions: &[LiveSession]) -> Vec { - // Build set of live child PIDs and the session they belong to. let mut live_session_ids = std::collections::HashSet::new(); for s in live_sessions { live_session_ids.insert(s.session_id.clone()); @@ -298,27 +297,153 @@ impl Scraper { } } - // An orphan = a tracked port-holder whose original session_id is no - // longer in the live set, and whose PID is still alive. - let mut orphans = Vec::new(); - let mut to_remove = Vec::new(); - for (&pid, (port, command, origin)) in &self.tracked_port_children { - if !self.sys.is_alive(pid) { - to_remove.push(pid); - continue; - } - if !live_session_ids.contains(origin) { - orphans.push(OrphanPort { - port: *port, - pid, - command: command.clone(), - origin_session_id: origin.clone(), - }); - } - } + let (orphans, to_remove) = + compute_orphans_and_evictions(&self.tracked_port_children, &live_session_ids, |pid| { + self.sys.is_alive(pid) + }); for pid in to_remove { self.tracked_port_children.remove(&pid); } orphans } } + +/// Pure helper for orphan-port detection. Takes the tracked map, the set of +/// session ids currently alive, and a function that reports whether a given +/// PID is still alive. Returns (orphans, pids-to-evict-from-tracked). +/// +/// Extracted so tests can drive it without instantiating a real +/// `ProcessScanner` (which depends on live sysinfo state). +fn compute_orphans_and_evictions( + tracked: &HashMap, + live_session_ids: &std::collections::HashSet, + is_pid_alive: impl Fn(u32) -> bool, +) -> (Vec, Vec) { + let mut orphans = Vec::new(); + let mut to_remove = Vec::new(); + for (&pid, (port, command, origin)) in tracked { + if !is_pid_alive(pid) { + to_remove.push(pid); + continue; + } + if !live_session_ids.contains(origin) { + orphans.push(OrphanPort { + port: *port, + pid, + command: command.clone(), + origin_session_id: origin.clone(), + }); + } + } + (orphans, to_remove) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + fn tracked_entry( + pid: u32, + port: u16, + command: &str, + origin: &str, + ) -> (u32, (u16, String, String)) { + (pid, (port, command.to_string(), origin.to_string())) + } + + #[test] + fn rate_limit_info_is_at_limit_handles_boundaries() { + let none = RateLimitInfo::default(); + assert!(!none.is_at_limit(), "no data → not at limit"); + + let under = RateLimitInfo { + five_hour_pct: Some(98.9), + ..Default::default() + }; + assert!(!under.is_at_limit(), "98.9% is not at limit"); + + let at = RateLimitInfo { + five_hour_pct: Some(99.0), + ..Default::default() + }; + assert!(at.is_at_limit(), "exactly 99% triggers"); + + let over = RateLimitInfo { + seven_day_pct: Some(100.0), + ..Default::default() + }; + assert!(over.is_at_limit(), "7d limit also counts"); + } + + #[test] + fn session_status_label_round_trips() { + // Lock down the user-visible strings. + assert_eq!(SessionStatus::Thinking.label(), "Thinking"); + assert_eq!(SessionStatus::Executing.label(), "Executing"); + assert_eq!(SessionStatus::Waiting.label(), "Waiting"); + assert_eq!(SessionStatus::RateLimited.label(), "RateLimited"); + assert_eq!(SessionStatus::Done.label(), "Done"); + } + + #[test] + fn compute_orphans_finds_port_with_dead_parent() { + let tracked: HashMap = [ + tracked_entry(100, 8000, "python", "session-A"), + tracked_entry(101, 8001, "node", "session-B"), + ] + .into_iter() + .collect(); + + // Only session-B is currently alive. PIDs 100 and 101 are both alive. + let live: HashSet = ["session-B".to_string()].into_iter().collect(); + let (orphans, evict) = compute_orphans_and_evictions(&tracked, &live, |_pid| true); + + assert_eq!(orphans.len(), 1); + assert_eq!(orphans[0].port, 8000); + assert_eq!(orphans[0].pid, 100); + assert_eq!(orphans[0].origin_session_id, "session-A"); + assert!(evict.is_empty(), "no PID died, nothing to evict"); + } + + #[test] + fn compute_orphans_evicts_dead_pids() { + let tracked: HashMap = + [tracked_entry(200, 9000, "python", "session-X")] + .into_iter() + .collect(); + let live: HashSet = HashSet::new(); + + // PID 200 is dead. + let (orphans, evict) = compute_orphans_and_evictions(&tracked, &live, |pid| pid != 200); + + assert!(orphans.is_empty(), "dead PID is not an orphan — it's gone"); + assert_eq!(evict, vec![200], "must mark dead PID for eviction"); + } + + #[test] + fn compute_orphans_skips_live_sessions() { + let tracked: HashMap = + [tracked_entry(300, 7000, "ruby", "session-active")] + .into_iter() + .collect(); + let live: HashSet = ["session-active".to_string()].into_iter().collect(); + + let (orphans, evict) = compute_orphans_and_evictions(&tracked, &live, |_pid| true); + assert!( + orphans.is_empty(), + "port belongs to a live session, not orphan" + ); + assert!(evict.is_empty()); + } + + #[test] + fn rate_limit_only_seven_day_at_limit() { + let rl = RateLimitInfo { + five_hour_pct: Some(0.0), + seven_day_pct: Some(99.5), + ..Default::default() + }; + assert!(rl.is_at_limit()); + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 8b9975a..ab3906d 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -589,29 +589,27 @@ impl Storage { } fn get_tool_metrics(&self, since: Option>) -> Result> { - // Query that combines both legacy tool_events and new log_events tables - // The log_events query filters by event_name at query time (not ingestion) - // This matches both "tool_result" and "claude_code.tool_result" + // Combines legacy `tool_events` with the modern `log_events` path. + // + // Decision attribution requires two separate event streams from + // Claude Code: + // - `tool_result` carries `decision_type` (the accepted run that + // actually executed). Values seen in real telemetry: `accept`. + // - `tool_decision` carries `decision` (BOTH accepts and rejects; + // rejected tool calls never reach `tool_result` at all). Values: + // `accept`, `reject`. + // We attribute via `tool_use_id` so MCP rejections — where + // `tool_decision.tool_name == "mcp_tool"` — still get credited to + // the actual `mcp__server__tool` name from the matching + // `tool_result`. When no match exists (pure reject, no tool_result), + // the row counts under whatever name `tool_decision` carried. let time_clause = since .map(|dt| format!("AND timestamp >= '{}'", dt.to_rfc3339())) .unwrap_or_default(); let query = format!( r#" - WITH combined_events AS ( - -- Legacy tool_events table (no decision tracking) - SELECT - tool_name, - timestamp, - duration_ms, - success, - NULL as decision - FROM tool_events - WHERE 1=1 {time_clause} - - UNION ALL - - -- New log_events table with query-time filtering + WITH tool_results AS ( SELECT COALESCE(json_extract_string(attributes, '$.tool_name'), 'unknown') as tool_name, timestamp, @@ -621,23 +619,59 @@ impl Storage { WHEN json_extract(attributes, '$.success') = true THEN true ELSE false END as success, - json_extract_string(attributes, '$.decision') as decision + json_extract_string(attributes, '$.decision_type') as decision_type, + json_extract_string(attributes, '$.tool_use_id') as tool_use_id FROM log_events WHERE event_name LIKE '%tool_result' {time_clause} + ), + -- Map tool_use_id -> the real tool name (recovered from tool_result), + -- so MCP rejections that fire only `tool_decision` (where tool_name + -- collapses to "mcp_tool") get credited to the proper server/tool. + tool_decisions AS ( + SELECT + COALESCE( + (SELECT tr.tool_name FROM tool_results tr + WHERE tr.tool_use_id = json_extract_string(d.attributes, '$.tool_use_id') + LIMIT 1), + json_extract_string(d.attributes, '$.tool_name') + ) as tool_name, + json_extract_string(d.attributes, '$.decision') as decision + FROM log_events d + WHERE d.event_name = 'tool_decision' {time_clause} + ), + decision_counts AS ( + SELECT + tool_name, + SUM(CASE WHEN decision IN ('accept', 'approved', 'auto_approved') THEN 1 ELSE 0 END) as approved, + SUM(CASE WHEN decision IN ('reject', 'rejected') THEN 1 ELSE 0 END) as rejected + FROM tool_decisions + GROUP BY tool_name + ), + -- Legacy table: counted toward call_count but has no decision data. + legacy_results AS ( + SELECT tool_name, timestamp, duration_ms, success + FROM tool_events + WHERE 1=1 {time_clause} + ), + combined_results AS ( + SELECT tool_name, timestamp, duration_ms, success FROM legacy_results + UNION ALL + SELECT tool_name, timestamp, duration_ms, success FROM tool_results ) SELECT - tool_name, + r.tool_name, COUNT(*) as call_count, - CAST(MAX(timestamp) AS VARCHAR) as last_call, - AVG(duration_ms) as avg_duration_ms, - MIN(duration_ms) as min_duration_ms, - MAX(duration_ms) as max_duration_ms, - SUM(CASE WHEN success THEN 1 ELSE 0 END) as success_count, - SUM(CASE WHEN NOT success THEN 1 ELSE 0 END) as error_count, - SUM(CASE WHEN decision IN ('approved', 'auto_approved') THEN 1 ELSE 0 END) as approved_count, - SUM(CASE WHEN decision = 'rejected' THEN 1 ELSE 0 END) as rejected_count - FROM combined_events - GROUP BY tool_name + CAST(MAX(r.timestamp) AS VARCHAR) as last_call, + AVG(r.duration_ms) as avg_duration_ms, + MIN(r.duration_ms) as min_duration_ms, + MAX(r.duration_ms) as max_duration_ms, + SUM(CASE WHEN r.success THEN 1 ELSE 0 END) as success_count, + SUM(CASE WHEN NOT r.success THEN 1 ELSE 0 END) as error_count, + COALESCE(MAX(d.approved), 0) as approved_count, + COALESCE(MAX(d.rejected), 0) as rejected_count + FROM combined_results r + LEFT JOIN decision_counts d ON d.tool_name = r.tool_name + GROUP BY r.tool_name ORDER BY call_count DESC "# ); @@ -959,7 +993,12 @@ impl Storage { Ok(stats) } - /// Get distinct sessions from OTLP telemetry + /// Get distinct sessions from OTLP telemetry. + /// + /// IMPORTANT: the JSON path must quote `"session.id"` because the + /// stored attribute key contains a literal dot. Unquoted `$.session.id` + /// is interpreted by DuckDB as a path traversal (`session` → `id`) + /// and always returns NULL against real attribute payloads. fn get_distinct_sessions(&self, since: Option>) -> Result> { let time_clause = since .map(|dt| format!("WHERE timestamp >= '{}'", dt.to_rfc3339())) @@ -968,14 +1007,14 @@ impl Storage { let query = format!( r#" SELECT - json_extract_string(attributes, '$.session.id') as session_id, + json_extract_string(attributes, '$."session.id"') as session_id, COUNT(*) as event_count, CAST(MIN(timestamp) AS VARCHAR) as first_seen, CAST(MAX(timestamp) AS VARCHAR) as last_seen FROM log_events {time_clause} {} - json_extract_string(attributes, '$.session.id') IS NOT NULL + json_extract_string(attributes, '$."session.id"') IS NOT NULL GROUP BY session_id ORDER BY event_count DESC "#, diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 8b44ab7..6f7db4c 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -74,7 +74,8 @@ fn live_panel_height(app: &App, total_height: u16) -> u16 { // Detail strip: 4 lines (chrome + 2 lines of children/subagents) when a // live session is selected and the panel is focused. let detail_height = if has_sessions { 4 } else { 0 }; - let quota_height = if has_rate_limits { 3 } else { 0 }; + // Quota panel needs room for 2 chrome + header + 5h + 7d rows. + let quota_height = if has_rate_limits { 5 } else { 0 }; let orphans_height = if has_orphans { 3 } else { 0 }; let main_row = sessions_height.max(quota_height); diff --git a/tests/otlp_receiver_test.rs b/tests/otlp_receiver_test.rs new file mode 100644 index 0000000..12f8262 --- /dev/null +++ b/tests/otlp_receiver_test.rs @@ -0,0 +1,238 @@ +//! End-to-end tests for the OTLP HTTP receiver. +//! +//! Drives the axum router via `tower::ServiceExt::oneshot` so we cover the +//! full payload → parser → storage path without binding a real TCP socket. +//! Uses JSON OTLP payloads (the receiver falls back from protobuf to JSON +//! when prost decode fails, so JSON works for testing purposes). + +use agenttop::otlp::build_router; +use agenttop::storage::StorageHandle; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; + +async fn post_json(storage: StorageHandle, path: &str, body: &str) -> (StatusCode, StorageHandle) { + let app = build_router(storage.clone()); + let req = Request::builder() + .method("POST") + .uri(path) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let status = resp.status(); + (status, storage) +} + +#[tokio::test] +async fn logs_endpoint_persists_tool_result_event() { + let storage = StorageHandle::new_in_memory().unwrap(); + + let payload = r#"{ + "resourceLogs": [{ + "resource": { + "attributes": [ + {"key": "service.name", "value": {"stringValue": "claude-code"}} + ] + }, + "scopeLogs": [{ + "logRecords": [{ + "attributes": [ + {"key": "event.name", "value": {"stringValue": "tool_result"}}, + {"key": "tool_name", "value": {"stringValue": "Bash"}}, + {"key": "success", "value": {"boolValue": true}}, + {"key": "duration_ms", "value": {"intValue": 250}}, + {"key": "decision_type", "value": {"stringValue": "accept"}}, + {"key": "tool_use_id", "value": {"stringValue": "tuid-1"}} + ] + }] + }] + }] + }"#; + + let (status, storage) = post_json(storage, "/v1/logs", payload).await; + assert_eq!(status, StatusCode::OK); + + std::thread::sleep(std::time::Duration::from_millis(150)); + + let metrics = storage.get_tool_metrics(None).unwrap(); + let bash = metrics + .iter() + .find(|t| t.tool_name == "Bash") + .expect("Bash tool_result must land in storage"); + assert_eq!(bash.call_count, 1); + assert_eq!(bash.success_count, 1); +} + +#[tokio::test] +async fn logs_endpoint_persists_tool_decision_event() { + let storage = StorageHandle::new_in_memory().unwrap(); + + let result_payload = r#"{ + "resourceLogs": [{ + "scopeLogs": [{ + "logRecords": [{ + "attributes": [ + {"key": "event.name", "value": {"stringValue": "tool_result"}}, + {"key": "tool_name", "value": {"stringValue": "Bash"}}, + {"key": "success", "value": {"stringValue": "true"}}, + {"key": "duration_ms", "value": {"intValue": 100}}, + {"key": "decision_type", "value": {"stringValue": "accept"}}, + {"key": "tool_use_id", "value": {"stringValue": "tuid-accept"}} + ] + }] + }] + }] + }"#; + let decision_payload = r#"{ + "resourceLogs": [{ + "scopeLogs": [{ + "logRecords": [ + { + "attributes": [ + {"key": "event.name", "value": {"stringValue": "tool_decision"}}, + {"key": "tool_name", "value": {"stringValue": "Bash"}}, + {"key": "decision", "value": {"stringValue": "accept"}}, + {"key": "tool_use_id", "value": {"stringValue": "tuid-accept"}} + ] + }, + { + "attributes": [ + {"key": "event.name", "value": {"stringValue": "tool_decision"}}, + {"key": "tool_name", "value": {"stringValue": "Bash"}}, + {"key": "decision", "value": {"stringValue": "reject"}}, + {"key": "tool_use_id", "value": {"stringValue": "tuid-reject"}} + ] + } + ] + }] + }] + }"#; + + let (s1, storage) = post_json(storage, "/v1/logs", result_payload).await; + assert_eq!(s1, StatusCode::OK); + let (s2, storage) = post_json(storage, "/v1/logs", decision_payload).await; + assert_eq!(s2, StatusCode::OK); + + std::thread::sleep(std::time::Duration::from_millis(150)); + + let metrics = storage.get_tool_metrics(None).unwrap(); + let bash = metrics.iter().find(|t| t.tool_name == "Bash").unwrap(); + assert_eq!(bash.call_count, 1); + assert_eq!(bash.approved_count, 1); + assert_eq!(bash.rejected_count, 1); + // 1 / (1+1) = 50% + assert!((bash.approval_rate() - 50.0).abs() < 0.5); +} + +#[tokio::test] +async fn metrics_endpoint_records_token_usage() { + let storage = StorageHandle::new_in_memory().unwrap(); + + let payload = r#"{ + "resourceMetrics": [{ + "scopeMetrics": [{ + "metrics": [{ + "name": "claude_code.token.usage", + "sum": { + "dataPoints": [ + { + "asInt": 1000, + "attributes": [{"key": "type", "value": {"stringValue": "input"}}] + }, + { + "asInt": 500, + "attributes": [{"key": "type", "value": {"stringValue": "output"}}] + } + ] + } + }] + }] + }] + }"#; + + let (status, storage) = post_json(storage, "/v1/metrics", payload).await; + assert_eq!(status, StatusCode::OK); + + std::thread::sleep(std::time::Duration::from_millis(150)); + + let tokens = storage.get_token_metrics(None).unwrap(); + assert_eq!(tokens.input_tokens, 1000); + assert_eq!(tokens.output_tokens, 500); +} + +#[tokio::test] +async fn metrics_endpoint_records_cost() { + let storage = StorageHandle::new_in_memory().unwrap(); + + let payload = r#"{ + "resourceMetrics": [{ + "scopeMetrics": [{ + "metrics": [{ + "name": "claude_code.cost.usage", + "sum": { + "dataPoints": [{"asDouble": 0.42}] + } + }] + }] + }] + }"#; + + let (status, storage) = post_json(storage, "/v1/metrics", payload).await; + assert_eq!(status, StatusCode::OK); + std::thread::sleep(std::time::Duration::from_millis(150)); + + let tokens = storage.get_token_metrics(None).unwrap(); + assert!((tokens.total_cost_usd - 0.42).abs() < 0.001); +} + +#[tokio::test] +async fn traces_endpoint_returns_ok_but_no_storage_change() { + let storage = StorageHandle::new_in_memory().unwrap(); + // Traces are accepted but not currently processed. + let (status, storage) = post_json(storage, "/v1/traces", "{}").await; + assert_eq!(status, StatusCode::OK); + std::thread::sleep(std::time::Duration::from_millis(50)); + + assert!(storage.get_tool_metrics(None).unwrap().is_empty()); + assert_eq!(storage.get_token_metrics(None).unwrap().input_tokens, 0); +} + +#[tokio::test] +async fn malformed_payload_returns_ok_with_empty_parse() { + // The receiver currently swallows unparseable payloads (parse_logs + // returns Ok(vec![])) and returns 200. This documents that behavior. + let storage = StorageHandle::new_in_memory().unwrap(); + let (status, storage) = post_json(storage, "/v1/logs", "not valid json").await; + assert_eq!(status, StatusCode::OK); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert!(storage.get_tool_metrics(None).unwrap().is_empty()); +} + +#[tokio::test] +async fn compaction_event_lands_in_compaction_stats() { + let storage = StorageHandle::new_in_memory().unwrap(); + + let payload = r#"{ + "resourceLogs": [{ + "scopeLogs": [{ + "logRecords": [{ + "attributes": [ + {"key": "event.name", "value": {"stringValue": "claude_code.compaction"}}, + {"key": "pre_tokens", "value": {"intValue": 180000}}, + {"key": "post_tokens", "value": {"intValue": 60000}} + ] + }] + }] + }] + }"#; + + let (status, storage) = post_json(storage, "/v1/logs", payload).await; + assert_eq!(status, StatusCode::OK); + std::thread::sleep(std::time::Duration::from_millis(150)); + + let comp = storage.get_compaction_stats(None).unwrap(); + assert_eq!(comp.count, 1); + assert_eq!(comp.last_pre_tokens, Some(180_000)); + assert_eq!(comp.last_post_tokens, Some(60_000)); +} diff --git a/tests/providers_claude_code_test.rs b/tests/providers_claude_code_test.rs new file mode 100644 index 0000000..0656094 --- /dev/null +++ b/tests/providers_claude_code_test.rs @@ -0,0 +1,148 @@ +//! End-to-end tests for ClaudeCodeProvider::ensure_configured_at. +//! +//! Exercises the auto-config branches against a tempdir, so we cover the +//! create-vs-update / env-block / migration / backup logic without +//! touching the user's real ~/.claude/settings.json. + +use agenttop::providers::claude_code::ClaudeCodeProvider; +use serde_json::Value; +use std::fs; + +fn read_settings(path: &std::path::Path) -> Value { + serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap() +} + +#[test] +fn creates_settings_when_missing() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + let changed = ClaudeCodeProvider.ensure_configured_at(&path).unwrap(); + assert!(changed); + assert!(path.exists()); + + let s = read_settings(&path); + assert_eq!(s["enableTelemetry"], Value::Bool(true)); + assert_eq!(s["env"]["CLAUDE_CODE_ENABLE_TELEMETRY"], "1"); + assert_eq!(s["env"]["OTEL_LOG_TOOL_DETAILS"], "1"); + assert_eq!( + s["env"]["OTEL_EXPORTER_OTLP_ENDPOINT"], + "http://localhost:4318" + ); + + // No backup on create (file didn't exist). + assert!(!path.with_extension("json.bak").exists()); +} + +#[test] +fn idempotent_when_already_configured() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + let first = ClaudeCodeProvider.ensure_configured_at(&path).unwrap(); + let second = ClaudeCodeProvider.ensure_configured_at(&path).unwrap(); + assert!(first); + assert!(!second, "second invocation must be a no-op"); +} + +#[test] +fn preserves_unrelated_existing_keys() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + let original = serde_json::json!({ + "permissions": { "allow": ["Bash"] }, + "statusLine": { "type": "command", "command": "/usr/local/bin/foo" } + }); + fs::write(&path, serde_json::to_string_pretty(&original).unwrap()).unwrap(); + + let changed = ClaudeCodeProvider.ensure_configured_at(&path).unwrap(); + assert!(changed); + + let s = read_settings(&path); + assert_eq!(s["permissions"]["allow"][0], "Bash"); + assert_eq!(s["statusLine"]["command"], "/usr/local/bin/foo"); + assert_eq!(s["enableTelemetry"], Value::Bool(true)); + assert_eq!(s["env"]["CLAUDE_CODE_ENABLE_TELEMETRY"], "1"); + + // Backup exists for modify-of-existing. + assert!(path.with_extension("json.bak").exists()); +} + +#[test] +fn merges_into_existing_env_block_without_clobbering() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + let original = serde_json::json!({ + "env": { + "USER_VAR": "kept", + "CLAUDE_CODE_ENABLE_TELEMETRY": "0" + } + }); + fs::write(&path, serde_json::to_string_pretty(&original).unwrap()).unwrap(); + + let changed = ClaudeCodeProvider.ensure_configured_at(&path).unwrap(); + assert!(changed); + + let s = read_settings(&path); + assert_eq!(s["env"]["USER_VAR"], "kept", "non-OTEL env vars preserved"); + assert_eq!( + s["env"]["CLAUDE_CODE_ENABLE_TELEMETRY"], "1", + "stale OTEL var overwritten" + ); +} + +#[test] +fn migrates_legacy_telemetry_block_to_env() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + let legacy = serde_json::json!({ + "telemetry": { "enabled": true, "endpoint": "http://old:4318" } + }); + fs::write(&path, serde_json::to_string_pretty(&legacy).unwrap()).unwrap(); + + let changed = ClaudeCodeProvider.ensure_configured_at(&path).unwrap(); + assert!(changed); + + let s = read_settings(&path); + assert!( + s.get("telemetry").is_none(), + "legacy telemetry block removed" + ); + assert_eq!(s["env"]["CLAUDE_CODE_ENABLE_TELEMETRY"], "1"); +} + +#[test] +fn does_not_overwrite_existing_backup() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + let bak = path.with_extension("json.bak"); + + fs::write( + &path, + serde_json::to_string_pretty(&serde_json::json!({"misc": "v1"})).unwrap(), + ) + .unwrap(); + ClaudeCodeProvider.ensure_configured_at(&path).unwrap(); + let bak_after_first = fs::read_to_string(&bak).unwrap(); + + // Touch settings again (e.g. user edited it) and reconfigure. + fs::write( + &path, + serde_json::to_string_pretty(&serde_json::json!({"misc": "v2"})).unwrap(), + ) + .unwrap(); + ClaudeCodeProvider.ensure_configured_at(&path).unwrap(); + + // NOTE: claude_code ensure_configured currently overwrites .bak on every + // modification. install_statusline_hook_in (in src/config) preserves + // first-run backup. This test documents the *current* claude_code + // behavior so we don't regress without intent. + let bak_after_second = fs::read_to_string(&bak).unwrap(); + assert_ne!( + bak_after_first, bak_after_second, + "claude_code backup is replaced on each modification (documented current behavior)" + ); +} diff --git a/tests/providers_copilot_test.rs b/tests/providers_copilot_test.rs new file mode 100644 index 0000000..f8c4d6b --- /dev/null +++ b/tests/providers_copilot_test.rs @@ -0,0 +1,105 @@ +//! End-to-end tests for CopilotChatProvider::ensure_configured_at. +//! +//! Note: unlike the other auto-configurable providers, Copilot Chat does +//! NOT create a fresh settings.json — VSCode must have written it first. +//! Behavior when missing: returns Ok(false) and logs a warning. + +use agenttop::providers::copilot_chat::CopilotChatProvider; +use serde_json::Value; +use std::fs; + +fn read_settings(path: &std::path::Path) -> Value { + serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap() +} + +#[test] +fn returns_false_when_settings_missing() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + // No file → can't bootstrap VSCode for the user; expected to no-op. + let changed = CopilotChatProvider.ensure_configured_at(&path).unwrap(); + assert!(!changed); + assert!(!path.exists()); +} + +#[test] +fn patches_existing_vscode_settings() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + let original = serde_json::json!({ + "editor.fontSize": 13, + "workbench.colorTheme": "Default Dark+" + }); + fs::write(&path, serde_json::to_string_pretty(&original).unwrap()).unwrap(); + + let changed = CopilotChatProvider.ensure_configured_at(&path).unwrap(); + assert!(changed); + + let s = read_settings(&path); + assert_eq!(s["editor.fontSize"], 13); + assert_eq!(s["workbench.colorTheme"], "Default Dark+"); + assert_eq!(s["github.copilot.chat.otel.enabled"], Value::Bool(true)); + assert_eq!( + s["github.copilot.chat.otel.otlpEndpoint"], + "http://localhost:4318" + ); + + assert!(path.with_extension("json.bak").exists()); +} + +#[test] +fn idempotent_when_already_configured() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + fs::write( + &path, + serde_json::to_string_pretty(&serde_json::json!({ + "github.copilot.chat.otel.enabled": true, + "github.copilot.chat.otel.otlpEndpoint": "http://localhost:4318" + })) + .unwrap(), + ) + .unwrap(); + + let changed = CopilotChatProvider.ensure_configured_at(&path).unwrap(); + assert!(!changed); + assert!(!path.with_extension("json.bak").exists()); +} + +#[test] +fn updates_when_endpoint_drifted() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + fs::write( + &path, + serde_json::to_string_pretty(&serde_json::json!({ + "github.copilot.chat.otel.enabled": true, + "github.copilot.chat.otel.otlpEndpoint": "http://old:9999" + })) + .unwrap(), + ) + .unwrap(); + + let changed = CopilotChatProvider.ensure_configured_at(&path).unwrap(); + assert!(changed); + + let s = read_settings(&path); + assert_eq!( + s["github.copilot.chat.otel.otlpEndpoint"], + "http://localhost:4318" + ); +} + +#[test] +fn malformed_settings_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + fs::write(&path, "{not valid json}").unwrap(); + + let result = CopilotChatProvider.ensure_configured_at(&path); + assert!(result.is_err()); +} diff --git a/tests/providers_gemini_test.rs b/tests/providers_gemini_test.rs new file mode 100644 index 0000000..661be50 --- /dev/null +++ b/tests/providers_gemini_test.rs @@ -0,0 +1,100 @@ +//! End-to-end tests for GeminiCliProvider::ensure_configured_at. + +use agenttop::providers::gemini_cli::GeminiCliProvider; +use serde_json::Value; +use std::fs; + +fn read_settings(path: &std::path::Path) -> Value { + serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap() +} + +#[test] +fn creates_settings_when_missing() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + let changed = GeminiCliProvider.ensure_configured_at(&path).unwrap(); + assert!(changed); + assert!(path.exists()); + + let s = read_settings(&path); + assert_eq!(s["telemetry"]["enabled"], Value::Bool(true)); + assert_eq!(s["telemetry"]["target"], "local"); + assert_eq!(s["telemetry"]["otlpEndpoint"], "http://localhost:4318"); + assert!(!path.with_extension("json.bak").exists()); +} + +#[test] +fn idempotent_when_already_configured() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + assert!(GeminiCliProvider.ensure_configured_at(&path).unwrap()); + assert!(!GeminiCliProvider.ensure_configured_at(&path).unwrap()); +} + +#[test] +fn preserves_unrelated_existing_keys() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + let original = serde_json::json!({ + "theme": "dark", + "model": "gemini-2.0-pro" + }); + fs::write(&path, serde_json::to_string_pretty(&original).unwrap()).unwrap(); + + let changed = GeminiCliProvider.ensure_configured_at(&path).unwrap(); + assert!(changed); + + let s = read_settings(&path); + assert_eq!(s["theme"], "dark"); + assert_eq!(s["model"], "gemini-2.0-pro"); + assert_eq!(s["telemetry"]["enabled"], Value::Bool(true)); + assert!(path.with_extension("json.bak").exists()); +} + +#[test] +fn overwrites_stale_telemetry_block() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + let original = serde_json::json!({ + "telemetry": { + "enabled": true, + "target": "remote", + "otlpEndpoint": "http://wrong:9999" + } + }); + fs::write(&path, serde_json::to_string_pretty(&original).unwrap()).unwrap(); + + let changed = GeminiCliProvider.ensure_configured_at(&path).unwrap(); + assert!(changed, "wrong endpoint must trigger an update"); + + let s = read_settings(&path); + assert_eq!(s["telemetry"]["target"], "local"); + assert_eq!(s["telemetry"]["otlpEndpoint"], "http://localhost:4318"); +} + +#[test] +fn returns_false_when_unchanged_after_first_run() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + let initial = serde_json::json!({ + "telemetry": { + "enabled": true, + "target": "local", + "otlpEndpoint": "http://localhost:4318", + "otlpProtocol": "http" + } + }); + fs::write(&path, serde_json::to_string_pretty(&initial).unwrap()).unwrap(); + + let changed = GeminiCliProvider.ensure_configured_at(&path).unwrap(); + assert!(!changed, "matching config must report no change"); + assert!( + !path.with_extension("json.bak").exists(), + "no backup when no modification" + ); +} diff --git a/tests/providers_qwen_test.rs b/tests/providers_qwen_test.rs new file mode 100644 index 0000000..80ad9f7 --- /dev/null +++ b/tests/providers_qwen_test.rs @@ -0,0 +1,91 @@ +//! End-to-end tests for QwenCodeProvider::ensure_configured_at. + +use agenttop::providers::qwen_code::QwenCodeProvider; +use serde_json::Value; +use std::fs; + +fn read_settings(path: &std::path::Path) -> Value { + serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap() +} + +#[test] +fn creates_settings_when_missing() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + assert!(QwenCodeProvider.ensure_configured_at(&path).unwrap()); + + let s = read_settings(&path); + assert_eq!(s["telemetry"]["enabled"], Value::Bool(true)); + assert_eq!(s["telemetry"]["otlpEndpoint"], "http://localhost:4318"); +} + +#[test] +fn idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + assert!(QwenCodeProvider.ensure_configured_at(&path).unwrap()); + assert!(!QwenCodeProvider.ensure_configured_at(&path).unwrap()); +} + +#[test] +fn preserves_unrelated_keys() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + fs::write( + &path, + serde_json::to_string_pretty(&serde_json::json!({ + "model": "qwen2.5-coder-32b" + })) + .unwrap(), + ) + .unwrap(); + + QwenCodeProvider.ensure_configured_at(&path).unwrap(); + + let s = read_settings(&path); + assert_eq!(s["model"], "qwen2.5-coder-32b"); + assert_eq!(s["telemetry"]["enabled"], Value::Bool(true)); +} + +#[test] +fn creates_backup_only_on_modification() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + + QwenCodeProvider.ensure_configured_at(&path).unwrap(); // creates file + assert!( + !path.with_extension("json.bak").exists(), + "no backup on initial create" + ); + + fs::write( + &path, + serde_json::to_string_pretty(&serde_json::json!({ + "telemetry": { "enabled": false } + })) + .unwrap(), + ) + .unwrap(); + + QwenCodeProvider.ensure_configured_at(&path).unwrap(); // modifies + assert!( + path.with_extension("json.bak").exists(), + "backup created on modification" + ); +} + +#[test] +fn parse_error_propagates() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + fs::write(&path, "{not valid json").unwrap(); + + let result = QwenCodeProvider.ensure_configured_at(&path); + assert!( + result.is_err(), + "malformed settings.json should produce a clear error" + ); +} diff --git a/tests/storage_test.rs b/tests/storage_test.rs index dfdaf18..714ac06 100644 --- a/tests/storage_test.rs +++ b/tests/storage_test.rs @@ -490,6 +490,403 @@ fn test_multiple_tool_events() { assert_eq!(write_metrics.error_count, 1); } +/// Round-trip realistic Claude Code shaped events through storage and +/// verify approval counts surface in `ToolMetrics`. This is the regression +/// guard for the bug where the SQL was querying `$.decision` on +/// `tool_result` (always NULL) — real Claude Code emits `decision_type` +/// on `tool_result` for accepts and a separate `tool_decision` event +/// (with `decision` attribute) for both accepts and rejects. +#[test] +fn test_approval_rate_from_real_event_shape() { + use agenttop::storage::{LogEvent, StorageHandle}; + + let storage = StorageHandle::new_in_memory().unwrap(); + + // Helper to build a tool_result event with decision_type=accept. + let result_event = |tool: &str, tool_use_id: &str| { + let mut attrs = HashMap::new(); + attrs.insert("tool_name".to_string(), tool.to_string()); + attrs.insert("success".to_string(), "true".to_string()); + attrs.insert("duration_ms".to_string(), "100".to_string()); + attrs.insert("decision_type".to_string(), "accept".to_string()); + attrs.insert("tool_use_id".to_string(), tool_use_id.to_string()); + LogEvent { + timestamp: Utc::now(), + event_name: Some("tool_result".to_string()), + body: None, + attributes: attrs, + } + }; + + // Helper to build a tool_decision event. + let decision_event = |tool: &str, decision: &str, tool_use_id: &str| { + let mut attrs = HashMap::new(); + attrs.insert("tool_name".to_string(), tool.to_string()); + attrs.insert("decision".to_string(), decision.to_string()); + attrs.insert("tool_use_id".to_string(), tool_use_id.to_string()); + LogEvent { + timestamp: Utc::now(), + event_name: Some("tool_decision".to_string()), + body: None, + attributes: attrs, + } + }; + + // Scenario: + // Bash: 3 accepts (3 tool_result + 3 tool_decision) + 1 reject + // (tool_decision only, no tool_result — the rejected one never executed). + // mcp__context7__resolve-library-id: 2 accepts (full name on tool_result, + // tool_decision uses "mcp_tool" → reconciled via tool_use_id). + // Read: 1 accept, no tool_decision event (auto-approved variant). + storage.record_log_events(vec![ + // Bash accepts + result_event("Bash", "bash-1"), + decision_event("Bash", "accept", "bash-1"), + result_event("Bash", "bash-2"), + decision_event("Bash", "accept", "bash-2"), + result_event("Bash", "bash-3"), + decision_event("Bash", "accept", "bash-3"), + // Bash reject — no tool_result, only tool_decision + decision_event("Bash", "reject", "bash-rejected-99"), + // MCP: tool_result has full name, tool_decision has "mcp_tool" + result_event("mcp__context7__resolve-library-id", "mcp-1"), + decision_event("mcp_tool", "accept", "mcp-1"), + result_event("mcp__context7__resolve-library-id", "mcp-2"), + decision_event("mcp_tool", "accept", "mcp-2"), + // Read auto-approved, no tool_decision + result_event("Read", "read-1"), + ]); + std::thread::sleep(std::time::Duration::from_millis(150)); + + let metrics = storage.get_tool_metrics(None).unwrap(); + + let bash = metrics + .iter() + .find(|m| m.tool_name == "Bash") + .expect("Bash metrics present"); + assert_eq!(bash.call_count, 3, "Bash executed 3 times"); + assert_eq!( + bash.approved_count, 3, + "Bash had 3 accepts via tool_decision" + ); + assert_eq!( + bash.rejected_count, 1, + "Bash had 1 reject — must come from tool_decision because rejects have no tool_result" + ); + // Approval rate: 3 / (3 + 1) = 75% + assert!( + (bash.approval_rate() - 75.0).abs() < 0.5, + "Bash approval rate should be 75%, got {}", + bash.approval_rate() + ); + + let mcp = metrics + .iter() + .find(|m| m.tool_name == "mcp__context7__resolve-library-id") + .expect( + "MCP tool present under full name (reconciled via tool_use_id, NOT under 'mcp_tool')", + ); + assert_eq!(mcp.call_count, 2); + assert_eq!( + mcp.approved_count, 2, + "MCP accepts must be credited to the proper name via tool_use_id join" + ); + assert_eq!(mcp.rejected_count, 0); + + // No phantom "mcp_tool" row from the tool_decision side leaking through. + assert!( + !metrics.iter().any(|m| m.tool_name == "mcp_tool"), + "raw 'mcp_tool' name should be resolved away when a matching tool_result exists" + ); + + let read = metrics + .iter() + .find(|m| m.tool_name == "Read") + .expect("Read metrics present"); + assert_eq!(read.call_count, 1); + assert_eq!(read.approved_count, 0, "no tool_decision events for Read"); + assert_eq!(read.rejected_count, 0); + // approval_rate() falls back to 100% when both counts are 0 (no data). + assert!((read.approval_rate() - 100.0).abs() < 0.5); +} + +// ============================================================================= +// Time-filter coverage (every getter that takes Option>) +// +// The TUI defaults to AllTime (since=None) so the WHERE-clause path was +// historically unexercised. This caused two real bugs that escaped to +// production: token-rate sparkline returned all zeros, and a deeper SQL +// type-coercion issue lurked behind it. Tests below pin the contract for +// each filtered getter. +// ============================================================================= + +fn put_log_event( + storage: &agenttop::storage::StorageHandle, + event_name: &str, + attrs: &[(&str, &str)], + timestamp: chrono::DateTime, +) { + use agenttop::storage::LogEvent; + let mut a = HashMap::new(); + for (k, v) in attrs { + a.insert(k.to_string(), v.to_string()); + } + storage.record_log_events(vec![LogEvent { + timestamp, + event_name: Some(event_name.to_string()), + body: None, + attributes: a, + }]); +} + +#[test] +fn test_get_tool_metrics_time_filter_excludes_old_rows() { + use agenttop::storage::StorageHandle; + use chrono::Duration; + + let storage = StorageHandle::new_in_memory().unwrap(); + put_log_event( + &storage, + "tool_result", + &[ + ("tool_name", "Bash"), + ("success", "true"), + ("duration_ms", "100"), + ], + Utc::now() - Duration::seconds(300), + ); + put_log_event( + &storage, + "tool_result", + &[ + ("tool_name", "Bash"), + ("success", "true"), + ("duration_ms", "100"), + ], + Utc::now() - Duration::seconds(10), + ); + std::thread::sleep(std::time::Duration::from_millis(150)); + + let recent = storage + .get_tool_metrics(Some(Utc::now() - Duration::seconds(60))) + .unwrap(); + let bash = recent.iter().find(|t| t.tool_name == "Bash").unwrap(); + assert_eq!(bash.call_count, 1, "only recent row inside the window"); + + let all = storage.get_tool_metrics(None).unwrap(); + let bash_all = all.iter().find(|t| t.tool_name == "Bash").unwrap(); + assert_eq!(bash_all.call_count, 2, "both rows visible without filter"); +} + +#[test] +fn test_get_api_metrics_time_filter() { + use agenttop::storage::StorageHandle; + use chrono::Duration; + + let storage = StorageHandle::new_in_memory().unwrap(); + put_log_event( + &storage, + "api_request", + &[("model", "sonnet"), ("latency_ms", "200")], + Utc::now() - Duration::seconds(300), + ); + put_log_event( + &storage, + "api_request", + &[("model", "sonnet"), ("latency_ms", "100")], + Utc::now() - Duration::seconds(5), + ); + std::thread::sleep(std::time::Duration::from_millis(150)); + + let recent = storage + .get_api_metrics(Some(Utc::now() - Duration::seconds(60))) + .unwrap(); + assert_eq!(recent.total_calls, 1); + + let all = storage.get_api_metrics(None).unwrap(); + assert_eq!(all.total_calls, 2); +} + +#[test] +fn test_get_distinct_sessions_time_filter() { + use agenttop::storage::StorageHandle; + use chrono::Duration; + + let storage = StorageHandle::new_in_memory().unwrap(); + put_log_event( + &storage, + "tool_result", + &[("session.id", "session-old")], + Utc::now() - Duration::seconds(300), + ); + put_log_event( + &storage, + "tool_result", + &[("session.id", "session-new")], + Utc::now() - Duration::seconds(5), + ); + std::thread::sleep(std::time::Duration::from_millis(150)); + + let recent = storage + .get_distinct_sessions(Some(Utc::now() - Duration::seconds(60))) + .unwrap(); + assert_eq!(recent.len(), 1); + assert_eq!(recent[0].session_id, "session-new"); +} + +#[test] +fn test_get_distinct_service_names_time_filter() { + use agenttop::storage::StorageHandle; + use chrono::Duration; + + let storage = StorageHandle::new_in_memory().unwrap(); + put_log_event( + &storage, + "tool_result", + &[("service.name", "claude-code")], + Utc::now() - Duration::seconds(300), + ); + put_log_event( + &storage, + "tool_result", + &[("service.name", "gemini-cli")], + Utc::now() - Duration::seconds(5), + ); + std::thread::sleep(std::time::Duration::from_millis(150)); + + let recent = storage + .get_distinct_service_names(Some(Utc::now() - Duration::seconds(60))) + .unwrap(); + assert_eq!(recent, vec!["gemini-cli".to_string()]); +} + +#[test] +fn test_get_compaction_stats_time_filter() { + use agenttop::storage::StorageHandle; + use chrono::Duration; + + let storage = StorageHandle::new_in_memory().unwrap(); + put_log_event( + &storage, + "claude_code.compaction", + &[("pre_tokens", "180000"), ("post_tokens", "80000")], + Utc::now() - Duration::seconds(300), + ); + put_log_event( + &storage, + "claude_code.compaction", + &[("pre_tokens", "120000"), ("post_tokens", "60000")], + Utc::now() - Duration::seconds(5), + ); + std::thread::sleep(std::time::Duration::from_millis(150)); + + let recent = storage + .get_compaction_stats(Some(Utc::now() - Duration::seconds(60))) + .unwrap(); + assert_eq!(recent.count, 1); + assert_eq!(recent.last_pre_tokens, Some(120_000)); + assert_eq!(recent.last_post_tokens, Some(60_000)); + + let all = storage.get_compaction_stats(None).unwrap(); + assert_eq!(all.count, 2); +} + +// ============================================================================= +// Empty-database safety: every getter on a fresh in-memory DB. +// +// Catches the "silent panic" class of bug — e.g. a `query_row` that errors +// on empty results without graceful handling. +// ============================================================================= + +#[test] +fn test_all_getters_safe_on_empty_db() { + use agenttop::storage::StorageHandle; + + let storage = StorageHandle::new_in_memory().unwrap(); + + let tool = storage.get_tool_metrics(None).unwrap(); + assert!(tool.is_empty()); + + let token = storage.get_token_metrics(None).unwrap(); + assert_eq!(token.input_tokens, 0); + assert_eq!(token.total_cost_usd, 0.0); + + let session = storage.get_session_metrics(None).unwrap(); + assert_eq!(session.active_time_secs, 0); + + let api = storage.get_api_metrics(None).unwrap(); + assert_eq!(api.total_calls, 0); + + let sessions = storage.get_distinct_sessions(None).unwrap(); + assert!(sessions.is_empty()); + + let services = storage.get_distinct_service_names(None).unwrap(); + assert!(services.is_empty()); + + let comp = storage.get_compaction_stats(None).unwrap(); + assert_eq!(comp.count, 0); + + let rate = storage.get_token_rate_series(300, 60).unwrap(); + assert_eq!(rate.len(), 60); + assert!(rate.iter().all(|&v| v == 0.0)); +} + +// ============================================================================= +// Token-rate sparkline boundary regression +// +// The original implementation dropped rows whose elapsed-time was exactly +// `window_secs` because the bucket index hit `points` (out of range). Fix: +// `idx.min(points - 1)` and millisecond-precision timestamps. +// ============================================================================= + +#[test] +fn test_token_rate_series_boundary_row_included() { + use agenttop::storage::StorageHandle; + + let storage = StorageHandle::new_in_memory().unwrap(); + storage.record_token_usage("input", 500); + storage.record_token_usage("output", 500); + std::thread::sleep(std::time::Duration::from_millis(150)); + + let series = storage.get_token_rate_series(60, 12).unwrap(); + let bucket_secs = 60.0 / 12.0; + let total: f64 = series.iter().map(|r| r * bucket_secs).sum(); + assert!( + (total - 1000.0).abs() < 0.5, + "all 1000 tokens must be counted regardless of bucket boundary, got {}", + total + ); +} + +#[test] +fn test_token_rate_series_distributes_across_buckets() { + use agenttop::storage::StorageHandle; + + let storage = StorageHandle::new_in_memory().unwrap(); + // 100 tokens at multiple distinct timestamps — should distribute across + // buckets, not stack into one. + storage.record_token_usage("input", 100); + std::thread::sleep(std::time::Duration::from_millis(50)); + storage.record_token_usage("input", 100); + std::thread::sleep(std::time::Duration::from_millis(50)); + storage.record_token_usage("input", 100); + std::thread::sleep(std::time::Duration::from_millis(150)); + + // Wide window → all three rows are well inside. + let series = storage.get_token_rate_series(600, 60).unwrap(); + let nonzero_buckets = series.iter().filter(|&&v| v > 0.0).count(); + // Could be 1-3 depending on timing; the key invariant is that no rows + // were dropped. + let bucket_secs = 600.0 / 60.0; + let total: f64 = series.iter().map(|r| r * bucket_secs).sum(); + assert!( + (total - 300.0).abs() < 1.0, + "total tokens preserved across buckets, got {} (nonzero={})", + total, + nonzero_buckets + ); +} + /// Test recording token usage #[test] fn test_token_usage_recording() { diff --git a/tests/tui_render_test.rs b/tests/tui_render_test.rs new file mode 100644 index 0000000..cb272a1 --- /dev/null +++ b/tests/tui_render_test.rs @@ -0,0 +1,360 @@ +//! Render-content tests for `tui::ui::draw`. +//! +//! Existing TUI tests verified rendering doesn't panic; these assert on the +//! actual buffer contents using `TestBackend`. The goal is to lock down the +//! data → UI mapping so a change to a format string or column order shows +//! up as a test failure rather than a "looks wrong" smoke-test report. + +use agenttop::scraper::{ + ChildProcess, HostMetrics, LiveSession, OrphanPort, RateLimitInfo, ScraperSnapshot, + SessionStatus, SubAgent, +}; +use agenttop::storage::{LogEvent, StorageHandle}; +use agenttop::tui::app::App; +use agenttop::tui::ui::draw; +use chrono::Utc; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use std::collections::HashMap; + +/// Render the App to a TestBackend of (w, h) and return the buffer as a +/// single space-collapsed string for easy substring search. +fn render_to_text(app: &App, w: u16, h: u16) -> String { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| draw(f, app)).unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + let cell = &buf[(x, y)]; + out.push_str(cell.symbol()); + } + out.push('\n'); + } + out +} + +fn fake_live_session( + agent: &'static str, + project: &str, + status: SessionStatus, + ctx_used: u64, + ctx_window: u64, +) -> LiveSession { + LiveSession { + agent_id: agent, + pid: 1234, + session_id: format!("sess-{}", project), + cwd: format!("/Users/test/{}", project), + project_name: project.into(), + started_at_ms: 0, + status, + model: "claude-opus-4-5".into(), + context_percent: Some(ctx_used as f64 / ctx_window as f64), + context_window: Some(ctx_window), + latest_context_tokens: ctx_used, + current_task: "Edit src/main.rs".into(), + input_tokens: 5_000, + output_tokens: 2_500, + cache_read_tokens: 100_000, + cache_creation_tokens: 1_000, + mem_mb: 256, + children: vec![], + subagents: vec![], + } +} + +fn tool_result(name: &str, success: bool) -> LogEvent { + let mut a = HashMap::new(); + a.insert("tool_name".into(), name.into()); + a.insert("success".into(), success.to_string()); + a.insert("duration_ms".into(), "100".into()); + LogEvent { + timestamp: Utc::now(), + event_name: Some("tool_result".into()), + body: None, + attributes: a, + } +} + +#[test] +fn empty_state_renders_without_panic_and_shows_no_live_panel() { + let storage = StorageHandle::new_in_memory().unwrap(); + let app = App::new(storage); + + let text = render_to_text(&app, 120, 30); + // Header always present. + assert!(text.contains("agenttop")); + // Empty live snapshot → no Live sessions panel border. + assert!( + !text.contains("Live sessions"), + "Live panel must hide when there's nothing to show" + ); +} + +#[test] +fn live_panel_shows_ctx_used_over_window_format() { + let storage = StorageHandle::new_in_memory().unwrap(); + let mut app = App::new(storage); + app.scraper_snapshot = ScraperSnapshot { + live_sessions: vec![fake_live_session( + "claude_code", + "alpha", + SessionStatus::Executing, + 120_000, + 200_000, + )], + ..ScraperSnapshot::default() + }; + + let text = render_to_text(&app, 200, 40); + assert!(text.contains("Live sessions")); + // CTX column format: "120K/200K 60%" — humanize_u64 emits with K suffix. + assert!( + text.contains("120.0K") || text.contains("120K"), + "CTX column should show used tokens; got:\n{}", + text + ); + assert!( + text.contains("200K") || text.contains("200.0K"), + "CTX column should show window size" + ); + // CTX% would be 60% — but column width may truncate "60%"; assert + // either the percentage or its constituent number appears. + assert!( + text.contains("60%") || text.contains("120.0K/200.0K"), + "CTX percentage or used/window pair must appear; got:\n{}", + text + ); +} + +#[test] +fn live_panel_color_codes_status() { + let storage = StorageHandle::new_in_memory().unwrap(); + let mut app = App::new(storage); + app.scraper_snapshot = ScraperSnapshot { + live_sessions: vec![fake_live_session( + "claude_code", + "alpha", + SessionStatus::RateLimited, + 150_000, + 200_000, + )], + ..ScraperSnapshot::default() + }; + + let text = render_to_text(&app, 160, 40); + assert!( + text.contains("RateLimited"), + "STATUS column should display 'RateLimited' literal" + ); +} + +#[test] +fn live_panel_shows_subagents_inline_in_task_column() { + let storage = StorageHandle::new_in_memory().unwrap(); + let mut app = App::new(storage); + let mut session = fake_live_session( + "claude_code", + "alpha", + SessionStatus::Thinking, + 50_000, + 200_000, + ); + session.subagents = vec![ + SubAgent { + name: "Explore".into(), + status: "running".into(), + tokens: 1_500, + }, + SubAgent { + name: "Plan".into(), + status: "done".into(), + tokens: 800, + }, + ]; + app.scraper_snapshot = ScraperSnapshot { + live_sessions: vec![session], + ..ScraperSnapshot::default() + }; + + let text = render_to_text(&app, 200, 30); + // Subagent summary inlines into the TASK column: "... · sub: Explore(1.5K), Plan(800)" + assert!( + text.contains("sub:") || text.contains("Explore"), + "subagent names should appear inline; got:\n{}", + text + ); +} + +#[test] +fn quota_panel_renders_when_rate_limits_present() { + let storage = StorageHandle::new_in_memory().unwrap(); + let mut app = App::new(storage); + // Combine with a session so the live panel gets enough vertical space + // for the quota panel to render its 5h + 7d rows. Quota alone would + // get clipped to just the title row. + app.scraper_snapshot = ScraperSnapshot { + live_sessions: vec![fake_live_session( + "claude_code", + "p", + SessionStatus::Thinking, + 50_000, + 200_000, + )], + rate_limits: vec![RateLimitInfo { + source: "claude".into(), + five_hour_pct: Some(42.0), + seven_day_pct: Some(15.0), + five_hour_resets_at: None, + seven_day_resets_at: None, + updated_at: None, + }], + ..ScraperSnapshot::default() + }; + + // Tall terminal so the quota panel has room for both 5h and 7d rows. + let text = render_to_text(&app, 200, 60); + assert!(text.contains("Quota")); + assert!(text.contains("42%"), "5-hour quota must render"); + assert!(text.contains("15%"), "7-day quota must render"); +} + +#[test] +fn orphan_ports_panel_renders_when_ports_present() { + let storage = StorageHandle::new_in_memory().unwrap(); + let mut app = App::new(storage); + // Pair with a session so the orphan strip gets actual vertical space. + app.scraper_snapshot = ScraperSnapshot { + live_sessions: vec![fake_live_session( + "claude_code", + "p", + SessionStatus::Thinking, + 50_000, + 200_000, + )], + orphan_ports: vec![OrphanPort { + port: 8080, + pid: 99999, + command: "python".into(), + origin_session_id: "abandoned-session".into(), + }], + ..ScraperSnapshot::default() + }; + + let text = render_to_text(&app, 200, 40); + assert!(text.contains("Orphans")); + assert!(text.contains("8080")); + assert!(text.contains("python")); +} + +#[test] +fn host_vitals_strip_renders_when_data_present() { + let storage = StorageHandle::new_in_memory().unwrap(); + let mut app = App::new(storage); + app.scraper_snapshot = ScraperSnapshot { + host_metrics: HostMetrics { + cpu_pct: 35.0, + mem_pct: 62.0, + load1: 1.42, + }, + ..ScraperSnapshot::default() + }; + + let text = render_to_text(&app, 200, 30); + assert!(text.contains("CPU")); + assert!(text.contains("MEM")); + assert!(text.contains("LOAD")); + assert!(text.contains("35%")); + assert!(text.contains("62%")); + assert!(text.contains("1.42")); +} + +#[test] +fn tools_table_shows_type_column_for_builtin_and_mcp() { + let storage = StorageHandle::new_in_memory().unwrap(); + storage.record_log_events(vec![ + tool_result("Bash", true), + tool_result("Read", true), + tool_result("mcp__context7__resolve-library-id", true), + ]); + std::thread::sleep(std::time::Duration::from_millis(150)); + + let mut app = App::new(storage); + app.refresh().unwrap(); + + let text = render_to_text(&app, 200, 40); + // Header row. + assert!(text.contains("TYPE")); + assert!(text.contains("TOOL")); + // At least one builtin row. + assert!(text.contains("builtin"), "builtin TYPE label must appear"); + // MCP tool renders as "server:tool" via display_name. + assert!( + text.contains("context7:resolve-library-id"), + "MCP tool should render with server:tool format" + ); + assert!(text.contains("mcp"), "mcp TYPE label must appear"); +} + +#[test] +fn footer_indicates_current_focus() { + let storage = StorageHandle::new_in_memory().unwrap(); + let app = App::new(storage); + + let text = render_to_text(&app, 160, 30); + assert!(text.contains("focus:")); + assert!(text.contains("Tools")); + assert!(text.contains("[Tab]")); +} + +#[test] +fn detail_strip_shows_children_for_selected_live_session() { + let storage = StorageHandle::new_in_memory().unwrap(); + let mut app = App::new(storage); + let mut session = fake_live_session( + "claude_code", + "alpha", + SessionStatus::Thinking, + 50_000, + 200_000, + ); + session.children = vec![ + ChildProcess { + pid: 12345, + command: "python".into(), + mem_kb: 102_400, + port: Some(8000), + }, + ChildProcess { + pid: 67890, + command: "node".into(), + mem_kb: 51_200, + port: None, + }, + ]; + app.scraper_snapshot = ScraperSnapshot { + live_sessions: vec![session], + ..ScraperSnapshot::default() + }; + app.toggle_focus(); // -> Live + + let text = render_to_text(&app, 200, 50); + assert!(text.contains("children:")); + assert!(text.contains("12345")); + assert!(text.contains("python")); + assert!(text.contains(":8000"), "open ports should render"); + assert!(text.contains("67890")); + assert!(text.contains("node")); +} + +#[test] +fn paused_indicator_shown_in_header() { + let storage = StorageHandle::new_in_memory().unwrap(); + let mut app = App::new(storage); + app.toggle_pause(); + + let text = render_to_text(&app, 160, 30); + assert!(text.contains("PAUSED")); +} diff --git a/tests/tui_state_test.rs b/tests/tui_state_test.rs new file mode 100644 index 0000000..98ca196 --- /dev/null +++ b/tests/tui_state_test.rs @@ -0,0 +1,360 @@ +//! State-transition tests for `tui::app::App`. +//! +//! Targets the un-tested branches of cycle_*, toggle_*, and select_* +//! methods. Each tests is self-contained — uses in-memory storage so no +//! filesystem or network state leaks across runs. + +use agenttop::scraper::{LiveSession, ScraperSnapshot, SessionStatus}; +use agenttop::storage::{LogEvent, StorageHandle}; +use agenttop::tui::app::{App, FocusPanel, ProjectFilter, SortColumn, TimeFilter}; +use chrono::Utc; +use std::collections::HashMap; + +fn make_app() -> App { + let storage = StorageHandle::new_in_memory().unwrap(); + App::new(storage) +} + +fn tool_event(name: &str, success: bool, duration: u64) -> LogEvent { + let mut a = HashMap::new(); + a.insert("tool_name".into(), name.into()); + a.insert("success".into(), success.to_string()); + a.insert("duration_ms".into(), duration.to_string()); + LogEvent { + timestamp: Utc::now(), + event_name: Some("tool_result".into()), + body: None, + attributes: a, + } +} + +fn fake_live_session(agent: &'static str, project: &str, session_id: &str) -> LiveSession { + LiveSession { + agent_id: agent, + pid: 1234, + session_id: session_id.into(), + cwd: format!("/Users/test/{}", project), + project_name: project.into(), + started_at_ms: 0, + status: SessionStatus::Thinking, + model: "claude-opus-4-5".into(), + context_percent: Some(0.42), + context_window: Some(200_000), + latest_context_tokens: 84_000, + current_task: "Edit src/main.rs".into(), + input_tokens: 5_000, + output_tokens: 2_500, + cache_read_tokens: 100_000, + cache_creation_tokens: 1_000, + mem_mb: 256, + children: vec![], + subagents: vec![], + } +} + +// ---------- toggle_sort cycle ---------- + +#[test] +fn toggle_sort_cycles_all_five_columns() { + let mut app = make_app(); + assert_eq!(app.sort_by, SortColumn::Calls); + app.toggle_sort(); + assert_eq!(app.sort_by, SortColumn::LastCall); + app.toggle_sort(); + assert_eq!(app.sort_by, SortColumn::AvgDuration); + app.toggle_sort(); + assert_eq!(app.sort_by, SortColumn::Name); + app.toggle_sort(); + assert_eq!(app.sort_by, SortColumn::Type); + app.toggle_sort(); + assert_eq!(app.sort_by, SortColumn::Calls, "must wrap back to Calls"); +} + +// ---------- toggle_time_filter cycle ---------- + +#[test] +fn toggle_time_filter_cycles_through_all_windows() { + let mut app = make_app(); + assert_eq!(app.time_filter, TimeFilter::AllTime); + app.toggle_time_filter(); + assert_eq!(app.time_filter, TimeFilter::LastHour); + app.toggle_time_filter(); + assert_eq!(app.time_filter, TimeFilter::Last24Hours); + app.toggle_time_filter(); + assert_eq!(app.time_filter, TimeFilter::Last7Days); + app.toggle_time_filter(); + assert_eq!( + app.time_filter, + TimeFilter::AllTime, + "must wrap from Last7Days back to AllTime" + ); +} + +// ---------- toggle_pause / detail ---------- + +#[test] +fn toggle_pause_flips_state() { + let mut app = make_app(); + assert!(!app.paused); + app.toggle_pause(); + assert!(app.paused); + app.toggle_pause(); + assert!(!app.paused); +} + +#[test] +fn toggle_detail_and_close_detail() { + let mut app = make_app(); + assert!(!app.show_detail); + app.toggle_detail(); + assert!(app.show_detail); + app.close_detail(); + assert!(!app.show_detail); +} + +// ---------- selection wrap-around (Tools focus, default) ---------- + +#[test] +fn select_next_wraps_at_end() { + let storage = StorageHandle::new_in_memory().unwrap(); + storage.record_log_events(vec![ + tool_event("Bash", true, 100), + tool_event("Edit", true, 50), + tool_event("Read", true, 25), + ]); + std::thread::sleep(std::time::Duration::from_millis(150)); + + let mut app = App::new(storage); + app.refresh().unwrap(); + assert_eq!(app.tool_metrics.len(), 3); + + app.selected_index = app.tool_metrics.len() - 1; + app.select_next(); + assert_eq!(app.selected_index, 0, "must wrap to first"); +} + +#[test] +fn select_previous_wraps_at_zero() { + let storage = StorageHandle::new_in_memory().unwrap(); + storage.record_log_events(vec![ + tool_event("Bash", true, 100), + tool_event("Edit", true, 50), + ]); + std::thread::sleep(std::time::Duration::from_millis(150)); + + let mut app = App::new(storage); + app.refresh().unwrap(); + + app.selected_index = 0; + app.select_previous(); + assert_eq!( + app.selected_index, + app.tool_metrics.len() - 1, + "must wrap to last" + ); +} + +#[test] +fn select_does_not_panic_on_empty_metrics() { + let mut app = make_app(); + // Empty tool_metrics + empty live_sessions. + app.select_next(); + app.select_previous(); + assert_eq!(app.selected_index, 0); +} + +// ---------- focus switching ---------- + +#[test] +fn toggle_focus_stays_on_tools_when_no_live_sessions() { + let mut app = make_app(); + assert_eq!(app.effective_focus(), FocusPanel::Tools); + app.toggle_focus(); + assert_eq!( + app.effective_focus(), + FocusPanel::Tools, + "no live sessions -> can't switch to Live" + ); +} + +#[test] +fn toggle_focus_cycles_tools_and_live_when_sessions_present() { + let mut app = make_app(); + app.scraper_snapshot = ScraperSnapshot { + live_sessions: vec![fake_live_session("claude_code", "proj", "s1")], + ..ScraperSnapshot::default() + }; + + assert_eq!(app.effective_focus(), FocusPanel::Tools); + app.toggle_focus(); + assert_eq!(app.effective_focus(), FocusPanel::Live); + app.toggle_focus(); + assert_eq!(app.effective_focus(), FocusPanel::Tools); +} + +#[test] +fn effective_focus_falls_back_when_focus_live_but_sessions_disappear() { + let mut app = make_app(); + app.scraper_snapshot = ScraperSnapshot { + live_sessions: vec![fake_live_session("claude_code", "proj", "s1")], + ..ScraperSnapshot::default() + }; + app.toggle_focus(); + assert_eq!(app.effective_focus(), FocusPanel::Live); + + // Sessions vanish (e.g. scraper tick returned empty list). + app.scraper_snapshot = ScraperSnapshot::default(); + assert_eq!( + app.effective_focus(), + FocusPanel::Tools, + "focus must fall back to Tools when live list is empty" + ); +} + +// ---------- live session selection ---------- + +#[test] +fn select_next_navigates_live_sessions_when_focused() { + let mut app = make_app(); + app.scraper_snapshot = ScraperSnapshot { + live_sessions: vec![ + fake_live_session("claude_code", "p1", "s1"), + fake_live_session("claude_code", "p2", "s2"), + fake_live_session("claude_code", "p3", "s3"), + ], + ..ScraperSnapshot::default() + }; + app.toggle_focus(); // -> Live + + assert_eq!(app.live_selected_index, 0); + app.select_next(); + assert_eq!(app.live_selected_index, 1); + app.select_next(); + assert_eq!(app.live_selected_index, 2); + app.select_next(); + assert_eq!(app.live_selected_index, 0, "wraps"); + + app.select_previous(); + assert_eq!(app.live_selected_index, 2, "wraps backwards"); +} + +#[test] +fn selected_live_session_returns_correct_entry() { + let mut app = make_app(); + app.scraper_snapshot = ScraperSnapshot { + live_sessions: vec![ + fake_live_session("claude_code", "p1", "s1"), + fake_live_session("claude_code", "p2", "s2"), + ], + ..ScraperSnapshot::default() + }; + app.live_selected_index = 1; + let selected = app.selected_live_session().unwrap(); + assert_eq!(selected.session_id, "s2"); +} + +// ---------- agent cycling ---------- + +#[test] +fn cycle_agent_walks_detected_list() { + let mut app = make_app(); + app.add_detected_agent("claude_code"); + app.add_detected_agent("codex"); + app.add_detected_agent("gemini_cli"); + + assert_eq!(app.current_agent(), Some("claude_code")); + app.cycle_agent(); + assert_eq!(app.current_agent(), Some("codex")); + app.cycle_agent(); + assert_eq!(app.current_agent(), Some("gemini_cli")); + app.cycle_agent(); + assert_eq!(app.current_agent(), Some("claude_code"), "wraps"); +} + +#[test] +fn cycle_agent_noop_when_empty() { + let mut app = make_app(); + app.cycle_agent(); + assert_eq!(app.current_agent(), None); +} + +#[test] +fn add_detected_agent_is_idempotent() { + let mut app = make_app(); + app.add_detected_agent("claude_code"); + app.add_detected_agent("claude_code"); + app.add_detected_agent("claude_code"); + assert_eq!(app.detected_agents.len(), 1); +} + +// ---------- project cycling ---------- + +#[test] +fn cycle_project_walks_all_then_each_project() { + use agenttop::storage::ProjectInfo; + let mut app = make_app(); + app.detected_projects = vec![ + ProjectInfo { + name: "alpha".into(), + event_count: 10, + first_seen: None, + last_seen: None, + }, + ProjectInfo { + name: "beta".into(), + event_count: 5, + first_seen: None, + last_seen: None, + }, + ]; + + assert!(matches!(app.project_filter, ProjectFilter::All)); + app.cycle_project(); + assert!(matches!(&app.project_filter, ProjectFilter::Project(n) if n == "alpha")); + app.cycle_project(); + assert!(matches!(&app.project_filter, ProjectFilter::Project(n) if n == "beta")); + app.cycle_project(); + assert!(matches!(app.project_filter, ProjectFilter::All), "wraps"); +} + +#[test] +fn cycle_project_noop_when_no_projects() { + let mut app = make_app(); + app.cycle_project(); + assert!(matches!(app.project_filter, ProjectFilter::All)); +} + +// ---------- pause prevents refresh from clobbering state ---------- + +#[test] +fn refresh_is_noop_when_paused() { + let storage = StorageHandle::new_in_memory().unwrap(); + storage.record_log_events(vec![tool_event("Bash", true, 100)]); + std::thread::sleep(std::time::Duration::from_millis(150)); + + let mut app = App::new(storage); + app.toggle_pause(); + + let before = app.tool_metrics.len(); + app.refresh().unwrap(); + let after = app.tool_metrics.len(); + assert_eq!(before, after, "paused refresh must not touch state"); +} + +// ---------- time-filter labels ---------- + +#[test] +fn time_filter_labels_are_stable() { + assert_eq!(TimeFilter::AllTime.label(), "All-time"); + assert_eq!(TimeFilter::LastHour.label(), "Last 1h"); + assert_eq!(TimeFilter::Last24Hours.label(), "Last 24h"); + assert_eq!(TimeFilter::Last7Days.label(), "Last 7d"); +} + +#[test] +fn time_filter_since_only_set_for_windowed_variants() { + assert!(TimeFilter::AllTime.since().is_none()); + assert!(TimeFilter::LastHour.since().is_some()); + assert!(TimeFilter::Last24Hours.since().is_some()); + assert!(TimeFilter::Last7Days.since().is_some()); +} diff --git a/tests/tui_test.rs b/tests/tui_test.rs index b84fd49..76353de 100644 --- a/tests/tui_test.rs +++ b/tests/tui_test.rs @@ -65,6 +65,92 @@ fn test_app_refresh_loads_data() { assert_eq!(read.unwrap().call_count, 2); } +/// End-to-end: realistic Claude Code event shapes (tool_result with +/// `decision_type` + separate `tool_decision` with `decision`) flow through +/// storage → App.tool_metrics → ToolMetrics.approval_rate() correctly. +/// +/// Regression guard: the README's "APR% may show as 100% when data is +/// unavailable" claim was actually wrong — the data IS present, but our SQL +/// was querying the wrong attribute name. This test fails if anyone +/// reintroduces that. +#[test] +fn test_app_approval_rate_end_to_end() { + let storage = StorageHandle::new_in_memory().unwrap(); + + let result = |tool: &str, tuid: &str| { + let mut a = HashMap::new(); + a.insert("tool_name".into(), tool.into()); + a.insert("success".into(), "true".into()); + a.insert("duration_ms".into(), "100".into()); + a.insert("decision_type".into(), "accept".into()); + a.insert("tool_use_id".into(), tuid.into()); + LogEvent { + timestamp: Utc::now(), + event_name: Some("tool_result".into()), + body: None, + attributes: a, + } + }; + let decision = |tool: &str, dec: &str, tuid: &str| { + let mut a = HashMap::new(); + a.insert("tool_name".into(), tool.into()); + a.insert("decision".into(), dec.into()); + a.insert("tool_use_id".into(), tuid.into()); + LogEvent { + timestamp: Utc::now(), + event_name: Some("tool_decision".into()), + body: None, + attributes: a, + } + }; + + storage.record_log_events(vec![ + // ExitPlanMode: mostly rejected (mirrors real-world pattern) + result("ExitPlanMode", "ep-1"), + decision("ExitPlanMode", "accept", "ep-1"), + decision("ExitPlanMode", "reject", "ep-r1"), + decision("ExitPlanMode", "reject", "ep-r2"), + decision("ExitPlanMode", "reject", "ep-r3"), + // Bash: nearly all accepted + result("Bash", "b-1"), + decision("Bash", "accept", "b-1"), + result("Bash", "b-2"), + decision("Bash", "accept", "b-2"), + decision("Bash", "reject", "b-r1"), + ]); + std::thread::sleep(std::time::Duration::from_millis(150)); + + let mut app = App::new(storage); + app.refresh().unwrap(); + + let exitplan = app + .tool_metrics + .iter() + .find(|t| t.tool_name == "ExitPlanMode") + .expect("ExitPlanMode must appear in tool_metrics"); + assert_eq!(exitplan.approved_count, 1); + assert_eq!(exitplan.rejected_count, 3); + assert!( + (exitplan.approval_rate() - 25.0).abs() < 0.5, + "ExitPlanMode approval rate should be 25% (1 accept / 4 total), got {}", + exitplan.approval_rate() + ); + + let bash = app + .tool_metrics + .iter() + .find(|t| t.tool_name == "Bash") + .expect("Bash must appear in tool_metrics"); + assert_eq!(bash.approved_count, 2); + assert_eq!(bash.rejected_count, 1); + let bash_apr = bash.approval_rate(); + assert!( + (bash_apr - 66.66).abs() < 0.5, + "Bash approval rate should be ~67% (2/3), got {}", + bash_apr + ); +} + /// Test that App correctly computes total tokens #[test] fn test_app_total_tokens() {