Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion src/app/polling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -662,6 +668,20 @@ fn select_status_match(
})
}

fn status_matches_for_session_records(
status_matches: Vec<SessionStatusMatch>,
session_records: &[SessionRecord],
) -> Vec<SessionStatusMatch> {
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, Option<String>>) -> String {
let mut visited = HashSet::new();
find_eldest_ancestor_recursive(session_id, parent_map, &mut visited)
Expand Down Expand Up @@ -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();
Expand Down
29 changes: 27 additions & 2 deletions src/opencode/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ pub fn opencode_query_session_by_dir(working_dir: &Path) -> Result<Option<String
struct SessionListEntry {
#[serde(default)]
id: String,
#[serde(default)]
directory: String,
}

let config = status_server::ServerStatusConfig::default();
Expand Down Expand Up @@ -264,6 +266,7 @@ pub fn opencode_query_session_by_dir(working_dir: &Path) -> Result<Option<String

let session_id = sessions
.into_iter()
.filter(|entry| Path::new(&entry.directory) == working_dir)
.find_map(|entry| match entry.id.trim() {
"" => None,
id => Some(id.to_string()),
Expand Down Expand Up @@ -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())?;
Expand Down Expand Up @@ -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";
Expand Down
28 changes: 27 additions & 1 deletion src/opencode/status_server.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::path::Path;
use std::time::{Duration, SystemTime};

use reqwest::Client;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading