use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use ag_git::GitClient;
use super::{draft, session_folder};
use crate::app::{SessionManager, orchestration};
use crate::domain::agent::{
AgentModel, AgentSelection, ReasoningLevel, SpeedMode, parse_persisted_session_agent_model,
};
use crate::domain::question::QuestionItem;
use crate::domain::session::{
DailyActivity, QueuedMessage, ReviewRequest, ReviewRequestSummary, Session, SessionDiffState,
SessionDiffStats, SessionFollowUpTask, SessionHandles, SessionId, SessionRole, SessionSize,
SessionStats, Status, activity_day_key_with_offset,
};
use crate::domain::session_message::{SessionMessage, SessionMessageKind, SessionTranscript};
use crate::domain::transient_message::TransientMessageStore;
use crate::infra::clock::Clock;
use crate::infra::db::{
AppRepositories, DbError, SessionDetailRow, SessionListRow, SessionMessageRow,
};
use crate::infra::fs::FsClient;
pub(crate) struct SessionLoadInput<'a> {
pub(crate) active_project_id: i64,
pub(crate) active_session_id: Option<&'a str>,
pub(crate) base: &'a Path,
pub(crate) clock: &'a dyn Clock,
pub(crate) db: &'a AppRepositories,
pub(crate) fs_client: &'a dyn FsClient,
pub(crate) working_dir: &'a Path,
}
struct LoadSessionContext<'a> {
active_session_id: Option<&'a str>,
base: &'a Path,
db: &'a AppRepositories,
fs_client: &'a dyn FsClient,
handles: &'a mut HashMap<SessionId, SessionHandles>,
orchestration_metadata: &'a HashMap<String, orchestration::OrchestrationSessionMetadata>,
project_name: &'a str,
session_worktree_availability: &'a mut HashMap<SessionId, bool>,
sessions: &'a mut Vec<Session>,
}
struct LoadedSessionInput {
controller_session_id: Option<SessionId>,
draft_attachments: Vec<crate::domain::turn_prompt::TurnPromptAttachment>,
follow_up_tasks: Vec<SessionFollowUpTask>,
folder: std::path::PathBuf,
parent_session_id: Option<SessionId>,
orchestration_progress: Option<String>,
project_name: String,
reasoning_level_override: Option<ReasoningLevel>,
review_request: Option<ReviewRequest>,
role: SessionRole,
row: SessionListRow,
session_agent: AgentSelection,
session_id: SessionId,
session_prompt: String,
session_queued_messages: Vec<QueuedMessage>,
session_questions: Vec<QuestionItem>,
session_summary: Option<String>,
session_status: Status,
session_transcript: Option<SessionTranscript>,
size: SessionSize,
speed_mode: SpeedMode,
}
pub(crate) async fn migrate_active_sessions_off_retired_models(db: &AppRepositories) {
let Ok(rows) = db.sessions().load_active_session_agent_models().await else {
return;
};
for row in rows {
let session_status = row.status.parse::<Status>().unwrap_or(Status::Done);
migrate_session_off_retired_model(db, &row.id, &row.agent, &row.model, session_status)
.await;
}
}
pub(crate) async fn migrate_session_off_retired_model(
db: &AppRepositories,
session_id: &str,
persisted_agent: &str,
persisted_model: &str,
session_status: Status,
) -> AgentSelection {
let session_agent = parse_persisted_session_agent_model(Some(persisted_agent), persisted_model);
if matches!(
session_status,
Status::Merged | Status::Done | Status::Canceled
) || AgentModel::retired_replacement(persisted_model).is_none()
{
return session_agent;
}
let session_agent_kind = session_agent.kind().to_string();
db.sessions()
.update_active_session_agent_model(
session_id,
&session_agent_kind,
session_agent.model().as_str(),
)
.await
.ok();
session_agent
}
impl SessionManager {
pub(crate) async fn load_sessions_with_fs_client(
input: SessionLoadInput<'_>,
handles: &mut HashMap<SessionId, SessionHandles>,
) -> (Vec<Session>, Vec<DailyActivity>, HashMap<SessionId, bool>) {
Self::try_load_sessions_with_fs_client(input, handles)
.await
.unwrap_or_default()
}
pub(crate) async fn try_load_sessions_with_fs_client(
input: SessionLoadInput<'_>,
handles: &mut HashMap<SessionId, SessionHandles>,
) -> Result<(Vec<Session>, Vec<DailyActivity>, HashMap<SessionId, bool>), DbError> {
let SessionLoadInput {
active_project_id,
active_session_id,
base,
clock,
db,
fs_client,
working_dir,
} = input;
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?;
let activity_timestamps = db
.activity()
.load_session_activity_timestamps()
.await
.unwrap_or_default();
let stats_activity = Self::daily_activity_from_timestamps(activity_timestamps, clock);
let orchestration_metadata =
orchestration::session_metadata_for_project(db, active_project_id).await;
let mut sessions: Vec<Session> = Vec::new();
let mut session_worktree_availability = HashMap::new();
let mut load_context = LoadSessionContext {
base,
db,
project_name: &project_name,
handles,
fs_client,
active_session_id,
orchestration_metadata: &orchestration_metadata,
sessions: &mut sessions,
session_worktree_availability: &mut session_worktree_availability,
};
for row in db_rows {
Self::push_loaded_session_row(&mut load_context, row).await;
}
Ok((sessions, stats_activity, session_worktree_availability))
}
fn daily_activity_from_timestamps(
timestamps: Vec<i64>,
clock: &dyn Clock,
) -> Vec<DailyActivity> {
let mut activity_by_day = BTreeMap::<i64, u32>::new();
for timestamp_seconds in timestamps {
let utc_offset_seconds = clock.local_utc_offset_seconds(timestamp_seconds);
let day_key = activity_day_key_with_offset(timestamp_seconds, utc_offset_seconds);
let session_count = activity_by_day.entry(day_key).or_default();
*session_count = session_count.saturating_add(1);
}
activity_by_day
.into_iter()
.map(|(day_key, session_count)| DailyActivity {
day_key,
session_count,
})
.collect()
}
async fn push_loaded_session_row(
load_context: &mut LoadSessionContext<'_>,
row: SessionListRow,
) {
let LoadSessionContext {
base,
db,
project_name,
handles,
orchestration_metadata,
fs_client,
active_session_id,
sessions,
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_detail, loaded_transcript) =
load_active_session_detail(db, *active_session_id, &row.id).await;
let (session_status, session_transcript) =
if let Some(existing_handle) = handles.get(&session_id) {
status_and_transcript_from_existing_handle(
existing_handle,
persisted_status,
loaded_transcript.as_ref(),
)
} else {
let transcript = insert_loaded_session_handle(
handles,
session_id.clone(),
persisted_status,
loaded_transcript,
);
(persisted_status, transcript)
};
let session_agent =
migrate_session_off_retired_model(db, &row.id, &row.agent, &row.model, session_status)
.await;
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 speed_mode = row.speed_mode.parse::<SpeedMode>().unwrap_or_default();
let session_queued_messages = handles
.get(&session_id)
.map(SessionHandles::queued_message_snapshot)
.unwrap_or_default();
let (role, orchestration_metadata) =
Self::loaded_orchestration_metadata(&row, orchestration_metadata);
sessions.push(Self::build_loaded_session(LoadedSessionInput {
controller_session_id: orchestration_metadata.controller_session_id,
draft_attachments,
follow_up_tasks: Vec::new(),
folder,
parent_session_id: row.parent_session_id.clone().map(SessionId::from),
orchestration_progress: orchestration_metadata.progress,
project_name: (*project_name).to_string(),
reasoning_level_override,
review_request,
role,
row,
session_agent,
session_id,
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,
session_transcript,
size: persisted_size,
speed_mode,
}));
}
fn loaded_orchestration_metadata(
row: &SessionListRow,
metadata: &HashMap<String, orchestration::OrchestrationSessionMetadata>,
) -> (SessionRole, orchestration::OrchestrationSessionMetadata) {
let role = row
.role
.as_deref()
.and_then(|value| value.parse::<SessionRole>().ok())
.unwrap_or_default();
let metadata = metadata.get(&row.id).cloned().unwrap_or_default();
(role, metadata)
}
pub(crate) async fn session_diff_stats_for_folder(
fs_client: &dyn FsClient,
git_client: &dyn GitClient,
folder: &Path,
base_branch: &str,
) -> SessionDiffStats {
if !fs_client.is_dir(folder.to_path_buf()) {
return SessionDiffStats::Unknown;
}
let folder = folder.to_path_buf();
let base_branch = base_branch.to_string();
let Ok(diff) = git_client.diff(folder, base_branch).await else {
return SessionDiffStats::Unknown;
};
SessionDiffStats::from_diff(&diff)
}
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;
};
let Ok(transcript) = load_session_transcript(db, session_id).await else {
return;
};
self.apply_session_detail(session_id, detail, transcript);
}
fn build_loaded_session(input: LoadedSessionInput) -> Session {
let mut session = Session {
agent: input.session_agent,
base_branch: input.row.base_branch,
created_at: input.row.created_at,
controller_session_id: input.controller_session_id,
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,
orchestration_progress: input.orchestration_progress,
parent_session_id: input.parent_session_id,
personality_id: input.row.personality_id,
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,
questions: input.session_questions,
review_request: input.review_request,
role: input.role,
size: input.size,
speed_mode: input.speed_mode,
stats: SessionStats {
added_lines: input.row.added_lines.cast_unsigned(),
deleted_lines: input.row.deleted_lines.cast_unsigned(),
diff_state: match input.row.has_diff {
Some(true) => SessionDiffState::Present,
Some(false) => SessionDiffState::Empty,
None => SessionDiffState::Unknown,
},
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,
transcript: input.session_transcript,
updated_at: input.row.updated_at,
transient_messages: TransientMessageStore::default(),
};
session.hydrate_summary_transient();
session
}
fn apply_session_detail(
&mut self,
session_id: &str,
detail: SessionDetailRow,
transcript: SessionTranscript,
) {
let session_transcript = self
.state
.handle(session_id)
.and_then(|handles| sync_handle_transcript_with_loaded(handles, Some(&transcript)))
.or_else(|| Some(transcript).filter(|transcript| !transcript.is_empty()));
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.transcript = session_transcript;
session.hydrate_summary_transient();
}
}
async fn load_active_session_detail(
db: &AppRepositories,
active_session_id: Option<&str>,
row_id: &str,
) -> (Option<SessionDetailRow>, Option<SessionTranscript>) {
if active_session_id.is_none_or(|active_id| active_id != row_id) {
return (None, None);
}
let Some(detail) = db
.sessions()
.load_session_detail(row_id)
.await
.ok()
.flatten()
else {
return (None, None);
};
let transcript = load_session_transcript(db, row_id).await.ok();
(Some(detail), transcript)
}
fn status_and_transcript_from_existing_handle(
existing_handle: &SessionHandles,
persisted_status: Status,
loaded_transcript: Option<&SessionTranscript>,
) -> (Status, Option<SessionTranscript>) {
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;
}
let transcript_from_handle =
sync_handle_transcript_with_loaded(existing_handle, loaded_transcript);
(merged_status, transcript_from_handle)
}
fn insert_loaded_session_handle(
handles: &mut HashMap<SessionId, SessionHandles>,
session_id: SessionId,
persisted_status: Status,
loaded_transcript: Option<SessionTranscript>,
) -> Option<SessionTranscript> {
let session_transcript = loaded_transcript.filter(|transcript| !transcript.is_empty());
let session_handle = if let Some(transcript) = session_transcript.clone() {
SessionHandles::new_with_transcript(persisted_status, transcript)
} else {
SessionHandles::new_unloaded(persisted_status)
};
handles.insert(session_id, session_handle);
session_transcript
}
async fn load_session_transcript(
db: &AppRepositories,
session_id: &str,
) -> Result<SessionTranscript, DbError> {
let messages = db.sessions().load_session_messages(session_id).await?;
Ok(SessionTranscript::new(session_messages_from_rows(messages)))
}
fn sync_handle_transcript_with_loaded(
handles: &SessionHandles,
loaded_transcript: Option<&SessionTranscript>,
) -> Option<SessionTranscript> {
handles.transcript_snapshot_with_loaded(loaded_transcript)
}
fn session_messages_from_rows(rows: Vec<SessionMessageRow>) -> Vec<SessionMessage> {
rows.into_iter()
.filter_map(|row| {
row.kind
.parse::<SessionMessageKind>()
.ok()
.map(|kind| SessionMessage::new(row.position, kind, row.content))
})
.collect()
}
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::Merged | Status::Done | Status::Canceled
) {
return false;
}
if is_draft_session && persisted_status == Status::Draft {
return false;
}
!matches!(
live_handle_status,
Some(Status::Merging | Status::Merged | Status::Done | Status::Canceled)
)
}
fn merge_loaded_session_status(status_from_db: Status, status_from_handle: Status) -> Status {
if matches!(
status_from_db,
Status::Merged | 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 std::time::{Instant, SystemTime};
use ag_git::{GitError, MockGitClient};
use super::*;
use crate::domain::session::{ForgeKind, ReviewRequestState, ReviewRequestSummary};
use crate::infra::clock::RealClock;
use crate::infra::db::SessionReviewRequestRow;
use crate::infra::fs;
struct ActivityOffsetClock;
impl Clock for ActivityOffsetClock {
fn local_utc_offset_seconds(&self, timestamp_seconds: i64) -> i64 {
if timestamp_seconds < 86_400 {
3_600
} else {
-3_600
}
}
fn now_instant(&self) -> Instant {
Instant::now()
}
fn now_system_time(&self) -> SystemTime {
SystemTime::UNIX_EPOCH
}
}
fn session_replay_text(session: &Session) -> String {
session
.transcript
.as_ref()
.and_then(SessionTranscript::replay_text)
.unwrap_or_default()
}
fn assistant_transcript(content: impl AsRef<str>) -> SessionTranscript {
SessionTranscript::new(vec![SessionMessage::conversation(
0,
SessionMessageKind::AssistantAnswer,
content.as_ref(),
)])
}
fn assistant_replay_text(content: impl AsRef<str>) -> String {
assistant_transcript(content)
.replay_text()
.expect("assistant transcript should have replay text")
}
#[test]
fn daily_activity_uses_clock_offset_for_each_timestamp() {
let timestamps = vec![86_399, 86_400, 86_399];
let clock = ActivityOffsetClock;
let activity = SessionManager::daily_activity_from_timestamps(timestamps, &clock);
let monotonic_time = clock.now_instant();
let system_time = clock.now_system_time();
assert!(monotonic_time <= Instant::now());
assert_eq!(system_time, SystemTime::UNIX_EPOCH);
assert_eq!(
activity,
vec![
DailyActivity {
day_key: 0,
session_count: 1,
},
DailyActivity {
day_key: 1,
session_count: 2,
},
]
);
}
#[tokio::test]
async fn session_diff_stats_preserve_binary_presence_and_git_errors() {
let folder = PathBuf::from("/tmp/session");
let existing_folder_client = create_folder_lookup_mock(vec![folder.clone()]);
let missing_folder_client = create_folder_lookup_mock(Vec::new());
let mut binary_diff_client = MockGitClient::new();
binary_diff_client.expect_diff().times(1).returning(|_, _| {
Box::pin(async {
Ok("diff --git a/image.png b/image.png\nBinary files differ\n".to_string())
})
});
let mut failing_diff_client = MockGitClient::new();
failing_diff_client
.expect_diff()
.times(1)
.returning(|_, _| {
Box::pin(async { Err(GitError::OutputParse("diff failed".to_string())) })
});
let binary_stats = SessionManager::session_diff_stats_for_folder(
&existing_folder_client,
&binary_diff_client,
&folder,
"main",
)
.await;
let error_stats = SessionManager::session_diff_stats_for_folder(
&existing_folder_client,
&failing_diff_client,
&folder,
"main",
)
.await;
let missing_folder_stats = SessionManager::session_diff_stats_for_folder(
&missing_folder_client,
&MockGitClient::new(),
&folder,
"main",
)
.await;
assert_eq!(
binary_stats,
SessionDiffStats::Known {
added_lines: 0,
deleted_lines: 0,
has_diff: true,
session_size: SessionSize::Xs,
}
);
assert_eq!(error_stats, SessionDiffStats::Unknown);
assert_eq!(missing_folder_stats, SessionDiffStats::Unknown);
}
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.expect("db should open");
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.7-flash",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
db.sessions()
.append_session_message(session_id, SessionMessageKind::AssistantAnswer, "DB Output")
.await
.expect("failed to append persisted message");
db.sessions()
.mark_session_diff_unknown(session_id)
.await
.expect("failed to mark session diff unknown");
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_with_transcript(live_status, assistant_transcript(&live_output)),
);
let (sessions, _, _) = SessionManager::load_sessions_with_fs_client(
SessionLoadInput {
active_project_id: project_id,
active_session_id: None,
base: base_path,
clock: &RealClock,
db: &db,
fs_client: &mock_fs_client,
working_dir: Path::new("/tmp/test"),
},
&mut handles,
)
.await;
let session = sessions
.iter()
.find(|session| session.id == session_id)
.expect("missing reloaded session");
assert_eq!(
session_replay_text(session),
assistant_replay_text(&live_output)
);
assert_eq!(session.status, live_status);
assert_eq!(session.stats.diff_state, SessionDiffState::Unknown);
let handle = handles
.get(session_id)
.expect("missing existing runtime handle");
let handle_output = handle
.transcript
.lock()
.expect("failed to lock handle transcript")
.replay_text()
.unwrap_or_default();
let handle_status = *handle.status.lock().expect("failed to lock handle status");
assert_eq!(handle_output, assistant_replay_text(&live_output));
assert_eq!(handle_status, live_status);
}
#[tokio::test]
async fn test_load_sessions_reports_worktree_availability() {
let db = AppRepositories::in_memory().await.expect("db should open");
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.7-flash",
"main",
"Draft",
project_id,
)
.await
.expect("failed to insert session with worktree");
db.sessions()
.insert_draft_session(
session_without_worktree_id,
"gemini-3.7-flash",
"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(
SessionLoadInput {
active_project_id: project_id,
active_session_id: None,
base: base_path,
clock: &RealClock,
db: &db,
fs_client: &mock_fs_client,
working_dir: Path::new("/tmp/test"),
},
&mut handles,
)
.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.expect("db should open");
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.7-flash", "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_message(
session_id,
SessionMessageKind::AssistantAnswer,
"persisted output",
)
.await
.expect("failed to append session message");
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_with_transcript(
Status::Review,
assistant_transcript("Live Output"),
),
);
let (sessions, _, _) = SessionManager::load_sessions_with_fs_client(
SessionLoadInput {
active_project_id: project_id,
active_session_id: Some(session_id),
base: base_path,
clock: &RealClock,
db: &db,
fs_client: &mock_fs_client,
working_dir: Path::new("/tmp/test"),
},
&mut handles,
)
.await;
let session = sessions
.iter()
.find(|session| session.id == session_id)
.expect("missing reloaded session");
assert_eq!(
session_replay_text(session),
assistant_replay_text("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.expect("db should open");
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.7-flash", "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_message(
session_id,
SessionMessageKind::AssistantAnswer,
"large output",
)
.await
.expect("failed to append message");
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(
SessionLoadInput {
active_project_id: project_id,
active_session_id: None,
base: base_path,
clock: &RealClock,
db: &db,
fs_client: &mock_fs_client,
working_dir: Path::new("/tmp/test"),
},
&mut handles,
)
.await;
let session = sessions
.iter()
.find(|session| session.id == session_id)
.expect("missing reloaded session");
assert_eq!(session_replay_text(session), "");
assert_eq!(session.prompt, "");
assert_eq!(session.questions, [] as [ag_protocol::QuestionItem; 0]);
assert!(session.summary.is_none());
let handle = handles.get(session_id).expect("missing runtime handle");
let handle_output = handle
.transcript
.lock()
.expect("failed to lock transcript")
.replay_text();
assert_eq!(handle_output, None);
}
#[tokio::test]
async fn test_load_sessions_hydrates_empty_handle_for_active_session() {
let db = AppRepositories::in_memory().await.expect("db should open");
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.7-flash", "main", "Review", project_id)
.await
.expect("failed to insert session");
db.sessions()
.append_session_message(
session_id,
SessionMessageKind::AssistantAnswer,
"persisted output",
)
.await
.expect("failed to append message");
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_unloaded(Status::Review),
);
let (sessions, _, _) = SessionManager::load_sessions_with_fs_client(
SessionLoadInput {
active_project_id: project_id,
active_session_id: Some(session_id),
base: base_path,
clock: &RealClock,
db: &db,
fs_client: &mock_fs_client,
working_dir: Path::new("/tmp/test"),
},
&mut handles,
)
.await;
let session = sessions
.iter()
.find(|session| session.id == session_id)
.expect("missing reloaded session");
assert_eq!(
session_replay_text(session),
assistant_replay_text("persisted output")
);
let handle = handles.get(session_id).expect("missing runtime handle");
let handle_output = handle
.transcript
.lock()
.expect("failed to lock transcript")
.replay_text()
.unwrap_or_default();
assert_eq!(handle_output, assistant_replay_text("persisted output"));
}
#[tokio::test]
async fn test_load_session_transcript_returns_query_errors() {
let (db, pool) = AppRepositories::in_memory_with_pool()
.await
.expect("db should open");
sqlx::query!("DROP TABLE session_message")
.execute(&pool)
.await
.expect("failed to drop session_message table");
let error = load_session_transcript(&db, "missing-session")
.await
.expect_err("transcript load should fail");
assert!(matches!(error, DbError::Query(_)));
}
#[tokio::test]
async fn test_load_sessions_terminal_db_status_overrides_handle_status() {
let db = AppRepositories::in_memory().await.expect("db should open");
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.7-flash", "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_with_transcript(Status::Review, assistant_transcript("output")),
);
let (sessions, _, _) = SessionManager::load_sessions_with_fs_client(
SessionLoadInput {
active_project_id: project_id,
active_session_id: None,
base: base_path,
clock: &RealClock,
db: &db,
fs_client: &mock_fs_client,
working_dir: Path::new("/tmp/test"),
},
&mut handles,
)
.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_switches_active_session_off_retired_model() {
let db = AppRepositories::in_memory().await.expect("db should open");
let project_id = db
.projects()
.upsert_project("/tmp/test", None)
.await
.expect("failed to upsert project");
let session_id = "retired-active-session";
db.sessions()
.insert_session(session_id, "gemini-3.1-pro", "main", "Review", project_id)
.await
.expect("failed to insert session");
let base_path = Path::new("/virtual/session-base");
let mock_fs_client = create_folder_lookup_mock(vec![session_folder(base_path, session_id)]);
let mut handles: HashMap<SessionId, SessionHandles> = HashMap::new();
let (sessions, _, _) = SessionManager::load_sessions_with_fs_client(
SessionLoadInput {
active_project_id: project_id,
active_session_id: None,
base: base_path,
clock: &RealClock,
db: &db,
fs_client: &mock_fs_client,
working_dir: Path::new("/tmp/test"),
},
&mut handles,
)
.await;
let session = sessions
.iter()
.find(|session| session.id == session_id)
.expect("missing reloaded session");
assert_eq!(session.agent.model(), AgentModel::Gemini31Pro);
let row = db
.sessions()
.load_session(session_id)
.await
.expect("failed to load session row")
.expect("missing session row");
assert_eq!(row.model, "gemini-3.1-pro-preview");
assert_eq!(row.agent, "antigravity");
}
#[tokio::test]
async fn test_migrate_session_preserves_updated_at() {
let (db, pool) = AppRepositories::in_memory_with_pool()
.await
.expect("db should open");
let project_id = db
.projects()
.upsert_project("/tmp/test", None)
.await
.expect("failed to upsert project");
let session_id = "retired-timestamp-session";
db.sessions()
.insert_session(session_id, "claude-opus-4-6", "main", "Review", project_id)
.await
.expect("failed to insert session");
sqlx::query(
r"
UPDATE session
SET updated_at = ?
WHERE id = ?
",
)
.bind(123_i64)
.bind(session_id)
.execute(&pool)
.await
.expect("failed to set historical timestamp");
migrate_session_off_retired_model(
&db,
session_id,
"claude",
"claude-opus-4-6",
Status::Review,
)
.await;
let row = db
.sessions()
.load_session(session_id)
.await
.expect("failed to load migrated session")
.expect("missing migrated session");
assert_eq!(row.agent, "claude");
assert_eq!(row.model, "claude-opus-5");
assert_eq!(row.updated_at, 123);
}
#[tokio::test]
async fn test_migrate_active_sessions_off_retired_models_covers_inactive_projects() {
let db = AppRepositories::in_memory().await.expect("db should open");
let active_project_id = db
.projects()
.upsert_project("/tmp/active", None)
.await
.expect("failed to upsert active project");
let inactive_project_id = db
.projects()
.upsert_project("/tmp/inactive", None)
.await
.expect("failed to upsert inactive project");
db.sessions()
.insert_session(
"active-project-session",
"claude-opus-4-6",
"main",
"Review",
active_project_id,
)
.await
.expect("failed to insert active-project session");
db.sessions()
.insert_session(
"inactive-project-session",
"gemini-3.5-flash",
"main",
"Review",
inactive_project_id,
)
.await
.expect("failed to insert inactive-project session");
db.sessions()
.insert_session(
"inactive-project-finished",
"gemini-3.5-flash",
"main",
"Done",
inactive_project_id,
)
.await
.expect("failed to insert finished inactive-project session");
migrate_active_sessions_off_retired_models(&db).await;
let active_project_row = db
.sessions()
.load_session("active-project-session")
.await
.expect("failed to load active-project session")
.expect("missing active-project session");
let inactive_project_row = db
.sessions()
.load_session("inactive-project-session")
.await
.expect("failed to load inactive-project session")
.expect("missing inactive-project session");
let finished_row = db
.sessions()
.load_session("inactive-project-finished")
.await
.expect("failed to load finished inactive-project session")
.expect("missing finished inactive-project session");
assert_eq!(active_project_row.model, "claude-opus-5");
assert_eq!(active_project_row.agent, "claude");
assert_eq!(inactive_project_row.model, "gemini-3.5-flash-lite");
assert_eq!(inactive_project_row.agent, "antigravity");
assert_eq!(finished_row.model, "gemini-3.5-flash");
}
#[tokio::test]
async fn test_migrate_session_preserves_retired_model_after_terminal_transition() {
let db = AppRepositories::in_memory().await.expect("db should open");
let project_id = db
.projects()
.upsert_project("/tmp/test", None)
.await
.expect("failed to upsert project");
let race_cases = [
("race-merged", "Merged"),
("race-done", "Done"),
("race-canceled", "Canceled"),
];
for (session_id, _) in race_cases {
db.sessions()
.insert_session(session_id, "claude-opus-4-6", "main", "Review", project_id)
.await
.expect("failed to insert active session");
}
let stale_rows = db
.sessions()
.load_active_session_agent_models()
.await
.expect("failed to load active sessions");
for (session_id, terminal_status) in race_cases {
db.sessions()
.update_session_status_with_timing_at(session_id, terminal_status, 1)
.await
.expect("failed to persist terminal transition");
}
for row in stale_rows {
let stale_status = row
.status
.parse::<Status>()
.expect("active status should parse");
migrate_session_off_retired_model(&db, &row.id, &row.agent, &row.model, stale_status)
.await;
}
for (session_id, terminal_status) in race_cases {
let row = db
.sessions()
.load_session(session_id)
.await
.expect("failed to load transitioned session")
.expect("missing transitioned session");
assert_eq!(row.status, terminal_status);
assert_eq!(row.agent, "claude");
assert_eq!(row.model, "claude-opus-4-6");
}
}
#[tokio::test]
async fn test_migrate_active_sessions_off_retired_models_ignores_query_failures() {
let (db, pool) = AppRepositories::in_memory_with_pool()
.await
.expect("db should open");
sqlx::query("DROP TABLE session")
.execute(&pool)
.await
.expect("session table should be dropped");
migrate_active_sessions_off_retired_models(&db).await;
assert!(
db.sessions()
.load_active_session_agent_models()
.await
.is_err()
);
}
#[tokio::test]
async fn test_load_sessions_keeps_retired_model_in_db_for_finished_session() {
let db = AppRepositories::in_memory().await.expect("db should open");
let project_id = db
.projects()
.upsert_project("/tmp/test", None)
.await
.expect("failed to upsert project");
let session_id = "retired-finished-session";
db.sessions()
.insert_session(session_id, "claude-opus-4-6", "main", "Done", project_id)
.await
.expect("failed to insert session");
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(
SessionLoadInput {
active_project_id: project_id,
active_session_id: None,
base: base_path,
clock: &RealClock,
db: &db,
fs_client: &mock_fs_client,
working_dir: Path::new("/tmp/test"),
},
&mut handles,
)
.await;
let session = sessions
.iter()
.find(|session| session.id == session_id)
.expect("missing reloaded session");
assert_eq!(session.agent.model(), AgentModel::ClaudeOpus5);
let row = db
.sessions()
.load_session(session_id)
.await
.expect("failed to load session row")
.expect("missing session row");
assert_eq!(row.model, "claude-opus-4-6");
}
#[tokio::test]
async fn test_load_sessions_maps_review_request_metadata() {
let db = AppRepositories::in_memory().await.expect("db should open");
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.7-flash", "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(
SessionLoadInput {
active_project_id: project_id,
active_session_id: None,
base: base_path,
clock: &RealClock,
db: &db,
fs_client: &mock_fs_client,
working_dir: Path::new("/tmp/test"),
},
&mut handles,
)
.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_read_only_and_terminal_status_from_db() {
let status_from_handle = Status::Draft;
let merged_status = merge_loaded_session_status(Status::Merged, status_from_handle);
let done_status = merge_loaded_session_status(Status::Done, status_from_handle);
assert_eq!(merged_status, Status::Merged);
assert_eq!(done_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 sync_handle_transcript_with_loaded_keeps_existing_live_transcript() {
let live_transcript = SessionTranscript::new(vec![
SessionMessage::conversation(0, SessionMessageKind::UserPrompt, "prompt"),
SessionMessage::conversation(1, SessionMessageKind::AssistantAnswer, "answer"),
]);
let handles = SessionHandles::new_with_transcript(Status::Review, live_transcript.clone());
let loaded_transcript = assistant_transcript("loaded answer");
let transcript = sync_handle_transcript_with_loaded(&handles, Some(&loaded_transcript));
assert_eq!(transcript, Some(live_transcript.clone()));
assert_eq!(
handles.transcript.lock().ok().as_deref(),
Some(&live_transcript)
);
}
#[test]
fn sync_handle_transcript_with_loaded_merges_partial_unloaded_transcript() {
let handles = SessionHandles::new_unloaded(Status::Review);
handles
.transcript
.lock()
.expect("transcript lock should not be poisoned")
.clone_from(&SessionTranscript::new(vec![SessionMessage::new(
2,
SessionMessageKind::WorkflowNotice,
"\n[Sync] Successfully synced onto main\n",
)]));
let loaded_transcript = SessionTranscript::new(vec![
SessionMessage::conversation(0, SessionMessageKind::UserPrompt, "original prompt"),
SessionMessage::conversation(1, SessionMessageKind::AssistantAnswer, "original answer"),
]);
let expected_transcript = SessionTranscript::new(vec![
SessionMessage::conversation(0, SessionMessageKind::UserPrompt, "original prompt"),
SessionMessage::conversation(1, SessionMessageKind::AssistantAnswer, "original answer"),
SessionMessage::new(
2,
SessionMessageKind::WorkflowNotice,
"\n[Sync] Successfully synced onto main\n",
),
]);
let transcript = sync_handle_transcript_with_loaded(&handles, Some(&loaded_transcript));
assert_eq!(transcript, Some(expected_transcript.clone()));
assert_eq!(
handles.transcript.lock().ok().as_deref(),
Some(&expected_transcript)
);
}
#[test]
fn sync_handle_transcript_with_loaded_merges_matching_and_conflicting_messages() {
let handles = SessionHandles::new_unloaded(Status::Review);
let persisted_notice = SessionMessage::new(
2,
SessionMessageKind::WorkflowNotice,
"\n[Sync] Successfully synced onto main\n",
);
handles
.transcript
.lock()
.expect("transcript lock should not be poisoned")
.clone_from(&SessionTranscript::new(vec![
SessionMessage::new(
0,
SessionMessageKind::WorkflowNotice,
"\n[Sync Error] persistence failed\n",
),
persisted_notice.clone(),
]));
let loaded_transcript = SessionTranscript::new(vec![
SessionMessage::conversation(0, SessionMessageKind::UserPrompt, "original prompt"),
SessionMessage::conversation(1, SessionMessageKind::AssistantAnswer, "original answer"),
persisted_notice.clone(),
]);
let transcript = sync_handle_transcript_with_loaded(&handles, Some(&loaded_transcript))
.expect("merged transcript should be available");
assert_eq!(
transcript.messages(),
&[
SessionMessage::conversation(0, SessionMessageKind::UserPrompt, "original prompt"),
SessionMessage::conversation(
1,
SessionMessageKind::AssistantAnswer,
"original answer"
),
persisted_notice,
SessionMessage::new(
3,
SessionMessageKind::WorkflowNotice,
"\n[Sync Error] persistence failed\n"
),
]
);
}
#[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_keeps_merged_session() {
let persisted_merged_should_skip =
should_skip_missing_folder_session(false, false, Status::Merged, Some(Status::Review));
let live_merged_should_skip =
should_skip_missing_folder_session(false, false, Status::Review, Some(Status::Merged));
assert!(!persisted_merged_should_skip);
assert!(!live_merged_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,
agent: "codex".to_string(),
base_branch: "main".to_string(),
created_at: 0,
deleted_lines: 0,
has_diff: Some(false),
id: "session-a".to_string(),
in_progress_started_at: None,
in_progress_total_seconds: 0,
input_tokens: 0,
is_draft: false,
model: "gpt-5.6-sol".to_string(),
output_tokens: 0,
parent_session_id: None,
personality_id: None,
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(),
}),
role: None,
size: "XS".to_string(),
speed_mode: "normal".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_eq!(items[0].options, [] as [std::string::String; 0]);
assert_eq!(items[1].text, "Need tests?");
assert_eq!(items[1].options, [] as [std::string::String; 0]);
}
#[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());
}
}