diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index d5668649..decbe825 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -1230,8 +1230,8 @@ async fn main() -> Result<()> { for s in sessions { let harness = harnesses .get(&s.id) - .map(|info| info.primary.to_string()) - .unwrap_or_else(|| "unknown".to_string()); + .map(|info| info.primary_label.clone()) + .unwrap_or_else(|| session::SessionHarnessInfo::runner_key(&s.agent_type)); println!("{} [{}] [{}] {}", s.id, s.state, harness, s.task); } } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index a8ed0f91..807605a3 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -2004,10 +2004,11 @@ pub async fn delete_session(db: &StateStore, id: &str) -> Result<()> { fn agent_program(cfg: &Config, agent_type: &str) -> Result { let harness = HarnessKind::from_agent_type(agent_type); - if let Some(runner) = cfg.harness_runner(harness.as_str()) { + let runner_key = SessionHarnessInfo::runner_key(agent_type); + if let Some(runner) = cfg.harness_runner(&runner_key) { let program = runner.program.trim(); if program.is_empty() { - anyhow::bail!("Configured harness runner for {harness} is missing a program"); + anyhow::bail!("Configured harness runner for {runner_key} is missing a program"); } return Ok(PathBuf::from(program)); } @@ -2685,7 +2686,7 @@ fn build_agent_command( profile: Option<&SessionAgentProfile>, ) -> Command { let harness = HarnessKind::from_agent_type(agent_type); - if let Some(runner) = cfg.harness_runner(harness.as_str()) { + if let Some(runner) = cfg.harness_runner(&SessionHarnessInfo::runner_key(agent_type)) { return build_configured_harness_command( runner, agent_program, @@ -3327,7 +3328,7 @@ impl fmt::Display for SessionStatus { writeln!(f, "Session: {}", s.id)?; writeln!(f, "Task: {}", s.task)?; writeln!(f, "Agent: {}", s.agent_type)?; - writeln!(f, "Harness: {}", self.harness.primary)?; + writeln!(f, "Harness: {}", self.harness.primary_label)?; writeln!(f, "Detected: {}", self.harness.detected_summary())?; writeln!(f, "State: {}", s.state)?; if let Some(profile) = self.profile.as_ref() { @@ -3880,6 +3881,24 @@ mod tests { Ok(()) } + #[test] + fn agent_program_uses_configured_runner_for_unknown_custom_harness() -> Result<()> { + let mut cfg = Config::default(); + cfg.harness_runners.insert( + "acme-runner".to_string(), + crate::config::HarnessRunnerConfig { + program: "acme-agent".to_string(), + ..Default::default() + }, + ); + + assert_eq!( + agent_program(&cfg, "acme-runner")?, + PathBuf::from("acme-agent") + ); + Ok(()) + } + #[test] fn build_agent_command_uses_configured_runner_for_cursor() { let mut cfg = Config::default(); diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index ffff01e7..2c1ba242 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -109,11 +109,38 @@ impl fmt::Display for HarnessKind { #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct SessionHarnessInfo { pub primary: HarnessKind, + pub primary_label: String, pub detected: Vec, } impl SessionHarnessInfo { + pub fn runner_key(agent_type: &str) -> String { + let canonical = HarnessKind::canonical_agent_type(agent_type); + match HarnessKind::from_agent_type(&canonical) { + HarnessKind::Unknown if canonical.is_empty() => { + HarnessKind::Unknown.as_str().to_string() + } + HarnessKind::Unknown => canonical, + harness => harness.as_str().to_string(), + } + } + + fn primary_label_for(agent_type: &str, primary: HarnessKind) -> String { + match primary { + HarnessKind::Unknown => { + let label = Self::runner_key(agent_type); + if label.is_empty() { + HarnessKind::Unknown.as_str().to_string() + } else { + label + } + } + harness => harness.as_str().to_string(), + } + } + pub fn detect(agent_type: &str, working_dir: &Path) -> Self { + let runner_key = Self::runner_key(agent_type); let detected = [ HarnessKind::Claude, HarnessKind::Codex, @@ -132,12 +159,43 @@ impl SessionHarnessInfo { }) .collect::>(); - let primary = match HarnessKind::from_agent_type(agent_type) { - HarnessKind::Unknown => detected.first().copied().unwrap_or(HarnessKind::Unknown), + let primary = match HarnessKind::from_agent_type(&runner_key) { + HarnessKind::Unknown if runner_key == HarnessKind::Unknown.as_str() => { + detected.first().copied().unwrap_or(HarnessKind::Unknown) + } + HarnessKind::Unknown => HarnessKind::Unknown, harness => harness, }; - Self { primary, detected } + Self { + primary, + primary_label: Self::primary_label_for(agent_type, primary), + detected, + } + } + + pub fn from_persisted( + harness_label: &str, + agent_type: &str, + working_dir: &Path, + detected: Vec, + ) -> Self { + let primary = HarnessKind::from_db_value(harness_label); + if primary == HarnessKind::Unknown && detected.is_empty() && harness_label.trim().is_empty() + { + return Self::detect(agent_type, working_dir); + } + + let normalized_label = harness_label.trim().to_ascii_lowercase(); + Self { + primary, + primary_label: if normalized_label.is_empty() { + Self::primary_label_for(agent_type, primary) + } else { + normalized_label + }, + detected, + } } pub fn detected_summary(&self) -> String { @@ -510,6 +568,7 @@ mod tests { let harness = SessionHarnessInfo::detect("claude", repo.path()); assert_eq!(harness.primary, HarnessKind::Claude); + assert_eq!(harness.primary_label, "claude"); assert_eq!( harness.detected, vec![HarnessKind::Claude, HarnessKind::Codex] @@ -519,13 +578,14 @@ mod tests { } #[test] - fn detect_session_harness_falls_back_to_project_markers_for_unknown_agent( + fn detect_session_harness_falls_back_to_project_markers_when_agent_unspecified( ) -> Result<(), Box> { let repo = TestDir::new("session-harness-markers")?; fs::create_dir_all(repo.path().join(".gemini"))?; - let harness = SessionHarnessInfo::detect("custom-runner", repo.path()); + let harness = SessionHarnessInfo::detect("", repo.path()); assert_eq!(harness.primary, HarnessKind::Gemini); + assert_eq!(harness.primary_label, "gemini"); assert_eq!(harness.detected, vec![HarnessKind::Gemini]); Ok(()) } @@ -543,4 +603,38 @@ mod tests { "custom-runner" ); } + + #[test] + fn detect_session_harness_preserves_custom_agent_label_without_markers() { + let harness = SessionHarnessInfo::detect(" custom-runner ", Path::new(".")); + assert_eq!(harness.primary, HarnessKind::Unknown); + assert_eq!(harness.primary_label, "custom-runner"); + assert!(harness.detected.is_empty()); + } + + #[test] + fn detect_session_harness_preserves_custom_agent_label_with_project_markers( + ) -> Result<(), Box> { + let repo = TestDir::new("session-harness-custom-markers")?; + fs::create_dir_all(repo.path().join(".claude"))?; + fs::create_dir_all(repo.path().join(".codex"))?; + + let harness = SessionHarnessInfo::detect("custom-runner", repo.path()); + assert_eq!(harness.primary, HarnessKind::Unknown); + assert_eq!(harness.primary_label, "custom-runner"); + assert_eq!( + harness.detected, + vec![HarnessKind::Claude, HarnessKind::Codex] + ); + Ok(()) + } + + #[test] + fn runner_key_uses_canonical_label_for_unknown_harnesses() { + assert_eq!( + SessionHarnessInfo::runner_key(" custom-runner "), + "custom-runner" + ); + assert_eq!(SessionHarnessInfo::runner_key("claude-code"), "claude"); + } } diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 31196d3c..7414f99a 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -706,7 +706,7 @@ impl StateStore { rusqlite::params![ session_id, canonical_agent_type, - harness.primary.to_string(), + harness.primary_label, detected_json ], )?; @@ -728,7 +728,7 @@ impl StateStore { session.project, session.task_group, session.agent_type, - harness.primary.to_string(), + harness.primary_label, detected_json, session.working_dir.to_string_lossy().to_string(), session.state.to_string(), @@ -1763,16 +1763,17 @@ impl StateStore { let harnesses = stmt .query_map([], |row| { let session_id: String = row.get(0)?; - let primary = HarnessKind::from_db_value(&row.get::<_, String>(1)?); + let harness_label: String = row.get(1)?; let detected = serde_json::from_str::>(&row.get::<_, String>(2)?) .unwrap_or_default(); let agent_type: String = row.get(3)?; let working_dir = PathBuf::from(row.get::<_, String>(4)?); - let info = if primary == HarnessKind::Unknown && detected.is_empty() { - SessionHarnessInfo::detect(&agent_type, &working_dir) - } else { - SessionHarnessInfo { primary, detected } - }; + let info = SessionHarnessInfo::from_persisted( + &harness_label, + &agent_type, + &working_dir, + detected, + ); Ok((session_id, info)) })? .collect::, _>>()?; @@ -1788,16 +1789,17 @@ impl StateStore { )?; stmt.query_row([session_id], |row| { - let primary = HarnessKind::from_db_value(&row.get::<_, String>(0)?); + let harness_label: String = row.get(0)?; let detected = serde_json::from_str::>(&row.get::<_, String>(1)?) .unwrap_or_default(); let agent_type: String = row.get(2)?; let working_dir = PathBuf::from(row.get::<_, String>(3)?); - let info = if primary == HarnessKind::Unknown && detected.is_empty() { - SessionHarnessInfo::detect(&agent_type, &working_dir) - } else { - SessionHarnessInfo { primary, detected } - }; + let info = SessionHarnessInfo::from_persisted( + &harness_label, + &agent_type, + &working_dir, + detected, + ); Ok(info) }) .optional() @@ -4191,10 +4193,41 @@ mod tests { .get_session_harness_info("sess-legacy")? .expect("legacy row should be backfilled"); assert_eq!(harness.primary, HarnessKind::Gemini); + assert_eq!(harness.primary_label, "gemini"); assert_eq!(harness.detected, vec![HarnessKind::Codex]); Ok(()) } + #[test] + fn insert_session_preserves_custom_harness_label_for_unknown_agent_types() -> Result<()> { + let tempdir = TestDir::new("store-custom-harness-label")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "sess-custom".to_string(), + task: "Run custom harness".to_string(), + project: "ecc".to_string(), + task_group: "compat".to_string(), + agent_type: "acme-runner".to_string(), + working_dir: PathBuf::from(tempdir.path()), + state: SessionState::Pending, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let harness = db + .get_session_harness_info("sess-custom")? + .expect("custom session should have harness info"); + assert_eq!(harness.primary, HarnessKind::Unknown); + assert_eq!(harness.primary_label, "acme-runner"); + Ok(()) + } + #[test] fn session_profile_round_trips_with_launch_settings() -> Result<()> { let tempdir = TestDir::new("store-session-profile")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 6345872e..a73be631 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -6347,7 +6347,7 @@ impl Dashboard { if let Some(harness) = self.session_harnesses.get(&session.id) { lines.push(format!( "Harness {} | Detected {}", - harness.primary, + harness.primary_label, harness.detected_summary() )); }