use std::path::Path;
use std::sync::{Arc, Mutex};
use askama::Template;
use tokio::sync::mpsc;
use tracing::warn;
use crate::app::assist::{
AssistContext, AssistPolicy, FailureTracker, append_assist_header, format_detail_lines,
run_agent_assist,
};
use crate::app::service::SessionUpdateVersionMap;
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::{COMMITTING_PROGRESS_LABEL, SessionId, SessionSize, Status};
use crate::domain::setting::SettingName;
use crate::domain::transcript_notice::TranscriptNotice;
use crate::infra::agent;
use crate::infra::db::AppRepositories;
use crate::infra::fs::FsClient;
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)";
const SESSION_COMMIT_DIFF_TRUNCATION_LIMIT: usize = 60_000;
const SESSION_COMMIT_DIFF_TRUNCATED_SECTION_MARKER: &str =
"[Commit message diff was truncated to fit context window]";
const AUTO_COMMIT_ERROR_TRUNCATION_LIMIT: usize = 20_000;
const AUTO_COMMIT_ERROR_TRUNCATED_SECTION_MARKER: &str =
"[Commit error was truncated to fit context window]";
#[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,
fenced_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: AppRepositories,
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,
pub(crate) session_update_versions: SessionUpdateVersionMap,
}
impl SessionTaskService {
pub(crate) fn next_session_update_version(
session_update_versions: &SessionUpdateVersionMap,
id: &str,
) -> u64 {
let mut session_update_versions = session_update_versions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let entry = session_update_versions
.entry(SessionId::from(id))
.or_insert(0);
*entry += 1;
*entry
}
pub(crate) fn remove_session_update_version(
session_update_versions: &SessionUpdateVersionMap,
id: &str,
) {
let mut session_update_versions = session_update_versions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
session_update_versions.remove(id);
}
pub(crate) fn emit_session_updated(
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
session_update_versions: &SessionUpdateVersionMap,
id: &str,
) -> u64 {
let version = Self::next_session_update_version(session_update_versions, id);
Self::send_app_event(
app_event_tx,
AppEvent::SessionUpdated {
session_id: SessionId::from(id),
version,
},
Some(id),
"SessionUpdated",
);
version
}
pub(crate) async fn refresh_persisted_session_diff_stats(
db: &AppRepositories,
fs_client: &dyn FsClient,
git_client: &dyn GitClient,
session_id: &str,
folder: &Path,
) -> Option<(SessionSize, u64, u64)> {
let base_branch = match db.sessions().get_session_base_branch(session_id).await {
Ok(base_branch) => base_branch?,
Err(error) => {
warn!(
session_id = session_id,
error = %error,
"failed to load session base branch while refreshing diff stats"
);
return None;
}
};
let (computed_size, added_lines, deleted_lines) =
SessionManager::session_diff_stats_for_folder(
fs_client,
git_client,
folder,
&base_branch,
)
.await;
if let Err(error) = db
.sessions()
.update_session_diff_stats(
added_lines,
deleted_lines,
session_id,
&computed_size.to_string(),
)
.await
{
warn!(
session_id = session_id,
error = %error,
"failed to persist refreshed session diff stats"
);
return None;
}
Some((computed_size, added_lines, deleted_lines))
}
pub(in crate::app) async fn handle_auto_commit(
context: AssistContext,
) -> Option<SessionCommitOutcome> {
Self::set_session_progress(
&context.app_event_tx,
&context.id,
Some(COMMITTING_PROGRESS_LABEL.to_string()),
);
let outcome = 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 = TranscriptNotice::Commit
.format_line(format!("committed with hash `{}`", outcome.commit_hash));
Self::emit_session_workflow_notice(&context.app_event_tx, &context.id, message);
Self::request_git_status_refresh(&context.app_event_tx);
Some(outcome)
}
Ok(None) => {
let message = TranscriptNotice::Commit.format_line("No changes to commit.");
Self::emit_session_workflow_notice(&context.app_event_tx, &context.id, message);
None
}
Err(commit_error) => {
let message = TranscriptNotice::CommitError.format(&commit_error);
Self::append_session_output(
&context.output,
&context.db,
&context.app_event_tx,
&context.session_update_versions,
&context.id,
&message,
)
.await;
None
}
};
Self::clear_session_progress(&context.app_event_tx, &context.id);
outcome
}
pub(super) fn request_git_status_refresh(app_event_tx: &mpsc::UnboundedSender<AppEvent>) {
Self::send_app_event(
app_event_tx,
AppEvent::RefreshGitStatus,
None,
"RefreshGitStatus",
);
}
pub(super) fn emit_session_workflow_notice(
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
id: &str,
message: String,
) {
Self::send_app_event(
app_event_tx,
AppEvent::SessionWorkflowNoticeUpdated {
notice: message,
session_id: SessionId::from(id),
},
Some(id),
"SessionWorkflowNoticeUpdated",
);
}
pub(crate) async fn load_include_coauthored_by_agentty_setting(
db: &AppRepositories,
session_id: &str,
) -> bool {
let project_id = match db.sessions().load_session_project_id(session_id).await {
Ok(project_id) => project_id,
Err(error) => {
warn!(
session_id = session_id,
error = %error,
"failed to load session project while reading coauthor setting"
);
return false;
}
};
let Some(project_id) = project_id else {
return false;
};
let persisted_value = match db
.settings()
.get_project_setting(project_id, SettingName::IncludeCoauthoredByAgentty)
.await
{
Ok(persisted_value) => persisted_value,
Err(error) => {
warn!(
project_id,
error = %error,
"failed to load include-coauthored-by-agentty setting"
);
return false;
}
};
let Some(setting_value) = persisted_value else {
return false;
};
match setting_value.parse::<bool>() {
Ok(parsed_value) => parsed_value,
Err(error) => {
warn!(
project_id,
value = setting_value,
error = %error,
"failed to parse persisted include-coauthored-by-agentty setting"
);
false
}
}
}
pub(crate) async fn load_auto_commit_model_setting(
db: &AppRepositories,
session_id: &str,
fallback_model: AgentModel,
) -> AgentModel {
let project_id = match db.sessions().load_session_project_id(session_id).await {
Ok(project_id) => project_id,
Err(error) => {
warn!(
session_id = session_id,
error = %error,
"failed to load session project while resolving auto-commit model"
);
return fallback_model;
}
};
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
.sessions()
.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: &AppRepositories,
project_id: Option<i64>,
setting_name: SettingName,
) -> Option<AgentModel> {
let project_id = project_id?;
let persisted_value = match db
.settings()
.get_project_setting(project_id, setting_name)
.await
{
Ok(persisted_value) => persisted_value,
Err(error) => {
warn!(
project_id,
setting = ?setting_name,
error = %error,
"failed to load persisted agent model setting"
);
return None;
}
};
let setting_value = persisted_value?;
match AgentModel::parse_persisted(&setting_value) {
Ok(model) => Some(model),
Err(error) => {
warn!(
project_id,
setting = ?setting_name,
value = setting_value,
error = %error,
"failed to parse persisted agent model setting"
);
None
}
}
}
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,
TranscriptNotice::CommitAssist,
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 compacted_error = compact_commit_error_for_assist(commit_error);
let prompt = Self::auto_commit_assist_prompt(&compacted_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,
session_update_versions: context.session_update_versions.clone(),
};
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 fence = agent::diff_fence(diff);
let fenced_diff = format!("{fence}diff\n{diff}\n{fence}");
let template = SessionCommitMessagePromptTemplate {
current_commit_message: stripped_current_commit_message.trim(),
fenced_diff: &fenced_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 = match 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
{
Ok(submission) => submission,
Err(error) if is_context_window_exceeded_error(&error) => {
let Some(truncated_diff) = truncate_session_diff_for_commit_message(diff) else {
return Err(error);
};
let truncated_prompt =
Self::session_commit_message_prompt(&truncated_diff, current_commit_message)?;
Self::submit_utility_prompt_with_backend(
session_model,
backend,
agent::OneShotRequest {
child_pid: None,
folder,
model: session_model,
prompt: &truncated_prompt,
request_kind: crate::infra::channel::AgentRequestKind::UtilityPrompt,
reasoning_level: crate::domain::agent::ReasoningLevel::default(),
},
)
.await?
}
Err(error) => return Err(error),
};
let answer_text = submission.response.to_answer_display_text();
let trimmed_answer_text = answer_text.trim();
let validated_message = if trimmed_answer_text.is_empty() {
fallback_session_commit_message(current_commit_message)
} else {
trimmed_answer_text.to_string()
};
validate_generated_commit_message(&validated_message)?;
Ok(append_agentty_coauthor_trailer(
validated_message.as_str(),
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,
session_update_versions,
} = 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,
&session_update_versions,
&id,
&answer_text,
)
.await;
}
if let Err(error) = db
.sessions()
.update_session_stats(&id, &assist_submission.stats)
.await
{
warn!(
session_id = id,
error = %error,
"failed to persist session stats after utility prompt"
);
}
if let Err(error) = db
.usage()
.upsert_session_usage(&id, session_model.as_str(), &assist_submission.stats)
.await
{
warn!(
session_id = id,
model = %session_model.as_str(),
error = %error,
"failed to persist session usage after utility prompt"
);
}
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: &AppRepositories,
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
session_update_versions: &SessionUpdateVersionMap,
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());
if let Err(error) = db
.sessions()
.update_session_status_with_timing_at(id, &new.to_string(), timestamp_seconds)
.await
{
warn!(
session_id = id,
status = %new,
error = %error,
"failed to persist session status update"
);
}
Self::emit_session_updated(app_event_tx, session_update_versions, id);
if Self::status_requires_full_refresh(new) {
Self::send_app_event(
app_event_tx,
AppEvent::RefreshSessions,
Some(id),
"RefreshSessions",
);
}
true
}
pub(crate) async fn append_session_output(
output: &Arc<Mutex<String>>,
db: &AppRepositories,
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
session_update_versions: &SessionUpdateVersionMap,
id: &str,
message: &str,
) {
match output.lock() {
Ok(mut buf) => buf.push_str(message),
Err(error) => {
warn!(
session_id = id,
error = %error,
"failed to lock session output buffer"
);
}
}
if let Err(error) = db.sessions().append_session_output(id, message).await {
warn!(
session_id = id,
error = %error,
"failed to persist session output"
);
}
Self::emit_session_updated(app_event_tx, session_update_versions, id);
}
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>,
) {
Self::send_app_event(
app_event_tx,
AppEvent::SessionProgressUpdated {
progress_message,
session_id: SessionId::from(id),
},
Some(id),
"SessionProgressUpdated",
);
}
fn status_requires_full_refresh(status: Status) -> bool {
matches!(
status,
Status::InProgress | Status::Review | Status::Merging | Status::Done | Status::Canceled
)
}
fn send_app_event(
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
event: AppEvent,
session_id: Option<&str>,
event_name: &str,
) {
if app_event_tx.send(event).is_err() {
if let Some(session_id) = session_id {
warn!(
session_id = session_id,
event = event_name,
"failed to send app event because the receiver is closed"
);
} else {
warn!(
event = event_name,
"failed to send app event because the receiver is closed"
);
}
}
}
}
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 fallback_session_commit_message(current_commit_message: Option<&str>) -> String {
let stripped_current_commit_message = current_commit_message
.map(strip_agentty_coauthor_trailer)
.unwrap_or_default();
if stripped_current_commit_message.trim().is_empty() {
return "Apply session updates".to_string();
}
stripped_current_commit_message
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("Apply session updates")
.to_string()
}
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}")
}
fn is_context_window_exceeded_error(error: &SessionError) -> bool {
match error {
SessionError::Workflow(message) => is_context_window_exceeded_error_message(message),
_ => false,
}
}
fn is_context_window_exceeded_error_message(message: &str) -> bool {
message.contains("contextWindowExceeded")
|| message.contains("context_window_exceeded")
|| message.contains("context window exceeded")
}
fn compact_commit_error_for_assist(commit_error: &str) -> String {
if !is_context_window_exceeded_error_message(commit_error) {
return commit_error.to_string();
}
if commit_error.chars().count() <= AUTO_COMMIT_ERROR_TRUNCATION_LIMIT {
return commit_error.to_string();
}
let half_limit = AUTO_COMMIT_ERROR_TRUNCATION_LIMIT / 2;
let error_head = commit_error.chars().take(half_limit).collect::<String>();
let error_tail = commit_error
.chars()
.rev()
.take(half_limit)
.collect::<String>()
.chars()
.rev()
.collect::<String>();
format!("{error_head}\n\n{AUTO_COMMIT_ERROR_TRUNCATED_SECTION_MARKER}\n\n{error_tail}")
}
fn truncate_session_diff_for_commit_message(diff: &str) -> Option<String> {
if diff.chars().count() <= SESSION_COMMIT_DIFF_TRUNCATION_LIMIT {
return None;
}
let half_limit = SESSION_COMMIT_DIFF_TRUNCATION_LIMIT / 2;
let diff_head = diff.chars().take(half_limit).collect::<String>();
let diff_tail = diff
.chars()
.rev()
.take(half_limit)
.collect::<String>()
.chars()
.rev()
.collect::<String>();
Some(format!(
"{diff_head}\n\n{SESSION_COMMIT_DIFF_TRUNCATED_SECTION_MARKER}\n\n{diff_tail}"
))
}
#[cfg(test)]
mod tests {
use std::fmt::Write as _;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant, SystemTime};
use super::*;
use crate::db::AppRepositories;
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: &AppRepositories, model: &str) {
let project_id = database
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
database
.sessions()
.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::Draft
));
}
#[tokio::test]
async fn test_update_status_accumulates_repeated_in_progress_intervals() {
let database = AppRepositories::in_memory().await;
let project_id = database
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
database
.sessions()
.insert_session(
"session-id",
"gpt-5.4",
"main",
&Status::Draft.to_string(),
project_id,
)
.await
.expect("failed to insert session");
let status = Mutex::new(Status::Draft);
let clock = StaticClock::new(SystemTime::UNIX_EPOCH + Duration::from_secs(10));
let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel();
let session_update_versions = Arc::default();
let entered_first_interval = SessionTaskService::update_status(
&status,
&clock,
&database,
&app_event_tx,
&session_update_versions,
"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_update_versions,
"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_update_versions,
"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_update_versions,
"session-id",
Status::Question,
)
.await;
let session_row = database
.sessions()
.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));
let fenced_diff = format!("```diff\n{diff}\n```");
assert!(
prompt.contains(&fenced_diff),
"commit-message prompt must wrap the diff in a ```diff``` fence so `@`-prefixed \
decorator tokens are not misread as file mentions"
);
}
#[test]
fn test_session_commit_message_prompt_escapes_triple_backtick_fence_in_diff() {
let diff = concat!(
"diff --git a/a.md b/a.md\n",
"+```\n",
"+example fenced block\n",
"+```\n",
);
let current_commit_message: Option<&str> = None;
let prompt =
SessionTaskService::session_commit_message_prompt(diff, current_commit_message)
.expect("prompt should render");
assert!(
prompt.contains("````diff\n"),
"outer fence must be longer than the longest backtick run in the diff to preserve \
prompt boundaries"
);
let matches = prompt.matches("\n````").count();
assert!(
matches >= 2,
"prompt must contain an opening and closing 4-backtick fence, got {matches} \
occurrences"
);
assert!(prompt.contains("+```\n"));
}
#[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")
);
}
#[tokio::test]
async fn test_generate_session_commit_message_with_backend_falls_back_for_blank_answer() {
let temp_directory = 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
));
Ok(mock_shell_command(
r#"{"answer":"","questions":[],"summary":null}"#,
"",
0,
))
});
let generated_message = SessionTaskService::generate_session_commit_message_with_backend(
temp_directory.path(),
AgentModel::ClaudeSonnet46,
"diff --git a/a.rs b/a.rs",
Some("Keep session commit accurate\n\n- Preserve existing behavior"),
&backend,
false,
)
.await
.expect("blank answer should fall back to continuity title");
assert_eq!(generated_message, "Keep session commit accurate");
}
#[tokio::test]
async fn test_generate_session_commit_message_with_backend_retries_with_truncated_diff() {
let temp_directory = tempfile::tempdir().expect("failed to create temp dir");
let diff = "diff line\n".repeat(SESSION_COMMIT_DIFF_TRUNCATION_LIMIT + 1);
let call_count = Arc::new(AtomicUsize::new(0));
let call_count_for_build = Arc::clone(&call_count);
let mut backend = MockAgentBackend::new();
backend
.expect_build_command()
.times(2)
.returning(move |request| {
let request_index = call_count_for_build.fetch_add(1, Ordering::SeqCst) + 1;
assert!(matches!(
request.request_kind,
AgentRequestKind::UtilityPrompt
));
if request_index == 1 {
assert!(request.prompt.contains("diff line"));
return Ok(mock_shell_command("", "contextWindowExceeded", 1));
}
assert!(
request
.prompt
.contains(SESSION_COMMIT_DIFF_TRUNCATED_SECTION_MARKER)
);
assert!(request.prompt.len() < SESSION_COMMIT_DIFF_TRUNCATION_LIMIT * 2);
Ok(mock_shell_command(
r#"{"answer":"Truncated diff commit","questions":[],"summary":null}"#,
"",
0,
))
});
let generated_message = SessionTaskService::generate_session_commit_message_with_backend(
temp_directory.path(),
AgentModel::ClaudeSonnet46,
diff.as_str(),
None,
&backend,
false,
)
.await
.expect("truncated retry should succeed");
assert_eq!(generated_message, "Truncated diff commit");
assert_eq!(call_count.load(Ordering::SeqCst), 2);
}
#[test]
fn test_is_context_window_exceeded_error_detects_window_limits() {
let overflow_error =
SessionError::Workflow("Codex app-server failed: contextWindowExceeded".to_string());
let other_error = SessionError::Workflow("network timeout".to_string());
let overflow_is_detected = is_context_window_exceeded_error(&overflow_error);
let other_is_detected = is_context_window_exceeded_error(&other_error);
assert!(overflow_is_detected);
assert!(!other_is_detected);
}
#[test]
fn test_compact_commit_error_for_assist_truncates_overflow_messages() {
let commit_error = "contextWindowExceeded\n".repeat(10_000);
let compacted = compact_commit_error_for_assist(&commit_error);
assert!(compacted.len() < commit_error.len());
assert!(compacted.contains(AUTO_COMMIT_ERROR_TRUNCATED_SECTION_MARKER));
}
#[test]
fn test_compact_commit_error_for_assist_keeps_non_overflow_messages() {
let commit_error = "network timeout while pushing";
let compacted = compact_commit_error_for_assist(commit_error);
assert_eq!(compacted, commit_error);
}
#[test]
fn test_truncate_session_commit_diff_preserves_edge_content() {
let mut diff = String::new();
for index in 0..=SESSION_COMMIT_DIFF_TRUNCATION_LIMIT {
writeln!(&mut diff, "file {index}").expect("writing to string should succeed");
}
let truncated =
truncate_session_diff_for_commit_message(&diff).expect("long diff should be truncated");
assert!(truncated.contains(SESSION_COMMIT_DIFF_TRUNCATED_SECTION_MARKER));
assert!(truncated.len() < diff.len());
assert!(truncated.starts_with("file 0"));
assert!(truncated.contains("file 60000"));
}
#[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 = AppRepositories::in_memory().await;
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.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,
session_update_versions: Arc::default(),
};
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 = AppRepositories::in_memory().await;
insert_review_session(&database, AgentModel::Gpt54.as_str()).await;
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,
session_update_versions: Arc::default(),
};
SessionTaskService::handle_auto_commit(context).await;
let output_text = output
.lock()
.map(|buffer| buffer.clone())
.unwrap_or_default();
let events = std::iter::from_fn(|| app_event_rx.try_recv().ok()).collect::<Vec<_>>();
assert!(!output_text.contains("[Commit] No changes to commit."));
assert!(events.contains(&AppEvent::SessionProgressUpdated {
progress_message: Some(COMMITTING_PROGRESS_LABEL.to_string()),
session_id: "session-id".into(),
}));
assert!(events.iter().any(|event| matches!(
event,
AppEvent::SessionWorkflowNoticeUpdated {
notice,
session_id,
} if session_id.as_str() == "session-id"
&& notice == "[Commit] No changes to commit."
)));
assert!(events.contains(&AppEvent::SessionProgressUpdated {
progress_message: None,
session_id: "session-id".into(),
}));
}
#[tokio::test]
async fn test_load_include_coauthored_by_agentty_setting_defaults_to_false() {
let database = AppRepositories::in_memory().await;
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 = AppRepositories::in_memory().await;
insert_review_session(&database, AgentModel::Gpt54.as_str()).await;
let project_id = database
.sessions()
.load_session_project_id("session-id")
.await
.expect("failed to load session project id")
.expect("session should have project id");
database
.settings()
.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 = AppRepositories::in_memory().await;
insert_review_session(&database, AgentModel::Gpt54.as_str()).await;
let summary_payload = "- Session branch updates README formatting.".to_string();
database
.sessions()
.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,
session_update_versions: Arc::default(),
};
SessionTaskService::handle_auto_commit(context).await;
let sessions = database
.sessions()
.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 events = std::iter::from_fn(|| app_event_rx.try_recv().ok()).collect::<Vec<_>>();
let output_text = output
.lock()
.map(|buffer| buffer.clone())
.unwrap_or_default();
assert!(!output_text.contains("[Commit] committed with hash `abc1234`"));
assert!(events.iter().any(|event| matches!(
event,
AppEvent::SessionWorkflowNoticeUpdated {
notice,
session_id,
} if session_id.as_str() == "session-id"
&& notice == "[Commit] committed with hash `abc1234`"
)));
assert!(events.contains(&AppEvent::RefreshGitStatus));
}
#[tokio::test]
async fn test_load_auto_commit_model_setting_prefers_project_fast_model() {
let database = AppRepositories::in_memory().await;
insert_review_session(&database, AgentModel::Gpt54.as_str()).await;
let project_id = database
.sessions()
.load_session_project_id("session-id")
.await
.expect("failed to load session project id")
.expect("session should have project id");
database
.settings()
.upsert_project_setting(
project_id,
SettingName::DefaultFastModel,
AgentModel::ClaudeHaiku4520251001.as_str(),
)
.await
.expect("failed to persist default fast model");
database
.settings()
.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 = AppRepositories::in_memory().await;
insert_review_session(&database, AgentModel::Gpt54.as_str()).await;
let project_id = database
.sessions()
.load_session_project_id("session-id")
.await
.expect("failed to load session project id")
.expect("session should have project id");
database
.settings()
.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
.settings()
.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 = AppRepositories::in_memory().await;
insert_review_session(&database, AgentModel::ClaudeOpus48.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::ClaudeOpus48,
session_update_versions: Arc::default(),
},
&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
.sessions()
.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 = AppRepositories::in_memory().await;
insert_review_session(&database, AgentModel::ClaudeOpus48.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::ClaudeOpus48,
session_update_versions: Arc::default(),
},
&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
.sessions()
.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 = AppRepositories::in_memory().await;
insert_review_session(&database, AgentModel::ClaudeOpus48.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.clone(),
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::ClaudeOpus48,
session_update_versions: Arc::default(),
},
&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"));
}
}