//! File-drive tools end to end: write/list run ungated; delete is a §15 //! gated category that executes only after approval. use std::sync::Arc; use std::time::Duration; use cm_domain::{ AccessPolicy, Agent, AgentId, AgentStatus, FileDrive, Role, RunState, User, UserId, Workspace, WorkspaceId, }; use cm_files::LocalBlobStore; use cm_llm::ScriptedProvider; use cm_runtime::{RunEventBody, Runtime, RuntimeConfig}; use cm_safety::{approvals, Decision}; const SCENARIOS: &str = r##" [[scenario]] marker = "[[scenario:write-report]]" [[scenario.turns]] events = [ { type = "tool_use", name = "files.write", input = { path = "reports/q2.md", content = "# Q2\nRevenue up 14%." } }, ] [[scenario.turns]] events = [ { type = "tool_use", name = "files.list", input = {} }, ] [[scenario.turns]] events = [ { type = "text", text = "Report saved and verified." }, ] [[scenario]] marker = "[[scenario:delete-report]]" [[scenario.turns]] events = [ { type = "tool_use", name = "files.delete", input = { path = "reports/q2.md" } }, ] [[scenario.turns]] events = [ { type = "text", text = "Deletion handled." }, ] "##; async fn seeded(pool: &sqlx::PgPool) -> (Workspace, User, Agent) { let workspace = Workspace { id: WorkspaceId::new(), name: "Acme".into(), plan: "team".into(), }; cm_db::repo::workspaces::insert(pool, &workspace) .await .unwrap(); let owner = User { id: UserId::new(), workspace_id: workspace.id, email: format!("{}@acme.test", UserId::new()), role: Role::Owner, display_name: "Owner".into(), created_at: time::OffsetDateTime::UNIX_EPOCH, }; cm_db::repo::users::insert(pool, &owner).await.unwrap(); let agent = Agent { id: AgentId::new(), workspace_id: workspace.id, name: "Scout".into(), job_title: "Analyst".into(), system_prompt: String::new(), avatar: String::new(), accent: String::new(), wallpaper: String::new(), managed_by: owner.id, status: AgentStatus::Online, }; cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default()) .await .unwrap(); (workspace, owner, agent) } fn runtime(pool: sqlx::PgPool) -> (Runtime, std::path::PathBuf) { let root = std::env::temp_dir().join(format!("cm-files-test-{}", uuid::Uuid::now_v7())); let rt = Runtime::with_blob_store( pool, Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()), RuntimeConfig::basic("scripted", 1024), Arc::new(LocalBlobStore::new(root.clone())), ); (rt, root) } async fn run_to_end( mut rx: tokio::sync::broadcast::Receiver, ) -> Vec { let mut events = Vec::new(); while let Ok(envelope) = rx.recv().await { let done = matches!( envelope.event, RunEventBody::RunCompleted { .. } | RunEventBody::Error { .. } | RunEventBody::RunSuspended { .. } ); events.push(envelope); if done { break; } } events } async fn wait_completed(pool: &sqlx::PgPool, run_id: uuid::Uuid) { for _ in 0..100 { let run = cm_db::repo::runs::get(pool, run_id).await.unwrap(); if run.state == RunState::Completed { return; } tokio::time::sleep(Duration::from_millis(50)).await; } panic!("run never completed"); } #[tokio::test] async fn write_and_list_run_ungated_and_persist_real_blobs() { let pool = cm_testkit::test_pool().await; let (workspace, _, agent) = seeded(&pool).await; let (rt, blob_root) = runtime(pool.clone()); let session = cm_db::repo::sessions::create(&pool, agent.id, workspace.id, "Files") .await .unwrap(); let started = rt .send_message(session.id, "save it [[scenario:write-report]]") .await .unwrap(); let events = run_to_end(started.events).await; assert!(matches!( events.last().unwrap().event, RunEventBody::RunCompleted { .. } )); // The node row exists with the right drive and size. let nodes = cm_db::repo::files::list(&pool, workspace.id, FileDrive::Documents, agent.id) .await .unwrap(); assert_eq!(nodes.len(), 1); assert_eq!(nodes[0].path, "reports/q2.md"); assert_eq!(nodes[0].size, "# Q2\nRevenue up 14%.".len() as i64); // The blob content is really on disk under the agent's scope. let blob_path = blob_root.join(&nodes[0].blob_ref); assert_eq!( std::fs::read_to_string(blob_path).unwrap(), "# Q2\nRevenue up 14%." ); // files.list saw it too (second turn output recorded as a step). let history = cm_db::repo::messages::history(&pool, session.id) .await .unwrap(); let steps = &history.last().unwrap().steps; assert_eq!(steps.len(), 2); let list_output = steps[1].output.as_ref().unwrap(); assert_eq!(list_output["files"][0]["path"], "reports/q2.md"); // No approvals were involved. assert!(approvals::list_pending(&pool, workspace.id) .await .unwrap() .is_empty()); } #[tokio::test] async fn delete_is_gated_and_removes_the_file_only_after_approval() { let pool = cm_testkit::test_pool().await; let (workspace, owner, agent) = seeded(&pool).await; let (rt, _blob_root) = runtime(pool.clone()); let session = cm_db::repo::sessions::create(&pool, agent.id, workspace.id, "Files") .await .unwrap(); // Create the file first. let write = rt .send_message(session.id, "save it [[scenario:write-report]]") .await .unwrap(); run_to_end(write.events).await; wait_completed(&pool, write.run_id).await; // Ask for deletion: intercepted with the file_deletion category. let delete = rt .send_message(session.id, "remove it [[scenario:delete-report]]") .await .unwrap(); let events = run_to_end(delete.events).await; let (category, preview) = events .iter() .find_map(|e| match &e.event { RunEventBody::ApprovalRequired { category, preview, .. } => Some((category.clone(), preview.clone())), _ => None, }) .expect("approval_required"); assert_eq!(category, "file_deletion"); assert_eq!(preview["summary"], "Delete file reports/q2.md"); // Still there while pending. let nodes = cm_db::repo::files::list(&pool, workspace.id, FileDrive::Documents, agent.id) .await .unwrap(); assert_eq!(nodes.len(), 1); // Approve → the file is gone. let pending = approvals::list_pending(&pool, workspace.id).await.unwrap(); approvals::decide(&pool, pending[0].id, owner.id, Decision::Approve) .await .unwrap(); rt.resume_run(cm_safety::ResumeReady { run_id: delete.run_id, approval_id: pending[0].id, approved: true, }) .await .unwrap(); wait_completed(&pool, delete.run_id).await; let nodes = cm_db::repo::files::list(&pool, workspace.id, FileDrive::Documents, agent.id) .await .unwrap(); assert!(nodes.is_empty(), "file must be deleted after approval"); }