capo-agent 0.6.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]

//! Walk a resumed session's history and re-mark files the agent has
//! already touched, so `write` / `edit` can proceed without an explicit
//! re-read in the new run.
//!
//! The walk inspects `Message::Assistant` entries for `AssistantContent::ToolCall`
//! whose tool name is `read` / `write` / `edit`. For each such call, we extract
//! `args.path` (resolving relative paths against `ctx.cwd`) and add it to
//! `ToolCtx.read_files`. We do NOT inspect the matching `Message::Tool` result —
//! false positives are harmless (set membership, not state), and false negatives
//! cause an extra re-read on first use rather than data loss.

use std::path::{Path, PathBuf};

use motosan_agent_loop::{AssistantContent, Message, StoredEntry};

use crate::error::Result;
use crate::tools::ToolCtx;

const FS_TOOL_NAMES: &[&str] = &["read", "write", "edit"];

pub async fn hydrate_read_files(entries: &[StoredEntry], ctx: &ToolCtx) -> Result<()> {
    for stored in entries {
        let Some(message) = stored.entry.as_message() else {
            continue;
        };
        let assistant_parts = match message {
            Message::Assistant { content, .. } => content,
            _ => continue,
        };
        for part in assistant_parts {
            let AssistantContent::ToolCall { call } = part else {
                continue;
            };
            if !FS_TOOL_NAMES.contains(&call.name.as_str()) {
                continue;
            }
            let Some(path_str) = call.args.get("path").and_then(|v| v.as_str()) else {
                continue;
            };
            let abs = resolve_relative(path_str, &ctx.cwd);
            // Try canonicalize for the stable key, fall back to abs if the path
            // doesn't exist (e.g. file was deleted between runs).
            let key = match tokio::fs::canonicalize(&abs).await {
                Ok(canonical) => canonical,
                Err(_) => abs,
            };
            ctx.mark_read(&key).await;
        }
    }
    Ok(())
}

fn resolve_relative(path: &str, cwd: &Path) -> PathBuf {
    let p = PathBuf::from(path);
    if p.is_absolute() {
        p
    } else {
        cwd.join(p)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::permissions::NoOpPermissionGate;
    use motosan_agent_loop::{Message, SessionEntry, ToolCallRef};
    use std::sync::Arc;
    use tempfile::tempdir;
    use tokio::sync::mpsc;

    fn assistant_with_tool_call(name: &str, path: &str) -> Message {
        Message::assistant_with_tool_call(
            "",
            ToolCallRef {
                id: "t1".into(),
                name: name.into(),
                args: serde_json::json!({ "path": path }),
            },
        )
    }

    fn fresh_ctx(cwd: &Path) -> ToolCtx {
        let (tx, _rx) = mpsc::channel(8);
        ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx)
    }

    #[tokio::test]
    async fn hydrates_read_tool_calls_into_read_files() {
        let dir = tempdir().unwrap();
        let file = dir.path().join("foo.rs");
        tokio::fs::write(&file, "x").await.unwrap();

        let ctx = fresh_ctx(dir.path());
        let entries = vec![StoredEntry {
            id: "e1".to_string(),
            parent_id: None,
            entry: SessionEntry::message(assistant_with_tool_call("read", "foo.rs")),
        }];

        assert!(!ctx.has_been_read(&file).await);
        hydrate_read_files(&entries, &ctx).await.unwrap();

        let canonical = tokio::fs::canonicalize(&file).await.unwrap();
        assert!(ctx.has_been_read(&canonical).await);
    }

    #[tokio::test]
    async fn ignores_non_fs_tool_calls() {
        let dir = tempdir().unwrap();
        let ctx = fresh_ctx(dir.path());

        let entries = vec![StoredEntry {
            id: "e1".to_string(),
            parent_id: None,
            entry: SessionEntry::message(assistant_with_tool_call("bash", "echo")),
        }];

        hydrate_read_files(&entries, &ctx).await.unwrap();
        // No fs file was named; read_files set is empty.
        let read_count = ctx.read_files.lock().await.len();
        assert_eq!(read_count, 0);
    }

    #[tokio::test]
    async fn hydrates_write_and_edit_alongside_read() {
        let dir = tempdir().unwrap();
        let f1 = dir.path().join("a.txt");
        let f2 = dir.path().join("b.txt");
        tokio::fs::write(&f1, "1").await.unwrap();
        tokio::fs::write(&f2, "2").await.unwrap();

        let ctx = fresh_ctx(dir.path());
        let entries = vec![
            StoredEntry {
                id: "e1".into(),
                parent_id: None,
                entry: SessionEntry::message(assistant_with_tool_call("write", "a.txt")),
            },
            StoredEntry {
                id: "e2".into(),
                parent_id: None,
                entry: SessionEntry::message(assistant_with_tool_call("edit", "b.txt")),
            },
        ];

        hydrate_read_files(&entries, &ctx).await.unwrap();
        let c1 = tokio::fs::canonicalize(&f1).await.unwrap();
        let c2 = tokio::fs::canonicalize(&f2).await.unwrap();
        assert!(ctx.has_been_read(&c1).await);
        assert!(ctx.has_been_read(&c2).await);
    }

    #[tokio::test]
    async fn falls_back_to_abs_path_when_file_missing() {
        let dir = tempdir().unwrap();
        let ctx = fresh_ctx(dir.path());

        let entries = vec![StoredEntry {
            id: "e1".into(),
            parent_id: None,
            entry: SessionEntry::message(assistant_with_tool_call("read", "ghost.txt")),
        }];

        hydrate_read_files(&entries, &ctx).await.unwrap();
        // Path doesn't exist → key is the non-canonicalized abs path.
        let abs = dir.path().join("ghost.txt");
        assert!(ctx.has_been_read(&abs).await);
    }
}