diff --git a/src/graph/mod.rs b/src/graph/mod.rs index 4fb5948..112261c 100644 --- a/src/graph/mod.rs +++ b/src/graph/mod.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; use std::str::FromStr; pub mod dependency; +pub mod session; pub mod store; /// Node type in the work graph diff --git a/src/graph/session.rs b/src/graph/session.rs new file mode 100644 index 0000000..cd833c4 --- /dev/null +++ b/src/graph/session.rs @@ -0,0 +1,496 @@ +use crate::db::Database; +use crate::graph::store::SqliteGraphStore; +use anyhow::{Result, anyhow}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tokio_rusqlite::OptionalExtension; +use uuid::Uuid; + +/// A session represents a work period for a goal +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Session { + pub id: String, + pub project_id: String, + pub goal_id: String, + pub started_at: DateTime, + pub ended_at: Option>, + pub handoff_notes: Option, + pub agent_ids: Vec, + pub summary: Option, +} + +/// Store for managing sessions +pub struct SessionStore { + db: Database, +} + +impl SessionStore { + /// Create a new SessionStore + pub fn new(db: Database) -> Self { + Self { db } + } + + /// Create a new session for a goal + pub async fn create_session(&self, project_id: &str, goal_id: &str) -> Result { + let session_id = format!("sess-{}", &Uuid::new_v4().simple().to_string()[..8]); + let now = Utc::now(); + let now_rfc3339 = now.to_rfc3339(); + let project_id_owned = project_id.to_string(); + let goal_id_owned = goal_id.to_string(); + let session_id_clone = session_id.clone(); + + self.db + .connection() + .call(move |conn| { + conn.execute( + "INSERT INTO sessions (id, project_id, goal_id, started_at, agent_ids) + VALUES (?, ?, ?, ?, ?)", + rusqlite::params![ + &session_id_clone, + &project_id_owned, + &goal_id_owned, + &now_rfc3339, + "[]" + ], + ) + .map_err(tokio_rusqlite::Error::Rusqlite) + }) + .await + .map_err(|e| anyhow!("failed to insert session: {}", e))?; + + Ok(Session { + id: session_id, + project_id: project_id.to_string(), + goal_id: goal_id.to_string(), + started_at: now, + ended_at: None, + handoff_notes: None, + agent_ids: vec![], + summary: None, + }) + } + + /// End a session and generate handoff notes + pub async fn end_session( + &self, + session_id: &str, + _graph_store: &SqliteGraphStore, + ) -> Result<()> { + // First, get the session to find the goal_id + let session = self + .get_session(session_id) + .await? + .ok_or_else(|| anyhow!("session not found: {}", session_id))?; + + let goal_id = session.goal_id.clone(); + let now = Utc::now(); + let now_rfc3339 = now.to_rfc3339(); + let session_id_owned = session_id.to_string(); + + // Generate handoff notes within a transaction + self.db + .connection() + .call(move |conn| { + conn.execute_batch("BEGIN IMMEDIATE") + .map_err(tokio_rusqlite::Error::Rusqlite)?; + let notes = generate_handoff_notes(conn, &goal_id) + .map_err(tokio_rusqlite::Error::Rusqlite)?; + conn.execute( + "UPDATE sessions SET ended_at = ?, handoff_notes = ? WHERE id = ?", + rusqlite::params![&now_rfc3339, ¬es, &session_id_owned], + ) + .map_err(tokio_rusqlite::Error::Rusqlite)?; + conn.execute_batch("COMMIT") + .map_err(tokio_rusqlite::Error::Rusqlite)?; + Ok::<(), tokio_rusqlite::Error>(()) + }) + .await + .map_err(|e| anyhow!("database error: {}", e))?; + + Ok(()) + } + + /// Get a session by ID + pub async fn get_session(&self, session_id: &str) -> Result> { + let session_id_owned = session_id.to_string(); + + let result = self + .db + .connection() + .call(move |conn| { + let mut stmt = conn.prepare( + "SELECT id, project_id, goal_id, started_at, ended_at, handoff_notes, agent_ids, summary + FROM sessions WHERE id = ?", + )?; + + let session: Option = stmt + .query_row([&session_id_owned], |row| { + let started_at_str: String = row.get(3)?; + let started_at = chrono::DateTime::parse_from_rfc3339(&started_at_str) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + .ok_or(rusqlite::Error::InvalidQuery)?; + + let ended_at_str: Option = row.get(4)?; + let ended_at = ended_at_str.and_then(|s| { + chrono::DateTime::parse_from_rfc3339(&s) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + }); + + let agent_ids_json: String = row.get(6)?; + let agent_ids: Vec = + serde_json::from_str(&agent_ids_json).unwrap_or_default(); + + Ok(Session { + id: row.get(0)?, + project_id: row.get(1)?, + goal_id: row.get(2)?, + started_at, + ended_at, + handoff_notes: row.get(5)?, + agent_ids, + summary: row.get(7)?, + }) + }) + .optional()?; + + Ok(session) + }) + .await + .map_err(|e| anyhow!("database error: {}", e))?; + + Ok(result) + } + + /// Get the most recent session for a goal + pub async fn get_latest_session(&self, goal_id: &str) -> Result> { + let goal_id_owned = goal_id.to_string(); + + let result = self + .db + .connection() + .call(move |conn| { + let mut stmt = conn.prepare( + "SELECT id, project_id, goal_id, started_at, ended_at, handoff_notes, agent_ids, summary + FROM sessions WHERE goal_id = ? + ORDER BY started_at DESC LIMIT 1", + )?; + + let session: Option = stmt + .query_row([&goal_id_owned], |row| { + let started_at_str: String = row.get(3)?; + let started_at = chrono::DateTime::parse_from_rfc3339(&started_at_str) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + .ok_or(rusqlite::Error::InvalidQuery)?; + + let ended_at_str: Option = row.get(4)?; + let ended_at = ended_at_str.and_then(|s| { + chrono::DateTime::parse_from_rfc3339(&s) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + }); + + let agent_ids_json: String = row.get(6)?; + let agent_ids: Vec = + serde_json::from_str(&agent_ids_json).unwrap_or_default(); + + Ok(Session { + id: row.get(0)?, + project_id: row.get(1)?, + goal_id: row.get(2)?, + started_at, + ended_at, + handoff_notes: row.get(5)?, + agent_ids, + summary: row.get(7)?, + }) + }) + .optional()?; + + Ok(session) + }) + .await + .map_err(|e| anyhow!("database error: {}", e))?; + + Ok(result) + } + + /// List all sessions for a goal + pub async fn list_sessions(&self, goal_id: &str) -> Result> { + let goal_id_owned = goal_id.to_string(); + + self.db + .connection() + .call(move |conn| { + let mut stmt = conn.prepare( + "SELECT id, project_id, goal_id, started_at, ended_at, handoff_notes, agent_ids, summary + FROM sessions WHERE goal_id = ? + ORDER BY started_at DESC", + )?; + + let mut sessions = vec![]; + let rows = stmt.query_map([&goal_id_owned], |row| { + let started_at_str: String = row.get(3)?; + let started_at = chrono::DateTime::parse_from_rfc3339(&started_at_str) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + .ok_or(rusqlite::Error::InvalidQuery)?; + + let ended_at_str: Option = row.get(4)?; + let ended_at = ended_at_str.and_then(|s| { + chrono::DateTime::parse_from_rfc3339(&s) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + }); + + let agent_ids_json: String = row.get(6)?; + let agent_ids: Vec = + serde_json::from_str(&agent_ids_json).unwrap_or_default(); + + Ok(Session { + id: row.get(0)?, + project_id: row.get(1)?, + goal_id: row.get(2)?, + started_at, + ended_at, + handoff_notes: row.get(5)?, + agent_ids, + summary: row.get(7)?, + }) + })?; + + for session_result in rows { + sessions.push(session_result?); + } + + Ok(sessions) + }) + .await + .map_err(|e| anyhow!("database error: {}", e)) + } +} + +/// Generate handoff notes from the current graph state +/// This runs synchronously within a transaction on the raw rusqlite connection +fn generate_handoff_notes(conn: &rusqlite::Connection, goal_id: &str) -> rusqlite::Result { + let mut notes = String::new(); + + // Query all descendants of the goal + let descendants = get_descendants(conn, goal_id)?; + let descendant_ids: Vec = descendants.iter().map(|d| d.0.clone()).collect(); + + if descendant_ids.is_empty() { + // No descendants, return empty template + notes.push_str("## Done\n\n"); + notes.push_str("## Remaining\n\n"); + notes.push_str("## Blocked\n\n"); + notes.push_str("## Decisions Made\n\n"); + return Ok(notes); + } + + // Build placeholders for SQL IN clause + let placeholders = descendant_ids + .iter() + .map(|_| "?") + .collect::>() + .join(","); + + // Query for Done nodes (Completed or Decided) + let done_query = format!( + "SELECT id, title, status FROM nodes WHERE id IN ({}) + AND (status = 'completed' OR status = 'decided') + ORDER BY completed_at ASC, created_at ASC", + placeholders + ); + notes.push_str("## Done\n"); + let mut stmt = conn.prepare(&done_query)?; + let done_nodes: Vec<_> = stmt + .query_map( + rusqlite::params_from_iter(descendant_ids.iter().map(|s| s.as_str())), + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + )? + .collect::, _>>()?; + if done_nodes.is_empty() { + notes.push_str("(none)\n"); + } else { + for (id, title, status) in done_nodes { + notes.push_str(&format!("- {}: {} ({})\n", id, title, status)); + } + } + notes.push('\n'); + + // Query for Remaining nodes (Ready, Pending, InProgress) + let remaining_query = format!( + "SELECT id, title, status FROM nodes WHERE id IN ({}) + AND (status = 'ready' OR status = 'pending' OR status = 'in_progress') + ORDER BY created_at ASC", + placeholders + ); + notes.push_str("## Remaining\n"); + let mut stmt = conn.prepare(&remaining_query)?; + let remaining_nodes: Vec<_> = stmt + .query_map( + rusqlite::params_from_iter(descendant_ids.iter().map(|s| s.as_str())), + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + )? + .collect::, _>>()?; + if remaining_nodes.is_empty() { + notes.push_str("(none)\n"); + } else { + for (id, title, _status) in remaining_nodes { + notes.push_str(&format!("- {}: {}\n", id, title)); + } + } + notes.push('\n'); + + // Query for Blocked nodes + let blocked_query = format!( + "SELECT id, title, blocked_reason FROM nodes WHERE id IN ({}) + AND status = 'blocked' + ORDER BY created_at ASC", + placeholders + ); + notes.push_str("## Blocked\n"); + let mut stmt = conn.prepare(&blocked_query)?; + let blocked_nodes: Vec<_> = stmt + .query_map( + rusqlite::params_from_iter(descendant_ids.iter().map(|s| s.as_str())), + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + }, + )? + .collect::, _>>()?; + if blocked_nodes.is_empty() { + notes.push_str("(none)\n"); + } else { + for (id, title, blocked_reason) in blocked_nodes { + if let Some(reason) = blocked_reason { + notes.push_str(&format!("- {}: {} — {}\n", id, title, reason)); + } else { + notes.push_str(&format!("- {}: {}\n", id, title)); + } + } + } + notes.push('\n'); + + // Query for Decisions Made (Decision nodes with Decided status) + let decisions_query = format!( + "SELECT id, title FROM nodes WHERE id IN ({}) + AND node_type = 'decision' AND status = 'decided' + ORDER BY created_at ASC", + placeholders + ); + notes.push_str("## Decisions Made\n"); + let mut stmt = conn.prepare(&decisions_query)?; + let decision_nodes: Vec<_> = stmt + .query_map( + rusqlite::params_from_iter(descendant_ids.iter().map(|s| s.as_str())), + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + )? + .collect::, _>>()?; + if decision_nodes.is_empty() { + notes.push_str("(none)\n"); + } else { + for (decision_id, decision_title) in decision_nodes { + // Find the chosen option + let mut chosen_stmt = conn.prepare( + "SELECT n.title, e.label FROM edges e + JOIN nodes n ON e.to_node = n.id + WHERE e.from_node = ? AND e.edge_type = 'chosen'", + )?; + + let chosen_option: Option<(String, Option)> = chosen_stmt + .query_row([&decision_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)) + }) + .optional()?; + + if let Some((option_title, rationale)) = chosen_option { + if let Some(r) = rationale { + notes.push_str(&format!( + "- {}: {} → {} ({})\n", + decision_id, decision_title, option_title, r + )); + } else { + notes.push_str(&format!( + "- {}: {} → {}\n", + decision_id, decision_title, option_title + )); + } + } else { + notes.push_str(&format!("- {}: {}\n", decision_id, decision_title)); + } + } + } + notes.push('\n'); + + Ok(notes) +} + +/// Get all descendants of a node via Contains edges (helper for handoff notes) +fn get_descendants( + conn: &rusqlite::Connection, + parent_id: &str, +) -> rusqlite::Result> { + // Recursive CTE to get all descendants + let query = " + WITH RECURSIVE descendants AS ( + SELECT id, node_type FROM nodes WHERE id = ? + UNION ALL + SELECT n.id, n.node_type FROM nodes n + JOIN edges e ON n.id = e.to_node + JOIN descendants d ON e.from_node = d.id + WHERE e.edge_type = 'contains' + ) + SELECT id, node_type FROM descendants WHERE id != ? + "; + + let mut stmt = conn.prepare(query)?; + let descendants = stmt + .query_map(rusqlite::params![parent_id, parent_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? + .collect::, _>>()?; + + Ok(descendants) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_session_creation() { + // This test structure will be used in session_test.rs + // Just verify Session struct can be created + let session = Session { + id: "sess-test".to_string(), + project_id: "proj-1".to_string(), + goal_id: "ra-1234".to_string(), + started_at: Utc::now(), + ended_at: None, + handoff_notes: None, + agent_ids: vec![], + summary: None, + }; + assert_eq!(session.id, "sess-test"); + } +} diff --git a/tests/session_test.rs b/tests/session_test.rs new file mode 100644 index 0000000..7a0e980 --- /dev/null +++ b/tests/session_test.rs @@ -0,0 +1,353 @@ +use anyhow::Result; +use chrono::Utc; +use rustagent::graph::session::SessionStore; +use rustagent::graph::store::GraphStore; +use rustagent::graph::*; +use std::collections::HashMap; + +mod common; +use common::{create_test_goal, create_test_task}; + +#[tokio::test] +async fn test_ac1_1_create_session_returns_valid_session() -> Result<()> { + // P1c.AC1.1: create_session(goal_id) creates a session record with start time and goal reference + let (db, graph_store) = common::setup_test_env().await?; + let session_store = SessionStore::new(db); + + // Create a goal first + let goal = create_test_goal("ra-1234", "proj-1", "Test Goal"); + graph_store.create_node(&goal).await?; + + let session = session_store.create_session("proj-1", "ra-1234").await?; + + assert!(session.id.starts_with("sess-")); + assert_eq!(session.project_id, "proj-1"); + assert_eq!(session.goal_id, "ra-1234"); + assert!(session.started_at <= Utc::now()); + assert!(session.ended_at.is_none()); + assert!(session.handoff_notes.is_none()); + assert_eq!(session.agent_ids, vec![] as Vec); + + Ok(()) +} + +#[tokio::test] +async fn test_ac1_2_end_session_generates_handoff_notes() -> Result<()> { + // P1c.AC1.2: end_session(session_id) generates deterministic handoff notes from graph state + let (db, graph_store) = common::setup_test_env().await?; + let session_store = SessionStore::new(db); + + // Create a goal and some tasks + let goal = create_test_goal("ra-1234", "proj-1", "Test Goal"); + graph_store.create_node(&goal).await?; + + // Create some tasks with different statuses + let task1 = create_test_task("ra-1234.1", "proj-1", "Task 1", NodeStatus::Completed); + let task2 = create_test_task("ra-1234.2", "proj-1", "Task 2", NodeStatus::Ready); + let task3 = create_test_task("ra-1234.3", "proj-1", "Task 3", NodeStatus::Blocked); + + graph_store.create_node(&task1).await?; + graph_store.create_node(&task2).await?; + graph_store.create_node(&task3).await?; + + // Add edges to make them part of the goal + graph_store + .add_edge(&GraphEdge { + id: generate_edge_id(), + edge_type: EdgeType::Contains, + from_node: "ra-1234".to_string(), + to_node: "ra-1234.1".to_string(), + label: None, + created_at: Utc::now(), + }) + .await?; + + graph_store + .add_edge(&GraphEdge { + id: generate_edge_id(), + edge_type: EdgeType::Contains, + from_node: "ra-1234".to_string(), + to_node: "ra-1234.2".to_string(), + label: None, + created_at: Utc::now(), + }) + .await?; + + graph_store + .add_edge(&GraphEdge { + id: generate_edge_id(), + edge_type: EdgeType::Contains, + from_node: "ra-1234".to_string(), + to_node: "ra-1234.3".to_string(), + label: None, + created_at: Utc::now(), + }) + .await?; + + // Create a session + let session = session_store.create_session("proj-1", "ra-1234").await?; + + // End the session + session_store.end_session(&session.id, &graph_store).await?; + + // Verify the session now has handoff notes + let ended_session = session_store + .get_session(&session.id) + .await? + .expect("session should exist"); + + assert!(ended_session.ended_at.is_some()); + assert!(ended_session.handoff_notes.is_some()); + + Ok(()) +} + +#[tokio::test] +async fn test_ac1_3_handoff_notes_contain_all_sections() -> Result<()> { + // P1c.AC1.3: Handoff notes contain Done, Remaining, Blocked, and Decisions Made sections + let (db, graph_store) = common::setup_test_env().await?; + let session_store = SessionStore::new(db); + + // Create a goal + let goal = create_test_goal("ra-5678", "proj-1", "Complex Goal"); + graph_store.create_node(&goal).await?; + + // Create tasks with different statuses + let completed_task = create_test_task( + "ra-5678.1", + "proj-1", + "Completed Task", + NodeStatus::Completed, + ); + let pending_task = create_test_task("ra-5678.2", "proj-1", "Pending Task", NodeStatus::Pending); + let blocked_task = create_test_task("ra-5678.3", "proj-1", "Blocked Task", NodeStatus::Blocked); + + graph_store.create_node(&completed_task).await?; + graph_store.create_node(&pending_task).await?; + let mut blocked_with_reason = blocked_task; + blocked_with_reason.blocked_reason = Some("Waiting for external dependency".to_string()); + graph_store.create_node(&blocked_with_reason).await?; + + // Create a decision + let decision = GraphNode { + id: "ra-5678.4".to_string(), + project_id: "proj-1".to_string(), + node_type: NodeType::Decision, + title: "Choice of Framework".to_string(), + description: "Deciding which framework to use".to_string(), + status: NodeStatus::Decided, + priority: Some(Priority::High), + assigned_to: None, + created_by: None, + labels: vec![], + created_at: Utc::now(), + started_at: None, + completed_at: None, + blocked_reason: None, + metadata: HashMap::new(), + }; + graph_store.create_node(&decision).await?; + + // Create an option and link it as chosen + let option = GraphNode { + id: "ra-5678.5".to_string(), + project_id: "proj-1".to_string(), + node_type: NodeType::Option, + title: "Use Rust".to_string(), + description: "Using Rust for performance".to_string(), + status: NodeStatus::Chosen, + priority: None, + assigned_to: None, + created_by: None, + labels: vec![], + created_at: Utc::now(), + started_at: None, + completed_at: None, + blocked_reason: None, + metadata: HashMap::new(), + }; + graph_store.create_node(&option).await?; + + // Add all edges + for (from, to) in &[ + ("ra-5678", "ra-5678.1"), + ("ra-5678", "ra-5678.2"), + ("ra-5678", "ra-5678.3"), + ("ra-5678", "ra-5678.4"), + ("ra-5678.4", "ra-5678.5"), + ] { + let edge_type = if from.ends_with(".4") && to.ends_with(".5") { + EdgeType::Chosen + } else { + EdgeType::Contains + }; + + graph_store + .add_edge(&GraphEdge { + id: generate_edge_id(), + edge_type, + from_node: from.to_string(), + to_node: to.to_string(), + label: if edge_type == EdgeType::Chosen { + Some("Best option for this use case".to_string()) + } else { + None + }, + created_at: Utc::now(), + }) + .await?; + } + + // Create and end session + let session = session_store.create_session("proj-1", "ra-5678").await?; + + session_store.end_session(&session.id, &graph_store).await?; + + let ended_session = session_store + .get_session(&session.id) + .await? + .expect("session should exist"); + + let notes = ended_session + .handoff_notes + .expect("handoff_notes should be present"); + + // Verify all sections exist + assert!(notes.contains("## Done"), "Should have 'Done' section"); + assert!( + notes.contains("## Remaining"), + "Should have 'Remaining' section" + ); + assert!( + notes.contains("## Blocked"), + "Should have 'Blocked' section" + ); + assert!( + notes.contains("## Decisions Made"), + "Should have 'Decisions Made' section" + ); + + // Verify content + assert!( + notes.contains("Completed Task"), + "Done section should contain completed task" + ); + assert!( + notes.contains("Pending Task") || notes.contains("ra-5678.2"), + "Remaining section should contain pending task" + ); + assert!( + notes.contains("Blocked Task"), + "Blocked section should contain blocked task" + ); + assert!( + notes.contains("Waiting for external dependency"), + "Blocked task should show reason" + ); + assert!( + notes.contains("Use Rust") || notes.contains("Choice of Framework"), + "Decisions Made section should reference chosen option" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_ac1_4_get_latest_session_returns_most_recent() -> Result<()> { + // P1c.AC1.4: get_latest_session(goal_id) returns the most recent session + let (db, graph_store) = common::setup_test_env().await?; + let session_store = SessionStore::new(db); + + // Create a goal + let goal = create_test_goal("ra-9999", "proj-1", "Test Goal"); + graph_store.create_node(&goal).await?; + + // Create two sessions for the same goal + let session1 = session_store.create_session("proj-1", "ra-9999").await?; + + // Add a small delay to ensure different timestamps + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + + let session2 = session_store.create_session("proj-1", "ra-9999").await?; + + // Get the latest session + let latest = session_store + .get_latest_session("ra-9999") + .await? + .expect("latest session should exist"); + + // Should be session2 (most recent) + assert_eq!(latest.id, session2.id); + assert_ne!(latest.id, session1.id); + + // Verify session1 still exists + let first = session_store + .get_session(&session1.id) + .await? + .expect("first session should still exist"); + assert_eq!(first.id, session1.id); + + Ok(()) +} + +#[tokio::test] +async fn test_list_sessions() -> Result<()> { + let (db, graph_store) = common::setup_test_env().await?; + let session_store = SessionStore::new(db); + + // Create a goal + let goal = create_test_goal("ra-list-test", "proj-1", "Test Goal"); + graph_store.create_node(&goal).await?; + + // Create multiple sessions for the same goal + let _session1 = session_store + .create_session("proj-1", "ra-list-test") + .await?; + + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + + let _session2 = session_store + .create_session("proj-1", "ra-list-test") + .await?; + + // List sessions + let sessions = session_store.list_sessions("ra-list-test").await?; + + assert_eq!(sessions.len(), 2); + // Should be in descending order by start time + assert!(sessions[0].started_at >= sessions[1].started_at); + + Ok(()) +} + +#[tokio::test] +async fn test_handoff_notes_with_no_tasks() -> Result<()> { + // Ensure handoff notes work even with no tasks + let (db, graph_store) = common::setup_test_env().await?; + let session_store = SessionStore::new(db); + + // Create a goal with no tasks + let goal = create_test_goal("ra-empty", "proj-1", "Empty Goal"); + graph_store.create_node(&goal).await?; + + let session = session_store.create_session("proj-1", "ra-empty").await?; + + session_store.end_session(&session.id, &graph_store).await?; + + let ended_session = session_store + .get_session(&session.id) + .await? + .expect("session should exist"); + + let notes = ended_session + .handoff_notes + .expect("handoff_notes should be present"); + + // Should have all sections but with "(none)" entries + assert!(notes.contains("## Done")); + assert!(notes.contains("## Remaining")); + assert!(notes.contains("## Blocked")); + assert!(notes.contains("## Decisions Made")); + + Ok(()) +}