use std::collections::{HashMap, HashSet};
use std::env;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[cfg(test)]
use ag_forge as forge;
use ag_forge::{RealReviewRequestClient, ReviewRequestClient};
#[cfg(test)]
use app::branch_publish::detected_forge_kind_from_git_push_error;
#[cfg(test)]
use app::branch_publish::{BranchPublishTaskFailure, branch_push_failure, push_session_branch};
use app::branch_publish::{BranchPublishTaskSession, run_branch_publish_action};
use app::merge_queue::{MergeQueue, MergeQueueProgress};
use app::project::ProjectManager;
use app::review::{
ReviewCacheEntry, mark_session_agent_review, review_view_state,
start_review_assist as spawn_review_assist,
};
use app::service::AppServices;
use app::session::SessionManager;
use app::setting::SettingsManager;
use app::tab::TabManager;
use app::task;
use session::SessionTaskService;
#[cfg(test)]
use session::{SyncMainOutcome, SyncSessionStartError, TurnAppliedState};
use tokio::sync::mpsc;
use super::events::AppEvent;
#[cfg(test)]
use super::events::{AppEventBatch, ReviewRequestStatusUpdate};
use super::roadmap::ActiveProjectRoadmap;
#[cfg(test)]
use super::roadmap::TASKS_ROADMAP_PATH;
use crate::app;
use crate::app::{AppError, session};
use crate::domain::agent::{AgentKind, AgentModel, ReasoningLevel};
use crate::domain::input::InputState;
use crate::domain::permission::PermissionMode;
use crate::domain::session::{FollowUpTaskAction, PublishBranchAction, Session, SessionId, Status};
use crate::infra::channel::TurnPrompt;
#[cfg(test)]
use crate::infra::db;
use crate::infra::fs::{FsClient, RealFsClient};
use crate::infra::git::{GitClient, RealGitClient};
use crate::infra::project_discovery::{ProjectDiscoveryClient, RealProjectDiscoveryClient};
use crate::infra::tmux::{RealTmuxClient, TmuxClient};
use crate::infra::{agent, app_server};
use crate::runtime::mode::{at_mention, question};
use crate::ui::markdown;
use crate::ui::state::app_mode::{AppMode, ConfirmationViewMode, QuestionFocus};
pub const AGENTTY_WT_DIR: &str = "wt";
pub fn agentty_home() -> PathBuf {
let agentty_root = env::var_os("AGENTTY_ROOT").map(PathBuf::from);
let home_dir = dirs::home_dir();
resolve_agentty_home(agentty_root, home_dir)
}
fn resolve_agentty_home(agentty_root: Option<PathBuf>, home_dir: Option<PathBuf>) -> PathBuf {
agentty_root
.filter(|path| !path.as_os_str().is_empty())
.or_else(|| home_dir.map(|path| path.join(".agentty")))
.unwrap_or_else(|| PathBuf::from(".agentty"))
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum UpdateStatus {
InProgress { version: String },
Complete { version: String },
Failed { version: String },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct SyncPopupContext {
pub(super) default_branch: String,
pub(super) project_name: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct TerminalContinuationDraft {
base_branch: String,
project_id: i64,
prompt_seed: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct SyncReviewRequestTaskResult {
pub(crate) outcome: session::SyncReviewRequestOutcome,
pub(crate) summary: Option<crate::domain::session::ReviewRequestSummary>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct SessionStatsUsage {
pub input_tokens: u64,
pub model: String,
pub output_tokens: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct SessionStatsSnapshot {
pub session_duration_seconds: Option<i64>,
pub usage_rows_result: Result<Vec<SessionStatsUsage>, String>,
}
#[cfg_attr(test, mockall::automock)]
pub(crate) trait SyncMainRunner: Send + Sync {
fn start_sync_main(
&self,
app_event_tx: mpsc::UnboundedSender<AppEvent>,
default_branch: Option<String>,
git_client: Arc<dyn GitClient>,
session_model: AgentModel,
working_dir: PathBuf,
);
}
pub(crate) struct TokioSyncMainRunner;
impl SyncMainRunner for TokioSyncMainRunner {
fn start_sync_main(
&self,
app_event_tx: mpsc::UnboundedSender<AppEvent>,
default_branch: Option<String>,
git_client: Arc<dyn GitClient>,
session_model: AgentModel,
working_dir: PathBuf,
) {
tokio::spawn(async move {
let result = SessionManager::sync_main_for_project(
default_branch,
working_dir,
git_client,
session_model,
)
.await;
let _ = app_event_tx.send(AppEvent::SyncMainCompleted { result });
});
}
}
pub(crate) struct AppClients {
pub(super) agent_availability_probe: Arc<dyn agent::AgentAvailabilityProbe>,
pub(super) app_server_client_override: Option<Arc<dyn app_server::AppServerClient>>,
pub(super) fs_client: Arc<dyn FsClient>,
pub(super) git_client: Arc<dyn GitClient>,
pub(super) project_discovery_client: Arc<dyn ProjectDiscoveryClient>,
pub(super) review_request_client: Arc<dyn ReviewRequestClient>,
pub(super) sync_main_runner: Arc<dyn SyncMainRunner>,
pub(super) tmux_client: Arc<dyn TmuxClient>,
}
impl AppClients {
pub(crate) fn new() -> Self {
Self {
agent_availability_probe: Arc::new(agent::RealAgentAvailabilityProbe),
app_server_client_override: None,
fs_client: Arc::new(RealFsClient),
git_client: Arc::new(RealGitClient),
project_discovery_client: Arc::new(RealProjectDiscoveryClient),
review_request_client: Arc::new(RealReviewRequestClient::default()),
sync_main_runner: Arc::new(TokioSyncMainRunner),
tmux_client: Arc::new(RealTmuxClient),
}
}
#[cfg(test)]
#[must_use]
pub(crate) fn with_agent_availability_probe(
mut self,
agent_availability_probe: Arc<dyn agent::AgentAvailabilityProbe>,
) -> Self {
self.agent_availability_probe = agent_availability_probe;
self
}
#[cfg(test)]
#[must_use]
pub(crate) fn with_app_server_client_override(
mut self,
app_server_client_override: Arc<dyn app_server::AppServerClient>,
) -> Self {
self.app_server_client_override = Some(app_server_client_override);
self
}
#[cfg(test)]
#[must_use]
pub(crate) fn with_tmux_client(mut self, tmux_client: Arc<dyn TmuxClient>) -> Self {
self.tmux_client = tmux_client;
self
}
#[cfg(test)]
#[must_use]
pub(crate) fn with_fs_client(mut self, fs_client: Arc<dyn FsClient>) -> Self {
self.fs_client = fs_client;
self
}
}
pub struct App {
pub mode: AppMode,
pub(crate) needs_redraw: bool,
pub settings: SettingsManager,
pub tabs: TabManager,
pub(crate) review_cache: HashMap<SessionId, ReviewCacheEntry>,
pub(crate) projects: ProjectManager,
pub(crate) services: AppServices,
pub(crate) sessions: SessionManager,
pub(crate) sync_main_runner: Arc<dyn SyncMainRunner>,
pub(super) active_project_has_tasks_tab: bool,
pub(super) active_project_roadmap: Option<ActiveProjectRoadmap>,
pub(super) task_roadmap_scroll_offset: u16,
pub(super) event_rx: mpsc::UnboundedReceiver<AppEvent>,
pub(super) latest_available_version: Option<String>,
pub(super) merge_queue: MergeQueue,
pub(super) session_progress_messages: HashMap<SessionId, String>,
pub(super) tmux_client: Arc<dyn TmuxClient>,
pub(super) markdown_render_cache: markdown::MarkdownRenderCache,
pub(super) session_output_layout_cache:
crate::ui::component::session_output::SessionOutputLayoutCache,
pub(super) last_seen_session_update_versions: HashMap<SessionId, u64>,
pub(super) update_status: Option<UpdateStatus>,
}
impl App {
pub(crate) fn mark_dirty(&mut self) {
self.needs_redraw = true;
}
pub(crate) fn needs_redraw(&self) -> bool {
self.needs_redraw
}
pub(crate) fn clear_redraw(&mut self) {
self.needs_redraw = false;
}
pub fn next_tab(&mut self) {
self.tabs.next(self.active_project_has_tasks_tab());
}
pub fn previous_tab(&mut self) {
self.tabs.previous(self.active_project_has_tasks_tab());
}
pub fn next(&mut self) {
self.sessions.next();
}
pub fn previous(&mut self) {
self.sessions.previous();
}
pub fn next_project(&mut self) {
self.projects.next_project();
}
pub fn previous_project(&mut self) {
self.projects.previous_project();
}
pub async fn switch_selected_project(&mut self) -> Result<(), AppError> {
let selected_project_id = self
.projects
.selected_project_id()
.ok_or_else(|| AppError::Workflow("No project selected".to_string()))?;
self.switch_project(selected_project_id).await
}
pub async fn switch_project(&mut self, project_id: i64) -> Result<(), AppError> {
let project = self
.services
.db()
.get_project(project_id)
.await?
.map(Self::project_from_row)
.ok_or_else(|| {
AppError::Workflow(format!("Project with id `{project_id}` was not found"))
})?;
let git_branch = self
.services
.git_client()
.detect_git_info(project.path.clone())
.await;
let git_upstream_ref = Self::load_git_upstream_ref(
self.services.git_client().as_ref(),
project.path.as_path(),
git_branch.as_deref(),
)
.await;
let _ = self
.services
.db()
.upsert_project(&project.path.to_string_lossy(), git_branch.as_deref())
.await;
let _ = self.services.db().set_active_project_id(project.id).await;
let _ = self
.services
.db()
.touch_project_last_opened(project.id)
.await;
self.projects.replace_git_status_cancel();
self.projects.update_active_project_context(
project.id,
project.display_label(),
git_branch,
git_upstream_ref,
project.path,
);
self.refresh_active_project_roadmap().await;
self.tabs.normalize(self.active_project_has_tasks_tab());
self.settings = SettingsManager::new(&self.services, project.id).await;
let default_session_model = SessionManager::load_default_session_model(
&self.services,
Some(project.id),
AgentKind::Gemini.default_model(),
)
.await;
self.sessions
.set_default_session_model(default_session_model);
self.reload_projects().await;
self.refresh_sessions_now().await;
Ok(())
}
pub async fn create_session(&mut self) -> Result<String, AppError> {
let session_id = self
.sessions
.create_session(&self.projects, &self.services)
.await?;
self.finish_session_creation(&session_id).await;
Ok(session_id)
}
pub async fn create_draft_session(&mut self) -> Result<String, AppError> {
let base_branch = self
.projects
.git_branch()
.ok_or_else(|| {
AppError::Workflow("Git branch is required to create a session".to_string())
})?
.to_string();
self.create_finalized_draft_session_for_project(
self.projects.active_project_id(),
&base_branch,
)
.await
}
pub async fn continue_terminal_session(
&mut self,
source_session_id: &str,
) -> Result<String, AppError> {
let continuation_draft = self
.terminal_session_continuation_draft(source_session_id)
.await?;
let session_id = self
.create_finalized_draft_session_for_project(
continuation_draft.project_id,
&continuation_draft.base_branch,
)
.await?;
self.stage_draft_message(&session_id, continuation_draft.prompt_seed)
.await?;
self.mode = AppMode::Prompt {
at_mention_state: None,
attachment_state: crate::ui::state::prompt::PromptAttachmentState::default(),
history_state: crate::ui::state::prompt::PromptHistoryState::new(Vec::new()),
review_status_message: None,
review_text: None,
slash_state: self.prompt_slash_state(),
session_id: SessionId::from(session_id.as_str()),
input: InputState::default(),
scroll_offset: None,
};
Ok(session_id)
}
async fn create_finalized_draft_session_for_project(
&mut self,
project_id: i64,
base_branch: &str,
) -> Result<String, AppError> {
let session_id = self
.sessions
.create_draft_session_for_project(&self.services, project_id, base_branch)
.await?;
self.finish_session_creation(&session_id).await;
Ok(session_id)
}
async fn finish_session_creation(&mut self, session_id: &str) {
self.process_pending_app_events().await;
self.reload_projects().await;
let index = self
.sessions
.sessions
.iter()
.position(|session| session.id == session_id)
.unwrap_or(0);
self.sessions.table_state.select(Some(index));
}
async fn terminal_session_continuation_draft(
&self,
source_session_id: &str,
) -> Result<TerminalContinuationDraft, AppError> {
let source_session = self
.sessions
.sessions
.iter()
.find(|session| session.id == source_session_id)
.ok_or_else(|| AppError::Workflow("Session not found".to_string()))?;
if !source_session.allows_terminal_continuation() {
return Err(AppError::Workflow(
"Only `Done` or `Canceled` sessions can be continued".to_string(),
));
}
let project_id = self
.services
.db()
.load_session_project_id(source_session_id)
.await?
.ok_or_else(|| {
AppError::Workflow(
"Source session has no project association. Restart Agentty from this project \
to backfill legacy sessions, then continue the session again."
.to_string(),
)
})?;
let prompt_seed = if let Some(merged_commit_hash) = self
.services
.db()
.load_session_merged_commit_hash(source_session_id)
.await?
{
Self::merged_commit_continuation_prompt(source_session, &merged_commit_hash)
} else {
source_session.continuation_prompt_seed().ok_or_else(|| {
AppError::Workflow(
"Terminal continuation requires a merged commit hash or persisted context"
.to_string(),
)
})?
};
Ok(TerminalContinuationDraft {
base_branch: source_session.base_branch.clone(),
project_id,
prompt_seed,
})
}
fn merged_commit_continuation_prompt(
_source_session: &Session,
merged_commit_hash: &str,
) -> String {
format!(
"Summarize changes from {merged_commit_hash} to use it as an initial context for this \
session"
)
}
pub async fn start_session(
&mut self,
session_id: &str,
prompt: impl Into<TurnPrompt>,
) -> Result<(), AppError> {
self.review_cache.remove(session_id);
Ok(self
.sessions
.start_session(&self.services, session_id, prompt)
.await?)
}
pub async fn stage_draft_message(
&mut self,
session_id: &str,
prompt: impl Into<TurnPrompt>,
) -> Result<(), AppError> {
Ok(self
.sessions
.stage_draft_message(&self.services, session_id, prompt)
.await?)
}
pub async fn start_staged_session(&mut self, session_id: &str) -> Result<(), AppError> {
Ok(self
.sessions
.start_staged_session(&self.services, session_id)
.await?)
}
pub async fn reply(&mut self, session_id: &str, prompt: impl Into<TurnPrompt>) {
self.review_cache.remove(session_id);
self.sessions
.reply(&self.services, session_id, prompt)
.await;
}
pub fn enqueue_message(
&mut self,
session_id: &str,
prompt: impl Into<TurnPrompt>,
) -> Result<(), crate::app::session::SessionError> {
self.sessions
.enqueue_message(&self.services, session_id, prompt)
}
pub(crate) fn review_view_state(&self, session_id: &str) -> (Option<String>, Option<String>) {
review_view_state(
&self.review_cache,
session_id,
self.settings.default_review_model,
)
}
pub async fn set_session_model(
&mut self,
session_id: &str,
session_model: AgentModel,
) -> Result<(), AppError> {
self.sessions
.set_session_model(&self.services, session_id, session_model)
.await?;
self.process_pending_app_events().await;
Ok(())
}
pub async fn set_session_reasoning_level(
&mut self,
session_id: &str,
reasoning_level_override: Option<ReasoningLevel>,
) -> Result<(), AppError> {
self.sessions
.set_session_reasoning_level(&self.services, session_id, reasoning_level_override)
.await?;
self.process_pending_app_events().await;
Ok(())
}
pub fn selected_session(&self) -> Option<&Session> {
self.sessions.selected_session()
}
pub fn session_at(&self, session_index: usize) -> Option<&Session> {
self.sessions.session_at(session_index)
}
pub fn session_id_for_index(&self, session_index: usize) -> Option<SessionId> {
self.sessions.session_id_for_index(session_index)
}
pub fn session_index_for_id(&self, session_id: &str) -> Option<usize> {
self.sessions.session_index_for_id(session_id)
}
pub fn session_progress_message(&self, session_id: &str) -> Option<&str> {
self.session_progress_messages
.get(session_id)
.map(std::string::String::as_str)
}
pub fn session_update_version(&self, session_id: &str) -> u64 {
self.last_seen_session_update_versions
.get(session_id)
.copied()
.unwrap_or_default()
}
pub(crate) fn session_output_layout_cache(
&self,
) -> &crate::ui::component::session_output::SessionOutputLayoutCache {
&self.session_output_layout_cache
}
pub(crate) fn markdown_render_cache(&self) -> &crate::ui::markdown::MarkdownRenderCache {
&self.markdown_render_cache
}
pub(crate) fn selected_follow_up_task_action(
&self,
session_id: &str,
) -> Option<FollowUpTaskAction> {
self.sessions.selected_follow_up_task_action(session_id)
}
pub(crate) fn has_multiple_follow_up_tasks(&self, session_id: &str) -> bool {
self.sessions.has_multiple_follow_up_tasks(session_id)
}
pub(crate) fn select_next_follow_up_task(&mut self, session_id: &str) {
self.sessions.select_next_follow_up_task(session_id);
}
pub(crate) fn select_previous_follow_up_task(&mut self, session_id: &str) {
self.sessions.select_previous_follow_up_task(session_id);
}
pub(crate) async fn launch_or_open_selected_follow_up_task(
&mut self,
session_id: &str,
) -> Result<(), AppError> {
let Some((position, task_text, launched_session_id)) =
self.selected_follow_up_task_snapshot(session_id)
else {
return Ok(());
};
if let Some(launched_session_id) = launched_session_id {
if self.open_session_if_present(&launched_session_id) {
return Ok(());
}
self.set_follow_up_task_launched_session_id(session_id, position, None)
.await?;
}
let sibling_session_id = self.create_session().await?;
self.start_session(&sibling_session_id, TurnPrompt::from_text(task_text))
.await?;
self.set_follow_up_task_launched_session_id(
session_id,
position,
Some(sibling_session_id.clone().into()),
)
.await?;
self.open_session(&sibling_session_id);
Ok(())
}
pub async fn delete_selected_session(&mut self) {
let session_id = self.selected_session().map(|session| session.id.clone());
self.sessions
.delete_selected_session(&self.projects, &self.services)
.await;
if let Some(session_id) = session_id {
at_mention::clear_pending_load(&session_id);
self.review_cache.remove(&session_id);
}
self.process_pending_app_events().await;
self.reload_projects().await;
}
pub async fn delete_selected_session_deferred_cleanup(&mut self) {
let session_id = self.selected_session().map(|session| session.id.clone());
self.sessions
.delete_selected_session_deferred_cleanup(&self.projects, &self.services)
.await;
if let Some(session_id) = session_id {
at_mention::clear_pending_load(&session_id);
self.review_cache.remove(&session_id);
}
self.process_pending_app_events().await;
self.reload_projects().await;
}
pub async fn cancel_session(&self, session_id: &str) -> Result<(), AppError> {
Ok(self
.sessions
.cancel_session(&self.services, session_id)
.await?)
}
pub async fn open_session_worktree_in_tmux(&self) {
let selected_open_command = self.configured_open_commands().into_iter().next();
self.open_session_worktree_in_tmux_with_command(selected_open_command.as_deref())
.await;
}
pub(crate) async fn open_session_worktree_in_tmux_with_command(
&self,
open_command: Option<&str>,
) {
let Some(session) = self.selected_session() else {
return;
};
if !self.services.fs_client().is_dir(session.folder.clone()) {
return;
}
let Some(window_id) = self
.tmux_client
.open_window_for_folder(session.folder.clone())
.await
else {
return;
};
let Some(open_command) = open_command
.map(str::trim)
.filter(|command| !command.is_empty())
else {
return;
};
self.tmux_client
.run_command_in_window(window_id, open_command.to_string())
.await;
}
pub(crate) fn start_publish_branch_action(
&mut self,
restore_view: ConfirmationViewMode,
session_id: &str,
publish_branch_action: PublishBranchAction,
remote_branch_name: Option<String>,
) {
let Some(branch_publish_session) = self.branch_publish_task_session(session_id) else {
self.mode = Self::view_info_popup_mode(
"Branch push failed".to_string(),
"Session is no longer available.".to_string(),
false,
String::new(),
restore_view,
);
return;
};
let loading_title = Self::branch_publish_loading_title(publish_branch_action);
let loading_message = Self::branch_publish_loading_message(
publish_branch_action,
remote_branch_name.as_deref(),
);
let loading_label = Self::branch_publish_loading_label(publish_branch_action);
let clock = self.services.clock();
let db = self.services.db().clone();
let event_sender = self.services.event_sender();
let git_client = self.services.git_client();
let review_request_client = self.services.review_request_client();
let background_restore_view = restore_view.clone();
let event_session_id = branch_publish_session.id.clone();
self.mode = Self::view_info_popup_mode(
loading_title,
loading_message,
true,
loading_label,
restore_view,
);
tokio::spawn(async move {
let result = run_branch_publish_action(
publish_branch_action,
branch_publish_session,
db,
clock,
git_client,
review_request_client,
remote_branch_name,
)
.await;
let _ = event_sender.send(AppEvent::BranchPublishActionCompleted {
restore_view: background_restore_view,
result: Box::new(result),
session_id: event_session_id,
});
});
}
#[must_use]
pub(crate) fn configured_open_commands(&self) -> Vec<String> {
self.settings.open_commands()
}
pub(crate) async fn append_output_for_session(&self, session_id: &str, output: &str) {
self.sessions
.append_output_for_session(&self.services, session_id, output)
.await;
}
pub(crate) async fn cleanup_prompt_attachment_files(&self, prompt: &TurnPrompt) {
self.sessions
.cleanup_prompt_attachment_files(&self.services, prompt)
.await;
}
pub(crate) async fn stats_for_session(&self, session_id: &str) -> SessionStatsSnapshot {
let session_duration_seconds =
match self.services.db().load_session_timestamps(session_id).await {
Ok(Some((created_at, updated_at))) => Some((updated_at - created_at).max(0)),
Ok(None) | Err(_) => None,
};
let usage_rows_result = self
.services
.db()
.load_session_usage(session_id)
.await
.map_err(|e| e.to_string())
.map(|usage_rows| {
usage_rows
.into_iter()
.map(|row| SessionStatsUsage {
input_tokens: row.input_tokens.unsigned_abs(),
model: row.model,
output_tokens: row.output_tokens.unsigned_abs(),
})
.collect()
});
SessionStatsSnapshot {
session_duration_seconds,
usage_rows_result,
}
}
pub async fn merge_session(&mut self, session_id: &str) -> Result<(), AppError> {
if self.merge_queue.is_queued_or_active(session_id) {
return Ok(());
}
self.validate_merge_request(session_id)?;
if self.merge_queue.has_active() {
self.mark_session_as_queued_for_merge(session_id).await?;
self.merge_queue.enqueue(SessionId::from(session_id));
return Ok(());
}
self.merge_queue.enqueue(SessionId::from(session_id));
self.start_next_merge_from_queue(true).await
}
pub async fn rebase_session(&self, session_id: &str) -> Result<(), AppError> {
Ok(self
.sessions
.rebase_session(&self.services, session_id)
.await?)
}
pub(crate) fn start_sync_main(&mut self) {
let sync_popup_context = self.sync_popup_context();
self.mode = AppMode::SyncBlockedPopup {
project_name: Some(sync_popup_context.project_name.clone()),
default_branch: Some(sync_popup_context.default_branch),
is_loading: true,
message: Self::sync_loading_message(),
title: "Sync in progress".to_string(),
};
let app_event_tx = self.services.event_sender();
let default_branch = self.projects.git_branch().map(str::to_string);
let working_dir = self.projects.working_dir().to_path_buf();
let git_client = self.services.git_client();
let _permission_mode = PermissionMode::default();
let session_model = self.sessions.default_session_model();
self.sync_main_runner.start_sync_main(
app_event_tx,
default_branch,
git_client,
session_model,
working_dir,
);
}
pub(crate) fn start_review_assist(
&mut self,
session_id: &str,
session_folder: &Path,
diff_hash: u64,
review_diff: &str,
session_summary: Option<&str>,
) {
mark_session_agent_review(&mut self.sessions, session_id);
spawn_review_assist(
self.services.event_sender(),
self.settings.default_review_model,
session_id,
session_folder,
diff_hash,
review_diff,
session_summary,
);
}
pub async fn refresh_sessions_if_needed(&mut self) -> bool {
self.sessions
.refresh_sessions_if_needed(&mut self.mode, &self.projects, &self.services)
.await
}
pub(crate) async fn refresh_sessions_now(&mut self) {
self.sessions
.refresh_sessions_now(&mut self.mode, &self.projects, &self.services)
.await;
self.restart_git_status_task();
}
pub(super) async fn reload_projects(&mut self) {
let project_items =
Self::load_project_items(self.services.db(), self.services.fs_client().as_ref()).await;
self.projects.replace_project_items(project_items);
}
pub(super) fn restart_git_status_task(&mut self) {
let cancel = self.projects.replace_git_status_cancel();
if !self.projects.has_git_branch() {
return;
}
task::TaskService::spawn_git_status_task(
self.projects.working_dir(),
self.projects.git_branch().unwrap_or_default().to_string(),
Self::session_git_status_targets(&self.sessions),
cancel,
self.services.event_sender(),
self.services.git_client(),
);
task::TaskService::spawn_review_request_status_task(
Self::review_request_sync_targets(&self.sessions),
self.projects.git_status_cancel(),
self.services.event_sender(),
self.services.git_client(),
self.services.review_request_client(),
self.services.review_comment_cache(),
);
}
pub(crate) fn session_git_status_targets(
sessions: &SessionManager,
) -> Vec<task::SessionGitStatusTarget> {
sessions
.state()
.sessions
.iter()
.filter(|session| !matches!(session.status, Status::Canceled | Status::Done))
.map(|session| task::SessionGitStatusTarget {
base_branch: session.base_branch.clone(),
branch_name: sessions
.session_branch_name(&session.id)
.map_or_else(|| session::session_branch(&session.id), str::to_string),
session_id: session.id.clone(),
})
.collect()
}
pub(crate) fn review_request_sync_targets(
sessions: &SessionManager,
) -> Vec<task::ReviewRequestSyncTarget> {
sessions
.state()
.sessions
.iter()
.filter(|session| session.can_sync_review_request())
.map(|session| task::ReviewRequestSyncTarget {
folder: session.folder.clone(),
linked_review_request: session.review_request.clone(),
published_upstream_ref: session.published_upstream_ref.clone(),
session_id: session.id.clone(),
})
.collect()
}
fn selected_follow_up_task_snapshot(
&self,
session_id: &str,
) -> Option<(usize, String, Option<SessionId>)> {
let position = self.sessions.selected_follow_up_task_position(session_id)?;
let session = self
.sessions
.sessions
.iter()
.find(|session| session.id == session_id)?;
let follow_up_task = session.follow_up_task(position)?;
Some((
follow_up_task.position,
follow_up_task.text.clone(),
follow_up_task.launched_session_id.clone(),
))
}
async fn set_follow_up_task_launched_session_id(
&mut self,
session_id: &str,
position: usize,
launched_session_id: Option<SessionId>,
) -> Result<(), AppError> {
self.services
.db()
.update_session_follow_up_task_launched_session_id(
session_id,
position,
launched_session_id.as_deref(),
)
.await?;
self.sessions.set_follow_up_task_launched_session_id(
session_id,
position,
launched_session_id,
);
Ok(())
}
fn open_session_if_present(&mut self, target_session_id: &str) -> bool {
let Some(session_index) = self.session_index_for_id(target_session_id) else {
return false;
};
self.open_session_by_index(target_session_id, session_index);
true
}
fn open_session(&mut self, target_session_id: &str) {
let Some(session_index) = self.session_index_for_id(target_session_id) else {
return;
};
self.open_session_by_index(target_session_id, session_index);
}
fn open_session_by_index(&mut self, target_session_id: &str, session_index: usize) {
self.sessions.table_state.select(Some(session_index));
let Some(session) = self.sessions.sessions.get(session_index) else {
return;
};
if session.status == Status::Question {
let questions = session.questions.clone();
let selected_option_index = question::default_option_index(&questions, 0);
let (review_status_message, review_text) = self.review_view_state(target_session_id);
self.mode = AppMode::Question {
at_mention_state: None,
session_id: SessionId::from(target_session_id),
questions,
review_status_message,
review_text,
responses: Vec::new(),
current_index: 0,
focus: QuestionFocus::Answer,
input: InputState::default(),
scroll_offset: None,
selected_option_index,
};
return;
}
let (review_status_message, review_text) = self.review_view_state(target_session_id);
self.mode = AppMode::View {
review_status_message,
review_text,
session_id: SessionId::from(target_session_id),
scroll_offset: None,
};
}
fn validate_merge_request(&self, session_id: &str) -> Result<(), AppError> {
let session = self.sessions.session_or_err(session_id)?;
if !(session.status.allows_review_actions() || session.status == Status::Queued) {
return Err(AppError::Workflow(
"Session must be in review or queued status".to_string(),
));
}
Ok(())
}
async fn mark_session_as_queued_for_merge(&self, session_id: &str) -> Result<(), AppError> {
let handles = self.sessions.session_handles_or_err(session_id)?;
let app_event_tx = self.services.event_sender();
let status_updated = SessionTaskService::update_status(
handles.status.as_ref(),
self.services.clock().as_ref(),
self.services.db(),
&app_event_tx,
&self.services.session_update_versions(),
session_id,
Status::Queued,
)
.await;
if !status_updated {
return Err(AppError::Workflow(
"Invalid status transition to Queued".to_string(),
));
}
Ok(())
}
async fn restore_queued_session_to_review(&self, session_id: &str) {
let session_status = self
.sessions
.session_or_err(session_id)
.map(|session| session.status);
if !matches!(session_status, Ok(Status::Queued)) {
return;
}
let Ok(handles) = self.sessions.session_handles_or_err(session_id) else {
return;
};
let app_event_tx = self.services.event_sender();
let _ = SessionTaskService::update_status(
handles.status.as_ref(),
self.services.clock().as_ref(),
self.services.db(),
&app_event_tx,
&self.services.session_update_versions(),
session_id,
Status::Review,
)
.await;
}
async fn start_next_merge_from_queue(&mut self, stop_on_failure: bool) -> Result<(), AppError> {
if self.merge_queue.has_active() {
return Ok(());
}
while let Some(next_session_id) = self.merge_queue.pop_next() {
match self
.sessions
.merge_session(&next_session_id, &self.projects, &self.services)
.await
{
Ok(()) => {
self.merge_queue.set_active(next_session_id);
return Ok(());
}
Err(error) => {
self.restore_queued_session_to_review(&next_session_id)
.await;
let merge_error = format!("\n[Merge Error] {error}\n");
self.append_output_for_session(&next_session_id, &merge_error)
.await;
if stop_on_failure {
return Err(error.into());
}
}
}
}
Ok(())
}
pub(super) async fn handle_merge_queue_progress(
&mut self,
session_ids: &HashSet<SessionId>,
previous_session_states: &HashMap<SessionId, Status>,
) {
let current_status = self
.merge_queue
.active_session_id()
.and_then(|active_session_id| {
self.sessions
.sessions
.iter()
.find(|session| session.id == active_session_id)
.map(|session| session.status)
});
let progress = self.merge_queue.progress_from_status_updates(
current_status,
session_ids,
previous_session_states,
);
if progress == MergeQueueProgress::StartNext {
let _ = self.start_next_merge_from_queue(false).await;
}
}
pub(super) fn retain_valid_session_progress_messages(&mut self) {
self.session_progress_messages.retain(|session_id, _| {
self.sessions
.sessions
.iter()
.find(|session| session.id == *session_id)
.is_some_and(|session| matches!(session.status, Status::InProgress))
});
}
fn branch_publish_task_session(&self, session_id: &str) -> Option<BranchPublishTaskSession> {
self.sessions
.sessions
.iter()
.find(|session| session.id == session_id)
.map(BranchPublishTaskSession::from_session)
}
pub(super) fn sync_popup_context(&self) -> SyncPopupContext {
let default_branch = self
.projects
.git_branch()
.map_or_else(|| "not detected".to_string(), str::to_string);
let project_name = self.projects.project_name().to_string();
SyncPopupContext {
default_branch,
project_name,
}
}
pub(super) fn sync_loading_message() -> String {
"Synchronizing with its upstream.".to_string()
}
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use mockall::predicate::eq;
use serde_json;
use tempfile::tempdir;
use super::*;
use crate::app::branch_publish::{BranchPublishActionUpdate, BranchPublishTaskSuccess};
use crate::app::review::ReviewUpdate;
use crate::app::session_state::SessionGitStatus;
use crate::app::{AppServiceDeps, diff_content_hash, review_loading_message};
use crate::domain::agent::AgentModel;
use crate::domain::session::{
ForgeKind, PublishedBranchSyncStatus, ReviewRequestState, ReviewRequestSummary,
SESSION_DATA_DIR, Session, SessionFollowUpTask, SessionHandles, SessionSize, SessionStats,
Status,
};
use crate::domain::setting::SettingName;
use crate::infra::agent::protocol::{AgentResponseSummary, QuestionItem};
use crate::infra::db::AppRepositories;
use crate::infra::file_index::FileEntry;
use crate::infra::project_discovery::{
HOME_PROJECT_SCAN_MAX_RESULTS, RealProjectDiscoveryClient,
};
use crate::infra::tmux::{MockTmuxClient, TmuxClient};
use crate::ui::state::app_mode::ConfirmationViewMode;
fn mock_app_server() -> Arc<dyn app_server::AppServerClient> {
Arc::new(app_server::MockAppServerClient::new())
}
fn test_app_clients_with_available_agent_kinds(
available_agent_kinds: Vec<AgentKind>,
) -> AppClients {
AppClients::new().with_agent_availability_probe(Arc::new(
agent::StaticAgentAvailabilityProbe {
available_agent_kinds,
},
))
}
fn test_app_clients() -> AppClients {
test_app_clients_with_available_agent_kinds(AgentKind::ALL.to_vec())
}
fn test_session(session_folder: PathBuf) -> Session {
Session {
base_branch: "main".to_string(),
created_at: 0,
draft_attachments: Vec::new(),
folder: session_folder,
follow_up_tasks: Vec::new(),
id: "session-1".into(),
in_progress_started_at: None,
in_progress_total_seconds: 0,
is_draft: false,
model: AgentModel::Gemini3FlashPreview,
output: String::new(),
project_name: "test-project".to_string(),
prompt: "test prompt".to_string(),
queued_messages: Vec::new(),
reasoning_level_override: None,
published_upstream_ref: None,
published_branch_sync_status: crate::domain::session::PublishedBranchSyncStatus::Idle,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
stats: SessionStats::default(),
status: Status::Review,
summary: None,
title: None,
updated_at: 0,
}
}
fn test_turn_applied_state(
questions: Vec<QuestionItem>,
follow_up_tasks: Vec<&str>,
summary: Option<AgentResponseSummary>,
token_usage_delta: SessionStats,
) -> TurnAppliedState {
TurnAppliedState {
follow_up_tasks: follow_up_tasks
.into_iter()
.enumerate()
.map(|(position, text)| SessionFollowUpTask {
id: i64::try_from(position).unwrap_or(i64::MAX),
launched_session_id: None,
position,
text: text.to_string(),
})
.collect(),
questions,
summary: summary.and_then(|summary| serde_json::to_string(&summary).ok()),
token_usage_delta,
}
}
fn test_confirmation_view_mode(session_id: &str) -> ConfirmationViewMode {
ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: session_id.into(),
}
}
fn test_pushed_branch_result(branch_name: &str) -> BranchPublishTaskSuccess {
BranchPublishTaskSuccess::Pushed {
branch_name: branch_name.to_string(),
review_request_creation: None,
upstream_reference: format!("origin/{branch_name}"),
}
}
async fn new_test_app_with_tmux_client(tmux_client: Arc<dyn TmuxClient>) -> App {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
let database = AppRepositories::in_memory().await;
let clients = test_app_clients()
.with_app_server_client_override(mock_app_server())
.with_tmux_client(tmux_client);
App::new_with_clients(base_path.clone(), base_path, None, database, clients)
.await
.expect("failed to build test app")
}
async fn new_test_app() -> App {
new_test_app_with_tmux_client(Arc::new(MockTmuxClient::new())).await
}
async fn app_with_fs_client(working_dir: PathBuf, fs_client: Arc<dyn FsClient>) -> App {
let database = AppRepositories::in_memory().await;
let clients = test_app_clients()
.with_app_server_client_override(mock_app_server())
.with_tmux_client(Arc::new(MockTmuxClient::new()))
.with_fs_client(fs_client);
App::new_with_clients(working_dir.clone(), working_dir, None, database, clients)
.await
.expect("failed to build test app")
}
#[tokio::test]
async fn test_new_with_clients_fails_when_no_backend_cli_is_available() {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
let database = AppRepositories::in_memory().await;
let clients = test_app_clients_with_available_agent_kinds(Vec::new())
.with_app_server_client_override(mock_app_server())
.with_tmux_client(Arc::new(MockTmuxClient::new()));
let result =
App::new_with_clients(base_path.clone(), base_path, None, database, clients).await;
assert!(matches!(
result,
Err(AppError::Workflow(message))
if message
== "No supported backend CLI found on `PATH`. Install `codex`, `claude`, or `gemini` and restart `agentty`."
));
}
#[tokio::test]
async fn session_git_status_targets_include_active_unpublished_sessions() {
let mut app = new_test_app().await;
let review_session = test_session(PathBuf::from("/tmp/session-review"));
let mut done_session = test_session(PathBuf::from("/tmp/session-done"));
done_session.id = "session-2".into();
done_session.status = Status::Done;
app.sessions.push_session(review_session);
app.sessions.push_session(done_session);
let targets = App::session_git_status_targets(&app.sessions);
assert_eq!(
targets,
vec![task::SessionGitStatusTarget {
base_branch: "main".to_string(),
branch_name: "wt/session-".to_string(),
session_id: "session-1".into(),
}]
);
}
#[tokio::test]
async fn session_git_status_targets_use_detected_session_branch_name_when_available() {
let mut app = new_test_app().await;
let review_session = test_session(PathBuf::from("/tmp/session-review"));
app.sessions.push_session(review_session);
app.sessions.replace_session_branch_names(HashMap::from([(
SessionId::from("session-1"),
"agentty/session-".to_string(),
)]));
let targets = App::session_git_status_targets(&app.sessions);
assert_eq!(
targets,
vec![task::SessionGitStatusTarget {
base_branch: "main".to_string(),
branch_name: "agentty/session-".to_string(),
session_id: "session-1".into(),
}]
);
}
#[tokio::test]
async fn test_switch_project_reloads_project_scoped_settings() {
let base_dir = tempdir().expect("failed to create temp dir");
let second_project_dir = tempdir().expect("failed to create second temp dir");
let base_path = base_dir.path().to_path_buf();
let database = AppRepositories::in_memory().await;
let first_project_id = database
.upsert_project(&base_path.to_string_lossy(), None)
.await
.expect("failed to insert first project");
let second_project_id = database
.upsert_project(&second_project_dir.path().to_string_lossy(), None)
.await
.expect("failed to insert second project");
database
.upsert_project_setting(
first_project_id,
SettingName::DefaultSmartModel,
AgentModel::ClaudeHaiku4520251001.as_str(),
)
.await
.expect("failed to persist first project smart model");
database
.upsert_project_setting(first_project_id, SettingName::OpenCommand, "npm run dev")
.await
.expect("failed to persist first project open command");
database
.upsert_project_setting(
second_project_id,
SettingName::DefaultSmartModel,
AgentModel::Gpt54.as_str(),
)
.await
.expect("failed to persist second project smart model");
database
.upsert_project_setting(second_project_id, SettingName::OpenCommand, "cargo test")
.await
.expect("failed to persist second project open command");
database
.set_active_project_id(first_project_id)
.await
.expect("failed to persist initial active project");
let mut app = App::new_with_clients(
base_path.clone(),
base_path,
None,
database,
test_app_clients(),
)
.await
.expect("failed to build app");
app.switch_project(second_project_id)
.await
.expect("failed to switch project");
assert_eq!(app.settings.default_smart_model, AgentModel::Gpt54);
assert_eq!(app.settings.open_command, "cargo test");
}
#[tokio::test]
async fn test_switch_project_refreshes_tasks_tab_cache() {
let base_dir = tempdir().expect("failed to create temp dir");
let second_project_dir = tempdir().expect("failed to create second temp dir");
let roadmap_dir = second_project_dir.path().join("docs/plan");
tokio::fs::create_dir_all(&roadmap_dir)
.await
.expect("failed to create roadmap dir");
tokio::fs::write(roadmap_dir.join("roadmap.md"), "# roadmap")
.await
.expect("failed to create roadmap file");
let base_path = base_dir.path().to_path_buf();
let database = AppRepositories::in_memory().await;
let first_project_id = database
.upsert_project(&base_path.to_string_lossy(), None)
.await
.expect("failed to insert first project");
let second_project_id = database
.upsert_project(&second_project_dir.path().to_string_lossy(), None)
.await
.expect("failed to insert second project");
database
.set_active_project_id(first_project_id)
.await
.expect("failed to persist initial active project");
let mut app = App::new_with_clients(
base_path.clone(),
base_path,
None,
database,
test_app_clients(),
)
.await
.expect("failed to build app");
app.scroll_task_roadmap_down();
app.scroll_task_roadmap_down();
let before_switch = app.active_project_has_tasks_tab();
app.switch_project(second_project_id)
.await
.expect("failed to switch project");
let after_switch = app.active_project_has_tasks_tab();
let task_scroll_offset = app.task_roadmap_scroll_offset();
assert!(!before_switch);
assert!(after_switch);
assert_eq!(task_scroll_offset, 0);
}
#[tokio::test]
async fn test_switch_project_updates_active_git_upstream_reference() {
let base_dir = tempdir().expect("failed to create temp dir");
let second_project_dir = tempdir().expect("failed to create second temp dir");
let base_path = base_dir.path().to_path_buf();
let second_project_path = second_project_dir.path().to_path_buf();
let database = AppRepositories::in_memory().await;
let first_project_id = database
.upsert_project(&base_path.to_string_lossy(), None)
.await
.expect("failed to insert first project");
let second_project_id = database
.upsert_project(&second_project_path.to_string_lossy(), None)
.await
.expect("failed to insert second project");
database
.set_active_project_id(first_project_id)
.await
.expect("failed to persist initial active project");
let mut app = App::new_with_clients(
base_path.clone(),
base_path,
None,
database,
test_app_clients(),
)
.await
.expect("failed to build app");
let mut mock_git_client = crate::infra::git::MockGitClient::new();
mock_git_client
.expect_detect_git_info()
.once()
.returning(|_| Box::pin(async { Some("feature/footer-bar".to_string()) }));
mock_git_client
.expect_current_upstream_reference()
.once()
.returning(|_| Box::pin(async { Ok("origin/feature/footer-bar".to_string()) }));
mock_git_client
.expect_find_git_repo_root()
.times(0..)
.returning(|path| Box::pin(async move { Some(path) }));
mock_git_client
.expect_fetch_remote()
.times(0..)
.returning(|_| Box::pin(async { Ok(()) }));
mock_git_client
.expect_branch_tracking_statuses()
.times(0..)
.returning(|_| Box::pin(async { Ok(HashMap::new()) }));
install_mock_git_client(&mut app, mock_git_client);
app.switch_project(second_project_id)
.await
.expect("failed to switch project");
assert_eq!(app.git_branch(), Some("feature/footer-bar"));
assert_eq!(app.git_upstream_ref(), Some("origin/feature/footer-bar"));
}
#[tokio::test]
async fn test_new_prefers_active_session_for_initial_selection() {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
let database = AppRepositories::in_memory().await;
let project_id = database
.upsert_project(&base_path.to_string_lossy(), None)
.await
.expect("failed to upsert project");
let active_session_id = "z-active-session";
let archive_session_id = "a-archive-session";
database
.insert_session(
active_session_id,
"gemini-3-flash-preview",
"main",
&Status::Review.to_string(),
project_id,
)
.await
.expect("failed to insert active session");
database
.insert_session(
archive_session_id,
"gemini-3-flash-preview",
"main",
&Status::Done.to_string(),
project_id,
)
.await
.expect("failed to insert archived session");
let active_folder_name = active_session_id.chars().take(8).collect::<String>();
let active_session_data_dir = base_path.join(active_folder_name).join(SESSION_DATA_DIR);
fs::create_dir_all(active_session_data_dir).expect("failed to create active session dir");
let app = App::new_with_clients(
base_path.clone(),
base_path,
None,
database,
test_app_clients(),
)
.await
.expect("failed to build app");
assert_eq!(
app.selected_session().map(|session| session.id.as_str()),
Some(active_session_id)
);
}
#[tokio::test]
async fn test_new_returns_error_when_startup_project_upsert_fails() {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
let (database, pool) = AppRepositories::in_memory_with_pool().await;
sqlx::query("DROP TABLE project")
.execute(&pool)
.await
.expect("failed to drop project table");
let error = App::new_with_clients(
base_path.clone(),
base_path,
None,
database,
test_app_clients(),
)
.await
.err()
.expect("expected startup project upsert failure");
assert!(
error
.to_string()
.contains("Failed to persist startup project")
);
}
#[tokio::test]
async fn test_new_returns_error_when_startup_active_project_persistence_fails() {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
let (database, pool) = AppRepositories::in_memory_with_pool().await;
sqlx::query("DROP TABLE setting")
.execute(&pool)
.await
.expect("failed to drop setting table");
let error = App::new_with_clients(
base_path.clone(),
base_path,
None,
database,
test_app_clients(),
)
.await
.err()
.expect("expected startup active project persistence failure");
assert!(
error
.to_string()
.contains("Failed to store active startup project")
);
}
#[tokio::test]
async fn test_new_with_clients_falls_back_from_stale_active_project_and_loads_current_sessions()
{
let temp_dir = tempdir().expect("failed to create temp dir");
let agentty_home = temp_dir.path().join("agentty-home");
let current_project_path = temp_dir.path().join("current-project");
fs::create_dir_all(&agentty_home).expect("failed to create agentty home");
fs::create_dir_all(¤t_project_path).expect("failed to create current project");
let missing_project_path = temp_dir.path().join("missing-project");
let database = AppRepositories::in_memory().await;
let current_project_id = database
.upsert_project(¤t_project_path.to_string_lossy(), Some("main"))
.await
.expect("failed to insert current project");
let missing_project_id = database
.upsert_project(&missing_project_path.to_string_lossy(), Some("missing"))
.await
.expect("failed to insert missing project");
database
.set_active_project_id(missing_project_id)
.await
.expect("failed to persist stale active project");
let current_session_id = "session-current";
let missing_session_id = "session-missing";
database
.insert_session(
current_session_id,
"gemini-3-flash-preview",
"main",
&Status::Review.to_string(),
current_project_id,
)
.await
.expect("failed to insert current project session");
database
.insert_session(
missing_session_id,
"gemini-3-flash-preview",
"main",
&Status::Review.to_string(),
missing_project_id,
)
.await
.expect("failed to insert stale project session");
let current_session_folder =
agentty_home.join(current_session_id.chars().take(8).collect::<String>());
fs::create_dir_all(current_session_folder.join(SESSION_DATA_DIR))
.expect("failed to create current session folder");
let app = App::new_with_clients(
agentty_home.clone(),
current_project_path.clone(),
Some("main".to_string()),
database,
test_app_clients(),
)
.await
.expect("failed to build app");
assert_eq!(app.active_project_id(), current_project_id);
assert_eq!(app.working_dir(), current_project_path.as_path());
assert_eq!(app.git_branch(), Some("main"));
assert_eq!(
app.selected_session().map(|session| session.id.as_str()),
Some(current_session_id)
);
assert_eq!(app.sessions.sessions.len(), 1);
assert_eq!(app.sessions.sessions[0].id, current_session_id);
assert!(
app.projects
.project_items()
.iter()
.any(|item| item.project.id == current_project_id)
);
assert!(
!app.projects
.project_items()
.iter()
.any(|item| item.project.id == missing_project_id)
);
}
async fn new_test_app_with_selected_session(
session_folder: PathBuf,
open_command: &str,
tmux_client: Arc<dyn TmuxClient>,
) -> App {
let mut app = new_test_app_with_tmux_client(tmux_client).await;
if !session_folder.as_os_str().is_empty() {
std::fs::create_dir_all(&session_folder).expect("failed to create session folder");
}
app.settings.open_command = open_command.to_string();
app.sessions.push_session(test_session(session_folder));
app.sessions.table_state.select(Some(0));
app
}
#[test]
fn branch_publish_popup_helpers_format_copy() {
let expected_restore_view = ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: Some(2),
session_id: "session-1".into(),
};
let loading_title = App::branch_publish_loading_title(PublishBranchAction::Push);
let loading_message = App::branch_publish_loading_message(PublishBranchAction::Push, None);
let custom_loading_message = App::branch_publish_loading_message(
PublishBranchAction::Push,
Some("review/custom-branch"),
);
let loading_label = App::branch_publish_loading_label(PublishBranchAction::Push);
let success_title = App::branch_publish_success_title(PublishBranchAction::Push);
let success_message = App::branch_publish_success_message(
"wt/session-1",
Some(&crate::app::branch_publish::ReviewRequestCreationInfo {
forge_kind: forge::ForgeKind::GitHub,
web_url: Some(
"https://github.com/org/repo/compare/main...wt%2Fsession-1?expand=1"
.to_string(),
),
}),
);
let fallback_success_message = App::branch_publish_success_message("wt/session-1", None);
let pull_request_loading_title =
App::branch_publish_loading_title(PublishBranchAction::PublishPullRequest);
let pull_request_loading_message =
App::branch_publish_loading_message(PublishBranchAction::PublishPullRequest, None);
let pull_request_loading_label =
App::branch_publish_loading_label(PublishBranchAction::PublishPullRequest);
let pull_request_success_title =
App::branch_publish_success_title(PublishBranchAction::PublishPullRequest);
let popup_mode = App::view_info_popup_mode(
"Working".to_string(),
"Publishing branch".to_string(),
true,
"Pushing branch...".to_string(),
expected_restore_view.clone(),
);
assert_eq!(loading_title, "Pushing branch");
assert_eq!(
loading_message,
"Publishing the session branch to the configured Git remote."
);
assert_eq!(
custom_loading_message,
"Publishing the session branch to `review/custom-branch` on the configured Git remote."
);
assert_eq!(loading_label, "Pushing branch...");
assert_eq!(success_title, "Branch pushed");
assert!(success_message.contains("Pushed session branch `wt/session-1`."));
assert!(success_message.contains("Open this link to create the pull request"));
assert!(
success_message
.contains("https://github.com/org/repo/compare/main...wt%2Fsession-1?expand=1")
);
assert!(fallback_success_message.contains("Create the review request manually"));
assert_eq!(pull_request_loading_title, "Publishing review request");
assert_eq!(
pull_request_loading_message,
"Pushing the session branch and creating or refreshing the active forge review \
request."
);
assert_eq!(pull_request_loading_label, "Publishing review request...");
assert_eq!(pull_request_success_title, "Review request published");
assert!(matches!(
popup_mode,
AppMode::ViewInfoPopup {
is_loading: true,
ref loading_label,
ref message,
ref restore_view,
ref title,
} if title == "Working"
&& message == "Publishing branch"
&& loading_label == "Pushing branch..."
&& restore_view == &expected_restore_view
));
}
#[test]
fn branch_push_failure_maps_blocked_and_failed_errors() {
let auth_error =
"Git push failed: fatal: could not read Username for 'https://github.com': terminal \
prompts disabled";
let failed_error = "remote rejected";
let blocked = branch_push_failure(PublishBranchAction::Push, auth_error);
let failed = branch_push_failure(PublishBranchAction::Push, failed_error);
assert_eq!(blocked.title, "Branch push blocked");
assert!(blocked.message.contains("Git push requires authentication"));
assert!(blocked.message.contains("gh auth login"));
assert_eq!(failed.title, "Branch push failed");
assert!(
failed
.message
.contains("Failed to publish session branch: remote rejected")
);
}
#[tokio::test]
async fn push_session_branch_auth_failure_shows_git_guidance() {
let branch_session = BranchPublishTaskSession::from_session(&test_session(PathBuf::from(
"/tmp/review-session",
)));
let mut mock_git_client = crate::infra::git::MockGitClient::new();
mock_git_client
.expect_push_current_branch()
.once()
.returning(|_| {
Box::pin(async {
Err(crate::infra::git::GitError::OutputParse(
"Git push failed: fatal: could not read Username for \
'https://github.com': terminal prompts disabled"
.to_string(),
))
})
});
let git_client: Arc<dyn crate::infra::git::GitClient> = Arc::new(mock_git_client);
let database = crate::infra::db::AppRepositories::in_memory().await;
let result = push_session_branch(
PublishBranchAction::Push,
&branch_session,
database,
git_client,
None,
)
.await;
assert!(matches!(
result,
Err(BranchPublishTaskFailure {
ref title,
ref message,
..
}) if title == "Branch push blocked"
&& message.contains("Git push requires authentication")
&& message.contains("gh auth login")
));
}
#[tokio::test]
async fn push_session_branch_preserves_blocked_when_remote_branch_exists() {
let branch_session = BranchPublishTaskSession::from_session(&test_session(PathBuf::from(
"/tmp/review-session",
)));
let mut mock_git_client = crate::infra::git::MockGitClient::new();
mock_git_client
.expect_remote_branch_exists()
.once()
.returning(|_, _| Box::pin(async { Ok(true) }));
let git_client: Arc<dyn crate::infra::git::GitClient> = Arc::new(mock_git_client);
let database = crate::infra::db::AppRepositories::in_memory().await;
let result = push_session_branch(
PublishBranchAction::Push,
&branch_session,
database,
git_client,
Some("feature/existing"),
)
.await;
let failure = result.expect_err("push should be blocked");
assert_eq!(failure.title, "Branch push blocked");
assert!(failure.message.contains("already exists"));
}
#[tokio::test]
async fn push_session_branch_shows_auth_guidance_when_ls_remote_fails_with_auth_error() {
let branch_session = BranchPublishTaskSession::from_session(&test_session(PathBuf::from(
"/tmp/review-session",
)));
let mut mock_git_client = crate::infra::git::MockGitClient::new();
mock_git_client
.expect_remote_branch_exists()
.once()
.returning(|_, _| {
Box::pin(async {
Err(crate::infra::git::GitError::CommandFailed {
command: "git ls-remote".to_string(),
stderr: "fatal: could not read Username for \
'https://github.com/org/repo': terminal prompts disabled"
.to_string(),
})
})
});
let git_client: Arc<dyn crate::infra::git::GitClient> = Arc::new(mock_git_client);
let database = crate::infra::db::AppRepositories::in_memory().await;
let result = push_session_branch(
PublishBranchAction::Push,
&branch_session,
database,
git_client,
Some("feature/new"),
)
.await;
let failure = result.expect_err("push should be blocked");
assert_eq!(failure.title, "Branch push blocked");
assert!(failure.message.contains("Git push requires authentication"));
assert!(failure.message.contains("gh auth login"));
}
#[tokio::test]
async fn branch_publish_task_helpers_reject_unsupported_session_states() {
let app = new_test_app().await;
let mut review_session = test_session(PathBuf::from("/tmp/review-session"));
review_session.status = Status::Done;
let done_snapshot = BranchPublishTaskSession::from_session(&review_session);
let push_result = run_branch_publish_action(
PublishBranchAction::Push,
done_snapshot.clone(),
app.services.db().clone(),
app.services.clock(),
app.services.git_client(),
app.services.review_request_client(),
None,
)
.await;
let helper_result = push_session_branch(
PublishBranchAction::Push,
&done_snapshot,
app.services.db().clone(),
app.services.git_client(),
None,
)
.await;
assert_eq!(
push_result,
Err(BranchPublishTaskFailure::failed(
PublishBranchAction::Push,
"Session must be in review to push the branch.".to_string(),
))
);
assert_eq!(
helper_result,
Err(BranchPublishTaskFailure::failed(
PublishBranchAction::Push,
"Session must be in review to push the branch.".to_string(),
))
);
}
#[tokio::test]
async fn push_session_branch_uses_custom_remote_branch_name_when_provided() {
let branch_session = BranchPublishTaskSession::from_session(&test_session(PathBuf::from(
"/tmp/review-session",
)));
let mut mock_git_client = crate::infra::git::MockGitClient::new();
mock_git_client
.expect_remote_branch_exists()
.once()
.returning(|_, _| Box::pin(async { Ok(false) }));
mock_git_client
.expect_push_current_branch_to_remote_branch()
.with(
mockall::predicate::eq(PathBuf::from("/tmp/review-session")),
mockall::predicate::eq("review/custom-branch".to_string()),
)
.once()
.returning(|_, _| Box::pin(async { Ok("origin/review/custom-branch".to_string()) }));
mock_git_client
.expect_repo_url()
.with(mockall::predicate::eq(PathBuf::from("/tmp/review-session")))
.once()
.returning(|_| {
Box::pin(async { Ok("https://github.com/agentty-xyz/agentty.git".to_string()) })
});
let git_client: Arc<dyn crate::infra::git::GitClient> = Arc::new(mock_git_client);
let database = crate::infra::db::AppRepositories::in_memory().await;
let result = push_session_branch(
PublishBranchAction::Push,
&branch_session,
database.clone(),
git_client,
Some("review/custom-branch"),
)
.await;
assert_eq!(
result,
Ok(BranchPublishTaskSuccess::Pushed {
branch_name: "review/custom-branch".to_string(),
review_request_creation: Some(crate::app::branch_publish::ReviewRequestCreationInfo {
forge_kind: forge::ForgeKind::GitHub,
web_url: Some(
"https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
.to_string()
),
}),
upstream_reference: "origin/review/custom-branch".to_string(),
})
);
}
#[tokio::test]
async fn push_session_branch_succeeds_without_review_request_link_for_unsupported_remote() {
let branch_session = BranchPublishTaskSession::from_session(&test_session(PathBuf::from(
"/tmp/review-session",
)));
let mut mock_git_client = crate::infra::git::MockGitClient::new();
mock_git_client
.expect_push_current_branch()
.once()
.returning(|_| Box::pin(async { Ok("origin/wt/session-1".to_string()) }));
mock_git_client
.expect_repo_url()
.with(mockall::predicate::eq(PathBuf::from("/tmp/review-session")))
.once()
.returning(|_| {
Box::pin(async { Ok("https://example.com/team/project.git".to_string()) })
});
let git_client: Arc<dyn crate::infra::git::GitClient> = Arc::new(mock_git_client);
let database = crate::infra::db::AppRepositories::in_memory().await;
let result = push_session_branch(
PublishBranchAction::Push,
&branch_session,
database,
git_client,
None,
)
.await;
assert_eq!(
result,
Ok(BranchPublishTaskSuccess::Pushed {
branch_name: session::session_branch("session-1"),
review_request_creation: None,
upstream_reference: "origin/wt/session-1".to_string(),
})
);
}
#[tokio::test]
async fn apply_branch_publish_action_update_sets_success_popup() {
let session_folder = tempdir().expect("failed to create temp dir");
let mut app = new_test_app_with_selected_session(
session_folder.path().to_path_buf(),
"",
Arc::new(MockTmuxClient::new()),
)
.await;
let expected_restore_view = ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: Some(1),
session_id: "session-1".into(),
};
app.apply_branch_publish_action_update(BranchPublishActionUpdate {
restore_view: expected_restore_view.clone(),
result: Ok(BranchPublishTaskSuccess::Pushed {
branch_name: "wt/session-1".to_string(),
review_request_creation: Some(crate::app::branch_publish::ReviewRequestCreationInfo {
forge_kind: forge::ForgeKind::GitHub,
web_url: Some(
"https://github.com/agentty-xyz/agentty/compare/main...wt%2Fsession-1?expand=1"
.to_string()
),
}),
upstream_reference: "origin/wt/session-1".to_string(),
}),
session_id: "session-1".into(),
});
assert!(matches!(
app.mode,
AppMode::ViewInfoPopup {
is_loading: false,
ref message,
ref restore_view,
ref title,
..
} if title == "Branch pushed"
&& message.contains("Pushed session branch `wt/session-1`.")
&& message.contains("https://github.com/agentty-xyz/agentty/compare/main...wt%2Fsession-1?expand=1")
&& restore_view == &expected_restore_view
));
assert_eq!(
app.sessions
.state()
.sessions
.first()
.and_then(|session| session.published_upstream_ref.as_deref()),
Some("origin/wt/session-1")
);
}
#[tokio::test]
async fn apply_branch_publish_action_update_sets_pull_request_success_popup() {
let session_folder = tempdir().expect("failed to create temp dir");
let mut app = new_test_app_with_selected_session(
session_folder.path().to_path_buf(),
"",
Arc::new(MockTmuxClient::new()),
)
.await;
let expected_restore_view = ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: Some(1),
session_id: "session-1".into(),
};
let review_request = crate::domain::session::ReviewRequest {
last_refreshed_at: 55,
summary: crate::domain::session::ReviewRequestSummary {
web_url: "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
..test_review_request_summary("#42", ReviewRequestState::Open)
},
};
app.apply_branch_publish_action_update(BranchPublishActionUpdate {
restore_view: expected_restore_view.clone(),
result: Ok(BranchPublishTaskSuccess::PullRequestPublished {
branch_name: "wt/session-1".to_string(),
review_request: review_request.clone(),
upstream_reference: "origin/wt/session-1".to_string(),
}),
session_id: "session-1".into(),
});
assert!(matches!(
app.mode,
AppMode::ViewInfoPopup {
is_loading: false,
ref message,
ref restore_view,
ref title,
..
} if title == "GitHub pull request published"
&& message.contains("Published session branch `wt/session-1`.")
&& message.contains("GitHub pull request #42 is ready")
&& message.contains("https://github.com/agentty-xyz/agentty/pull/42")
&& restore_view == &expected_restore_view
));
assert_eq!(
app.sessions
.state()
.sessions
.first()
.and_then(|session| session.review_request.clone()),
Some(review_request)
);
}
#[tokio::test]
async fn apply_branch_publish_action_update_sets_gitlab_merge_request_success_popup() {
let session_folder = tempdir().expect("failed to create temp dir");
let mut app = new_test_app_with_selected_session(
session_folder.path().to_path_buf(),
"",
Arc::new(MockTmuxClient::new()),
)
.await;
let expected_restore_view = ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: Some(2),
session_id: "session-1".into(),
};
let review_request = crate::domain::session::ReviewRequest {
last_refreshed_at: 77,
summary: crate::domain::session::ReviewRequestSummary {
display_id: "!24".to_string(),
forge_kind: ForgeKind::GitLab,
source_branch: "wt/session-1".to_string(),
state: ReviewRequestState::Open,
status_summary: Some("Draft".to_string()),
target_branch: "main".to_string(),
title: "Add GitLab support".to_string(),
web_url: "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/24".to_string(),
},
};
app.apply_branch_publish_action_update(BranchPublishActionUpdate {
restore_view: expected_restore_view.clone(),
result: Ok(BranchPublishTaskSuccess::PullRequestPublished {
branch_name: "wt/session-1".to_string(),
review_request,
upstream_reference: "origin/wt/session-1".to_string(),
}),
session_id: "session-1".into(),
});
assert!(matches!(
app.mode,
AppMode::ViewInfoPopup {
is_loading: false,
ref message,
ref restore_view,
ref title,
..
} if title == "GitLab merge request published"
&& message.contains("Published session branch `wt/session-1`.")
&& message.contains("GitLab merge request !24 is ready")
&& message.contains("https://gitlab.com/agentty-xyz/agentty/-/merge_requests/24")
&& restore_view == &expected_restore_view
));
}
#[tokio::test]
async fn stats_for_session_returns_duration_and_usage_rows() {
let app = new_test_app().await;
let session_id = "session-stats";
let project_id = app
.services
.db()
.upsert_project("/tmp/stats-project", Some("main"))
.await
.expect("failed to upsert project");
app.services
.db()
.insert_session(
session_id,
"gemini-2.5-flash",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
let usage = SessionStats {
added_lines: 0,
deleted_lines: 0,
input_tokens: 1_200,
output_tokens: 650,
};
app.services
.db()
.upsert_session_usage(session_id, "gemini-2.5-flash", &usage)
.await
.expect("failed to upsert usage");
let stats = app.stats_for_session(session_id).await;
assert_eq!(stats.session_duration_seconds, Some(0));
assert_eq!(
stats.usage_rows_result,
Ok(vec![SessionStatsUsage {
input_tokens: 1_200,
model: "gemini-2.5-flash".to_string(),
output_tokens: 650,
}])
);
}
#[tokio::test]
async fn stats_for_session_returns_none_duration_for_unknown_session() {
let app = new_test_app().await;
let stats = app.stats_for_session("missing-session").await;
assert_eq!(stats.session_duration_seconds, None);
assert_eq!(stats.usage_rows_result, Ok(Vec::new()));
}
#[tokio::test]
async fn configured_open_commands_returns_trimmed_non_empty_entries() {
let mut app = new_test_app().await;
app.settings.open_command = " cargo test \n npm run dev \n".to_string();
let open_commands = app.configured_open_commands();
assert_eq!(
open_commands,
vec!["cargo test".to_string(), "npm run dev".to_string()]
);
}
#[tokio::test]
async fn open_session_worktree_in_tmux_runs_configured_open_command_when_window_opens() {
let session_folder = PathBuf::from("/tmp/session-open-command");
let mut mock_tmux_client = MockTmuxClient::new();
mock_tmux_client
.expect_open_window_for_folder()
.with(eq(session_folder))
.times(1)
.returning(|_| Box::pin(async { Some("@42".to_string()) }));
mock_tmux_client
.expect_run_command_in_window()
.with(eq("@42".to_string()), eq("npm run dev".to_string()))
.times(1)
.returning(|_, _| Box::pin(async {}));
let app = new_test_app_with_selected_session(
PathBuf::from("/tmp/session-open-command"),
" npm run dev ",
Arc::new(mock_tmux_client),
)
.await;
app.open_session_worktree_in_tmux().await;
}
#[tokio::test]
async fn open_session_worktree_in_tmux_skips_open_command_when_setting_is_blank() {
let session_folder = PathBuf::from("/tmp/session-empty-open-command");
let mut mock_tmux_client = MockTmuxClient::new();
mock_tmux_client
.expect_open_window_for_folder()
.with(eq(session_folder))
.times(1)
.returning(|_| Box::pin(async { Some("@42".to_string()) }));
mock_tmux_client.expect_run_command_in_window().times(0);
let app = new_test_app_with_selected_session(
PathBuf::from("/tmp/session-empty-open-command"),
" ",
Arc::new(mock_tmux_client),
)
.await;
app.open_session_worktree_in_tmux().await;
}
#[tokio::test]
async fn open_session_worktree_in_tmux_skips_missing_worktree_folder() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let missing_session_folder = temp_dir.path().join("missing-session-worktree");
let mut mock_tmux_client = MockTmuxClient::new();
mock_tmux_client.expect_open_window_for_folder().times(0);
mock_tmux_client.expect_run_command_in_window().times(0);
let mut app = new_test_app_with_tmux_client(Arc::new(mock_tmux_client)).await;
app.settings.open_command = "npm run dev".to_string();
app.sessions
.push_session(test_session(missing_session_folder));
app.sessions.table_state.select(Some(0));
app.open_session_worktree_in_tmux().await;
}
#[tokio::test]
async fn open_session_worktree_in_tmux_uses_first_configured_command() {
let session_folder = PathBuf::from("/tmp/session-multiple-open-commands");
let mut mock_tmux_client = MockTmuxClient::new();
mock_tmux_client
.expect_open_window_for_folder()
.with(eq(session_folder))
.times(1)
.returning(|_| Box::pin(async { Some("@42".to_string()) }));
mock_tmux_client
.expect_run_command_in_window()
.with(eq("@42".to_string()), eq("cargo test".to_string()))
.times(1)
.returning(|_, _| Box::pin(async {}));
let app = new_test_app_with_selected_session(
PathBuf::from("/tmp/session-multiple-open-commands"),
" cargo test \n npm run dev ",
Arc::new(mock_tmux_client),
)
.await;
app.open_session_worktree_in_tmux().await;
}
#[test]
fn sync_main_popup_mode_success_message_tracks_project_and_branch() {
let sync_popup_context = SyncPopupContext {
default_branch: "develop".to_string(),
project_name: "agentty".to_string(),
};
let sync_main_outcome = SyncMainOutcome {
pulled_commit_titles: vec![
"Add audit log indexing".to_string(),
"Fix merge conflict prompt wording".to_string(),
],
pulled_commits: Some(2),
pushed_commit_titles: vec!["Polish sync popup alignment".to_string()],
pushed_commits: Some(1),
resolved_conflict_files: vec!["src/lib.rs".to_string()],
};
let mode = App::sync_main_popup_mode(Ok(sync_main_outcome), &sync_popup_context);
let expected_message = concat!(
"Successfully synchronized with its upstream.\n",
"\n",
"## 1. 2 commits pulled\n",
" - Add audit log indexing\n",
" - Fix merge conflict prompt wording\n",
"\n",
"## 2. 1 commit pushed\n",
" - Polish sync popup alignment\n",
"\n",
"## 3. conflicts fixed: src/lib.rs"
);
assert!(matches!(mode, AppMode::SyncBlockedPopup { .. }));
if let AppMode::SyncBlockedPopup {
default_branch,
is_loading,
message,
project_name,
title,
} = mode
{
assert_eq!(title, "Sync complete");
assert_eq!(default_branch.as_deref(), Some("develop"));
assert!(!is_loading);
assert_eq!(message, expected_message);
assert_eq!(project_name.as_deref(), Some("agentty"));
}
}
#[test]
fn sync_main_popup_mode_blocked_message_tracks_project_and_branch() {
let sync_popup_context = SyncPopupContext {
default_branch: "develop".to_string(),
project_name: "agentty".to_string(),
};
let mode = App::sync_main_popup_mode(
Err(SyncSessionStartError::MainHasUncommittedChanges {
default_branch: "develop".to_string(),
}),
&sync_popup_context,
);
assert!(matches!(
mode,
AppMode::SyncBlockedPopup {
ref default_branch,
is_loading: false,
ref title,
ref message,
ref project_name,
} if title == "Sync blocked"
&& default_branch.as_deref() == Some("develop")
&& message.contains("uncommitted changes")
&& project_name.as_deref() == Some("agentty")
));
}
#[test]
fn sync_main_popup_mode_auth_failure_shows_authorization_guidance() {
let sync_popup_context = SyncPopupContext {
default_branch: "main".to_string(),
project_name: "agentty".to_string(),
};
let sync_error = SyncSessionStartError::Other(
"Git push failed: fatal: could not read Username for 'https://github.com': terminal \
prompts disabled"
.to_string(),
);
let mode = App::sync_main_popup_mode(Err(sync_error), &sync_popup_context);
assert!(matches!(
mode,
AppMode::SyncBlockedPopup {
ref default_branch,
is_loading: false,
ref title,
ref message,
ref project_name,
} if title == "Sync failed"
&& default_branch.as_deref() == Some("main")
&& message.contains("Git push requires authentication")
&& message.contains("`gh auth login`")
&& message.contains("then run sync again")
&& project_name.as_deref() == Some("agentty")
));
}
#[test]
fn sync_push_auth_error_detects_github_from_prompt_url() {
let detail = "Git push failed: fatal: could not read Password for 'https://github.com/team/project': terminal \
prompts disabled\nConfigured remotes:\n github.com";
let forge_kind = detected_forge_kind_from_git_push_error(detail);
assert_eq!(forge_kind, Some(forge::ForgeKind::GitHub));
}
#[test]
fn sync_push_auth_error_prefers_github_when_fallback_markers_are_ambiguous() {
let detail = "Git push failed: authentication failed. Configure remotes:\n github.com";
let forge_kind = detected_forge_kind_from_git_push_error(detail);
assert_eq!(forge_kind, Some(forge::ForgeKind::GitHub));
}
#[test]
fn app_event_batch_collect_event_keeps_latest_at_mention_entries_update() {
let mut event_batch = AppEventBatch::default();
let first_entries = vec![FileEntry {
is_dir: false,
path: "src/main.rs".to_string(),
}];
let second_entries = vec![FileEntry {
is_dir: true,
path: "crates".to_string(),
}];
event_batch.collect_event(AppEvent::AtMentionEntriesLoaded {
entries: first_entries,
session_id: "session-1".into(),
});
event_batch.collect_event(AppEvent::AtMentionEntriesLoaded {
entries: second_entries.clone(),
session_id: "session-1".into(),
});
assert_eq!(
event_batch
.at_mention_entries_updates
.get("session-1")
.cloned(),
Some(second_entries)
);
}
#[test]
fn app_event_batch_collect_event_merges_agent_response_token_usage() {
let mut event_batch = AppEventBatch::default();
let latest_turn = test_turn_applied_state(
vec![
QuestionItem::new("Need branch?"),
QuestionItem::new("Need tests?"),
],
vec!["Document the batched reducer path."],
Some(AgentResponseSummary {
session: "Session summary".to_string(),
turn: "Latest turn summary".to_string(),
}),
SessionStats {
added_lines: 0,
deleted_lines: 0,
input_tokens: 7,
output_tokens: 11,
},
);
event_batch.collect_event(AppEvent::AgentResponseReceived {
session_id: "session-1".into(),
turn_applied_state: test_turn_applied_state(
vec![QuestionItem::new("Old question")],
vec!["Old follow-up task"],
Some(AgentResponseSummary {
session: "Old session summary".to_string(),
turn: "Old turn summary".to_string(),
}),
SessionStats {
added_lines: 0,
deleted_lines: 0,
input_tokens: 3,
output_tokens: 5,
},
),
});
event_batch.collect_event(AppEvent::AgentResponseReceived {
session_id: "session-1".into(),
turn_applied_state: latest_turn.clone(),
});
let merged_turn = event_batch.applied_turns.get("session-1");
assert_eq!(
merged_turn.map(|turn| turn.questions.clone()),
Some(latest_turn.questions.clone())
);
assert_eq!(
merged_turn.map(|turn| {
turn.follow_up_tasks
.iter()
.map(|task| task.text.clone())
.collect::<Vec<_>>()
}),
Some(vec!["Document the batched reducer path.".to_string()])
);
assert_eq!(
merged_turn.and_then(|turn| turn.summary.as_deref()),
latest_turn.summary.as_deref()
);
assert_eq!(
merged_turn.map(|turn| turn.token_usage_delta.input_tokens),
Some(10)
);
assert_eq!(
merged_turn.map(|turn| turn.token_usage_delta.output_tokens),
Some(16)
);
}
#[test]
fn app_event_batch_collect_event_stores_update_status() {
let mut event_batch = AppEventBatch::default();
event_batch.collect_event(AppEvent::UpdateStatusChanged {
update_status: UpdateStatus::InProgress {
version: "v1.0.0".to_string(),
},
});
event_batch.collect_event(AppEvent::UpdateStatusChanged {
update_status: UpdateStatus::Complete {
version: "v1.0.0".to_string(),
},
});
assert_eq!(
event_batch.update_status,
Some(UpdateStatus::Complete {
version: "v1.0.0".to_string()
})
);
}
#[test]
fn app_event_batch_collect_event_keeps_latest_same_session_updates() {
let mut event_batch = AppEventBatch::default();
event_batch.collect_event(AppEvent::SessionModelUpdated {
session_id: "session-a".into(),
session_model: AgentModel::Gemini3FlashPreview,
});
event_batch.collect_event(AppEvent::SessionModelUpdated {
session_id: "session-a".into(),
session_model: AgentModel::Gemini31ProPreview,
});
event_batch.collect_event(AppEvent::SessionProgressUpdated {
progress_message: Some("first".to_string()),
session_id: "session-a".into(),
});
event_batch.collect_event(AppEvent::SessionProgressUpdated {
progress_message: Some("second".to_string()),
session_id: "session-a".into(),
});
event_batch.collect_event(AppEvent::SessionSizeUpdated {
added_lines: 1,
deleted_lines: 2,
session_id: "session-a".into(),
session_size: SessionSize::S,
});
event_batch.collect_event(AppEvent::SessionSizeUpdated {
added_lines: 8,
deleted_lines: 13,
session_id: "session-a".into(),
session_size: SessionSize::L,
});
event_batch.collect_event(AppEvent::SessionTitleGenerationFinished {
generation: 1,
session_id: "session-a".into(),
});
event_batch.collect_event(AppEvent::SessionTitleGenerationFinished {
generation: 2,
session_id: "session-a".into(),
});
event_batch.collect_event(AppEvent::SessionUpdated {
session_id: "session-a".into(),
version: 1,
});
event_batch.collect_event(AppEvent::SessionUpdated {
session_id: "session-a".into(),
version: 2,
});
event_batch.collect_event(AppEvent::AgentResponseReceived {
session_id: "session-a".into(),
turn_applied_state: test_turn_applied_state(
vec![QuestionItem::new("first question")],
Vec::new(),
None,
SessionStats::default(),
),
});
event_batch.collect_event(AppEvent::AgentResponseReceived {
session_id: "session-a".into(),
turn_applied_state: test_turn_applied_state(
vec![QuestionItem::new("second question")],
Vec::new(),
None,
SessionStats::default(),
),
});
assert_eq!(
event_batch.session_model_updates.get("session-a"),
Some(&AgentModel::Gemini31ProPreview)
);
assert_eq!(
event_batch.session_progress_updates.get("session-a"),
Some(&Some("second".to_string()))
);
assert_eq!(
event_batch.session_size_updates.get("session-a"),
Some(&(8, 13, SessionSize::L))
);
assert_eq!(
event_batch.session_update_versions.get("session-a"),
Some(&2)
);
assert_eq!(
event_batch
.session_title_generation_finished
.get("session-a"),
Some(&2)
);
assert_eq!(event_batch.session_ids.len(), 1);
assert_eq!(
event_batch
.applied_turns
.get("session-a")
.map(|turn_applied_state| turn_applied_state.questions.clone()),
Some(vec![QuestionItem::new("second question")])
);
}
#[test]
fn app_event_batch_collect_event_uses_final_wins_for_review_and_branch_publish() {
let mut event_batch = AppEventBatch::default();
event_batch.collect_event(AppEvent::ReviewPrepared {
diff_hash: 11,
review_text: "first review".to_string(),
session_id: "session-a".into(),
});
event_batch.collect_event(AppEvent::ReviewPreparationFailed {
diff_hash: 12,
error: "latest failure".to_string(),
session_id: "session-a".into(),
});
event_batch.collect_event(AppEvent::ReviewPrepared {
diff_hash: 21,
review_text: "stable review".to_string(),
session_id: "session-b".into(),
});
event_batch.collect_event(AppEvent::BranchPublishActionCompleted {
restore_view: test_confirmation_view_mode("session-a"),
result: Box::new(Ok(test_pushed_branch_result("feature/first"))),
session_id: "session-a".into(),
});
event_batch.collect_event(AppEvent::BranchPublishActionCompleted {
restore_view: test_confirmation_view_mode("session-b"),
result: Box::new(Ok(test_pushed_branch_result("feature/final"))),
session_id: "session-b".into(),
});
assert_eq!(
event_batch.review_updates.get("session-a"),
Some(&ReviewUpdate {
diff_hash: 12,
result: Err("latest failure".to_string()),
})
);
assert_eq!(
event_batch.review_updates.get("session-b"),
Some(&ReviewUpdate {
diff_hash: 21,
result: Ok("stable review".to_string()),
})
);
assert_eq!(
event_batch.branch_publish_action_update,
Some(BranchPublishActionUpdate {
restore_view: test_confirmation_view_mode("session-b"),
result: Ok(test_pushed_branch_result("feature/final")),
session_id: "session-b".into(),
})
);
assert!(event_batch.should_refresh_git_status);
}
#[test]
fn app_event_batch_collect_event_marks_successful_sync_for_git_status_refresh() {
let mut event_batch = AppEventBatch::default();
event_batch.collect_event(AppEvent::SyncMainCompleted {
result: Ok(SyncMainOutcome {
pulled_commit_titles: vec!["Upstream fix".to_string()],
pulled_commits: Some(1),
pushed_commit_titles: vec!["Local tweak".to_string()],
pushed_commits: Some(2),
resolved_conflict_files: Vec::new(),
}),
});
assert!(event_batch.should_refresh_git_status);
assert!(matches!(
event_batch.sync_main_result,
Some(Ok(SyncMainOutcome {
pulled_commits: Some(1),
pushed_commits: Some(2),
..
}))
));
}
#[tokio::test]
async fn apply_app_events_update_status_changed_updates_app_state() {
let mut app = new_test_app().await;
assert!(app.update_status().is_none());
app.clear_redraw();
app.apply_app_events(AppEvent::UpdateStatusChanged {
update_status: UpdateStatus::InProgress {
version: "v2.0.0".to_string(),
},
})
.await;
assert_eq!(
app.update_status().cloned(),
Some(UpdateStatus::InProgress {
version: "v2.0.0".to_string()
})
);
assert!(app.needs_redraw());
}
#[tokio::test]
async fn apply_app_events_session_updated_same_version_keeps_redraw_clean() {
let mut app = new_test_app().await;
app.apply_app_events(AppEvent::SessionUpdated {
session_id: "session-1".into(),
version: 7,
})
.await;
app.clear_redraw();
app.apply_app_events(AppEvent::SessionUpdated {
session_id: "session-1".into(),
version: 7,
})
.await;
assert!(!app.needs_redraw());
}
#[tokio::test]
async fn apply_app_events_git_status_updated_updates_project_and_session_state() {
let mut app = new_test_app().await;
app.sessions
.push_session(test_session(PathBuf::from("/tmp/session-git-status")));
app.apply_app_events(AppEvent::GitStatusUpdated {
session_statuses: HashMap::from([(
SessionId::from("session-1"),
SessionGitStatus {
base_status: Some((4, 2)),
remote_status: Some((1, 0)),
},
)]),
status: Some((1, 3)),
})
.await;
assert_eq!(app.git_status_info(), Some((1, 3)));
assert_eq!(
app.sessions.session_git_statuses().get("session-1"),
Some(&SessionGitStatus {
base_status: Some((4, 2)),
remote_status: Some((1, 0)),
})
);
}
#[tokio::test]
async fn apply_app_events_refresh_git_status_restarts_task() {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
let database = AppRepositories::in_memory().await;
let clients = test_app_clients()
.with_app_server_client_override(mock_app_server())
.with_tmux_client(Arc::new(MockTmuxClient::new()));
let mut app = App::new_with_clients(
base_path.clone(),
base_path.clone(),
Some("main".to_string()),
database,
clients,
)
.await
.expect("failed to build test app");
let mut mock_git_client = crate::infra::git::MockGitClient::new();
mock_git_client
.expect_find_git_repo_root()
.times(1)
.returning(|dir| Box::pin(async move { Some(dir) }));
mock_git_client
.expect_fetch_remote()
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
mock_git_client
.expect_branch_tracking_statuses()
.times(1)
.returning(|_| {
Box::pin(async { Ok(HashMap::from([("main".to_string(), Some((2_u32, 1_u32)))])) })
});
install_mock_git_client(&mut app, mock_git_client);
app.apply_app_events(AppEvent::RefreshGitStatus).await;
let next_event = tokio::time::timeout(Duration::from_secs(1), app.next_app_event())
.await
.expect("git status refresh event should arrive");
assert_eq!(
next_event,
Some(AppEvent::GitStatusUpdated {
session_statuses: HashMap::new(),
status: Some((2, 1)),
})
);
}
#[tokio::test]
async fn apply_app_events_agent_response_switches_view_mode_to_question_mode() {
let mut app = new_test_app().await;
app.sessions
.push_session(test_session(PathBuf::from("/tmp/session-question-view")));
app.mode = AppMode::View {
review_status_message: Some("Preparing review...".to_string()),
review_text: Some("Focused review".to_string()),
session_id: "session-1".into(),
scroll_offset: None,
};
let expected_questions = vec![
QuestionItem::with_options(
"Need a target branch?",
vec!["main".to_string(), "develop".to_string()],
),
QuestionItem::with_options(
"Need integration tests?",
vec!["Yes".to_string(), "No".to_string()],
),
];
let turn_applied_state = test_turn_applied_state(
vec![
QuestionItem::with_options(
"Need a target branch?",
vec!["main".to_string(), "develop".to_string()],
),
QuestionItem::with_options(
"Need integration tests?",
vec!["Yes".to_string(), "No".to_string()],
),
],
Vec::new(),
None,
SessionStats::default(),
);
app.apply_app_events(AppEvent::AgentResponseReceived {
session_id: "session-1".into(),
turn_applied_state,
})
.await;
assert!(matches!(
app.mode,
AppMode::Question {
ref session_id,
ref questions,
review_status_message: Some(ref review_status_message),
review_text: Some(ref review_text),
ref responses,
current_index: 0,
ref input,
selected_option_index: Some(0),
..
} if session_id == "session-1"
&& questions == &expected_questions
&& review_status_message == "Preparing review..."
&& review_text == "Focused review"
&& responses.is_empty()
&& input.text().is_empty()
));
}
#[tokio::test]
async fn apply_app_events_agent_response_keeps_list_mode_when_not_viewing_session() {
let mut app = new_test_app().await;
app.mode = AppMode::List;
app.apply_app_events(AppEvent::AgentResponseReceived {
session_id: "session-1".into(),
turn_applied_state: test_turn_applied_state(
vec![QuestionItem::new("Need context?")],
Vec::new(),
None,
SessionStats::default(),
),
})
.await;
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn apply_app_events_agent_response_updates_session_summary() {
let mut app = new_test_app().await;
app.sessions
.push_session(test_session(PathBuf::from("/tmp/session-summary-view")));
let expected_summary = serde_json::to_string(&AgentResponseSummary {
turn: "- Added structured protocol summary fields.".to_string(),
session: "- Session output now renders persisted summary separately.".to_string(),
})
.expect("summary should serialize");
app.apply_app_events(AppEvent::AgentResponseReceived {
session_id: "session-1".into(),
turn_applied_state: test_turn_applied_state(
Vec::new(),
Vec::new(),
Some(AgentResponseSummary {
turn: "- Added structured protocol summary fields.".to_string(),
session: "- Session output now renders persisted summary separately."
.to_string(),
}),
SessionStats::default(),
),
})
.await;
assert_eq!(
app.sessions.sessions[0].summary.as_deref(),
Some(expected_summary.as_str())
);
}
#[tokio::test]
async fn apply_app_events_agent_response_updates_session_follow_up_tasks() {
let mut app = new_test_app().await;
app.sessions
.push_session(test_session(PathBuf::from("/tmp/session-follow-up-view")));
app.apply_app_events(AppEvent::AgentResponseReceived {
session_id: "session-1".into(),
turn_applied_state: test_turn_applied_state(
Vec::new(),
vec![
"Document the new shortcut.",
"Add a focused regression test.",
],
None,
SessionStats::default(),
),
})
.await;
assert_eq!(
app.sessions.sessions[0]
.follow_up_tasks
.iter()
.map(|task| task.text.clone())
.collect::<Vec<_>>(),
vec![
"Document the new shortcut.".to_string(),
"Add a focused regression test.".to_string()
]
);
}
#[tokio::test]
async fn apply_app_events_ignores_stale_published_branch_sync_updates() {
let mut app = new_test_app().await;
app.sessions
.push_session(test_session(PathBuf::from("/tmp/session-branch-sync-view")));
app.apply_app_events(AppEvent::PublishedBranchSyncUpdated {
session_id: "session-1".into(),
sync_operation_id: "sync-1".to_string(),
sync_status: PublishedBranchSyncStatus::InProgress,
})
.await;
app.apply_app_events(AppEvent::PublishedBranchSyncUpdated {
session_id: "session-1".into(),
sync_operation_id: "sync-2".to_string(),
sync_status: PublishedBranchSyncStatus::InProgress,
})
.await;
app.apply_app_events(AppEvent::PublishedBranchSyncUpdated {
session_id: "session-1".into(),
sync_operation_id: "sync-1".to_string(),
sync_status: PublishedBranchSyncStatus::Failed,
})
.await;
assert_eq!(
app.sessions.sessions[0].published_branch_sync_status,
PublishedBranchSyncStatus::InProgress
);
}
#[tokio::test]
async fn apply_app_events_preserves_completed_published_branch_sync_updates() {
let mut app = new_test_app().await;
let event_sender = app.services.event_sender();
app.sessions.push_session(test_session(PathBuf::from(
"/tmp/session-branch-sync-success",
)));
event_sender
.send(AppEvent::PublishedBranchSyncUpdated {
session_id: "session-1".into(),
sync_operation_id: "sync-1".to_string(),
sync_status: PublishedBranchSyncStatus::Succeeded,
})
.expect("queued event should send");
app.apply_app_events(AppEvent::PublishedBranchSyncUpdated {
session_id: "session-1".into(),
sync_operation_id: "sync-1".to_string(),
sync_status: PublishedBranchSyncStatus::InProgress,
})
.await;
assert_eq!(
app.sessions.sessions[0].published_branch_sync_status,
PublishedBranchSyncStatus::Succeeded
);
}
#[tokio::test]
async fn apply_app_events_agent_response_updates_questions_and_token_usage() {
let mut app = new_test_app().await;
let mut session = test_session(PathBuf::from("/tmp/session-stats-view"));
session.questions = vec![QuestionItem::new("Old question?")];
session.stats.input_tokens = 5;
session.stats.output_tokens = 8;
app.sessions.push_session(session);
app.apply_app_events(AppEvent::AgentResponseReceived {
session_id: "session-1".into(),
turn_applied_state: test_turn_applied_state(
Vec::new(),
Vec::new(),
None,
SessionStats {
added_lines: 0,
deleted_lines: 0,
input_tokens: 13,
output_tokens: 21,
},
),
})
.await;
assert!(app.sessions.sessions[0].questions.is_empty());
assert_eq!(app.sessions.sessions[0].stats.input_tokens, 18);
assert_eq!(app.sessions.sessions[0].stats.output_tokens, 29);
}
#[tokio::test]
async fn apply_app_events_agent_response_starts_auto_review_from_synced_handle_status() {
let mut app = new_test_app().await;
let session_id = "session-1";
let diff_text = "diff --git a/file.rs b/file.rs\n+new line";
let expected_hash = diff_content_hash(diff_text);
app.sessions
.push_session(test_session(PathBuf::from("/tmp/session-auto-review-sync")));
app.sessions.sessions[0].status = Status::InProgress;
app.sessions.handles.insert(
session_id.to_string().into(),
SessionHandles::new(String::new(), Status::InProgress),
);
*app.sessions
.handles
.get(session_id)
.expect("expected session handles")
.status
.lock()
.expect("expected handle status lock") = Status::Review;
let mut mock_git_client = crate::infra::git::MockGitClient::new();
mock_git_client
.expect_diff()
.returning(move |_, _| Box::pin(async move { Ok(diff_text.to_string()) }));
install_mock_git_client(&mut app, mock_git_client);
app.apply_app_events(AppEvent::AgentResponseReceived {
session_id: session_id.into(),
turn_applied_state: test_turn_applied_state(
Vec::new(),
Vec::new(),
None,
SessionStats::default(),
),
})
.await;
assert!(matches!(
app.review_cache.get(session_id),
Some(ReviewCacheEntry::Loading { diff_hash }) if *diff_hash == expected_hash
));
assert_eq!(app.sessions.sessions[0].status, Status::AgentReview);
assert_eq!(
*app.sessions
.handles
.get(session_id)
.expect("expected session handles")
.status
.lock()
.expect("expected handle status lock"),
Status::AgentReview
);
}
#[tokio::test]
async fn apply_app_events_agent_response_starts_auto_review_when_snapshot_already_review() {
let mut app = new_test_app().await;
let session_id = "session-1";
let diff_text = "diff --git a/file.rs b/file.rs\n+new line";
let expected_hash = diff_content_hash(diff_text);
app.sessions
.push_session(test_session(PathBuf::from("/tmp/session-already-review")));
app.sessions.sessions[0].status = Status::Review;
app.sessions.handles.insert(
session_id.to_string().into(),
SessionHandles::new(String::new(), Status::Review),
);
let mut mock_git_client = crate::infra::git::MockGitClient::new();
mock_git_client
.expect_diff()
.returning(move |_, _| Box::pin(async move { Ok(diff_text.to_string()) }));
install_mock_git_client(&mut app, mock_git_client);
app.apply_app_events(AppEvent::AgentResponseReceived {
session_id: session_id.into(),
turn_applied_state: test_turn_applied_state(
Vec::new(),
Vec::new(),
None,
SessionStats::default(),
),
})
.await;
assert!(matches!(
app.review_cache.get(session_id),
Some(ReviewCacheEntry::Loading { diff_hash }) if *diff_hash == expected_hash
));
assert_eq!(app.sessions.sessions[0].status, Status::AgentReview);
}
#[tokio::test]
async fn apply_app_events_agent_response_batches_same_session_turns() {
let mut app = new_test_app().await;
let event_sender = app.services.event_sender();
app.sessions
.push_session(test_session(PathBuf::from("/tmp/session-batched-turns")));
let first_turn = test_turn_applied_state(
vec![QuestionItem::new("First question?")],
Vec::new(),
None,
SessionStats {
added_lines: 0,
deleted_lines: 0,
input_tokens: 2,
output_tokens: 3,
},
);
let second_turn = test_turn_applied_state(
vec![QuestionItem::new("Latest question?")],
vec!["Capture reducer batching coverage."],
None,
SessionStats {
added_lines: 0,
deleted_lines: 0,
input_tokens: 5,
output_tokens: 8,
},
);
event_sender
.send(AppEvent::AgentResponseReceived {
session_id: "session-1".into(),
turn_applied_state: second_turn,
})
.expect("queued event should send");
app.apply_app_events(AppEvent::AgentResponseReceived {
session_id: "session-1".into(),
turn_applied_state: first_turn,
})
.await;
assert_eq!(
app.sessions.sessions[0].questions,
vec![QuestionItem::new("Latest question?")]
);
assert_eq!(app.sessions.sessions[0].stats.input_tokens, 7);
assert_eq!(app.sessions.sessions[0].stats.output_tokens, 11);
assert_eq!(
app.sessions.sessions[0]
.follow_up_tasks
.iter()
.map(|task| task.text.clone())
.collect::<Vec<_>>(),
vec!["Capture reducer batching coverage.".to_string()]
);
}
#[tokio::test]
async fn launch_or_open_selected_follow_up_task_opens_existing_sibling_session() {
let mut app = new_test_app().await;
let mut source_session = test_session(PathBuf::from("/tmp/source-session"));
source_session.follow_up_tasks = vec![SessionFollowUpTask {
id: 1,
launched_session_id: Some("session-2".into()),
position: 0,
text: "Open the sibling session.".to_string(),
}];
let mut sibling_session = test_session(PathBuf::from("/tmp/sibling-session"));
sibling_session.id = "session-2".into();
sibling_session.title = Some("Sibling session".to_string());
app.sessions.push_session(source_session);
app.sessions.push_session(sibling_session);
app.launch_or_open_selected_follow_up_task("session-1")
.await
.expect("follow-up task should open the linked sibling session");
assert_eq!(app.sessions.table_state.selected(), Some(1));
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
..
} if session_id == "session-2"
));
}
#[tokio::test]
async fn launch_or_open_selected_follow_up_task_clears_stale_sibling_link_before_launch() {
let mut app = new_test_app().await;
let mut source_session = test_session(PathBuf::from("/tmp/source-session"));
source_session.follow_up_tasks = vec![SessionFollowUpTask {
id: 1,
launched_session_id: Some("missing-session".into()),
position: 0,
text: "Open the sibling session.".to_string(),
}];
app.sessions.push_session(source_session);
let result = app
.launch_or_open_selected_follow_up_task("session-1")
.await;
assert!(matches!(
result,
Err(AppError::Session(crate::app::SessionError::Workflow(message)))
if message == "Git branch is required to create a session"
));
assert_eq!(app.sessions.sessions.len(), 1);
assert_eq!(
app.sessions.sessions[0].follow_up_tasks[0].launched_session_id,
None
);
}
#[tokio::test]
async fn apply_app_events_session_updated_keeps_done_view_review_state() {
let mut app = new_test_app().await;
app.sessions
.push_session(test_session(PathBuf::from("/tmp/session-done-view")));
app.sessions.handles.insert(
"session-1".into(),
SessionHandles::new("Merge finished".to_string(), Status::Done),
);
app.mode = AppMode::View {
review_status_message: Some(review_loading_message(AgentModel::Gpt54)),
review_text: Some("Review text".to_string()),
session_id: "session-1".into(),
scroll_offset: Some(9),
};
app.apply_app_events(AppEvent::SessionUpdated {
session_id: "session-1".into(),
version: 1,
})
.await;
assert!(matches!(
app.mode,
AppMode::View {
scroll_offset: Some(9),
..
}
));
}
#[tokio::test]
async fn apply_app_events_refresh_keeps_viewed_merging_session_without_worktree() {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
let database = AppRepositories::in_memory().await;
let project_id = database
.upsert_project(&base_path.to_string_lossy(), None)
.await
.expect("failed to upsert project");
database
.insert_session(
"session-1",
AgentModel::Gemini3FlashPreview.as_str(),
"main",
&Status::Merging.to_string(),
project_id,
)
.await
.expect("failed to insert merging session");
let mut app = App::new_with_clients(
base_path.clone(),
base_path.clone(),
None,
database,
test_app_clients(),
)
.await
.expect("failed to build app");
let session_folder = base_path.join("session-1");
let mut viewed_session = test_session(session_folder);
viewed_session.status = Status::Merging;
app.sessions.push_session(viewed_session);
app.sessions.handles.insert(
"session-1".into(),
SessionHandles::new("Merging".to_string(), Status::Merging),
);
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: "session-1".into(),
scroll_offset: None,
};
app.apply_app_events(AppEvent::RefreshSessions).await;
assert!(
app.sessions
.sessions
.iter()
.any(|session| session.id == "session-1" && session.status == Status::Merging)
);
assert!(matches!(
app.mode,
AppMode::View {
ref session_id, ..
} if session_id == "session-1"
));
}
#[test]
fn discover_home_project_paths_includes_git_repos_and_excludes_session_worktrees() {
let home_directory = tempdir().expect("failed to create temp dir");
let top_level_repo = home_directory.path().join("agentty");
create_git_repo_marker(top_level_repo.as_path());
let nested_repo = home_directory.path().join("code").join("service");
create_git_repo_marker(nested_repo.as_path());
let session_worktree_root = home_directory.path().join("agentty-worktrees");
let session_worktree_repo = session_worktree_root.join("a1b2c3d4");
create_git_repo_marker(session_worktree_repo.as_path());
let discovered_project_paths = App::discover_home_project_paths(
home_directory.path(),
session_worktree_root.as_path(),
);
assert!(
discovered_project_paths.contains(&top_level_repo),
"top-level git repository should be discovered"
);
assert!(
discovered_project_paths.contains(&nested_repo),
"nested git repository should be discovered"
);
assert!(
!discovered_project_paths.contains(&session_worktree_repo),
"session worktree repositories must be excluded"
);
}
#[test]
fn discover_home_project_paths_respects_repository_limit() {
let home_directory = tempdir().expect("failed to create temp dir");
for index in 0..=HOME_PROJECT_SCAN_MAX_RESULTS {
let repository = home_directory.path().join(format!("repo-{index}"));
create_git_repo_marker(repository.as_path());
}
let discovered_project_paths = App::discover_home_project_paths(
home_directory.path(),
Path::new("/tmp/non-session-worktree"),
);
assert_eq!(
discovered_project_paths.len(),
HOME_PROJECT_SCAN_MAX_RESULTS
);
}
#[test]
fn resolve_agentty_home_returns_env_override_when_set() {
let agentty_root = Some(PathBuf::from("/tmp/custom-agentty"));
let home_dir = Some(PathBuf::from("/home/test-user"));
let home = resolve_agentty_home(agentty_root, home_dir);
assert_eq!(home, PathBuf::from("/tmp/custom-agentty"));
}
#[test]
fn resolve_agentty_home_falls_back_to_home_directory_when_override_is_empty() {
let agentty_root = Some(PathBuf::new());
let home_dir = Some(PathBuf::from("/home/test-user"));
let home = resolve_agentty_home(agentty_root, home_dir);
assert_eq!(home, PathBuf::from("/home/test-user/.agentty"));
}
#[test]
fn resolve_agentty_home_falls_back_to_relative_directory_without_home_dir() {
let agentty_root = None;
let home_dir = None;
let home = resolve_agentty_home(agentty_root, home_dir);
assert_eq!(home, PathBuf::from(".agentty"));
}
#[test]
fn is_session_worktree_project_path_returns_true_for_agentty_worktree_path() {
let session_worktree_root = Path::new("/home/test/.agentty/wt");
let project_path = "/home/test/.agentty/wt/a1b2c3d4";
let is_session_worktree =
App::is_session_worktree_project_path(project_path, session_worktree_root);
assert!(is_session_worktree);
}
#[test]
fn is_session_worktree_project_path_returns_false_for_main_repository_path() {
let session_worktree_root = Path::new("/home/test/.agentty/wt");
let project_path = "/home/test/src/agentty";
let is_session_worktree =
App::is_session_worktree_project_path(project_path, session_worktree_root);
assert!(!is_session_worktree);
}
#[test]
fn is_existing_project_path_returns_true_when_fs_client_reports_directory() {
let project_path = "/home/test/src/agentty";
let expected_path = PathBuf::from(project_path);
let mut fs_client = crate::infra::fs::MockFsClient::new();
fs_client
.expect_is_dir()
.once()
.withf(move |path| path == &expected_path)
.return_const(true);
let project_exists = App::is_existing_project_path(&fs_client, project_path);
assert!(project_exists);
}
#[tokio::test]
async fn active_project_has_tasks_tab_returns_true_when_roadmap_file_exists() {
let project_path = PathBuf::from("/home/test/src/agentty");
let roadmap_path = project_path.join(TASKS_ROADMAP_PATH);
let mut fs_client = crate::infra::fs::MockFsClient::new();
fs_client.expect_is_dir().returning(|_| true);
fs_client
.expect_is_file()
.once()
.withf(move |path| path == &roadmap_path)
.return_const(true);
let roadmap_path = project_path.join(TASKS_ROADMAP_PATH);
fs_client
.expect_read_file()
.once()
.withf(move |path| path == &roadmap_path)
.return_once(|_| Box::pin(async { Ok(b"# roadmap".to_vec()) }));
let app = app_with_fs_client(project_path, Arc::new(fs_client)).await;
let has_tasks_tab = app.active_project_has_tasks_tab();
let cached_has_tasks_tab = app.active_project_has_tasks_tab();
assert!(has_tasks_tab);
assert!(cached_has_tasks_tab);
}
#[tokio::test]
async fn active_project_has_tasks_tab_returns_false_when_roadmap_file_is_missing() {
let project_path = PathBuf::from("/home/test/src/agentty");
let roadmap_path = project_path.join(TASKS_ROADMAP_PATH);
let mut fs_client = crate::infra::fs::MockFsClient::new();
fs_client.expect_is_dir().returning(|_| true);
fs_client
.expect_is_file()
.once()
.withf(move |path| path == &roadmap_path)
.return_const(false);
let app = app_with_fs_client(project_path, Arc::new(fs_client)).await;
let has_tasks_tab = app.active_project_has_tasks_tab();
let cached_has_tasks_tab = app.active_project_has_tasks_tab();
assert!(!has_tasks_tab);
assert!(!cached_has_tasks_tab);
}
#[tokio::test]
async fn apply_app_events_refreshes_roadmap_after_successful_sync_main() {
let project_path = PathBuf::from("/home/test/src/agentty");
let roadmap_path = project_path.join(TASKS_ROADMAP_PATH);
let roadmap_path_for_file_check = roadmap_path.clone();
let roadmap_path_for_read = roadmap_path.clone();
let roadmap_snapshots = Arc::new(Mutex::new(VecDeque::from([
b"# roadmap before sync".to_vec(),
b"# roadmap after sync".to_vec(),
])));
let mut fs_client = crate::infra::fs::MockFsClient::new();
fs_client.expect_is_dir().returning(|_| true);
fs_client
.expect_is_file()
.times(2)
.withf(move |path| path == &roadmap_path_for_file_check)
.return_const(true);
fs_client
.expect_read_file()
.times(2)
.withf(move |path| path == &roadmap_path_for_read)
.returning(move |_| {
let roadmap_snapshots = Arc::clone(&roadmap_snapshots);
Box::pin(async move {
let mut roadmap_snapshots = roadmap_snapshots
.lock()
.expect("roadmap snapshot lock should not be poisoned");
Ok(roadmap_snapshots
.pop_front()
.expect("expected a queued roadmap snapshot"))
})
});
let mut app = app_with_fs_client(project_path, Arc::new(fs_client)).await;
app.apply_app_events(AppEvent::SyncMainCompleted {
result: Ok(SyncMainOutcome {
pulled_commit_titles: vec!["Refresh roadmap".to_string()],
pulled_commits: Some(1),
pushed_commit_titles: Vec::new(),
pushed_commits: Some(0),
resolved_conflict_files: Vec::new(),
}),
})
.await;
assert_eq!(
app.active_project_roadmap,
Some(ActiveProjectRoadmap::Loaded(
"# roadmap after sync".to_string()
))
);
assert!(app.active_project_has_tasks_tab());
}
#[tokio::test]
async fn apply_app_events_refreshes_roadmap_after_full_session_refresh() {
let project_path = PathBuf::from("/home/test/src/agentty");
let roadmap_path = project_path.join(TASKS_ROADMAP_PATH);
let roadmap_path_for_file_check = roadmap_path.clone();
let roadmap_path_for_read = roadmap_path.clone();
let roadmap_snapshots = Arc::new(Mutex::new(VecDeque::from([
b"# roadmap before merge".to_vec(),
b"# roadmap after merge".to_vec(),
])));
let mut fs_client = crate::infra::fs::MockFsClient::new();
fs_client.expect_is_dir().returning(|_| true);
fs_client
.expect_is_file()
.times(2)
.withf(move |path| path == &roadmap_path_for_file_check)
.return_const(true);
fs_client
.expect_read_file()
.times(2)
.withf(move |path| path == &roadmap_path_for_read)
.returning(move |_| {
let roadmap_snapshots = Arc::clone(&roadmap_snapshots);
Box::pin(async move {
let mut roadmap_snapshots = roadmap_snapshots
.lock()
.expect("roadmap snapshot lock should not be poisoned");
Ok(roadmap_snapshots
.pop_front()
.expect("expected a queued roadmap snapshot"))
})
});
let mut app = app_with_fs_client(project_path, Arc::new(fs_client)).await;
app.apply_app_events(AppEvent::RefreshSessions).await;
assert_eq!(
app.active_project_roadmap,
Some(ActiveProjectRoadmap::Loaded(
"# roadmap after merge".to_string()
))
);
assert!(app.active_project_has_tasks_tab());
}
#[test]
fn visible_project_rows_excludes_missing_and_session_worktree_projects() {
let existing_project_path = "/home/test/src/agentty".to_string();
let session_worktree_project_path = "/home/test/.agentty/wt/a1b2c3d4".to_string();
let missing_project_path = "/home/test/src/removed".to_string();
let session_worktree_root = Path::new("/home/test/.agentty/wt");
let project_rows = vec![
project_list_row_fixture(1, existing_project_path.clone()),
project_list_row_fixture(2, session_worktree_project_path),
project_list_row_fixture(3, missing_project_path.clone()),
];
let mut fs_client = crate::infra::fs::MockFsClient::new();
let existing_project_path_for_match = PathBuf::from(existing_project_path.clone());
let missing_project_path_for_match = PathBuf::from(missing_project_path);
fs_client
.expect_is_dir()
.once()
.withf(move |path| path == &existing_project_path_for_match)
.return_const(true);
fs_client
.expect_is_dir()
.once()
.withf(move |path| path == &missing_project_path_for_match)
.return_const(false);
let visible_rows =
App::visible_project_rows(project_rows, &fs_client, session_worktree_root);
assert_eq!(visible_rows.len(), 1);
assert_eq!(visible_rows[0].path, existing_project_path);
}
#[tokio::test]
async fn resolve_startup_active_project_id_falls_back_when_stored_project_path_is_missing() {
let current_project_dir = tempdir().expect("failed to create current project dir");
let current_project_path = current_project_dir.path().to_path_buf();
let missing_project_path = current_project_path.join("removed-project");
let database = AppRepositories::in_memory().await;
let current_project_id = database
.upsert_project(¤t_project_path.to_string_lossy(), Some("main"))
.await
.expect("failed to insert current project");
let missing_project_id = database
.upsert_project(&missing_project_path.to_string_lossy(), Some("main"))
.await
.expect("failed to insert missing project");
database
.set_active_project_id(missing_project_id)
.await
.expect("failed to persist active project");
let missing_project_path = missing_project_path.clone();
let mut fs_client = crate::infra::fs::MockFsClient::new();
fs_client
.expect_is_dir()
.once()
.withf(move |path| path == &missing_project_path)
.return_const(false);
let resolved_project_id =
App::resolve_startup_active_project_id(&database, &fs_client, current_project_id).await;
assert_eq!(resolved_project_id, current_project_id);
}
#[tokio::test]
async fn apply_app_events_refresh_sessions_reloads_project_active_session_count() {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
let database = AppRepositories::in_memory().await;
let project_id = database
.upsert_project(&base_path.to_string_lossy(), None)
.await
.expect("failed to upsert project");
database
.insert_session(
"session-active",
"gemini-3-flash-preview",
"main",
&Status::Review.to_string(),
project_id,
)
.await
.expect("failed to insert active session");
let session_folder_name = "session-".chars().take(8).collect::<String>();
let session_data_dir = base_path.join(session_folder_name).join(SESSION_DATA_DIR);
fs::create_dir_all(session_data_dir).expect("failed to create session dir");
let mut app = App::new_with_clients(
base_path.clone(),
base_path,
None,
database,
test_app_clients(),
)
.await
.expect("failed to build app");
let initial_active_count = app
.projects
.project_items()
.iter()
.find(|item| item.project.id == project_id)
.map_or(0, |item| item.active_session_count);
assert_eq!(initial_active_count, 1);
app.services
.db()
.update_session_status_with_timing_at("session-active", &Status::Done.to_string(), 0)
.await
.expect("failed to update session status");
app.apply_app_events(AppEvent::RefreshSessions).await;
let updated_active_count = app
.projects
.project_items()
.iter()
.find(|item| item.project.id == project_id)
.map_or(0, |item| item.active_session_count);
assert_eq!(updated_active_count, 0);
}
#[tokio::test]
async fn load_project_items_uses_persisted_rows_without_home_scan() {
let database = AppRepositories::in_memory().await;
let home_directory = tempdir().expect("failed to create temp dir");
let discovered_repo = home_directory.path().join("agentty");
create_git_repo_marker(discovered_repo.as_path());
let fs_client = RealFsClient;
let session_worktree_root = home_directory.path().join(".agentty").join(AGENTTY_WT_DIR);
let project_items = App::load_project_items_with_session_worktree_root(
&database,
&fs_client,
session_worktree_root.as_path(),
)
.await;
assert!(project_items.is_empty());
assert!(
database
.load_projects_with_stats()
.await
.expect("failed to load projects")
.is_empty()
);
}
#[tokio::test]
async fn refresh_project_catalog_on_startup_discovers_home_directory_repositories() {
let database = AppRepositories::in_memory().await;
let home_directory = tempdir().expect("failed to create temp dir");
let discovered_repo = home_directory.path().join("agentty");
create_git_repo_marker(discovered_repo.as_path());
let fs_client = RealFsClient;
let session_worktree_root = home_directory.path().join(".agentty").join(AGENTTY_WT_DIR);
App::load_projects_from_home_directory(
&database,
&RealProjectDiscoveryClient,
session_worktree_root.as_path(),
Some(home_directory.path()),
)
.await;
let project_items = App::load_project_items_with_session_worktree_root(
&database,
&fs_client,
session_worktree_root.as_path(),
)
.await;
assert_eq!(project_items.len(), 1);
assert_eq!(project_items[0].project.path, discovered_repo);
}
fn create_git_repo_marker(repository_path: &Path) {
fs::create_dir_all(repository_path.join(".git"))
.expect("failed to create repository .git marker");
}
fn project_list_row_fixture(project_id: i64, project_path: String) -> db::ProjectListRow {
db::ProjectListRow {
active_session_count: 0,
created_at: 0,
display_name: None,
git_branch: Some("main".to_string()),
id: project_id,
is_favorite: false,
last_opened_at: None,
last_session_updated_at: None,
path: project_path,
session_count: 0,
updated_at: 0,
}
}
fn install_mock_git_client(app: &mut App, mock_git_client: crate::infra::git::MockGitClient) {
let mock_git_client: Arc<dyn crate::infra::git::GitClient> = Arc::new(mock_git_client);
let base_path = app.services.base_path().to_path_buf();
let db = app.services.db().clone();
let event_sender = app.services.event_sender();
let available_agent_kinds = app.services.available_agent_kinds();
let app_server_client_override = app.services.app_server_client_override();
let fs_client = app.services.fs_client();
let review_request_client = app.services.review_request_client();
app.services = AppServices::new(
base_path,
app.services.clock(),
event_sender,
AppServiceDeps {
app_server_client_override,
available_agent_kinds,
fs_client,
git_client: Arc::clone(&mock_git_client),
repositories: db,
review_request_client,
},
);
}
#[tokio::test]
async fn test_continue_terminal_session_opens_draft_prompt_for_done_session_with_hash() {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
let database = AppRepositories::in_memory().await;
let clients = test_app_clients()
.with_app_server_client_override(mock_app_server())
.with_tmux_client(Arc::new(MockTmuxClient::new()));
let mut app = App::new_with_clients(
base_path.clone(),
base_path.clone(),
Some("main".to_string()),
database,
clients,
)
.await
.expect("failed to build test app");
let project_id = app
.services
.db()
.upsert_project(&base_path.to_string_lossy(), None)
.await
.expect("failed to insert project");
app.services
.db()
.insert_session("done-source", "gpt-5.4", "release", "Done", project_id)
.await
.expect("failed to insert source session row");
let merged_commit_hash = "704de31d0f4b5a1234567890abcdef1234567890";
app.services
.db()
.update_session_merged_commit_hash("done-source", Some(merged_commit_hash))
.await
.expect("failed to persist merged commit hash");
let mut source_session = crate::domain::session::tests::SessionFixtureBuilder::new()
.id("done-source")
.status(Status::Done)
.project_name("project-alpha")
.title(Some("Done source".to_string()))
.build();
source_session.base_branch = "release".to_string();
app.sessions.push_session(source_session);
let mut mock_git_client = crate::infra::git::MockGitClient::new();
mock_git_client
.expect_find_git_repo_root()
.times(1..)
.returning(|path| Box::pin(async move { Some(path) }));
mock_git_client
.expect_fetch_remote()
.times(0..)
.returning(|_| Box::pin(async { Ok(()) }));
mock_git_client
.expect_branch_tracking_statuses()
.times(0..)
.returning(|_| Box::pin(async { Ok(HashMap::new()) }));
mock_git_client
.expect_get_ref_ahead_behind()
.times(0..)
.returning(|_, _, _| Box::pin(async { Ok((0, 0)) }));
install_mock_git_client(&mut app, mock_git_client);
let continued_session_id = app
.continue_terminal_session("done-source")
.await
.expect("expected terminal continuation to succeed");
assert_ne!(continued_session_id, "done-source");
assert!(matches!(
app.mode,
AppMode::Prompt {
ref input,
ref session_id,
..
} if session_id.as_str() == continued_session_id
&& input.text().is_empty()
));
let continued_session = app
.sessions
.sessions
.iter()
.find(|session| session.id == continued_session_id)
.expect("expected created continuation draft");
assert!(continued_session.is_draft_session());
assert_eq!(continued_session.base_branch, "release");
assert_eq!(continued_session.status, Status::New);
assert_eq!(
continued_session.prompt,
format!(
"Summarize changes from {merged_commit_hash} to use it as an initial context for \
this session"
)
);
assert!(matches!(
app.selected_session(),
Some(session) if session.id == continued_session_id
));
}
#[tokio::test]
async fn test_continue_terminal_session_falls_back_to_persisted_context_without_hash() {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
let database = AppRepositories::in_memory().await;
let clients = test_app_clients()
.with_app_server_client_override(mock_app_server())
.with_tmux_client(Arc::new(MockTmuxClient::new()));
let mut app = App::new_with_clients(
base_path.clone(),
base_path,
Some("main".to_string()),
database,
clients,
)
.await
.expect("failed to build test app");
let project_id = app.active_project_id();
app.services
.db()
.insert_session("canceled-source", "gpt-5.4", "main", "Canceled", project_id)
.await
.expect("failed to insert source session row");
let source_session = crate::domain::session::tests::SessionFixtureBuilder::new()
.id("canceled-source")
.status(Status::Canceled)
.summary(Some("# Summary\n\nUse the saved context.".to_string()))
.title(Some("Canceled source".to_string()))
.build();
app.sessions.push_session(source_session);
let mut mock_git_client = crate::infra::git::MockGitClient::new();
mock_git_client
.expect_find_git_repo_root()
.times(1..)
.returning(|path| Box::pin(async move { Some(path) }));
mock_git_client
.expect_fetch_remote()
.times(0..)
.returning(|_| Box::pin(async { Ok(()) }));
mock_git_client
.expect_branch_tracking_statuses()
.times(0..)
.returning(|_| Box::pin(async { Ok(HashMap::new()) }));
mock_git_client
.expect_get_ref_ahead_behind()
.times(0..)
.returning(|_, _, _| Box::pin(async { Ok((0, 0)) }));
install_mock_git_client(&mut app, mock_git_client);
let continued_session_id = app
.continue_terminal_session("canceled-source")
.await
.expect("expected canceled continuation to succeed");
assert!(matches!(
app.mode,
AppMode::Prompt {
ref input,
ref session_id,
..
} if session_id.as_str() == continued_session_id && input.text().is_empty()
));
let continued_session = app
.sessions
.sessions
.iter()
.find(|session| session.id == continued_session_id)
.expect("expected created continuation draft");
assert_eq!(
continued_session.prompt,
"Continue the work from this previous Agentty session.\n\nPrevious session: Canceled \
source\nProject: project\nStatus: Canceled\n\nPrevious session summary:\n# \
Summary\n\nUse the saved context.\n"
);
}
#[tokio::test]
async fn test_continue_terminal_session_rejects_non_terminal_source_session() {
let mut app = new_test_app().await;
let source_session = crate::domain::session::tests::SessionFixtureBuilder::new()
.id("review-source")
.status(Status::Review)
.summary(Some("summary".to_string()))
.build();
app.sessions.push_session(source_session);
let result = app.continue_terminal_session("review-source").await;
assert!(matches!(
result,
Err(AppError::Workflow(message))
if message == "Only `Done` or `Canceled` sessions can be continued"
));
}
#[tokio::test]
async fn test_continue_terminal_session_reports_legacy_session_without_project() {
let mut app = new_test_app().await;
let source_session = crate::domain::session::tests::SessionFixtureBuilder::new()
.id("legacy-source")
.status(Status::Canceled)
.summary(Some("summary".to_string()))
.build();
app.sessions.push_session(source_session);
let result = app.continue_terminal_session("legacy-source").await;
assert!(matches!(
result,
Err(AppError::Workflow(message))
if message == "Source session has no project association. Restart Agentty from \
this project to backfill legacy sessions, then continue the session again."
));
}
#[tokio::test]
async fn apply_review_update_stores_success_in_cache() {
let mut app = new_test_app().await;
let session_id = "session-review-cache";
let review_text = "## Review\nLooks good.";
let mut session = test_session(PathBuf::from("/tmp/session-review-cache"));
session.id = session_id.to_string().into();
session.status = Status::AgentReview;
app.sessions.push_session(session);
app.sessions.handles.insert(
session_id.to_string().into(),
SessionHandles::new(String::new(), Status::AgentReview),
);
app.review_cache.insert(
session_id.to_string().into(),
ReviewCacheEntry::Loading { diff_hash: 123 },
);
app.apply_review_update(
session_id,
ReviewUpdate {
diff_hash: 123,
result: Ok(review_text.to_string()),
},
);
assert!(matches!(
app.review_cache.get(session_id),
Some(ReviewCacheEntry::Ready { text, diff_hash }) if text == review_text && *diff_hash == 123
));
assert_eq!(app.sessions.sessions[0].status, Status::Review);
assert_eq!(
*app.sessions
.handles
.get(session_id)
.expect("expected session handles")
.status
.lock()
.expect("expected handle status lock"),
Status::Review
);
}
#[tokio::test]
async fn apply_review_update_stores_failure_in_cache() {
let mut app = new_test_app().await;
let session_id = "session-review-fail";
let error_message = "Review assist failed with exit code 1";
app.review_cache.insert(
session_id.to_string().into(),
ReviewCacheEntry::Loading { diff_hash: 456 },
);
app.apply_review_update(
session_id,
ReviewUpdate {
diff_hash: 456,
result: Err(error_message.to_string()),
},
);
assert!(matches!(
app.review_cache.get(session_id),
Some(ReviewCacheEntry::Failed { error, diff_hash }) if error == error_message && *diff_hash == 456
));
}
#[tokio::test]
async fn apply_review_update_ignores_stale_diff_hash() {
let mut app = new_test_app().await;
let session_id = "session-review-stale";
app.review_cache.insert(
session_id.to_string().into(),
ReviewCacheEntry::Loading { diff_hash: 999 },
);
app.apply_review_update(
session_id,
ReviewUpdate {
diff_hash: 111,
result: Ok("stale review".to_string()),
},
);
assert!(matches!(
app.review_cache.get(session_id),
Some(ReviewCacheEntry::Loading { diff_hash }) if *diff_hash == 999
));
}
#[tokio::test]
async fn apply_review_update_keeps_non_agent_review_status_unchanged() {
let mut app = new_test_app().await;
let session_id = "session-review-progress";
let mut session = test_session(PathBuf::from("/tmp/session-review-progress"));
session.id = session_id.to_string().into();
session.status = Status::InProgress;
app.sessions.push_session(session);
app.sessions.handles.insert(
session_id.to_string().into(),
SessionHandles::new(String::new(), Status::InProgress),
);
app.review_cache.insert(
session_id.to_string().into(),
ReviewCacheEntry::Loading { diff_hash: 222 },
);
app.apply_review_update(
session_id,
ReviewUpdate {
diff_hash: 222,
result: Ok("## Review\nBackground review".to_string()),
},
);
assert_eq!(app.sessions.sessions[0].status, Status::InProgress);
assert_eq!(
*app.sessions
.handles
.get(session_id)
.expect("expected session handles")
.status
.lock()
.expect("expected handle status lock"),
Status::InProgress
);
}
#[tokio::test]
async fn auto_start_reviews_clears_cache_on_in_progress_transition() {
let mut app = new_test_app().await;
let session_id = "session-1";
app.sessions
.push_session(test_session(PathBuf::from("/tmp/session-cache-clear")));
app.sessions.sessions[0].status = Status::InProgress;
app.review_cache.insert(
session_id.to_string().into(),
ReviewCacheEntry::Ready {
diff_hash: 789,
text: "old review".to_string(),
},
);
let session_ids = HashSet::from([session_id.into()]);
app.auto_start_reviews(&session_ids).await;
assert!(!app.review_cache.contains_key(session_id));
}
#[tokio::test]
async fn auto_start_reviews_skips_when_diff_hash_unchanged() {
let mut app = new_test_app().await;
let session_id = "session-1";
app.sessions
.push_session(test_session(PathBuf::from("/tmp/session-hash-skip")));
app.sessions.sessions[0].status = Status::Review;
let diff_text = "diff --git a/file.rs b/file.rs\n+new line";
let hash = diff_content_hash(diff_text);
app.review_cache.insert(
session_id.to_string().into(),
ReviewCacheEntry::Ready {
diff_hash: hash,
text: "existing review".to_string(),
},
);
let session_ids = HashSet::from([session_id.into()]);
let mut mock_git_client = crate::infra::git::MockGitClient::new();
mock_git_client
.expect_diff()
.returning(move |_, _| Box::pin(async move { Ok(diff_text.to_string()) }));
install_mock_git_client(&mut app, mock_git_client);
app.auto_start_reviews(&session_ids).await;
assert!(matches!(
app.review_cache.get(session_id),
Some(ReviewCacheEntry::Ready { text, .. }) if text == "existing review"
));
}
#[tokio::test]
async fn auto_start_reviews_skips_when_already_loading_with_same_hash() {
let mut app = new_test_app().await;
let session_id = "session-1";
app.sessions
.push_session(test_session(PathBuf::from("/tmp/session-loading-skip")));
app.sessions.sessions[0].status = Status::Review;
let diff_text = "diff --git a/file.rs b/file.rs\n+new line";
let hash = diff_content_hash(diff_text);
app.review_cache.insert(
session_id.to_string().into(),
ReviewCacheEntry::Loading { diff_hash: hash },
);
let session_ids = HashSet::from([session_id.into()]);
let mut mock_git_client = crate::infra::git::MockGitClient::new();
mock_git_client
.expect_diff()
.returning(move |_, _| Box::pin(async move { Ok(diff_text.to_string()) }));
install_mock_git_client(&mut app, mock_git_client);
app.auto_start_reviews(&session_ids).await;
assert!(matches!(
app.review_cache.get(session_id),
Some(ReviewCacheEntry::Loading { diff_hash }) if *diff_hash == hash
));
assert_eq!(app.sessions.sessions[0].status, Status::Review);
}
#[tokio::test]
async fn auto_start_reviews_skips_when_auto_review_is_suppressed() {
let mut app = new_test_app().await;
let session_id = "session-1";
app.sessions
.push_session(test_session(PathBuf::from("/tmp/session-suppressed-skip")));
app.sessions.sessions[0].status = Status::Review;
let diff_text = "diff --git a/file.rs b/file.rs\n+stopped turn";
let hash = diff_content_hash(diff_text);
app.review_cache.insert(
session_id.to_string().into(),
ReviewCacheEntry::Suppressed { diff_hash: hash },
);
let session_ids = HashSet::from([session_id.into()]);
let mut mock_git_client = crate::infra::git::MockGitClient::new();
mock_git_client
.expect_diff()
.returning(move |_, _| Box::pin(async move { Ok(diff_text.to_string()) }));
install_mock_git_client(&mut app, mock_git_client);
app.auto_start_reviews(&session_ids).await;
assert!(matches!(
app.review_cache.get(session_id),
Some(ReviewCacheEntry::Suppressed { diff_hash }) if *diff_hash == hash
));
assert_eq!(app.sessions.sessions[0].status, Status::Review);
}
#[tokio::test]
async fn auto_start_reviews_starts_loading_for_review_session() {
let mut app = new_test_app().await;
let session_id = "session-1";
app.sessions
.push_session(test_session(PathBuf::from("/tmp/session-hash-start")));
app.sessions.sessions[0].status = Status::Review;
let diff_text = "diff --git a/file.rs b/file.rs\n+new line";
let expected_hash = diff_content_hash(diff_text);
let session_ids = HashSet::from([session_id.into()]);
let mut mock_git_client = crate::infra::git::MockGitClient::new();
mock_git_client
.expect_diff()
.returning(move |_, _| Box::pin(async move { Ok(diff_text.to_string()) }));
install_mock_git_client(&mut app, mock_git_client);
app.auto_start_reviews(&session_ids).await;
assert!(matches!(
app.review_cache.get(session_id),
Some(ReviewCacheEntry::Loading { diff_hash }) if *diff_hash == expected_hash
));
assert_eq!(app.sessions.sessions[0].status, Status::AgentReview);
}
#[tokio::test]
async fn delete_selected_session_clears_review_cache() {
let mut app = new_test_app().await;
app.sessions
.push_session(test_session(PathBuf::from("/tmp/session-delete-cache")));
app.sessions.table_state.select(Some(0));
let session_id = app.sessions.sessions[0].id.clone();
app.review_cache.insert(
session_id.clone(),
ReviewCacheEntry::Ready {
diff_hash: 42,
text: "cached review".to_string(),
},
);
app.delete_selected_session().await;
assert!(!app.review_cache.contains_key(session_id.as_str()));
}
fn test_review_request_summary(
display_id: &str,
state: ReviewRequestState,
) -> ReviewRequestSummary {
ReviewRequestSummary {
display_id: display_id.to_string(),
forge_kind: ForgeKind::GitHub,
source_branch: "wt/session-id".to_string(),
state,
status_summary: None,
target_branch: "main".to_string(),
title: "feat".to_string(),
web_url: String::new(),
}
}
#[tokio::test]
async fn test_apply_review_request_status_update_ignores_background_errors() {
let mut app = new_test_app().await;
app.mode = AppMode::List;
let update = ReviewRequestStatusUpdate {
result: Err("network timeout".to_string()),
session_id: "session-1".into(),
};
app.apply_review_request_status_update(update).await;
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_apply_review_request_status_update_persists_summary() {
let mut app = new_test_app().await;
let project_id = app.active_project_id();
let session_id = "session-1";
app.services
.db()
.insert_session(
session_id,
"gemini-3-flash-preview",
"main",
&Status::Review.to_string(),
project_id,
)
.await
.expect("failed to insert session");
let session_folder_name = session_id.chars().take(8).collect::<String>();
let session_data_dir = app
.services
.base_path()
.join(session_folder_name)
.join(SESSION_DATA_DIR);
fs::create_dir_all(session_data_dir).expect("failed to create session data dir");
app.refresh_sessions_now().await;
let summary = test_review_request_summary("#5", ReviewRequestState::Open);
let task_result = SyncReviewRequestTaskResult {
outcome: session::SyncReviewRequestOutcome::Open {
display_id: "#5".to_string(),
status_summary: None,
},
summary: Some(summary),
};
let update = ReviewRequestStatusUpdate {
result: Ok(task_result),
session_id: session_id.into(),
};
app.apply_review_request_status_update(update).await;
assert_eq!(app.sessions.sessions.len(), 1);
let session = &app.sessions.sessions[0];
let review_request = session
.review_request
.as_ref()
.expect("expected linked review request after sync");
assert_eq!(review_request.summary.display_id, "#5");
}
#[tokio::test]
async fn test_apply_review_request_status_update_closed_cancels_session() {
let mut app = new_test_app().await;
let project_id = app.active_project_id();
let session_id = "session-closed";
app.services
.db()
.insert_session(
session_id,
"gemini-3-flash-preview",
"main",
&Status::Review.to_string(),
project_id,
)
.await
.expect("failed to insert session");
let session_folder_name = session_id.chars().take(8).collect::<String>();
let session_data_dir = app
.services
.base_path()
.join(session_folder_name)
.join(SESSION_DATA_DIR);
fs::create_dir_all(session_data_dir).expect("failed to create session data dir");
app.refresh_sessions_now().await;
let task_result = SyncReviewRequestTaskResult {
outcome: session::SyncReviewRequestOutcome::Closed {
display_id: "#7".to_string(),
},
summary: Some(test_review_request_summary(
"#7",
ReviewRequestState::Closed,
)),
};
let update = ReviewRequestStatusUpdate {
result: Ok(task_result),
session_id: session_id.into(),
};
app.apply_review_request_status_update(update).await;
app.process_pending_app_events().await;
let session = app
.sessions
.session_or_err(session_id)
.expect("expected session to remain loaded");
assert_eq!(session.status, Status::Canceled);
}
#[tokio::test]
async fn test_apply_review_request_status_update_merged_marks_session_done() {
let mut app = new_test_app().await;
let project_id = app.active_project_id();
let session_id = "session-merged";
app.services
.db()
.insert_session(
session_id,
"gemini-3-flash-preview",
"main",
&Status::Review.to_string(),
project_id,
)
.await
.expect("failed to insert session");
let session_folder_name = session_id.chars().take(8).collect::<String>();
let session_data_dir = app
.services
.base_path()
.join(session_folder_name)
.join(SESSION_DATA_DIR);
fs::create_dir_all(session_data_dir).expect("failed to create session data dir");
app.refresh_sessions_now().await;
let task_result = SyncReviewRequestTaskResult {
outcome: session::SyncReviewRequestOutcome::Merged {
display_id: "#9".to_string(),
},
summary: Some(test_review_request_summary(
"#9",
ReviewRequestState::Merged,
)),
};
let update = ReviewRequestStatusUpdate {
result: Ok(task_result),
session_id: session_id.into(),
};
app.apply_review_request_status_update(update).await;
app.process_pending_app_events().await;
let session = app
.sessions
.session_or_err(session_id)
.expect("expected session to remain loaded");
assert_eq!(session.status, Status::Done);
}
}