diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 7b8d8e7a..226730f8 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -457,6 +457,39 @@ enum GraphCommands { #[arg(long)] json: bool, }, + /// Record an observation against a context graph entity + AddObservation { + /// Optional source session ID or alias for provenance + #[arg(long)] + session_id: Option, + /// Entity ID + #[arg(long)] + entity_id: i64, + /// Observation type such as completion_summary, incident_note, or reminder + #[arg(long = "type")] + observation_type: String, + /// Observation summary + #[arg(long)] + summary: String, + /// Details in key=value form + #[arg(long = "detail")] + details: Vec, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// List observations in the shared context graph + Observations { + /// Filter to observations for a specific entity ID + #[arg(long)] + entity_id: Option, + /// Maximum observations to return + #[arg(long, default_value_t = 20)] + limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Recall relevant context graph entities for a query Recall { /// Filter by source session ID or alias @@ -1243,6 +1276,44 @@ async fn main() -> Result<()> { println!("{}", format_graph_relations_human(&relations)); } } + GraphCommands::AddObservation { + session_id, + entity_id, + observation_type, + summary, + details, + json, + } => { + let resolved_session_id = session_id + .as_deref() + .map(|value| resolve_session_id(&db, value)) + .transpose()?; + let details = parse_key_value_pairs(&details, "graph observation details")?; + let observation = db.add_context_observation( + resolved_session_id.as_deref(), + entity_id, + &observation_type, + &summary, + &details, + )?; + if json { + println!("{}", serde_json::to_string_pretty(&observation)?); + } else { + println!("{}", format_graph_observation_human(&observation)); + } + } + GraphCommands::Observations { + entity_id, + limit, + json, + } => { + let observations = db.list_context_observations(entity_id, limit)?; + if json { + println!("{}", serde_json::to_string_pretty(&observations)?); + } else { + println!("{}", format_graph_observations_human(&observations)); + } + } GraphCommands::Recall { session_id, query, @@ -2249,6 +2320,58 @@ fn format_graph_relations_human(relations: &[session::ContextGraphRelation]) -> lines.join("\n") } +fn format_graph_observation_human(observation: &session::ContextGraphObservation) -> String { + let mut lines = vec![ + format!("Context graph observation #{}", observation.id), + format!( + "Entity: #{} [{}] {}", + observation.entity_id, observation.entity_type, observation.entity_name + ), + format!("Type: {}", observation.observation_type), + format!("Summary: {}", observation.summary), + ]; + if let Some(session_id) = observation.session_id.as_deref() { + lines.push(format!("Session: {}", short_session(session_id))); + } + if observation.details.is_empty() { + lines.push("Details: none recorded".to_string()); + } else { + lines.push("Details:".to_string()); + for (key, value) in &observation.details { + lines.push(format!("- {key}={value}")); + } + } + lines.push(format!( + "Created: {}", + observation.created_at.format("%Y-%m-%d %H:%M:%S UTC") + )); + lines.join("\n") +} + +fn format_graph_observations_human(observations: &[session::ContextGraphObservation]) -> String { + if observations.is_empty() { + return "No context graph observations found.".to_string(); + } + + let mut lines = vec![format!( + "Context graph observations: {}", + observations.len() + )]; + for observation in observations { + let mut line = format!( + "- #{} [{}] {}", + observation.id, observation.observation_type, observation.entity_name + ); + if let Some(session_id) = observation.session_id.as_deref() { + line.push_str(&format!(" | {}", short_session(session_id))); + } + lines.push(line); + lines.push(format!(" summary {}", observation.summary)); + } + + lines.join("\n") +} + fn format_graph_recall_human( entries: &[session::ContextGraphRecallEntry], session_id: Option<&str>, @@ -2268,12 +2391,13 @@ fn format_graph_recall_human( )]; for entry in entries { let mut line = format!( - "- #{} [{}] {} | score {} | relations {}", + "- #{} [{}] {} | score {} | relations {} | observations {}", entry.entity.id, entry.entity.entity_type, entry.entity.name, entry.score, - entry.relation_count + entry.relation_count, + entry.observation_count ); if let Some(session_id) = entry.entity.session_id.as_deref() { line.push_str(&format!(" | {}", short_session(session_id))); @@ -4226,6 +4350,49 @@ mod tests { } } + #[test] + fn cli_parses_graph_add_observation_command() { + let cli = Cli::try_parse_from([ + "ecc", + "graph", + "add-observation", + "--session-id", + "latest", + "--entity-id", + "7", + "--type", + "completion_summary", + "--summary", + "Finished auth callback recovery", + "--detail", + "tests_run=2", + "--json", + ]) + .expect("graph add-observation should parse"); + + match cli.command { + Some(Commands::Graph { + command: + GraphCommands::AddObservation { + session_id, + entity_id, + observation_type, + summary, + details, + json, + }, + }) => { + assert_eq!(session_id.as_deref(), Some("latest")); + assert_eq!(entity_id, 7); + assert_eq!(observation_type, "completion_summary"); + assert_eq!(summary, "Finished auth callback recovery"); + assert_eq!(details, vec!["tests_run=2"]); + assert!(json); + } + _ => panic!("expected graph add-observation subcommand"), + } + } + #[test] fn format_decisions_human_renders_details() { let text = format_decisions_human( @@ -4334,17 +4501,39 @@ mod tests { "recovery".to_string(), ], relation_count: 2, + observation_count: 1, }], Some("sess-12345678"), "auth callback recovery", ); assert!(text.contains("Relevant memory: 1 entries")); - assert!(text.contains("[file] callback.ts | score 319 | relations 2")); + assert!(text.contains("[file] callback.ts | score 319 | relations 2 | observations 1")); assert!(text.contains("matches auth, callback, recovery")); assert!(text.contains("path src/routes/auth/callback.ts")); } + #[test] + fn format_graph_observations_human_renders_summaries() { + let text = format_graph_observations_human(&[session::ContextGraphObservation { + id: 5, + session_id: Some("sess-12345678".to_string()), + entity_id: 11, + entity_type: "session".to_string(), + entity_name: "sess-12345678".to_string(), + observation_type: "completion_summary".to_string(), + summary: "Finished auth callback recovery with 2 tests".to_string(), + details: BTreeMap::from([("tests_run".to_string(), "2".to_string())]), + created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") + .unwrap() + .with_timezone(&chrono::Utc), + }]); + + assert!(text.contains("Context graph observations: 1")); + assert!(text.contains("[completion_summary] sess-12345678")); + assert!(text.contains("summary Finished auth callback recovery with 2 tests")); + } + #[test] fn format_graph_sync_stats_human_renders_counts() { let text = format_graph_sync_stats_human( diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 7bd380f1..40e15ea7 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -190,12 +190,26 @@ pub struct ContextGraphEntityDetail { pub incoming: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextGraphObservation { + pub id: i64, + pub session_id: Option, + pub entity_id: i64, + pub entity_type: String, + pub entity_name: String, + pub observation_type: String, + pub summary: String, + pub details: BTreeMap, + pub created_at: DateTime, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ContextGraphRecallEntry { pub entity: ContextGraphEntity, pub score: u64, pub matched_terms: Vec, pub relation_count: usize, + pub observation_count: usize, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index c0f465d3..01b1fa06 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -14,9 +14,10 @@ use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; use super::{ default_project_label, default_task_group_label, normalize_group_label, ContextGraphEntity, - ContextGraphEntityDetail, ContextGraphRecallEntry, ContextGraphRelation, ContextGraphSyncStats, - DecisionLogEntry, FileActivityAction, FileActivityEntry, Session, SessionAgentProfile, - SessionMessage, SessionMetrics, SessionState, WorktreeInfo, + ContextGraphEntityDetail, ContextGraphObservation, ContextGraphRecallEntry, + ContextGraphRelation, ContextGraphSyncStats, DecisionLogEntry, FileActivityAction, + FileActivityEntry, Session, SessionAgentProfile, SessionMessage, SessionMetrics, SessionState, + WorktreeInfo, }; pub struct StateStore { @@ -259,6 +260,16 @@ impl StateStore { UNIQUE(from_entity_id, to_entity_id, relation_type) ); + CREATE TABLE IF NOT EXISTS context_graph_observations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + entity_id INTEGER NOT NULL REFERENCES context_graph_entities(id) ON DELETE CASCADE, + observation_type TEXT NOT NULL, + summary TEXT NOT NULL, + details_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS pending_worktree_queue ( session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, repo_root TEXT NOT NULL, @@ -319,6 +330,8 @@ impl StateStore { ON context_graph_relations(from_entity_id, created_at, id); CREATE INDEX IF NOT EXISTS idx_context_graph_relations_to ON context_graph_relations(to_entity_id, created_at, id); + CREATE INDEX IF NOT EXISTS idx_context_graph_observations_entity + ON context_graph_observations(entity_id, created_at, id); CREATE INDEX IF NOT EXISTS idx_conflict_incidents_sessions ON conflict_incidents(first_session_id, second_session_id, resolved_at, updated_at); CREATE INDEX IF NOT EXISTS idx_pending_worktree_queue_requested_at @@ -2047,7 +2060,22 @@ impl StateStore { SELECT COUNT(*) FROM context_graph_relations r WHERE r.from_entity_id = e.id OR r.to_entity_id = e.id - ) AS relation_count + ) AS relation_count, + COALESCE(( + SELECT group_concat(summary, ' ') + FROM ( + SELECT summary + FROM context_graph_observations o + WHERE o.entity_id = e.id + ORDER BY o.created_at DESC, o.id DESC + LIMIT 4 + ) + ), '') AS observation_text, + ( + SELECT COUNT(*) + FROM context_graph_observations o + WHERE o.entity_id = e.id + ) AS observation_count FROM context_graph_entities e WHERE (?1 IS NULL OR e.session_id = ?1) ORDER BY e.updated_at DESC, e.id DESC @@ -2060,7 +2088,9 @@ impl StateStore { |row| { let entity = map_context_graph_entity(row)?; let relation_count = row.get::<_, i64>(9)?.max(0) as usize; - Ok((entity, relation_count)) + let observation_text = row.get::<_, String>(10)?; + let observation_count = row.get::<_, i64>(11)?.max(0) as usize; + Ok((entity, relation_count, observation_text, observation_count)) }, )? .collect::, _>>()?; @@ -2068,24 +2098,29 @@ impl StateStore { let now = chrono::Utc::now(); let mut entries = candidates .into_iter() - .filter_map(|(entity, relation_count)| { - let matched_terms = context_graph_matched_terms(&entity, &terms); - if matched_terms.is_empty() { - return None; - } + .filter_map( + |(entity, relation_count, observation_text, observation_count)| { + let matched_terms = + context_graph_matched_terms(&entity, &observation_text, &terms); + if matched_terms.is_empty() { + return None; + } - Some(ContextGraphRecallEntry { - score: context_graph_recall_score( - matched_terms.len(), + Some(ContextGraphRecallEntry { + score: context_graph_recall_score( + matched_terms.len(), + relation_count, + observation_count, + entity.updated_at, + now, + ), + entity, + matched_terms, relation_count, - entity.updated_at, - now, - ), - entity, - matched_terms, - relation_count, - }) - }) + observation_count, + }) + }, + ) .collect::>(); entries.sort_by(|left, right| { @@ -2165,6 +2200,95 @@ impl StateStore { })) } + pub fn add_context_observation( + &self, + session_id: Option<&str>, + entity_id: i64, + observation_type: &str, + summary: &str, + details: &BTreeMap, + ) -> Result { + if observation_type.trim().is_empty() { + return Err(anyhow::anyhow!( + "Context graph observation type cannot be empty" + )); + } + if summary.trim().is_empty() { + return Err(anyhow::anyhow!( + "Context graph observation summary cannot be empty" + )); + } + + let now = chrono::Utc::now().to_rfc3339(); + let details_json = serde_json::to_string(details)?; + self.conn.execute( + "INSERT INTO context_graph_observations ( + session_id, entity_id, observation_type, summary, details_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + session_id, + entity_id, + observation_type.trim(), + summary.trim(), + details_json, + now, + ], + )?; + let observation_id = self.conn.last_insert_rowid(); + self.conn + .query_row( + "SELECT o.id, o.session_id, o.entity_id, e.entity_type, e.name, + o.observation_type, o.summary, o.details_json, o.created_at + FROM context_graph_observations o + JOIN context_graph_entities e ON e.id = o.entity_id + WHERE o.id = ?1", + rusqlite::params![observation_id], + map_context_graph_observation, + ) + .map_err(Into::into) + } + + pub fn add_session_observation( + &self, + session_id: &str, + observation_type: &str, + summary: &str, + details: &BTreeMap, + ) -> Result { + let session_entity = self.sync_context_graph_session(session_id)?; + self.add_context_observation( + Some(session_id), + session_entity.id, + observation_type, + summary, + details, + ) + } + + pub fn list_context_observations( + &self, + entity_id: Option, + limit: usize, + ) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT o.id, o.session_id, o.entity_id, e.entity_type, e.name, + o.observation_type, o.summary, o.details_json, o.created_at + FROM context_graph_observations o + JOIN context_graph_entities e ON e.id = o.entity_id + WHERE (?1 IS NULL OR o.entity_id = ?1) + ORDER BY o.created_at DESC, o.id DESC + LIMIT ?2", + )?; + + let entries = stmt + .query_map( + rusqlite::params![entity_id, limit as i64], + map_context_graph_observation, + )? + .collect::, _>>()?; + Ok(entries) + } + pub fn upsert_context_relation( &self, session_id: Option<&str>, @@ -3147,6 +3271,30 @@ fn map_context_graph_relation(row: &rusqlite::Row<'_>) -> rusqlite::Result, +) -> rusqlite::Result { + let details_json = row + .get::<_, Option>(7)? + .unwrap_or_else(|| "{}".to_string()); + let details = serde_json::from_str(&details_json).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(error)) + })?; + let created_at = parse_store_timestamp(row.get::<_, String>(8)?, 8)?; + + Ok(ContextGraphObservation { + id: row.get(0)?, + session_id: row.get(1)?, + entity_id: row.get(2)?, + entity_type: row.get(3)?, + entity_name: row.get(4)?, + observation_type: row.get(5)?, + summary: row.get(6)?, + details, + created_at, + }) +} + fn context_graph_recall_terms(query: &str) -> Vec { let mut terms = Vec::new(); for raw_term in @@ -3161,7 +3309,11 @@ fn context_graph_recall_terms(query: &str) -> Vec { terms } -fn context_graph_matched_terms(entity: &ContextGraphEntity, terms: &[String]) -> Vec { +fn context_graph_matched_terms( + entity: &ContextGraphEntity, + observation_text: &str, + terms: &[String], +) -> Vec { let mut haystacks = vec![ entity.entity_type.to_ascii_lowercase(), entity.name.to_ascii_lowercase(), @@ -3174,6 +3326,9 @@ fn context_graph_matched_terms(entity: &ContextGraphEntity, terms: &[String]) -> haystacks.push(key.to_ascii_lowercase()); haystacks.push(value.to_ascii_lowercase()); } + if !observation_text.trim().is_empty() { + haystacks.push(observation_text.to_ascii_lowercase()); + } let mut matched = Vec::new(); for term in terms { @@ -3187,6 +3342,7 @@ fn context_graph_matched_terms(entity: &ContextGraphEntity, terms: &[String]) -> fn context_graph_recall_score( matched_term_count: usize, relation_count: usize, + observation_count: usize, updated_at: chrono::DateTime, now: chrono::DateTime, ) -> u64 { @@ -3203,7 +3359,10 @@ fn context_graph_recall_score( } }; - (matched_term_count as u64 * 100) + (relation_count.min(9) as u64 * 10) + recency_bonus + (matched_term_count as u64 * 100) + + (relation_count.min(9) as u64 * 10) + + (observation_count.min(6) as u64 * 8) + + recency_bonus } fn parse_store_timestamp( @@ -3990,6 +4149,57 @@ mod tests { Ok(()) } + #[test] + fn add_and_list_context_observations() -> Result<()> { + let tempdir = TestDir::new("store-context-observations")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "deep memory".to_string(), + project: "workspace".to_string(), + task_group: "knowledge".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let entity = db.upsert_context_entity( + Some("session-1"), + "decision", + "Prefer recovery-first routing", + None, + "Recovered installs should go through the portal first", + &BTreeMap::new(), + )?; + let observation = db.add_context_observation( + Some("session-1"), + entity.id, + "note", + "Customer wiped setup and got charged twice", + &BTreeMap::from([("customer".to_string(), "viktor".to_string())]), + )?; + + let observations = db.list_context_observations(Some(entity.id), 10)?; + assert_eq!(observations.len(), 1); + assert_eq!(observations[0].id, observation.id); + assert_eq!(observations[0].entity_name, "Prefer recovery-first routing"); + assert_eq!(observations[0].observation_type, "note"); + assert_eq!( + observations[0].details.get("customer"), + Some(&"viktor".to_string()) + ); + + Ok(()) + } + #[test] fn recall_context_entities_ranks_matching_entities() -> Result<()> { let tempdir = TestDir::new("store-context-recall")?; @@ -4051,6 +4261,13 @@ mod tests { "references", "Callback route references the dashboard summary", )?; + db.add_context_observation( + Some("session-1"), + recovery.id, + "incident_note", + "Previous auth callback recovery incident affected Viktor after a wipe", + &BTreeMap::new(), + )?; let results = db.recall_context_entities(Some("session-1"), "Investigate auth callback recovery", 3)?; @@ -4068,6 +4285,7 @@ mod tests { .any(|term| term == "recovery")); assert_eq!(results[0].relation_count, 2); assert_eq!(results[1].entity.id, recovery.id); + assert_eq!(results[1].observation_count, 1); assert!(!results.iter().any(|entry| entry.entity.id == unrelated.id)); Ok(()) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 824691a9..b4cf65be 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -4153,6 +4153,11 @@ impl Dashboard { } SessionState::Completed => { let summary = self.build_completion_summary(session); + self.persist_completion_summary_observation( + session, + &summary, + "completion_summary", + ); if self.cfg.completion_summary_notifications.enabled { completion_summaries.push(summary.clone()); } else if self.cfg.desktop_notifications.session_completed { @@ -4174,6 +4179,11 @@ impl Dashboard { } SessionState::Failed => { let summary = self.build_completion_summary(session); + self.persist_completion_summary_observation( + session, + &summary, + "failure_summary", + ); failed_notifications.push(( "ECC 2.0: Session failed".to_string(), format!( @@ -4226,6 +4236,34 @@ impl Dashboard { self.last_session_states = next_states; } + fn persist_completion_summary_observation( + &self, + session: &Session, + summary: &SessionCompletionSummary, + observation_type: &str, + ) { + let observation_summary = format!( + "{} | files {} | tests {}/{} | warnings {}", + truncate_for_dashboard(&summary.task, 72), + summary.files_changed, + summary.tests_passed, + summary.tests_run, + summary.warnings.len() + ); + let details = completion_summary_observation_details(summary, session); + if let Err(error) = self.db.add_session_observation( + &session.id, + observation_type, + &observation_summary, + &details, + ) { + tracing::warn!( + "Failed to persist completion observation for {}: {error}", + session.id + ); + } + } + fn sync_approval_notifications(&mut self) { let latest_message = match self.db.latest_unread_approval_message() { Ok(message) => message, @@ -5320,12 +5358,13 @@ impl Dashboard { let mut lines = vec!["Relevant memory".to_string()]; for entry in entries { let mut line = format!( - "- #{} [{}] {} | score {} | relations {}", + "- #{} [{}] {} | score {} | relations {} | observations {}", entry.entity.id, entry.entity.entity_type, truncate_for_dashboard(&entry.entity.name, 60), entry.score, - entry.relation_count + entry.relation_count, + entry.observation_count ); if let Some(session_id) = entry.entity.session_id.as_deref() { if session_id != session.id { @@ -5345,6 +5384,14 @@ impl Dashboard { truncate_for_dashboard(&entry.entity.summary, 72) )); } + if let Ok(observations) = self.db.list_context_observations(Some(entry.entity.id), 1) { + if let Some(observation) = observations.first() { + lines.push(format!( + " memory {}", + truncate_for_dashboard(&observation.summary, 72) + )); + } + } } lines @@ -8517,6 +8564,39 @@ fn summarize_completion_warnings( warnings } +fn completion_summary_observation_details( + summary: &SessionCompletionSummary, + session: &Session, +) -> BTreeMap { + let mut details = BTreeMap::new(); + details.insert("state".to_string(), session.state.to_string()); + details.insert( + "files_changed".to_string(), + summary.files_changed.to_string(), + ); + details.insert("tokens_used".to_string(), summary.tokens_used.to_string()); + details.insert( + "duration_secs".to_string(), + summary.duration_secs.to_string(), + ); + details.insert("cost_usd".to_string(), format!("{:.4}", summary.cost_usd)); + details.insert("tests_run".to_string(), summary.tests_run.to_string()); + details.insert("tests_passed".to_string(), summary.tests_passed.to_string()); + if !summary.recent_files.is_empty() { + details.insert("recent_files".to_string(), summary.recent_files.join(" | ")); + } + if !summary.key_decisions.is_empty() { + details.insert( + "key_decisions".to_string(), + summary.key_decisions.join(" | "), + ); + } + if !summary.warnings.is_empty() { + details.insert("warnings".to_string(), summary.warnings.join(" | ")); + } + details +} + fn session_started_webhook_body(session: &Session, compare_url: Option<&str>) -> String { let mut lines = vec![ "*ECC 2.0: Session started*".to_string(), @@ -10444,11 +10524,25 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ "Handles auth callback recovery and billing fallback", &BTreeMap::from([("area".to_string(), "auth".to_string())]), )?; + let entity = dashboard + .db + .list_context_entities(Some(&memory.id), Some("file"), 10)? + .into_iter() + .find(|entry| entry.name == "callback.ts") + .expect("callback entity"); + dashboard.db.add_context_observation( + Some(&memory.id), + entity.id, + "completion_summary", + "Recovered auth callback incident with billing fallback", + &BTreeMap::new(), + )?; let text = dashboard.selected_session_metrics_text(); assert!(text.contains("Relevant memory")); assert!(text.contains("[file] callback.ts")); assert!(text.contains("matches auth, callback, recovery")); + assert!(text.contains("memory Recovered auth callback incident with billing fallback")); Ok(()) } @@ -11876,6 +11970,73 @@ diff --git a/src/lib.rs b/src/lib.rs Ok(()) } + #[test] + fn refresh_persists_completion_summary_observation() -> Result<()> { + let root = + std::env::temp_dir().join(format!("ecc2-completion-observation-{}", Uuid::new_v4())); + fs::create_dir_all(root.join(".claude").join("metrics"))?; + + let mut cfg = build_config(&root.join(".claude")); + cfg.completion_summary_notifications.delivery = + crate::notifications::CompletionSummaryDelivery::TuiPopup; + cfg.desktop_notifications.session_completed = false; + + let db = StateStore::open(&cfg.db_path)?; + let mut session = sample_session( + "done-observation", + "claude", + SessionState::Running, + Some("ecc/observation"), + 144, + 42, + ); + session.task = "Recover auth callback after wipe".to_string(); + db.insert_session(&session)?; + + let metrics_path = cfg.tool_activity_metrics_path(); + fs::create_dir_all(metrics_path.parent().unwrap())?; + fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"done-observation\",\"tool_name\":\"Bash\",\"input_summary\":\"cargo test -q\",\"input_params_json\":\"{\\\"command\\\":\\\"cargo test -q\\\"}\",\"output_summary\":\"ok\",\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"done-observation\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/routes/auth/callback.ts\",\"output_summary\":\"updated callback\",\"file_events\":[{\"path\":\"src/routes/auth/callback.ts\",\"action\":\"modify\",\"diff_preview\":\"portal first\",\"patch_preview\":\"+ portal first\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" + ), + )?; + + let mut dashboard = Dashboard::new(db, cfg); + dashboard + .db + .update_state("done-observation", &SessionState::Completed)?; + + dashboard.refresh(); + + let session_entity = dashboard + .db + .list_context_entities(Some("done-observation"), Some("session"), 10)? + .into_iter() + .find(|entity| entity.name == "done-observation") + .expect("session entity"); + let observations = dashboard + .db + .list_context_observations(Some(session_entity.id), 10)?; + assert!(!observations.is_empty()); + assert_eq!(observations[0].observation_type, "completion_summary"); + assert!(observations[0] + .summary + .contains("Recover auth callback after wipe")); + assert_eq!( + observations[0].details.get("tests_run"), + Some(&"1".to_string()) + ); + assert!(observations[0] + .details + .get("recent_files") + .is_some_and(|value| value.contains("modify src/routes/auth/callback.ts"))); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn dismiss_completion_popup_promotes_the_next_summary() { let mut dashboard = test_dashboard(Vec::new(), 0);