use std::collections::HashMap;
use std::path::Path;
use super::{draft, session_folder};
use crate::app::SessionManager;
use crate::domain::agent::{AgentKind, AgentModel, ReasoningLevel};
use crate::domain::question::QuestionItem;
use crate::domain::session::{
DailyActivity, PublishedBranchSyncStatus, ReviewRequest, ReviewRequestSummary, Session,
SessionFollowUpTask, SessionHandles, SessionId, SessionSize, SessionStats, Status,
};
use crate::infra::db::{AppRepositories, SessionDetailRow, SessionListRow};
use crate::infra::fs::FsClient;
use crate::infra::git::GitClient;
struct LoadSessionContext<'a> {
active_session_id: Option<&'a str>,
base: &'a Path,
db: &'a AppRepositories,
follow_up_tasks_by_session: &'a mut HashMap<SessionId, Vec<SessionFollowUpTask>>,
fs_client: &'a dyn FsClient,
handles: &'a mut HashMap<SessionId, SessionHandles>,
project_name: &'a str,
session_worktree_availability: &'a mut HashMap<SessionId, bool>,
sessions: &'a mut Vec<Session>,
}
struct LoadedSessionInput {
draft_attachments: Vec<crate::domain::turn_prompt::TurnPromptAttachment>,
follow_up_tasks: Vec<SessionFollowUpTask>,
folder: std::path::PathBuf,
project_name: String,
reasoning_level_override: Option<ReasoningLevel>,
review_request: Option<ReviewRequest>,
row: SessionListRow,
session_model: AgentModel,
session_id: SessionId,
session_output: String,
session_prompt: String,
session_queued_messages: Vec<String>,
session_questions: Vec<QuestionItem>,
session_summary: Option<String>,
session_status: Status,
size: SessionSize,
}
impl SessionManager {
pub(crate) async fn load_sessions_with_fs_client(
base: &Path,
db: &AppRepositories,
active_project_id: i64,
working_dir: &Path,
handles: &mut HashMap<SessionId, SessionHandles>,
fs_client: &dyn FsClient,
active_session_id: Option<&str>,
) -> (Vec<Session>, Vec<DailyActivity>, HashMap<SessionId, bool>) {
let project_name = working_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_string();
let db_rows = db
.sessions()
.load_sessions_for_project(active_project_id)
.await
.unwrap_or_default();
let persisted_follow_up_tasks = db
.sessions()
.load_session_follow_up_tasks()
.await
.unwrap_or_default();
let stats_activity = db
.activity()
.load_session_activity()
.await
.unwrap_or_default();
let mut sessions: Vec<Session> = Vec::new();
let mut follow_up_tasks_by_session = HashMap::<SessionId, Vec<_>>::new();
let mut session_worktree_availability = HashMap::new();
for persisted_follow_up_task in persisted_follow_up_tasks {
follow_up_tasks_by_session
.entry(SessionId::from(persisted_follow_up_task.session_id.clone()))
.or_default()
.push(persisted_follow_up_task.into_session_follow_up_task());
}
let mut load_context = LoadSessionContext {
base,
db,
project_name: &project_name,
handles,
fs_client,
active_session_id,
sessions: &mut sessions,
follow_up_tasks_by_session: &mut follow_up_tasks_by_session,
session_worktree_availability: &mut session_worktree_availability,
};
for row in db_rows {
Self::push_loaded_session_row(&mut load_context, row).await;
}
(sessions, stats_activity, session_worktree_availability)
}
async fn push_loaded_session_row(
load_context: &mut LoadSessionContext<'_>,
row: SessionListRow,
) {
let LoadSessionContext {
base,
db,
project_name,
handles,
fs_client,
active_session_id,
sessions,
follow_up_tasks_by_session,
session_worktree_availability,
} = load_context;
let session_id = SessionId::from(row.id.clone());
let folder = session_folder(base, &session_id);
let persisted_status = row.status.parse::<Status>().unwrap_or(Status::Done);
let persisted_size = row.size.parse::<SessionSize>().unwrap_or_default();
let has_session_folder = fs_client.is_dir(folder.clone());
let live_handle_status = handles
.get(&session_id)
.and_then(|existing| existing.status.lock().ok().map(|status| *status));
if should_skip_missing_folder_session(
has_session_folder,
row.is_draft,
persisted_status,
live_handle_status,
) {
return;
}
session_worktree_availability.insert(session_id.clone(), has_session_folder);
let session_model = AgentModel::parse_persisted(&row.model)
.unwrap_or_else(|_| AgentKind::Gemini.default_model());
let session_detail = if active_session_id.is_some_and(|active_id| active_id == row.id) {
db.sessions()
.load_session_detail(&row.id)
.await
.ok()
.flatten()
} else {
None
};
let (session_output, session_status) =
if let Some(existing_handle) = handles.get(&session_id) {
output_and_status_from_existing_handle(
existing_handle,
persisted_status,
session_detail.as_ref(),
)
} else {
let output = insert_loaded_session_handle(
handles,
session_id.clone(),
persisted_status,
session_detail.as_ref(),
);
(output, persisted_status)
};
let review_request = parse_review_request(&row);
let draft_attachments =
draft::load_staged_draft_attachments(*fs_client, base, &session_id).await;
let questions = session_detail
.as_ref()
.and_then(|detail| detail.questions.as_deref())
.and_then(parse_questions_json)
.unwrap_or_default();
let reasoning_level_override = row
.reasoning_level_override
.as_deref()
.and_then(|value| value.parse::<ReasoningLevel>().ok());
let follow_up_tasks = follow_up_tasks_by_session
.remove(&session_id)
.unwrap_or_default();
let session_queued_messages = handles
.get(&session_id)
.map(SessionHandles::queued_message_transcripts)
.unwrap_or_default();
sessions.push(Self::build_loaded_session(LoadedSessionInput {
draft_attachments,
follow_up_tasks,
folder,
project_name: (*project_name).to_string(),
reasoning_level_override,
review_request,
row,
session_model,
session_id,
session_output,
session_prompt: session_detail
.as_ref()
.map(|detail| detail.prompt.clone())
.unwrap_or_default(),
session_queued_messages,
session_questions: questions,
session_summary: session_detail.and_then(|detail| detail.summary),
session_status,
size: persisted_size,
}));
}
pub(crate) async fn session_diff_stats_for_folder(
fs_client: &dyn FsClient,
git_client: &dyn GitClient,
folder: &Path,
base_branch: &str,
) -> (SessionSize, u64, u64) {
if !fs_client.is_dir(folder.to_path_buf()) {
return (SessionSize::Xs, 0, 0);
}
let folder = folder.to_path_buf();
let base_branch = base_branch.to_string();
let diff = git_client
.diff(folder, base_branch)
.await
.ok()
.unwrap_or_default();
let (added_lines, deleted_lines) = SessionStats::line_change_counts(&diff);
(SessionSize::from_diff(&diff), added_lines, deleted_lines)
}
pub(crate) async fn load_session_detail_into_state(
&mut self,
db: &AppRepositories,
session_id: &str,
) {
let Some(detail) = db
.sessions()
.load_session_detail(session_id)
.await
.ok()
.flatten()
else {
return;
};
self.apply_session_detail(session_id, detail);
}
fn build_loaded_session(input: LoadedSessionInput) -> Session {
Session {
base_branch: input.row.base_branch,
created_at: input.row.created_at,
draft_attachments: input.draft_attachments,
folder: input.folder,
follow_up_tasks: input.follow_up_tasks,
id: input.session_id,
in_progress_started_at: input.row.in_progress_started_at,
in_progress_total_seconds: input.row.in_progress_total_seconds,
is_draft: input.row.is_draft,
model: input.session_model,
output: input.session_output,
project_name: input.project_name,
prompt: input.session_prompt,
queued_messages: input.session_queued_messages,
reasoning_level_override: input.reasoning_level_override,
published_upstream_ref: input.row.published_upstream_ref,
published_branch_sync_status: PublishedBranchSyncStatus::Idle,
questions: input.session_questions,
review_request: input.review_request,
size: input.size,
stats: SessionStats {
added_lines: input.row.added_lines.cast_unsigned(),
deleted_lines: input.row.deleted_lines.cast_unsigned(),
input_tokens: input.row.input_tokens.cast_unsigned(),
output_tokens: input.row.output_tokens.cast_unsigned(),
},
status: input.session_status,
summary: input.session_summary,
title: input.row.title,
updated_at: input.row.updated_at,
workflow_notice: None,
}
}
fn apply_session_detail(&mut self, session_id: &str, detail: SessionDetailRow) {
let session_output = if let Some(handles) = self.state.handles.get(session_id)
&& let Ok(mut handle_output) = handles.output.lock()
{
if handle_output.is_empty() {
handle_output.clone_from(&detail.output);
}
handle_output.clone()
} else {
detail.output.clone()
};
let Some(session) = self.state.session_mut_for_id(session_id) else {
return;
};
session.prompt = detail.prompt;
if let Some(questions) = detail.questions {
session.questions = parse_questions_json(&questions).unwrap_or_default();
}
session.summary = detail.summary;
session.output = session_output;
}
}
fn output_and_status_from_existing_handle(
existing_handle: &SessionHandles,
persisted_status: Status,
session_detail: Option<&SessionDetailRow>,
) -> (String, Status) {
let output_from_handle =
existing_handle
.output
.lock()
.ok()
.map_or_else(String::new, |mut output| {
if output.is_empty()
&& let Some(detail) = session_detail
{
output.push_str(&detail.output);
}
output.clone()
});
let status_from_handle = existing_handle
.status
.lock()
.ok()
.map_or(persisted_status, |status| *status);
let merged_status = merge_loaded_session_status(persisted_status, status_from_handle);
if let Ok(mut handle_status) = existing_handle.status.lock() {
*handle_status = merged_status;
}
(output_from_handle, merged_status)
}
fn insert_loaded_session_handle(
handles: &mut HashMap<SessionId, SessionHandles>,
session_id: SessionId,
persisted_status: Status,
session_detail: Option<&SessionDetailRow>,
) -> String {
let output = session_detail
.map(|detail| detail.output.clone())
.unwrap_or_default();
handles.insert(
session_id,
SessionHandles::new(output.clone(), persisted_status),
);
output
}
fn should_skip_missing_folder_session(
has_session_folder: bool,
is_draft_session: bool,
persisted_status: Status,
live_handle_status: Option<Status>,
) -> bool {
if has_session_folder {
return false;
}
if matches!(persisted_status, Status::Done | Status::Canceled) {
return false;
}
if is_draft_session && persisted_status == Status::Draft {
return false;
}
!matches!(
live_handle_status,
Some(Status::Merging | Status::Done | Status::Canceled)
)
}
fn merge_loaded_session_status(status_from_db: Status, status_from_handle: Status) -> Status {
if matches!(status_from_db, Status::Done | Status::Canceled) {
return status_from_db;
}
status_from_handle
}
fn parse_review_request(row: &SessionListRow) -> Option<ReviewRequest> {
let review_request_row = row.review_request.as_ref()?;
let forge_kind = parse_optional_enum(Some(review_request_row.forge_kind.as_str())).ok()?;
let state = parse_optional_enum(Some(review_request_row.state.as_str())).ok()?;
Some(ReviewRequest {
last_refreshed_at: review_request_row.last_refreshed_at,
summary: ReviewRequestSummary {
display_id: review_request_row.display_id.clone(),
forge_kind,
source_branch: review_request_row.source_branch.clone(),
state,
status_summary: review_request_row.status_summary.clone(),
target_branch: review_request_row.target_branch.clone(),
title: review_request_row.title.clone(),
web_url: review_request_row.web_url.clone(),
},
})
}
fn parse_optional_enum<T>(value: Option<&str>) -> Result<T, ()>
where
T: std::str::FromStr,
{
value.ok_or(())?.parse().map_err(|_| ())
}
fn parse_questions_json(raw_json: &str) -> Option<Vec<QuestionItem>> {
if raw_json.is_empty() {
return None;
}
if let Ok(items) = serde_json::from_str::<Vec<QuestionItem>>(raw_json) {
return Some(items);
}
serde_json::from_str::<Vec<String>>(raw_json)
.ok()
.map(|texts| {
texts
.into_iter()
.map(|text| QuestionItem {
options: Vec::new(),
text,
})
.collect()
})
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::*;
use crate::domain::session::{ForgeKind, ReviewRequestState, ReviewRequestSummary};
use crate::infra::db::SessionReviewRequestRow;
use crate::infra::fs;
fn create_folder_lookup_mock(existing_folders: Vec<PathBuf>) -> fs::MockFsClient {
let mut mock_fs_client = fs::MockFsClient::new();
mock_fs_client
.expect_is_dir()
.times(0..)
.returning(move |path| existing_folders.contains(&path));
mock_fs_client.expect_read_file().times(0..).returning(|_| {
Box::pin(async {
Err(fs::FsError::Io(std::io::Error::from(
std::io::ErrorKind::NotFound,
)))
})
});
mock_fs_client
}
#[tokio::test]
async fn test_load_sessions_preserves_live_handle_output_and_status() {
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/test", None)
.await
.expect("failed to upsert project");
let session_id = "test-session";
db.sessions()
.insert_session(
session_id,
"gemini-3-flash-preview",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
db.sessions()
.append_session_output(session_id, "DB Output")
.await
.expect("failed to append persisted output");
let base_path = Path::new("/virtual/session-base");
let session_dir = session_folder(base_path, session_id);
let mock_fs_client = create_folder_lookup_mock(vec![session_dir]);
let mut handles: HashMap<SessionId, SessionHandles> = HashMap::new();
let live_output = "Live Output".to_string();
let live_status = Status::Review;
handles.insert(
session_id.to_string().into(),
SessionHandles::new(live_output.clone(), live_status),
);
let (sessions, _, _) = SessionManager::load_sessions_with_fs_client(
base_path,
&db,
project_id,
Path::new("/tmp/test"),
&mut handles,
&mock_fs_client,
None,
)
.await;
let session = sessions
.iter()
.find(|session| session.id == session_id)
.expect("missing reloaded session");
assert_eq!(session.output, live_output);
assert_eq!(session.status, live_status);
let handle = handles
.get(session_id)
.expect("missing existing runtime handle");
let handle_output = handle
.output
.lock()
.expect("failed to lock handle output")
.clone();
let handle_status = *handle.status.lock().expect("failed to lock handle status");
assert_eq!(handle_output, live_output);
assert_eq!(handle_status, live_status);
}
#[tokio::test]
async fn test_load_sessions_reports_worktree_availability() {
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/test", None)
.await
.expect("failed to upsert project");
let session_with_worktree_id = "worktree-available";
let session_without_worktree_id = "draft-missing";
db.sessions()
.insert_session(
session_with_worktree_id,
"gemini-3-flash-preview",
"main",
"Draft",
project_id,
)
.await
.expect("failed to insert session with worktree");
db.sessions()
.insert_draft_session(
session_without_worktree_id,
"gemini-3-flash-preview",
"main",
"Draft",
project_id,
)
.await
.expect("failed to insert draft session");
let base_path = Path::new("/virtual/session-base");
let mock_fs_client =
create_folder_lookup_mock(vec![session_folder(base_path, session_with_worktree_id)]);
let mut handles: HashMap<SessionId, SessionHandles> = HashMap::new();
let (_, _, session_worktree_availability) = SessionManager::load_sessions_with_fs_client(
base_path,
&db,
project_id,
Path::new("/tmp/test"),
&mut handles,
&mock_fs_client,
None,
)
.await;
assert_eq!(
session_worktree_availability.get(session_with_worktree_id),
Some(&true)
);
assert_eq!(
session_worktree_availability.get(session_without_worktree_id),
Some(&false)
);
}
#[tokio::test]
async fn test_load_sessions_reads_persisted_summary_for_active_session() {
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/test", None)
.await
.expect("failed to upsert project");
let session_id = "test-session";
db.sessions()
.insert_session(
session_id,
"gemini-3-flash-preview",
"main",
"Review",
project_id,
)
.await
.expect("failed to insert session");
db.sessions()
.update_session_prompt(session_id, "persisted prompt")
.await
.expect("failed to update session prompt");
db.sessions()
.update_session_questions(
session_id,
r#"[{"text":"persisted question?","options":["Yes"]}]"#,
)
.await
.expect("failed to update session questions");
db.sessions()
.update_session_summary(session_id, "persisted summary")
.await
.expect("failed to update session summary");
db.sessions()
.append_session_output(session_id, "persisted output")
.await
.expect("failed to append session output");
let base_path = Path::new("/virtual/session-base");
let session_dir = session_folder(base_path, session_id);
let mock_fs_client = create_folder_lookup_mock(vec![session_dir]);
let mut handles: HashMap<SessionId, SessionHandles> = HashMap::new();
handles.insert(
session_id.to_string().into(),
SessionHandles::new("Live Output".to_string(), Status::Review),
);
let (sessions, _, _) = SessionManager::load_sessions_with_fs_client(
base_path,
&db,
project_id,
Path::new("/tmp/test"),
&mut handles,
&mock_fs_client,
Some(session_id),
)
.await;
let session = sessions
.iter()
.find(|session| session.id == session_id)
.expect("missing reloaded session");
assert_eq!(session.output, "Live Output");
assert_eq!(session.prompt, "persisted prompt");
assert_eq!(
session.questions,
vec![QuestionItem {
options: vec!["Yes".to_string()],
text: "persisted question?".to_string(),
}]
);
assert_eq!(session.summary.as_deref(), Some("persisted summary"));
}
#[tokio::test]
async fn test_load_sessions_defers_persisted_detail_for_inactive_session() {
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/test", None)
.await
.expect("failed to upsert project");
let session_id = "inactive-session";
db.sessions()
.insert_session(
session_id,
"gemini-3-flash-preview",
"main",
"Review",
project_id,
)
.await
.expect("failed to insert session");
db.sessions()
.update_session_prompt(session_id, "large prompt")
.await
.expect("failed to update prompt");
db.sessions()
.update_session_questions(session_id, r#"["Need detail?"]"#)
.await
.expect("failed to update questions");
db.sessions()
.update_session_summary(session_id, "large summary")
.await
.expect("failed to update summary");
db.sessions()
.append_session_output(session_id, "large output")
.await
.expect("failed to append output");
let base_path = Path::new("/virtual/session-base");
let session_dir = session_folder(base_path, session_id);
let mock_fs_client = create_folder_lookup_mock(vec![session_dir]);
let mut handles: HashMap<SessionId, SessionHandles> = HashMap::new();
let (sessions, _, _) = SessionManager::load_sessions_with_fs_client(
base_path,
&db,
project_id,
Path::new("/tmp/test"),
&mut handles,
&mock_fs_client,
None,
)
.await;
let session = sessions
.iter()
.find(|session| session.id == session_id)
.expect("missing reloaded session");
assert!(session.output.is_empty());
assert!(session.prompt.is_empty());
assert!(session.questions.is_empty());
assert!(session.summary.is_none());
let handle = handles.get(session_id).expect("missing runtime handle");
let handle_output = handle.output.lock().expect("failed to lock output");
assert!(handle_output.is_empty());
}
#[tokio::test]
async fn test_load_sessions_hydrates_empty_handle_for_active_session() {
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/test", None)
.await
.expect("failed to upsert project");
let session_id = "active-session";
db.sessions()
.insert_session(
session_id,
"gemini-3-flash-preview",
"main",
"Review",
project_id,
)
.await
.expect("failed to insert session");
db.sessions()
.append_session_output(session_id, "persisted output")
.await
.expect("failed to append output");
let base_path = Path::new("/virtual/session-base");
let session_dir = session_folder(base_path, session_id);
let mock_fs_client = create_folder_lookup_mock(vec![session_dir]);
let mut handles: HashMap<SessionId, SessionHandles> = HashMap::new();
handles.insert(
session_id.to_string().into(),
SessionHandles::new(String::new(), Status::Review),
);
let (sessions, _, _) = SessionManager::load_sessions_with_fs_client(
base_path,
&db,
project_id,
Path::new("/tmp/test"),
&mut handles,
&mock_fs_client,
Some(session_id),
)
.await;
let session = sessions
.iter()
.find(|session| session.id == session_id)
.expect("missing reloaded session");
assert_eq!(session.output, "persisted output");
let handle = handles.get(session_id).expect("missing runtime handle");
let handle_output = handle.output.lock().expect("failed to lock output");
assert_eq!(handle_output.as_str(), "persisted output");
}
#[tokio::test]
async fn test_load_sessions_terminal_db_status_overrides_handle_status() {
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/test", None)
.await
.expect("failed to upsert project");
let session_id = "test-session";
db.sessions()
.insert_session(
session_id,
"gemini-3-flash-preview",
"main",
"Done",
project_id,
)
.await
.expect("failed to insert session");
let base_path = Path::new("/virtual/session-base");
let session_dir = session_folder(base_path, session_id);
let mock_fs_client = create_folder_lookup_mock(vec![session_dir]);
let mut handles: HashMap<SessionId, SessionHandles> = HashMap::new();
handles.insert(
session_id.to_string().into(),
SessionHandles::new("output".to_string(), Status::Review),
);
let (sessions, _, _) = SessionManager::load_sessions_with_fs_client(
base_path,
&db,
project_id,
Path::new("/tmp/test"),
&mut handles,
&mock_fs_client,
None,
)
.await;
let session = sessions
.iter()
.find(|session| session.id == session_id)
.expect("missing reloaded session");
assert_eq!(session.status, Status::Done);
let handle = handles
.get(session_id)
.expect("missing existing runtime handle");
let handle_status = *handle.status.lock().expect("failed to lock handle status");
assert_eq!(handle_status, Status::Done);
}
#[tokio::test]
async fn test_load_sessions_maps_review_request_metadata() {
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/test", None)
.await
.expect("failed to upsert project");
let review_request = ReviewRequest {
last_refreshed_at: 999,
summary: ReviewRequestSummary {
display_id: "#17".to_string(),
forge_kind: ForgeKind::GitHub,
source_branch: "feature/forge".to_string(),
state: ReviewRequestState::Closed,
status_summary: Some("closed by maintainer".to_string()),
target_branch: "main".to_string(),
title: "Add forge review support".to_string(),
web_url: "https://github.com/team/project/pull/17".to_string(),
},
};
let session_id = "test-session";
db.sessions()
.insert_session(
session_id,
"gemini-3-flash-preview",
"main",
"Done",
project_id,
)
.await
.expect("failed to insert session");
db.reviews()
.update_session_review_request(session_id, Some(review_request.clone()))
.await
.expect("failed to persist review request metadata");
let base_path = Path::new("/virtual/session-base");
let mock_fs_client = create_folder_lookup_mock(Vec::new());
let mut handles: HashMap<SessionId, SessionHandles> = HashMap::new();
let (sessions, _, _) = SessionManager::load_sessions_with_fs_client(
base_path,
&db,
project_id,
Path::new("/tmp/test"),
&mut handles,
&mock_fs_client,
None,
)
.await;
let session = sessions
.iter()
.find(|session| session.id == session_id)
.expect("missing reloaded session");
assert_eq!(session.review_request, Some(review_request));
}
#[test]
fn merge_loaded_session_status_prefers_terminal_status_from_db() {
let status_from_db = Status::Done;
let status_from_handle = Status::Draft;
let merged_status = merge_loaded_session_status(status_from_db, status_from_handle);
assert_eq!(merged_status, Status::Done);
}
#[test]
fn merge_loaded_session_status_prefers_handle_for_non_terminal_db_status() {
let status_from_db = Status::Review;
let status_from_handle = Status::InProgress;
let merged_status = merge_loaded_session_status(status_from_db, status_from_handle);
assert_eq!(merged_status, Status::InProgress);
}
#[test]
fn should_skip_missing_folder_session_keeps_live_merging_session() {
let has_session_folder = false;
let persisted_status = Status::Merging;
let live_handle_status = Some(Status::Merging);
let should_skip = should_skip_missing_folder_session(
has_session_folder,
false,
persisted_status,
live_handle_status,
);
assert!(!should_skip);
}
#[test]
fn should_skip_missing_folder_session_skips_orphaned_active_session() {
let has_session_folder = false;
let persisted_status = Status::Review;
let live_handle_status = None;
let should_skip = should_skip_missing_folder_session(
has_session_folder,
false,
persisted_status,
live_handle_status,
);
assert!(should_skip);
}
#[test]
fn should_skip_missing_folder_session_keeps_new_draft_session() {
let has_session_folder = false;
let persisted_status = Status::Draft;
let live_handle_status = None;
let should_skip = should_skip_missing_folder_session(
has_session_folder,
true,
persisted_status,
live_handle_status,
);
assert!(!should_skip);
}
#[test]
fn parse_review_request_returns_none_for_invalid_row() {
let row = SessionListRow {
added_lines: 0,
base_branch: "main".to_string(),
created_at: 0,
deleted_lines: 0,
id: "session-a".to_string(),
in_progress_started_at: None,
in_progress_total_seconds: 0,
input_tokens: 0,
is_draft: false,
model: "gpt-5.4".to_string(),
output_tokens: 0,
project_id: Some(1),
reasoning_level_override: None,
published_upstream_ref: None,
review_request: Some(SessionReviewRequestRow {
display_id: "#42".to_string(),
forge_kind: "UnknownForge".to_string(),
last_refreshed_at: 0,
source_branch: "feature/forge".to_string(),
state: "Open".to_string(),
status_summary: None,
target_branch: "main".to_string(),
title: "Add forge review support".to_string(),
web_url: "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
}),
size: "XS".to_string(),
status: "Review".to_string(),
title: None,
updated_at: 0,
};
let review_request = parse_review_request(&row);
assert_eq!(review_request, None);
}
#[test]
fn test_parse_questions_json_new_format() {
let json = r#"[{"text":"Pick one?","options":["A","B"]}]"#;
let result = parse_questions_json(json);
let items = result.expect("expected Some");
assert_eq!(items.len(), 1);
assert_eq!(items[0].text, "Pick one?");
assert_eq!(items[0].options, vec!["A", "B"]);
}
#[test]
fn test_parse_questions_json_legacy_format() {
let json = r#"["Need target?","Need tests?"]"#;
let result = parse_questions_json(json);
let items = result.expect("expected Some");
assert_eq!(items.len(), 2);
assert_eq!(items[0].text, "Need target?");
assert!(items[0].options.is_empty());
assert_eq!(items[1].text, "Need tests?");
assert!(items[1].options.is_empty());
}
#[test]
fn test_parse_questions_json_empty_string_returns_none() {
let result = parse_questions_json("");
assert!(result.is_none());
}
#[test]
fn test_parse_questions_json_invalid_json_returns_none() {
let result = parse_questions_json("{not valid json");
assert!(result.is_none());
}
}