M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,603 @@
|
||||
//! Tool usage statistics aggregation for the pager.
|
||||
//!
|
||||
//! This module provides:
|
||||
//!
|
||||
//! - [`ToolCategory`] — categories for tool calls (Execute, Read, Edit, Search, ListDir, Other)
|
||||
//! - [`BlockStatus`] — status of a tool block (Success, Failed, Running)
|
||||
//! - [`CategoryStats`] — per-category statistics (counts, failures, positions)
|
||||
//! - [`ToolUsageStats`] — aggregated stats with scope (Session / SelectedTurn)
|
||||
//!
|
||||
//! ## Phase 1 MVP
|
||||
//!
|
||||
//! Stats are computed over visible scrollback blocks only (ToolCallBlock variants).
|
||||
//! Thinking blocks and non-tool RenderBlock variants are excluded.
|
||||
//! Time tracking is deferred to Phase 2.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::ops::Range;
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::style::Color;
|
||||
|
||||
use crate::scrollback::blocks::tool::ToolCallBlock;
|
||||
use crate::scrollback::state::ScrollbackState;
|
||||
use crate::theme::Theme;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToolCategory — categories derived from ToolCallBlock variants only
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Block category for stats aggregation.
|
||||
/// Maps scrollback block variants to semantic groups.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum ToolCategory {
|
||||
/// Shell command execution (run_terminal_cmd, bash).
|
||||
Execute,
|
||||
/// File read operations (read_file).
|
||||
Read,
|
||||
/// File edits (search_replace, write, apply_patch).
|
||||
Edit,
|
||||
/// Search/grep operations (grep, glob).
|
||||
Search,
|
||||
/// Skill invocations (user-defined skills via the Skill tool).
|
||||
Skill,
|
||||
/// Directory listing (list_dir, ls).
|
||||
ListDir,
|
||||
/// Web fetch (URL content retrieval).
|
||||
WebFetch,
|
||||
/// Web search (web search with citations).
|
||||
WebSearch,
|
||||
/// Other/unknown tool types.
|
||||
Other,
|
||||
/// Agent thinking/reasoning.
|
||||
Thinking,
|
||||
/// Agent response message.
|
||||
Message,
|
||||
}
|
||||
|
||||
impl ToolCategory {
|
||||
/// Display name for UI.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Execute => "Execute",
|
||||
Self::Read => "Read",
|
||||
Self::Edit => "Edit",
|
||||
Self::Search => "Search",
|
||||
Self::Skill => "Skill",
|
||||
Self::ListDir => "ListDir",
|
||||
Self::WebFetch => "Fetch",
|
||||
Self::WebSearch => "WebSearch",
|
||||
Self::Other => "Other",
|
||||
Self::Thinking => "Thinking",
|
||||
Self::Message => "Message",
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact symbol for sequence bar (single char, no emoji).
|
||||
pub fn symbol(&self) -> char {
|
||||
match self {
|
||||
Self::Execute => '█',
|
||||
Self::Read => '▓',
|
||||
Self::Edit => '▒',
|
||||
Self::Search => '░',
|
||||
Self::Skill => crate::glyphs::diamond_filled_char(),
|
||||
Self::ListDir => '▀',
|
||||
Self::WebFetch => '▄',
|
||||
Self::WebSearch => '○',
|
||||
Self::Other => crate::glyphs::diamond_filled_char(),
|
||||
Self::Thinking => crate::glyphs::diamond_hollow_char(),
|
||||
Self::Message => '▪',
|
||||
}
|
||||
}
|
||||
|
||||
/// Category color from theme.
|
||||
pub fn color(&self, theme: &Theme) -> Color {
|
||||
match self {
|
||||
Self::Execute => theme.command,
|
||||
Self::Read => theme.accent_system,
|
||||
Self::Edit => theme.accent_success,
|
||||
Self::Search => theme.running,
|
||||
Self::Skill => theme.accent_skill,
|
||||
Self::ListDir => theme.accent_model,
|
||||
Self::WebFetch => theme.accent_tool,
|
||||
Self::WebSearch => theme.accent_tool,
|
||||
Self::Other => theme.path,
|
||||
Self::Thinking => theme.accent_running,
|
||||
Self::Message => theme.text_primary,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map from ToolCallBlock to category.
|
||||
/// Only ToolCallBlock variants are supported; other blocks are excluded.
|
||||
pub fn from_tool_block(tc: &ToolCallBlock) -> Self {
|
||||
match tc {
|
||||
ToolCallBlock::Execute(_) => Self::Execute,
|
||||
ToolCallBlock::Read(_) => Self::Read,
|
||||
ToolCallBlock::Edit(_) => Self::Edit,
|
||||
ToolCallBlock::Search(_) => Self::Search,
|
||||
ToolCallBlock::Skill(_) => Self::Skill,
|
||||
ToolCallBlock::ListDir(_) => Self::ListDir,
|
||||
ToolCallBlock::WebFetch(_) => Self::WebFetch,
|
||||
ToolCallBlock::WebSearch(_) => Self::WebSearch,
|
||||
ToolCallBlock::IntegrationSearch(_) | ToolCallBlock::UseTool(_) => Self::Other,
|
||||
ToolCallBlock::MemorySearch(_)
|
||||
| ToolCallBlock::Other(_)
|
||||
| ToolCallBlock::Lifecycle(_) => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BlockStatus — status dimension (not category)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Status of a tool block at aggregation time.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub enum BlockStatus {
|
||||
/// Tool completed successfully.
|
||||
#[default]
|
||||
Success,
|
||||
/// Tool failed with an error.
|
||||
Failed,
|
||||
/// Tool is currently running/streaming.
|
||||
Running,
|
||||
}
|
||||
|
||||
/// A single entry in the activity lineage (ordered timeline).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LineageEntry {
|
||||
/// What kind of block this is.
|
||||
pub category: ToolCategory,
|
||||
/// Duration in ms (None if unknown/pre-completed).
|
||||
pub duration_ms: Option<i64>,
|
||||
/// Whether this block is still running.
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CategoryStats — per-category aggregation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Statistics for a single tool category.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CategoryStats {
|
||||
/// Number of operations in this category (all statuses).
|
||||
pub count: usize,
|
||||
/// Count by status.
|
||||
pub by_status: HashMap<BlockStatus, usize>,
|
||||
/// Sequence positions where this category appeared.
|
||||
/// Used for building the sequence strip.
|
||||
pub sequence_positions: Vec<usize>,
|
||||
/// Total elapsed time in ms for this category (Phase 2).
|
||||
pub total_time_ms: i64,
|
||||
}
|
||||
|
||||
impl CategoryStats {
|
||||
/// Number of failed operations.
|
||||
pub fn failed_count(&self) -> usize {
|
||||
*self.by_status.get(&BlockStatus::Failed).unwrap_or(&0)
|
||||
}
|
||||
|
||||
/// Number of running operations.
|
||||
pub fn running_count(&self) -> usize {
|
||||
*self.by_status.get(&BlockStatus::Running).unwrap_or(&0)
|
||||
}
|
||||
|
||||
/// Percentage of total operations.
|
||||
pub fn percent_of(&self, total: usize) -> f64 {
|
||||
if total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.count as f64 / total as f64) * 100.0
|
||||
}
|
||||
|
||||
/// Format total time as human-readable string (Phase 2).
|
||||
pub fn format_time(&self) -> String {
|
||||
if self.total_time_ms == 0 {
|
||||
return "—".to_string();
|
||||
}
|
||||
let secs = self.total_time_ms / 1000;
|
||||
let ms = self.total_time_ms % 1000;
|
||||
if secs == 0 {
|
||||
format!("{}ms", ms)
|
||||
} else if secs < 60 {
|
||||
format!("{}.{:03}s", secs, ms)
|
||||
} else {
|
||||
let mins = secs / 60;
|
||||
let secs = secs % 60;
|
||||
format!("{}m {}s", mins, secs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StatsScope — aggregation scope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aggregation scope.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum StatsScope {
|
||||
/// All visible scrollback entries for the current agent session.
|
||||
#[default]
|
||||
Session,
|
||||
/// The turn returned by `ScrollbackState::current_turn()`,
|
||||
/// with fallback to the latest turn if no current turn exists.
|
||||
SelectedTurn,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToolUsageStats — full aggregation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aggregated tool usage statistics.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolUsageStats {
|
||||
/// Per-category stats (includes Thinking and Message).
|
||||
pub categories: BTreeMap<ToolCategory, CategoryStats>,
|
||||
/// Total operations across all categories.
|
||||
pub total_operations: usize,
|
||||
/// When stats were last computed.
|
||||
pub computed_at: Instant,
|
||||
/// Scope of aggregation.
|
||||
pub scope: StatsScope,
|
||||
/// Ordered timeline of all blocks with category and duration.
|
||||
pub lineage: Vec<LineageEntry>,
|
||||
}
|
||||
|
||||
impl Default for ToolUsageStats {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
categories: BTreeMap::new(),
|
||||
total_operations: 0,
|
||||
computed_at: Instant::now(),
|
||||
scope: StatsScope::default(),
|
||||
lineage: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolUsageStats {
|
||||
/// Aggregate stats from entire visible scrollback (session scope).
|
||||
pub fn from_scrollback(scrollback: &ScrollbackState) -> Self {
|
||||
let n = scrollback.len();
|
||||
Self::from_range(scrollback, 0..n, StatsScope::Session)
|
||||
}
|
||||
|
||||
/// Aggregate stats from a specific turn.
|
||||
///
|
||||
/// If turn_index is out of range, returns empty stats.
|
||||
pub fn from_turn(scrollback: &ScrollbackState, turn_index: usize) -> Self {
|
||||
match scrollback.turn(turn_index) {
|
||||
Some(turn) => Self::from_range(scrollback, turn.range(), StatsScope::SelectedTurn),
|
||||
None => ToolUsageStats {
|
||||
scope: StatsScope::SelectedTurn,
|
||||
computed_at: Instant::now(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregate stats from selected turn if any, otherwise latest turn.
|
||||
///
|
||||
/// Returns empty stats if no turns exist.
|
||||
pub fn from_selected_or_latest(scrollback: &ScrollbackState) -> Self {
|
||||
// Prefer selected turn; fall back to latest turn.
|
||||
let turn_idx = scrollback.current_turn().or_else(|| {
|
||||
let count = scrollback.turns().len();
|
||||
if count > 0 { Some(count - 1) } else { None }
|
||||
});
|
||||
|
||||
match turn_idx {
|
||||
Some(idx) => Self::from_turn(scrollback, idx),
|
||||
None => ToolUsageStats {
|
||||
scope: StatsScope::SelectedTurn,
|
||||
computed_at: Instant::now(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Core aggregation logic over a range of entry indices.
|
||||
///
|
||||
/// Uses real ScrollbackState/Entry APIs:
|
||||
/// - scrollback.len() / scrollback.entry(i)
|
||||
/// - scrollback.turns() / scrollback.turn(i) / scrollback.current_turn()
|
||||
fn from_range(scrollback: &ScrollbackState, range: Range<usize>, scope: StatsScope) -> Self {
|
||||
let mut stats = ToolUsageStats {
|
||||
scope,
|
||||
computed_at: Instant::now(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for idx in range.clone() {
|
||||
let Some(entry) = scrollback.entry(idx) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match &entry.block {
|
||||
crate::scrollback::block::RenderBlock::ToolCall(tc) => {
|
||||
let category = ToolCategory::from_tool_block(tc);
|
||||
let status = if entry.is_running {
|
||||
BlockStatus::Running
|
||||
} else if !Self::tool_block_is_success(tc) {
|
||||
BlockStatus::Failed
|
||||
} else {
|
||||
BlockStatus::Success
|
||||
};
|
||||
|
||||
let elapsed = Self::tool_block_elapsed_ms(tc);
|
||||
let cat_stats = stats.categories.entry(category).or_default();
|
||||
cat_stats.count += 1;
|
||||
*cat_stats.by_status.entry(status).or_default() += 1;
|
||||
cat_stats.sequence_positions.push(idx);
|
||||
if let Some(ms) = elapsed {
|
||||
cat_stats.total_time_ms += ms;
|
||||
}
|
||||
|
||||
stats.lineage.push(LineageEntry {
|
||||
category,
|
||||
duration_ms: elapsed,
|
||||
running: entry.is_running,
|
||||
});
|
||||
}
|
||||
crate::scrollback::block::RenderBlock::Thinking(thinking) => {
|
||||
let elapsed = thinking.elapsed_time_ms();
|
||||
let cat_stats = stats.categories.entry(ToolCategory::Thinking).or_default();
|
||||
cat_stats.count += 1;
|
||||
let status = if entry.is_running {
|
||||
BlockStatus::Running
|
||||
} else {
|
||||
BlockStatus::Success
|
||||
};
|
||||
*cat_stats.by_status.entry(status).or_default() += 1;
|
||||
cat_stats.sequence_positions.push(idx);
|
||||
if let Some(ms) = elapsed {
|
||||
cat_stats.total_time_ms += ms;
|
||||
}
|
||||
|
||||
stats.lineage.push(LineageEntry {
|
||||
category: ToolCategory::Thinking,
|
||||
duration_ms: elapsed,
|
||||
running: entry.is_running,
|
||||
});
|
||||
}
|
||||
crate::scrollback::block::RenderBlock::AgentMessage(_) => {
|
||||
let cat_stats = stats.categories.entry(ToolCategory::Message).or_default();
|
||||
cat_stats.count += 1;
|
||||
let status = if entry.is_running {
|
||||
BlockStatus::Running
|
||||
} else {
|
||||
BlockStatus::Success
|
||||
};
|
||||
*cat_stats.by_status.entry(status).or_default() += 1;
|
||||
cat_stats.sequence_positions.push(idx);
|
||||
|
||||
stats.lineage.push(LineageEntry {
|
||||
category: ToolCategory::Message,
|
||||
duration_ms: None,
|
||||
running: entry.is_running,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
stats.total_operations = stats.categories.values().map(|c| c.count).sum();
|
||||
|
||||
stats
|
||||
}
|
||||
|
||||
/// Check if a tool block completed successfully.
|
||||
///
|
||||
/// Each ToolCallBlock variant has an is_success() method or error field.
|
||||
fn tool_block_is_success(tc: &ToolCallBlock) -> bool {
|
||||
match tc {
|
||||
ToolCallBlock::Execute(b) => b.is_success(),
|
||||
ToolCallBlock::Read(b) => b.is_success(),
|
||||
ToolCallBlock::Edit(b) => b.is_success(),
|
||||
ToolCallBlock::Search(b) => b.is_success(),
|
||||
ToolCallBlock::ListDir(b) => b.is_success(),
|
||||
ToolCallBlock::WebFetch(b) => b.is_success(),
|
||||
ToolCallBlock::WebSearch(b) => b.is_success(),
|
||||
ToolCallBlock::IntegrationSearch(b) => b.is_success(),
|
||||
ToolCallBlock::UseTool(b) => b.is_success(),
|
||||
ToolCallBlock::MemorySearch(b) => b.is_success(),
|
||||
ToolCallBlock::Skill(b) => b.is_success(),
|
||||
ToolCallBlock::Other(b) => b.is_success(),
|
||||
ToolCallBlock::Lifecycle(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get elapsed time from a tool block (Phase 2).
|
||||
fn tool_block_elapsed_ms(tc: &ToolCallBlock) -> Option<i64> {
|
||||
match tc {
|
||||
ToolCallBlock::Execute(b) => b.elapsed_ms(),
|
||||
ToolCallBlock::Read(b) => b.elapsed_ms(),
|
||||
ToolCallBlock::Edit(b) => b.elapsed_ms(),
|
||||
ToolCallBlock::Search(b) => b.elapsed_ms(),
|
||||
ToolCallBlock::ListDir(b) => b.elapsed_ms(),
|
||||
ToolCallBlock::WebFetch(b) => b.elapsed_ms(),
|
||||
ToolCallBlock::WebSearch(b) => b.elapsed_ms(),
|
||||
ToolCallBlock::IntegrationSearch(b) => b.elapsed_ms(),
|
||||
ToolCallBlock::UseTool(b) => b.elapsed_ms(),
|
||||
ToolCallBlock::MemorySearch(b) => b.elapsed_ms(),
|
||||
ToolCallBlock::Skill(b) => b.elapsed_ms(),
|
||||
ToolCallBlock::Other(b) => b.elapsed_ms(),
|
||||
ToolCallBlock::Lifecycle(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get categories sorted by count (descending).
|
||||
pub fn sorted_categories(&self) -> Vec<(ToolCategory, &CategoryStats)> {
|
||||
let mut items: Vec<_> = self.categories.iter().map(|(k, v)| (*k, v)).collect();
|
||||
items.sort_by_key(|item| std::cmp::Reverse(item.1.count));
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::tool::{
|
||||
EditToolCallBlock, ExecuteToolCallBlock, ReadToolCallBlock, ToolCallBlock,
|
||||
};
|
||||
use crate::scrollback::state::ScrollbackState;
|
||||
|
||||
#[test]
|
||||
fn test_tool_category_labels() {
|
||||
assert_eq!(ToolCategory::Execute.label(), "Execute");
|
||||
assert_eq!(ToolCategory::Read.label(), "Read");
|
||||
assert_eq!(ToolCategory::Edit.label(), "Edit");
|
||||
assert_eq!(ToolCategory::Search.label(), "Search");
|
||||
assert_eq!(ToolCategory::ListDir.label(), "ListDir");
|
||||
assert_eq!(ToolCategory::Other.label(), "Other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_category_stats_percent() {
|
||||
let stats = CategoryStats {
|
||||
count: 10,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(stats.percent_of(100), 10.0);
|
||||
assert_eq!(stats.percent_of(0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_status_default() {
|
||||
let status = BlockStatus::default();
|
||||
assert_eq!(status, BlockStatus::Success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_scrollback_mixed_categories() {
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
|
||||
// Push mixed tool blocks
|
||||
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute(
|
||||
ExecuteToolCallBlock::new("cargo build"),
|
||||
)));
|
||||
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Read(
|
||||
ReadToolCallBlock::new("src/main.rs"),
|
||||
)));
|
||||
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Edit(
|
||||
EditToolCallBlock::new("src/lib.rs", Vec::new()),
|
||||
)));
|
||||
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute(
|
||||
ExecuteToolCallBlock::new("cargo test"),
|
||||
)));
|
||||
|
||||
let stats = ToolUsageStats::from_scrollback(&scrollback);
|
||||
|
||||
assert_eq!(stats.total_operations, 4);
|
||||
assert_eq!(stats.scope, StatsScope::Session);
|
||||
|
||||
// Verify category counts
|
||||
let execute_count = stats
|
||||
.categories
|
||||
.get(&ToolCategory::Execute)
|
||||
.map(|c| c.count)
|
||||
.unwrap_or(0);
|
||||
let read_count = stats
|
||||
.categories
|
||||
.get(&ToolCategory::Read)
|
||||
.map(|c| c.count)
|
||||
.unwrap_or(0);
|
||||
let edit_count = stats
|
||||
.categories
|
||||
.get(&ToolCategory::Edit)
|
||||
.map(|c| c.count)
|
||||
.unwrap_or(0);
|
||||
|
||||
assert_eq!(execute_count, 2);
|
||||
assert_eq!(read_count, 1);
|
||||
assert_eq!(edit_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_scrollback_failed_blocks() {
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
|
||||
// Push execute blocks - one with error, one without
|
||||
let mut success_block = ExecuteToolCallBlock::new("echo hello");
|
||||
success_block.finish();
|
||||
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute(success_block)));
|
||||
|
||||
let mut failed_block =
|
||||
ExecuteToolCallBlock::new("bad_command").with_error("command not found");
|
||||
failed_block.finish();
|
||||
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute(failed_block)));
|
||||
|
||||
let stats = ToolUsageStats::from_scrollback(&scrollback);
|
||||
|
||||
assert_eq!(stats.total_operations, 2);
|
||||
|
||||
let exec_stats = stats.categories.get(&ToolCategory::Execute).unwrap();
|
||||
assert_eq!(exec_stats.count, 2);
|
||||
assert_eq!(exec_stats.failed_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_selected_or_latest_empty_scrollback() {
|
||||
let scrollback = ScrollbackState::new();
|
||||
let stats = ToolUsageStats::from_selected_or_latest(&scrollback);
|
||||
|
||||
assert_eq!(stats.total_operations, 0);
|
||||
assert_eq!(stats.scope, StatsScope::SelectedTurn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_selected_or_latest_no_turns_fallback() {
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
|
||||
// Push tool blocks without starting a turn
|
||||
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute(
|
||||
ExecuteToolCallBlock::new("echo test"),
|
||||
)));
|
||||
|
||||
let stats = ToolUsageStats::from_selected_or_latest(&scrollback);
|
||||
|
||||
// No turns exist, so it falls back to latest turn (which is empty)
|
||||
// or returns empty stats
|
||||
assert_eq!(stats.scope, StatsScope::SelectedTurn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_selected_or_latest_with_turns() {
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
|
||||
// Push user prompt (starts a turn) and some tool blocks
|
||||
scrollback.push_block(RenderBlock::user_prompt("test prompt"));
|
||||
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute(
|
||||
ExecuteToolCallBlock::new("echo hello"),
|
||||
)));
|
||||
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Read(
|
||||
ReadToolCallBlock::new("file.txt"),
|
||||
)));
|
||||
|
||||
// Set as current turn
|
||||
scrollback.set_selected(Some(0));
|
||||
|
||||
let stats = ToolUsageStats::from_selected_or_latest(&scrollback);
|
||||
|
||||
assert_eq!(stats.scope, StatsScope::SelectedTurn);
|
||||
// Should have aggregated the tool blocks in the turn
|
||||
// Verify it computed without panicking
|
||||
let _ = stats.total_operations;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_block_is_success() {
|
||||
let success = ExecuteToolCallBlock::new("echo test");
|
||||
assert!(ToolUsageStats::tool_block_is_success(
|
||||
&ToolCallBlock::Execute(success)
|
||||
));
|
||||
|
||||
let mut failed = ExecuteToolCallBlock::new("bad");
|
||||
failed.set_error(Some("error".into()));
|
||||
assert!(!ToolUsageStats::tool_block_is_success(
|
||||
&ToolCallBlock::Execute(failed)
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user