diff --git a/src/app/polling.rs b/src/app/polling.rs index 47a37bf..b71bcda 100644 --- a/src/app/polling.rs +++ b/src/app/polling.rs @@ -16,7 +16,7 @@ use uuid::Uuid; use super::SubagentTodoSummary; use crate::db::Database; use crate::notification::{TaskCompletionNotificationConfig, notify_task_completion}; -use crate::opencode::status_server::SessionStatusMatch; +use crate::opencode::status_server::{SessionRecord, SessionStatusMatch}; use crate::opencode::{ServerStatusProvider, Status}; use crate::types::{SessionMessageItem, SessionState, SessionStatusSource, SessionTodoItem}; @@ -192,6 +192,12 @@ pub fn spawn_status_poller( .await { Ok(statuses) => { + let statuses = match task_session_records.as_deref() { + Some(records) => { + status_matches_for_session_records(statuses, records) + } + None => statuses, + }; let selected_status_match = select_status_match( statuses.clone(), complete_session_parent_map.as_ref(), @@ -662,6 +668,20 @@ fn select_status_match( }) } +fn status_matches_for_session_records( + status_matches: Vec, + session_records: &[SessionRecord], +) -> Vec { + let session_ids: HashSet<&str> = session_records + .iter() + .map(|record| record.session_id.as_str()) + .collect(); + status_matches + .into_iter() + .filter(|status_match| session_ids.contains(status_match.session_id.as_str())) + .collect() +} + fn find_eldest_ancestor(session_id: &str, parent_map: &HashMap>) -> String { let mut visited = HashSet::new(); find_eldest_ancestor_recursive(session_id, parent_map, &mut visited) @@ -805,6 +825,25 @@ mod tests { assert!(select_status_match(Vec::new(), None).is_none()); } + #[test] + fn status_matches_exclude_sessions_rejected_by_exact_directory_matching() { + let records = vec![SessionRecord { + session_id: "frontier".to_string(), + directory: "/home/cc/frontier".to_string(), + title: None, + parent_session_id: None, + }]; + let statuses = vec![ + status_match("frontier", None), + status_match("frontier-abc", None), + ]; + + let matches = status_matches_for_session_records(statuses, &records); + + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].session_id, "frontier"); + } + #[test] fn select_status_match_uses_complete_parent_map_to_find_eldest() { let mut parent_map = HashMap::new(); diff --git a/src/opencode/mod.rs b/src/opencode/mod.rs index 9f4b066..baf18f8 100644 --- a/src/opencode/mod.rs +++ b/src/opencode/mod.rs @@ -225,6 +225,8 @@ pub fn opencode_query_session_by_dir(working_dir: &Path) -> Result Result None, id => Some(id.to_string()), @@ -692,8 +695,11 @@ mod tests { fn test_query_session_by_dir_uses_api_with_directory_filter() -> Result<()> { let _guard = TEST_ENV_LOCK.lock().expect("test env mutex should lock"); let working_dir = tempfile::tempdir()?; - let (port, handle) = - spawn_session_lookup_server(r#"[{"id":"sid-latest","directory":"/tmp/project"}]"#)?; + let response_body = format!( + r#"[{{"id":"sid-latest","directory":"{}"}}]"#, + working_dir.path().display() + ); + let (port, handle) = spawn_session_lookup_server(&response_body)?; let _port_guard = EnvVarGuard::set("OPENCODE_KANBAN_STATUS_PORT", port.to_string()); let found = opencode_query_session_by_dir(working_dir.path())?; @@ -726,6 +732,25 @@ mod tests { .expect("mock session lookup server thread should join"); Ok(()) } + + #[test] + fn test_query_session_by_dir_rejects_directory_prefix_collisions() -> Result<()> { + let _guard = TEST_ENV_LOCK.lock().expect("test env mutex should lock"); + let working_dir = Path::new("/home/cc/frontier"); + let (port, handle) = spawn_session_lookup_server( + r#"[{"id":"sid-wrong","directory":"/home/cc/frontier_/abc"},{"id":"sid-right","directory":"/home/cc/frontier"}]"#, + )?; + let _port_guard = EnvVarGuard::set("OPENCODE_KANBAN_STATUS_PORT", port.to_string()); + + let found = opencode_query_session_by_dir(working_dir)?; + + assert_eq!(found.as_deref(), Some("sid-right")); + handle + .join() + .expect("mock session lookup server thread should join"); + Ok(()) + } + #[test] fn test_open_in_web_generates_correct_url() { let session_id = "test-session-123"; diff --git a/src/opencode/status_server.rs b/src/opencode/status_server.rs index 81548a6..741448b 100644 --- a/src/opencode/status_server.rs +++ b/src/opencode/status_server.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::Path; use std::time::{Duration, SystemTime}; use reqwest::Client; @@ -161,7 +162,11 @@ impl ServerStatusProvider { .await .map_err(|err| map_reqwest_error(err, "SERVER_READ_FAILED"))?; - parse_session_records_body(&body) + let mut records = parse_session_records_body(&body)?; + if let Some(directory) = directory { + records.retain(|record| Path::new(&record.directory) == Path::new(directory)); + } + Ok(records) } pub async fn fetch_all_statuses( @@ -1013,6 +1018,27 @@ mod tests { assert_eq!(sub.title.as_deref(), Some("Sub Session")); } + #[tokio::test(flavor = "multi_thread")] + async fn list_all_session_records_rejects_directory_prefix_collisions() { + let port = spawn_single_response_server( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[{\"id\":\"frontier\",\"directory\":\"/home/cc/frontier\"},{\"id\":\"frontier-abc\",\"directory\":\"/home/cc/frontier_/abc\"}]".to_string(), + ) + .await; + let provider = ServerStatusProvider::new(ServerStatusConfig { + port, + request_timeout: Duration::from_millis(500), + ..ServerStatusConfig::default() + }); + + let records = provider + .list_all_session_records(Some("/home/cc/frontier")) + .await + .expect("session records should parse"); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].session_id, "frontier"); + } + #[tokio::test(flavor = "multi_thread")] async fn fetch_session_todo_parses_todo_array() { let port = spawn_single_response_server(