Skip to main content

koda_core/
progress.rs

1//! Structured progress tracking.
2//!
3//! Auto-extracts progress from tool results into DB metadata.
4//! Survives compaction. Injected into system prompt so the LLM
5//! always knows what's been done even after context is trimmed.
6
7use crate::db::Database;
8use crate::persistence::Persistence;
9
10/// Extract progress from a tool call and persist it.
11pub async fn track_progress(
12    db: &Database,
13    session_id: &str,
14    tool_name: &str,
15    _tool_args: &str,
16    tool_result: &str,
17) {
18    let entry = match tool_name {
19        "Write" => extract_write_progress(tool_result),
20        "Edit" => extract_edit_progress(tool_result),
21        "Delete" => extract_delete_progress(tool_result),
22        "Bash" => extract_bash_progress(tool_result),
23        _ => None,
24    };
25
26    if let Some(entry) = entry {
27        append_progress(db, session_id, &entry).await;
28    }
29}
30
31/// Get the current progress summary for injection into the system prompt.
32pub async fn get_progress_summary(db: &Database, session_id: &str) -> Option<String> {
33    match db.get_metadata(session_id, "progress").await {
34        Ok(Some(progress)) if !progress.is_empty() => Some(format!(
35            "\n## Session Progress\n\
36                 The following actions have been completed this session:\n\
37                 {progress}"
38        )),
39        _ => None,
40    }
41}
42
43async fn append_progress(db: &Database, session_id: &str, entry: &str) {
44    let existing = db
45        .get_metadata(session_id, "progress")
46        .await
47        .ok()
48        .flatten()
49        .unwrap_or_default();
50
51    // Cap at 20 entries to avoid unbounded growth
52    let lines: Vec<&str> = existing.lines().collect();
53    let mut updated = if lines.len() >= 20 {
54        // Keep last 15 + new entry
55        lines[lines.len() - 15..].join("\n")
56    } else {
57        existing
58    };
59
60    if !updated.is_empty() {
61        updated.push('\n');
62    }
63    updated.push_str(entry);
64
65    let _ = db.set_metadata(session_id, "progress", &updated).await;
66}
67
68fn extract_write_progress(result: &str) -> Option<String> {
69    // Write tool output: "Created file: path" or "Wrote N bytes to path"
70    if result.contains("Created") || result.contains("Wrote") {
71        let path = result.lines().next().unwrap_or(result).trim();
72        Some(format!("- \u{2705} {path}"))
73    } else {
74        None
75    }
76}
77
78fn extract_edit_progress(result: &str) -> Option<String> {
79    if result.contains("Applied") || result.contains("edited") || result.contains("replacement") {
80        let first_line = result.lines().next().unwrap_or(result).trim();
81        let short = if first_line.len() > 80 {
82            format!("{}...", &first_line[..80])
83        } else {
84            first_line.to_string()
85        };
86        Some(format!("- \u{270f}\u{fe0f} {short}"))
87    } else {
88        None
89    }
90}
91
92fn extract_delete_progress(result: &str) -> Option<String> {
93    if result.contains("Deleted") || result.contains("removed") {
94        let first_line = result.lines().next().unwrap_or(result).trim();
95        Some(format!("- \u{1f5d1}\u{fe0f} {first_line}"))
96    } else {
97        None
98    }
99}
100
101fn extract_bash_progress(result: &str) -> Option<String> {
102    // Track test results and build outcomes
103    let lower = result.to_lowercase();
104    if lower.contains("test result: ok") || lower.contains("tests passed") {
105        Some("- \u{2705} Tests passed".to_string())
106    } else if lower.contains("test result: failed") || lower.contains("tests failed") {
107        Some("- \u{274c} Tests failed".to_string())
108    } else if lower.contains("build succeeded")
109        || lower.contains("finished") && lower.contains("target")
110    {
111        Some("- \u{1f3d7}\u{fe0f} Build succeeded".to_string())
112    } else if lower.contains("error:") && lower.contains("could not compile") {
113        Some("- \u{274c} Build failed".to_string())
114    } else {
115        None
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn test_write_progress() {
125        assert!(extract_write_progress("Created file: src/main.rs").is_some());
126        assert!(extract_write_progress("Wrote 100 bytes to foo.rs").is_some());
127        assert!(extract_write_progress("Error: permission denied").is_none());
128    }
129
130    #[test]
131    fn test_edit_progress() {
132        assert!(extract_edit_progress("Applied 2 replacements to src/lib.rs").is_some());
133        assert!(extract_edit_progress("No changes needed").is_none());
134    }
135
136    #[test]
137    fn test_bash_progress() {
138        assert!(extract_bash_progress("test result: ok. 50 passed").is_some());
139        assert!(extract_bash_progress("test result: FAILED. 1 failed").is_some());
140        assert!(extract_bash_progress("hello world").is_none());
141    }
142
143    #[test]
144    fn test_delete_progress() {
145        assert!(extract_delete_progress("Deleted src/old.rs").is_some());
146        assert!(extract_delete_progress("File not found").is_none());
147    }
148}