use std::path::Path;
use std::sync::{Arc, Mutex};
use askama::Template;
use tokio::sync::mpsc;
use crate::app::assist::{
AssistContext, AssistPolicy, FailureTracker, append_assist_header, format_detail_lines,
run_agent_assist,
};
use crate::app::session::{Clock, SessionError, unix_timestamp_from_system_time};
use crate::app::{AppEvent, SessionManager};
use crate::domain::agent::AgentModel;
use crate::domain::session::{SessionSize, Status};
use crate::domain::setting::SettingName;
use crate::infra::agent;
use crate::infra::db::Database;
use crate::infra::git::{self as git, GitClient};
const AUTO_COMMIT_ASSIST_POLICY: AssistPolicy = AssistPolicy {
max_attempts: 10,
max_identical_failure_streak: 3,
};
const SESSION_COMMIT_COAUTHORED_BY_AGENTTY_TRAILER: &str =
"Co-Authored-By: [Agentty](https://github.com/agentty-xyz/agentty)";
#[derive(Template)]
#[template(path = "auto_commit_assist_prompt.md", escape = "none")]
struct AutoCommitAssistPromptTemplate<'a> {
commit_error: &'a str,
}
#[derive(Template)]
#[template(path = "session_commit_message_prompt.md", escape = "none")]
struct SessionCommitMessagePromptTemplate<'a> {
current_commit_message: &'a str,
diff: &'a str,
}
pub(crate) struct SessionTaskService;
pub(crate) struct SessionCommitOutcome {
pub(crate) commit_hash: String,
pub(crate) commit_message: String,
}
pub(crate) struct RunAgentAssistTaskInput {
pub(crate) app_event_tx: mpsc::UnboundedSender<AppEvent>,
pub(crate) child_pid: Arc<Mutex<Option<u32>>>,
pub(crate) db: Database,
pub(crate) folder: std::path::PathBuf,
pub(crate) id: String,
pub(crate) output: Arc<Mutex<String>>,
pub(crate) prompt: String,
pub(crate) session_model: AgentModel,
}
impl SessionTaskService {
pub(crate) async fn refresh_persisted_session_diff_stats(
db: &Database,
git_client: &dyn GitClient,
session_id: &str,
folder: &Path,
) -> Option<(SessionSize, u64, u64)> {
let base_branch = db
.get_session_base_branch(session_id)
.await
.ok()
.flatten()?;
let (computed_size, added_lines, deleted_lines) =
SessionManager::session_diff_stats_for_folder(git_client, folder, &base_branch).await;
db.update_session_diff_stats(
added_lines,
deleted_lines,
session_id,
&computed_size.to_string(),
)
.await
.ok()?;
Some((computed_size, added_lines, deleted_lines))
}
pub(in crate::app) async fn handle_auto_commit(context: AssistContext) {
match Self::commit_changes_with_assist(&context).await {
Ok(Some(outcome)) => {
SessionManager::update_session_title_from_commit_message(
&context.db,
&context.id,
&outcome.commit_message,
&context.app_event_tx,
)
.await;
let message = format!("\n[Commit] committed with hash `{}`\n", outcome.commit_hash);
Self::append_session_output(
&context.output,
&context.db,
&context.app_event_tx,
&context.id,
&message,
)
.await;
Self::request_git_status_refresh(&context.app_event_tx);
}
Ok(None) => {
let message = "\n[Commit] No changes to commit.\n";
Self::append_session_output(
&context.output,
&context.db,
&context.app_event_tx,
&context.id,
message,
)
.await;
}
Err(commit_error) => {
let message = format!("\n[Commit Error] {commit_error}\n");
Self::append_session_output(
&context.output,
&context.db,
&context.app_event_tx,
&context.id,
&message,
)
.await;
}
}
}
pub(super) fn request_git_status_refresh(app_event_tx: &mpsc::UnboundedSender<AppEvent>) {
let _ = app_event_tx.send(AppEvent::RefreshGitStatus);
}
pub(crate) async fn load_include_coauthored_by_agentty_setting(
db: &Database,
session_id: &str,
) -> bool {
let Some(project_id) = db.load_session_project_id(session_id).await.ok().flatten() else {
return false;
};
db.get_project_setting(project_id, SettingName::IncludeCoauthoredByAgentty)
.await
.ok()
.flatten()
.and_then(|setting_value| setting_value.parse::<bool>().ok())
.unwrap_or(false)
}
pub(crate) async fn load_auto_commit_model_setting(
db: &Database,
session_id: &str,
fallback_model: AgentModel,
) -> AgentModel {
let project_id = db.load_session_project_id(session_id).await.ok().flatten();
if let Some(model) =
Self::load_project_model_setting(db, project_id, SettingName::DefaultFastModel).await
{
return model;
}
if let Some(model) =
Self::load_project_model_setting(db, project_id, SettingName::DefaultSmartModel).await
{
return model;
}
fallback_model
}
async fn commit_changes_with_assist(
context: &AssistContext,
) -> Result<Option<SessionCommitOutcome>, SessionError> {
let mut failure_tracker =
FailureTracker::new(AUTO_COMMIT_ASSIST_POLICY.max_identical_failure_streak);
let skip_verify_hooks = cfg!(test);
for assist_attempt in 1..=AUTO_COMMIT_ASSIST_POLICY.max_attempts + 1 {
match Self::commit_changes_with_git_client(context, skip_verify_hooks).await {
Ok(commit_outcome) => {
return Ok(Some(commit_outcome));
}
Err(commit_error) if commit_error.to_string().contains("Nothing to commit") => {
return Ok(None);
}
Err(commit_error) => {
if cfg!(test) {
return Err(commit_error);
}
let commit_error_str = commit_error.to_string();
if failure_tracker.observe(&commit_error_str) {
return Err(SessionError::Workflow(format!(
"Auto-commit assistance made no progress: repeated identical commit \
failure. Last error: {commit_error_str}"
)));
}
if assist_attempt > AUTO_COMMIT_ASSIST_POLICY.max_attempts {
return Err(commit_error);
}
Self::append_commit_assist_header(context, assist_attempt, &commit_error_str)
.await;
Self::run_commit_assist_for_error(context, &commit_error_str).await?;
}
}
}
Err(SessionError::Workflow(
"Failed to auto-commit after assistance attempts".to_string(),
))
}
async fn commit_changes_with_git_client(
context: &AssistContext,
no_verify: bool,
) -> Result<SessionCommitOutcome, SessionError> {
let base_branch = context
.db
.get_session_base_branch(&context.id)
.await?
.ok_or_else(|| {
SessionError::Workflow("Missing session base branch for auto-commit".to_string())
})?;
let auto_commit_model =
Self::load_auto_commit_model_setting(&context.db, &context.id, context.session_model)
.await;
Self::commit_session_changes(
context.git_client.as_ref(),
&context.folder,
&base_branch,
auto_commit_model,
no_verify,
Self::load_include_coauthored_by_agentty_setting(&context.db, &context.id).await,
)
.await
}
async fn load_project_model_setting(
db: &Database,
project_id: Option<i64>,
setting_name: SettingName,
) -> Option<AgentModel> {
let project_id = project_id?;
db.get_project_setting(project_id, setting_name)
.await
.ok()
.flatten()
.and_then(|setting_value| AgentModel::parse_persisted(&setting_value).ok())
}
async fn append_commit_assist_header(
context: &AssistContext,
assist_attempt: usize,
commit_error: &str,
) {
let formatted_error = Self::format_commit_error_for_display(commit_error);
append_assist_header(
context,
"Commit",
assist_attempt,
AUTO_COMMIT_ASSIST_POLICY.max_attempts,
"Resolving auto-commit failure:",
&formatted_error,
)
.await;
}
async fn run_commit_assist_for_error(
context: &AssistContext,
commit_error: &str,
) -> Result<(), SessionError> {
let prompt = Self::auto_commit_assist_prompt(commit_error)?;
let assist_context = AssistContext {
app_event_tx: context.app_event_tx.clone(),
child_pid: Arc::clone(&context.child_pid),
db: context.db.clone(),
folder: context.folder.clone(),
git_client: Arc::clone(&context.git_client),
id: context.id.clone(),
output: Arc::clone(&context.output),
session_model: context.session_model,
};
run_agent_assist(&assist_context, &prompt)
.await
.map_err(|error| error.with_context("Commit assistance failed"))
}
fn auto_commit_assist_prompt(commit_error: &str) -> Result<String, SessionError> {
let commit_error = commit_error.trim();
let template = AutoCommitAssistPromptTemplate { commit_error };
template.render().map_err(|error| {
SessionError::Workflow(format!(
"Failed to render `auto_commit_assist_prompt.md`: {error}"
))
})
}
fn format_commit_error_for_display(commit_error: &str) -> String {
format_detail_lines(commit_error)
}
fn session_commit_message_prompt(
diff: &str,
current_commit_message: Option<&str>,
) -> Result<String, SessionError> {
let stripped_current_commit_message =
current_commit_message.map_or_else(String::new, strip_agentty_coauthor_trailer);
let template = SessionCommitMessagePromptTemplate {
current_commit_message: stripped_current_commit_message.trim(),
diff,
};
template.render().map_err(|error| {
SessionError::Workflow(format!(
"Failed to render `session_commit_message_prompt.md`: {error}"
))
})
}
pub(crate) async fn commit_session_changes(
git_client: &dyn GitClient,
folder: &Path,
base_branch: &str,
session_model: AgentModel,
no_verify: bool,
include_coauthored_by_agentty: bool,
) -> Result<SessionCommitOutcome, SessionError> {
if cfg!(test) {
let folder = folder.to_path_buf();
if git_client.is_worktree_clean(folder.clone()).await? {
return Err(SessionError::Workflow(
"Nothing to commit: no changes detected".to_string(),
));
}
let has_session_commit = git_client
.has_commits_since(folder.clone(), base_branch.to_string())
.await?;
let current_commit_message = if has_session_commit {
git_client.head_commit_message(folder.clone()).await?
} else {
None
};
let Some(current_commit_message) = current_commit_message.as_deref().map(str::trim)
else {
return Err(SessionError::Workflow(
"Session commit generation requires an existing commit message during tests"
.to_string(),
));
};
if current_commit_message.is_empty() {
return Err(SessionError::Workflow(
"Session commit generation requires a non-blank existing commit message \
during tests"
.to_string(),
));
}
let commit_message = append_agentty_coauthor_trailer(
strip_agentty_coauthor_trailer(current_commit_message).trim(),
include_coauthored_by_agentty,
);
git_client
.commit_all_preserving_single_commit(
folder.clone(),
base_branch.to_string(),
commit_message.clone(),
git::SingleCommitMessageStrategy::Replace,
no_verify,
)
.await?;
let commit_hash = git_client.head_short_hash(folder).await?;
return Ok(SessionCommitOutcome {
commit_hash,
commit_message,
});
}
let backend = agent::create_backend(session_model.kind());
Self::commit_session_changes_with_backend(
git_client,
folder,
base_branch,
session_model,
backend.as_ref(),
no_verify,
include_coauthored_by_agentty,
)
.await
}
async fn commit_session_changes_with_backend(
git_client: &dyn GitClient,
folder: &Path,
base_branch: &str,
session_model: AgentModel,
backend: &dyn agent::AgentBackend,
no_verify: bool,
include_coauthored_by_agentty: bool,
) -> Result<SessionCommitOutcome, SessionError> {
let folder = folder.to_path_buf();
if git_client.is_worktree_clean(folder.clone()).await? {
return Err(SessionError::Workflow(
"Nothing to commit: no changes detected".to_string(),
));
}
let diff = git_client
.diff(folder.clone(), base_branch.to_string())
.await?;
let has_session_commit = git_client
.has_commits_since(folder.clone(), base_branch.to_string())
.await?;
let current_commit_message = if has_session_commit {
git_client.head_commit_message(folder.clone()).await?
} else {
None
};
let generated_commit_message = Self::generate_session_commit_message_with_backend(
folder.as_path(),
session_model,
diff.as_str(),
current_commit_message.as_deref(),
backend,
include_coauthored_by_agentty,
)
.await?;
git_client
.commit_all_preserving_single_commit(
folder.clone(),
base_branch.to_string(),
generated_commit_message.clone(),
git::SingleCommitMessageStrategy::Replace,
no_verify,
)
.await?;
let commit_hash = git_client.head_short_hash(folder).await?;
Ok(SessionCommitOutcome {
commit_hash,
commit_message: generated_commit_message,
})
}
async fn generate_session_commit_message_with_backend(
folder: &Path,
session_model: AgentModel,
diff: &str,
current_commit_message: Option<&str>,
backend: &dyn agent::AgentBackend,
include_coauthored_by_agentty: bool,
) -> Result<String, SessionError> {
let prompt = Self::session_commit_message_prompt(diff, current_commit_message)?;
let submission = Self::submit_utility_prompt_with_backend(
session_model,
backend,
agent::OneShotRequest {
child_pid: None,
folder,
model: session_model,
prompt: &prompt,
request_kind: crate::infra::channel::AgentRequestKind::UtilityPrompt,
reasoning_level: crate::domain::agent::ReasoningLevel::default(),
},
)
.await?;
let answer_text = submission.response.to_answer_display_text();
let trimmed_answer_text = answer_text.trim();
if trimmed_answer_text.is_empty() {
return Err(SessionError::Workflow(
"Session commit message model returned blank answer text".to_string(),
));
}
validate_generated_commit_message(trimmed_answer_text)?;
Ok(append_agentty_coauthor_trailer(
trimmed_answer_text,
include_coauthored_by_agentty,
))
}
pub(crate) async fn run_agent_assist_task(
input: RunAgentAssistTaskInput,
) -> Result<(), SessionError> {
let backend = agent::create_backend(input.session_model.kind());
Self::run_agent_assist_task_with_backend(input, backend.as_ref()).await
}
async fn run_agent_assist_task_with_backend(
input: RunAgentAssistTaskInput,
backend: &dyn agent::AgentBackend,
) -> Result<(), SessionError> {
let RunAgentAssistTaskInput {
app_event_tx,
child_pid,
db,
folder,
id,
output,
prompt,
session_model,
} = input;
let assist_submission = Self::submit_utility_prompt_with_backend(
session_model,
backend,
agent::OneShotRequest {
child_pid: Some(child_pid.as_ref()),
folder: &folder,
model: session_model,
prompt: &prompt,
request_kind: crate::infra::channel::AgentRequestKind::UtilityPrompt,
reasoning_level: crate::domain::agent::ReasoningLevel::default(),
},
)
.await?;
let answer_text = assist_submission.response.to_answer_display_text();
if !answer_text.trim().is_empty() {
Self::append_session_output(&output, &db, &app_event_tx, &id, &answer_text).await;
}
let _ = db.update_session_stats(&id, &assist_submission.stats).await;
let _ = db
.upsert_session_usage(&id, session_model.as_str(), &assist_submission.stats)
.await;
Ok(())
}
async fn submit_utility_prompt_with_backend(
session_model: AgentModel,
backend: &dyn agent::AgentBackend,
request: agent::OneShotRequest<'_>,
) -> Result<agent::OneShotSubmission, SessionError> {
if agent::transport_mode(session_model.kind()).uses_app_server() {
let app_server_client = agent::create_app_server_client(session_model.kind(), None)
.ok_or_else(|| {
SessionError::Workflow(format!(
"{} provider did not provide an app-server client",
session_model.kind()
))
})?;
return agent::submit_one_shot_with_app_server_client(
app_server_client.as_ref(),
request,
)
.await
.map_err(SessionError::Workflow);
}
agent::submit_one_shot_with_backend(backend, request)
.await
.map_err(SessionError::Workflow)
}
pub(crate) async fn update_status(
status: &Mutex<Status>,
clock: &dyn Clock,
db: &Database,
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
id: &str,
new: Status,
) -> bool {
let should_update = if let Ok(mut current) = status.lock() {
if (*current).can_transition_to(new) {
*current = new;
true
} else {
false
}
} else {
false
};
if !should_update {
return false;
}
let timestamp_seconds = unix_timestamp_from_system_time(clock.now_system_time());
let _ = db
.update_session_status_with_timing_at(id, &new.to_string(), timestamp_seconds)
.await;
let session_id = id.to_string();
let _ = app_event_tx.send(AppEvent::SessionUpdated { session_id });
if Self::status_requires_full_refresh(new) {
let _ = app_event_tx.send(AppEvent::RefreshSessions);
}
true
}
pub(crate) async fn append_session_output(
output: &Arc<Mutex<String>>,
db: &Database,
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
id: &str,
message: &str,
) {
if let Ok(mut buf) = output.lock() {
buf.push_str(message);
}
let _ = db.append_session_output(id, message).await;
let _ = app_event_tx.send(AppEvent::SessionUpdated {
session_id: id.to_string(),
});
}
pub(crate) fn clear_session_progress(app_event_tx: &mpsc::UnboundedSender<AppEvent>, id: &str) {
Self::set_session_progress(app_event_tx, id, None);
}
pub(crate) fn set_session_progress(
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
id: &str,
progress_message: Option<String>,
) {
let _ = app_event_tx.send(AppEvent::SessionProgressUpdated {
progress_message,
session_id: id.to_string(),
});
}
fn status_requires_full_refresh(status: Status) -> bool {
matches!(
status,
Status::InProgress | Status::Review | Status::Merging | Status::Done | Status::Canceled
)
}
}
fn strip_agentty_coauthor_trailer(commit_message: &str) -> String {
commit_message
.lines()
.filter(|line| line.trim() != SESSION_COMMIT_COAUTHORED_BY_AGENTTY_TRAILER)
.collect::<Vec<_>>()
.join("\n")
}
fn validate_generated_commit_message(commit_message: &str) -> Result<(), SessionError> {
if commit_message
.lines()
.any(|line| line.trim() == SESSION_COMMIT_COAUTHORED_BY_AGENTTY_TRAILER)
{
return Err(SessionError::Workflow(
"Session commit message model must not emit the Agentty coauthor trailer".to_string(),
));
}
Ok(())
}
fn append_agentty_coauthor_trailer(
commit_message: &str,
include_coauthored_by_agentty: bool,
) -> String {
let trimmed_commit_message = commit_message.trim().to_string();
if !include_coauthored_by_agentty || trimmed_commit_message.is_empty() {
return trimmed_commit_message;
}
format!("{trimmed_commit_message}\n\n{SESSION_COMMIT_COAUTHORED_BY_AGENTTY_TRAILER}")
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::process::Command;
use std::sync::Mutex as StdMutex;
use std::time::{Duration, Instant, SystemTime};
use super::*;
use crate::db::Database;
use crate::infra::agent::tests::MockAgentBackend;
use crate::infra::channel::AgentRequestKind;
use crate::infra::git::{GitError, MockGitClient};
struct StaticClock {
now_system_time: StdMutex<SystemTime>,
}
impl StaticClock {
fn new(now_system_time: SystemTime) -> Self {
Self {
now_system_time: StdMutex::new(now_system_time),
}
}
fn set_now_system_time(&self, now_system_time: SystemTime) {
*self
.now_system_time
.lock()
.expect("static clock lock should not be poisoned") = now_system_time;
}
}
impl Clock for StaticClock {
fn now_instant(&self) -> Instant {
Instant::now()
}
fn now_system_time(&self) -> SystemTime {
*self
.now_system_time
.lock()
.expect("static clock lock should not be poisoned")
}
}
fn mock_shell_command(stdout: &str, stderr: &str, exit_code: i32) -> Command {
let mut command = Command::new("sh");
command.arg("-c").arg(
"printf '%s' \"$ASSIST_STDOUT\"; printf '%s' \"$ASSIST_STDERR\" >&2; exit \
\"$ASSIST_EXIT\"",
);
command.env("ASSIST_STDOUT", stdout);
command.env("ASSIST_STDERR", stderr);
command.env("ASSIST_EXIT", exit_code.to_string());
command.stdout(std::process::Stdio::piped());
command.stderr(std::process::Stdio::piped());
command
}
async fn insert_review_session(database: &Database, model: &str) {
let project_id = database
.upsert_project("/tmp/project", Some("main"))
.await
.expect("failed to upsert project");
database
.insert_session("session-id", model, "main", "Review", project_id)
.await
.expect("failed to insert session");
}
#[test]
fn test_status_requires_full_refresh_for_lifecycle_statuses() {
let refresh_statuses = [
Status::InProgress,
Status::Review,
Status::Merging,
Status::Done,
Status::Canceled,
];
for status in refresh_statuses {
assert!(SessionTaskService::status_requires_full_refresh(status));
}
assert!(!SessionTaskService::status_requires_full_refresh(
Status::New
));
}
#[tokio::test]
async fn test_update_status_accumulates_repeated_in_progress_intervals() {
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
let project_id = database
.upsert_project("/tmp/project", Some("main"))
.await
.expect("failed to upsert project");
database
.insert_session(
"session-id",
"gpt-5.4",
"main",
&Status::New.to_string(),
project_id,
)
.await
.expect("failed to insert session");
let status = Mutex::new(Status::New);
let clock = StaticClock::new(SystemTime::UNIX_EPOCH + Duration::from_secs(10));
let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel();
let entered_first_interval = SessionTaskService::update_status(
&status,
&clock,
&database,
&app_event_tx,
"session-id",
Status::InProgress,
)
.await;
clock.set_now_system_time(SystemTime::UNIX_EPOCH + Duration::from_secs(70));
let left_first_interval = SessionTaskService::update_status(
&status,
&clock,
&database,
&app_event_tx,
"session-id",
Status::Review,
)
.await;
clock.set_now_system_time(SystemTime::UNIX_EPOCH + Duration::from_secs(100));
let entered_second_interval = SessionTaskService::update_status(
&status,
&clock,
&database,
&app_event_tx,
"session-id",
Status::InProgress,
)
.await;
clock.set_now_system_time(SystemTime::UNIX_EPOCH + Duration::from_secs(190));
let left_second_interval = SessionTaskService::update_status(
&status,
&clock,
&database,
&app_event_tx,
"session-id",
Status::Question,
)
.await;
let session_row = database
.load_sessions()
.await
.expect("failed to load sessions")
.into_iter()
.find(|row| row.id == "session-id")
.expect("missing session row");
assert!(entered_first_interval);
assert!(left_first_interval);
assert!(entered_second_interval);
assert!(left_second_interval);
assert_eq!(session_row.status, "Question");
assert_eq!(session_row.in_progress_started_at, None);
assert_eq!(session_row.in_progress_total_seconds, 150);
}
#[test]
fn test_auto_commit_assist_prompt_includes_commit_error() {
let commit_error = "Failed to commit: merge conflict remains";
let prompt = SessionTaskService::auto_commit_assist_prompt(commit_error)
.expect("auto commit assist prompt should render");
assert!(prompt.contains("Failed to commit: merge conflict remains"));
assert!(prompt.contains("return the required protocol JSON object"));
assert!(prompt.contains("`answer` field only"));
}
#[test]
fn test_format_commit_error_for_display_returns_bulleted_lines() {
let commit_error = "line one\nline two";
let formatted = SessionTaskService::format_commit_error_for_display(commit_error);
assert_eq!(formatted, "- line one\n- line two");
}
#[test]
fn test_session_commit_message_prompt_includes_continuity_and_diff() {
let diff = "diff --git a/a.rs b/a.rs";
let current_commit_message = Some("Keep session commit accurate");
let prompt =
SessionTaskService::session_commit_message_prompt(diff, current_commit_message)
.expect("prompt should render");
assert!(prompt.contains("Keep session commit accurate"));
assert!(prompt.contains(diff));
assert!(prompt.contains("required protocol JSON object"));
assert!(!prompt.contains("Return one plain-text commit message"));
assert!(!prompt.contains(SESSION_COMMIT_COAUTHORED_BY_AGENTTY_TRAILER));
}
#[test]
fn test_session_commit_message_prompt_strips_coauthor_trailer_from_continuity() {
let diff = "diff --git a/a.rs b/a.rs";
let current_commit_message = format!(
"Keep session commit accurate\n\n{SESSION_COMMIT_COAUTHORED_BY_AGENTTY_TRAILER}"
);
let prompt = SessionTaskService::session_commit_message_prompt(
diff,
Some(current_commit_message.as_str()),
)
.expect("prompt should render");
assert!(!prompt.contains(SESSION_COMMIT_COAUTHORED_BY_AGENTTY_TRAILER));
assert!(prompt.contains("Keep session commit accurate"));
}
#[tokio::test]
async fn test_generate_session_commit_message_with_backend_rejects_plain_text_output() {
let temp_directory = tempfile::tempdir().expect("failed to create temp dir");
let mut backend = MockAgentBackend::new();
backend
.expect_build_command()
.times(2)
.returning(|request| {
assert!(matches!(
request.request_kind,
AgentRequestKind::UtilityPrompt
));
Ok(mock_shell_command(
"Refactor agent prompt and protocol handling",
"",
0,
))
});
let error = SessionTaskService::generate_session_commit_message_with_backend(
temp_directory.path(),
AgentModel::ClaudeSonnet46,
"diff --git a/a.rs b/a.rs",
None,
&backend,
false,
)
.await
.expect_err("plain-text one-shot commit message should fail");
assert!(
error
.to_string()
.contains("did not match the required JSON schema")
);
assert!(
error
.to_string()
.contains("response:\nRefactor agent prompt and protocol handling")
);
}
#[test]
fn test_append_agentty_coauthor_trailer_appends_trailer_once() {
let commit_message = "Refine settings page";
let appended_commit_message = append_agentty_coauthor_trailer(commit_message, true);
assert_eq!(
appended_commit_message,
format!("Refine settings page\n\n{SESSION_COMMIT_COAUTHORED_BY_AGENTTY_TRAILER}")
);
}
#[test]
fn test_append_agentty_coauthor_trailer_leaves_message_unchanged_when_disabled() {
let commit_message = "Refine settings page";
let appended_commit_message = append_agentty_coauthor_trailer(commit_message, false);
assert_eq!(appended_commit_message, "Refine settings page");
}
#[test]
fn test_validate_generated_commit_message_rejects_agentty_trailer() {
let commit_message =
format!("Refine settings page\n\n{SESSION_COMMIT_COAUTHORED_BY_AGENTTY_TRAILER}");
let error = validate_generated_commit_message(&commit_message)
.expect_err("generated trailer should fail validation");
assert_eq!(
error.to_string(),
"Session commit message model must not emit the Agentty coauthor trailer"
);
}
#[test]
fn test_strip_agentty_coauthor_trailer_removes_trailer_line() {
let commit_message =
format!("Refine settings page\n\n{SESSION_COMMIT_COAUTHORED_BY_AGENTTY_TRAILER}");
let stripped_commit_message = strip_agentty_coauthor_trailer(&commit_message);
assert_eq!(stripped_commit_message, "Refine settings page\n");
}
#[tokio::test]
async fn test_handle_auto_commit_appends_commit_error_from_mock_git_client() {
let mut mock_git_client = MockGitClient::new();
mock_git_client
.expect_is_worktree_clean()
.times(1)
.returning(|_| {
Box::pin(async { Err(GitError::OutputParse("commit failed".to_string())) })
});
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
insert_review_session(&database, AgentModel::Gpt54.as_str()).await;
let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel();
let output = Arc::new(Mutex::new(String::new()));
let context = AssistContext {
app_event_tx,
child_pid: Arc::new(Mutex::new(None)),
db: database,
folder: PathBuf::from("/tmp/project"),
git_client: Arc::new(mock_git_client),
id: "session-id".to_string(),
output: Arc::clone(&output),
session_model: AgentModel::Gpt54,
};
SessionTaskService::handle_auto_commit(context).await;
let output_text = output
.lock()
.map(|buffer| buffer.clone())
.unwrap_or_default();
assert!(output_text.contains("[Commit Error] commit failed"));
}
#[tokio::test]
async fn test_handle_auto_commit_reports_when_no_changes_exist() {
let mut mock_git_client = MockGitClient::new();
mock_git_client
.expect_is_worktree_clean()
.times(1)
.returning(|_| Box::pin(async { Ok::<_, GitError>(true) }));
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
insert_review_session(&database, AgentModel::Gpt54.as_str()).await;
let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel();
let output = Arc::new(Mutex::new(String::new()));
let context = AssistContext {
app_event_tx,
child_pid: Arc::new(Mutex::new(None)),
db: database,
folder: PathBuf::from("/tmp/project"),
git_client: Arc::new(mock_git_client),
id: "session-id".to_string(),
output: Arc::clone(&output),
session_model: AgentModel::Gpt54,
};
SessionTaskService::handle_auto_commit(context).await;
let output_text = output
.lock()
.map(|buffer| buffer.clone())
.unwrap_or_default();
assert!(output_text.contains("[Commit] No changes to commit."));
}
#[tokio::test]
async fn test_load_include_coauthored_by_agentty_setting_defaults_to_false() {
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
insert_review_session(&database, AgentModel::Gpt54.as_str()).await;
let include_coauthored_by_agentty =
SessionTaskService::load_include_coauthored_by_agentty_setting(&database, "session-id")
.await;
assert!(!include_coauthored_by_agentty);
}
#[tokio::test]
async fn test_load_include_coauthored_by_agentty_setting_defaults_invalid_value_to_false() {
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
insert_review_session(&database, AgentModel::Gpt54.as_str()).await;
let project_id = database
.load_session_project_id("session-id")
.await
.expect("failed to load session project id")
.expect("session should have project id");
database
.upsert_project_setting(
project_id,
SettingName::IncludeCoauthoredByAgentty,
"invalid-bool",
)
.await
.expect("failed to persist invalid coauthor flag");
let include_coauthored_by_agentty =
SessionTaskService::load_include_coauthored_by_agentty_setting(&database, "session-id")
.await;
assert!(!include_coauthored_by_agentty);
}
#[tokio::test]
async fn test_handle_auto_commit_preserves_agent_session_summary() {
let mut mock_git_client = MockGitClient::new();
mock_git_client
.expect_is_worktree_clean()
.times(1)
.returning(|_| Box::pin(async { Ok::<_, GitError>(false) }));
mock_git_client
.expect_has_commits_since()
.times(1)
.returning(|_, _| Box::pin(async { Ok::<_, GitError>(true) }));
mock_git_client
.expect_head_commit_message()
.times(1)
.returning(|_| {
Box::pin(async {
Ok::<_, GitError>(Some(
"Refine README updates\n\n- Keep title aligned with commit".to_string(),
))
})
});
mock_git_client
.expect_commit_all_preserving_single_commit()
.times(1)
.returning(|_, _, _, _, _| Box::pin(async { Ok::<_, GitError>(()) }));
mock_git_client
.expect_head_short_hash()
.times(1)
.returning(|_| Box::pin(async { Ok::<_, GitError>("abc1234".to_string()) }));
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
insert_review_session(&database, AgentModel::Gpt54.as_str()).await;
let summary_payload = "- Session branch updates README formatting.".to_string();
database
.update_session_summary("session-id", &summary_payload)
.await
.expect("failed to persist summary text");
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel();
let output = Arc::new(Mutex::new(String::new()));
let context = AssistContext {
app_event_tx,
child_pid: Arc::new(Mutex::new(None)),
db: database.clone(),
folder: PathBuf::from("/tmp/project"),
git_client: Arc::new(mock_git_client),
id: "session-id".to_string(),
output: Arc::clone(&output),
session_model: AgentModel::Gpt54,
};
SessionTaskService::handle_auto_commit(context).await;
let sessions = database
.load_sessions()
.await
.expect("failed to load sessions");
assert_eq!(sessions[0].title.as_deref(), Some("Refine README updates"));
assert_eq!(
sessions[0].summary.as_deref(),
Some("- Session branch updates README formatting.")
);
let output_text = output
.lock()
.map(|buffer| buffer.clone())
.unwrap_or_default();
assert!(output_text.contains("[Commit] committed with hash `abc1234`"));
let events = std::iter::from_fn(|| app_event_rx.try_recv().ok()).collect::<Vec<_>>();
assert!(events.contains(&AppEvent::RefreshGitStatus));
}
#[tokio::test]
async fn test_load_auto_commit_model_setting_prefers_project_fast_model() {
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
insert_review_session(&database, AgentModel::Gpt54.as_str()).await;
let project_id = database
.load_session_project_id("session-id")
.await
.expect("failed to load session project id")
.expect("session should have project id");
database
.upsert_project_setting(
project_id,
SettingName::DefaultFastModel,
AgentModel::ClaudeHaiku4520251001.as_str(),
)
.await
.expect("failed to persist default fast model");
database
.upsert_project_setting(
project_id,
SettingName::DefaultSmartModel,
AgentModel::Gemini31ProPreview.as_str(),
)
.await
.expect("failed to persist default smart model");
let auto_commit_model = SessionTaskService::load_auto_commit_model_setting(
&database,
"session-id",
AgentModel::Gpt54,
)
.await;
assert_eq!(auto_commit_model, AgentModel::ClaudeHaiku4520251001);
}
#[tokio::test]
async fn test_load_auto_commit_model_setting_falls_back_through_defaults() {
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
insert_review_session(&database, AgentModel::Gpt54.as_str()).await;
let project_id = database
.load_session_project_id("session-id")
.await
.expect("failed to load session project id")
.expect("session should have project id");
database
.upsert_project_setting(
project_id,
SettingName::DefaultSmartModel,
AgentModel::Gemini31ProPreview.as_str(),
)
.await
.expect("failed to persist default smart model");
let smart_fallback_model = SessionTaskService::load_auto_commit_model_setting(
&database,
"session-id",
AgentModel::Gpt54,
)
.await;
assert_eq!(smart_fallback_model, AgentModel::Gemini31ProPreview);
database
.upsert_project_setting(project_id, SettingName::DefaultSmartModel, "invalid")
.await
.expect("failed to persist invalid smart model");
let session_fallback_model = SessionTaskService::load_auto_commit_model_setting(
&database,
"session-id",
AgentModel::Gpt54,
)
.await;
assert_eq!(session_fallback_model, AgentModel::Gpt54);
}
#[tokio::test]
async fn test_run_agent_assist_task_unwraps_one_shot_answer_without_raw_json() {
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
insert_review_session(&database, AgentModel::ClaudeOpus47.as_str()).await;
let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel();
let output = Arc::new(Mutex::new(String::new()));
let child_pid = Arc::new(Mutex::new(None));
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let mut backend = MockAgentBackend::new();
backend.expect_build_command().times(1).returning(|request| {
assert!(matches!(
request.request_kind,
AgentRequestKind::UtilityPrompt
));
assert_eq!(request.prompt, "Resolve conflict");
Ok(mock_shell_command(
r#"{"result":"{\"answer\":\"Resolved the rebase conflict.\",\"questions\":[],\"summary\":null}","usage":{"input_tokens":11,"output_tokens":7}}"#,
"",
0,
))
});
let result = SessionTaskService::run_agent_assist_task_with_backend(
RunAgentAssistTaskInput {
app_event_tx,
child_pid: Arc::clone(&child_pid),
db: database.clone(),
folder: temp_dir.path().to_path_buf(),
id: "session-id".to_string(),
output: Arc::clone(&output),
prompt: "Resolve conflict".to_string(),
session_model: AgentModel::ClaudeOpus47,
},
&backend,
)
.await;
assert!(
result.is_ok(),
"assist task should succeed: {:?}",
result.err()
);
let output_text = output.lock().map(|buf| buf.clone()).unwrap_or_default();
assert!(output_text.contains("Resolved the rebase conflict."));
assert!(!output_text.contains(r#"{"answer""#));
assert_eq!(*child_pid.lock().expect("failed to lock child pid"), None);
let sessions = database
.load_sessions()
.await
.expect("failed to load sessions");
assert_eq!(sessions[0].input_tokens, 11);
assert_eq!(sessions[0].output_tokens, 7);
}
#[tokio::test]
async fn test_run_agent_assist_task_rejects_plain_text_output() {
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
insert_review_session(&database, AgentModel::ClaudeOpus47.as_str()).await;
let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel();
let output = Arc::new(Mutex::new(String::new()));
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let mut backend = MockAgentBackend::new();
backend
.expect_build_command()
.times(2)
.returning(|request| {
assert!(matches!(
request.request_kind,
AgentRequestKind::UtilityPrompt
));
Ok(mock_shell_command(
r#"{"result":"plain text","usage":{"input_tokens":2,"output_tokens":1}}"#,
"",
0,
))
});
let error = SessionTaskService::run_agent_assist_task_with_backend(
RunAgentAssistTaskInput {
app_event_tx,
child_pid: Arc::new(Mutex::new(None)),
db: database.clone(),
folder: temp_dir.path().to_path_buf(),
id: "session-id".to_string(),
output: Arc::clone(&output),
prompt: "Resolve conflict".to_string(),
session_model: AgentModel::ClaudeOpus47,
},
&backend,
)
.await
.expect_err("plain-text utility output should fail");
assert!(
error
.to_string()
.contains("did not match the required JSON schema")
);
assert!(error.to_string().contains("response:\nplain text"));
let output_text = output.lock().map(|buf| buf.clone()).unwrap_or_default();
assert!(output_text.is_empty());
let sessions = database
.load_sessions()
.await
.expect("failed to load sessions");
assert_eq!(sessions[0].input_tokens, 0);
assert_eq!(sessions[0].output_tokens, 0);
}
#[tokio::test]
async fn test_run_agent_assist_task_returns_error_for_non_zero_exit_status() {
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
insert_review_session(&database, AgentModel::ClaudeOpus47.as_str()).await;
let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel();
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let mut backend = MockAgentBackend::new();
backend
.expect_build_command()
.times(1)
.returning(|_| Ok(mock_shell_command("", "assist failed", 7)));
let result = SessionTaskService::run_agent_assist_task_with_backend(
RunAgentAssistTaskInput {
app_event_tx,
child_pid: Arc::new(Mutex::new(None)),
db: database,
folder: temp_dir.path().to_path_buf(),
id: "session-id".to_string(),
output: Arc::new(Mutex::new(String::new())),
prompt: "Resolve conflict".to_string(),
session_model: AgentModel::ClaudeOpus47,
},
&backend,
)
.await;
assert!(result.is_err());
let error_text = result.expect_err("expected non-zero exit to fail");
assert!(error_text.to_string().contains("exit code 7"));
assert!(error_text.to_string().contains("assist failed"));
}
}