use std::path::Path;
use std::sync::{Arc, Mutex};
use ag_agent::agent;
use ag_git::{self as git, GitClient};
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::{AppServices, SessionUpdateVersionMap};
use crate::app::session::{Clock, SessionError, unix_timestamp_from_system_time};
use crate::app::{AppEvent, SessionManager, setting};
use crate::domain::agent::{AgentKind, AgentSelection};
#[cfg(test)]
use crate::domain::agent::{AgentModel, AgentSelectionMetadata};
use crate::domain::session::{
COMMITTING_PROGRESS_LABEL, SessionHandles, SessionId, SessionSize, Status,
};
use crate::domain::session_message::{SessionMessageKind, SessionTranscript};
use crate::domain::setting::SettingName;
use crate::domain::transcript_notice::TranscriptNotice;
use crate::infra::db::AppRepositories;
use crate::infra::fs::FsClient;
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 StatusTransition {
app_event_tx: mpsc::UnboundedSender<AppEvent>,
clock: Arc<dyn Clock>,
db: AppRepositories,
session_id: SessionId,
session_update_versions: SessionUpdateVersionMap,
status: Arc<Mutex<Status>>,
}
impl StatusTransition {
pub(crate) fn from_services(
services: &AppServices,
handles: &SessionHandles,
session_id: impl Into<SessionId>,
) -> Self {
Self {
app_event_tx: services.event_sender(),
clock: services.clock(),
db: services.db().clone(),
session_id: session_id.into(),
session_update_versions: services.session_update_versions(),
status: Arc::clone(&handles.status),
}
}
pub(crate) fn from_parts(
app_event_tx: mpsc::UnboundedSender<AppEvent>,
clock: Arc<dyn Clock>,
db: AppRepositories,
session_id: impl Into<SessionId>,
session_update_versions: SessionUpdateVersionMap,
status: Arc<Mutex<Status>>,
) -> Self {
Self {
app_event_tx,
clock,
db,
session_id: session_id.into(),
session_update_versions,
status,
}
}
pub(crate) async fn apply(&self, status: Status) -> bool {
SessionTaskService::update_status(
self.status.as_ref(),
self.clock.as_ref(),
&self.db,
&self.app_event_tx,
&self.session_update_versions,
self.session_id.as_str(),
status,
)
.await
}
pub(crate) async fn apply_or_invalid_transition(
&self,
status: Status,
) -> Result<(), SessionError> {
if self.apply(status).await {
return Ok(());
}
Err(SessionError::Workflow(format!(
"Invalid status transition to {status}"
)))
}
}
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) prompt: String,
pub(crate) session_agent: AgentSelection,
pub(crate) session_update_versions: SessionUpdateVersionMap,
pub(crate) transcript: Arc<Mutex<SessionTranscript>>,
}
pub(crate) struct SessionTranscriptMessageAppend<'a> {
pub(crate) kind: SessionMessageKind,
pub(crate) raw_content: &'a str,
}
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_workflow_notice(
&context.transcript,
&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_agent_setting(
db: &AppRepositories,
session_id: &str,
fallback_selection: AgentSelection,
) -> AgentSelection {
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_selection;
}
};
setting::load_default_fast_agent_selection_from_repositories(
db,
project_id,
fallback_selection,
AgentKind::ALL,
)
.await
}
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_agent =
Self::load_auto_commit_agent_setting(&context.db, &context.id, context.session_agent)
.await;
Self::commit_session_changes(
context.git_client.as_ref(),
&context.folder,
&base_branch,
auto_commit_agent,
no_verify,
Self::load_include_coauthored_by_agentty_setting(&context.db, &context.id).await,
)
.await
}
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(),
session_agent: context.session_agent,
session_update_versions: context.session_update_versions.clone(),
transcript: Arc::clone(&context.transcript),
};
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_agent: AgentSelection,
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_agent.kind());
Self::commit_session_changes_with_backend(
git_client,
folder,
base_branch,
session_agent,
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_agent: AgentSelection,
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_agent,
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_agent: AgentSelection,
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_agent,
backend,
agent::OneShotRequest {
agent_kind: session_agent.kind(),
child_pid: None,
folder,
model: session_agent.model(),
prompt: &prompt,
request_kind: ag_agent::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_agent,
backend,
agent::OneShotRequest {
agent_kind: session_agent.kind(),
child_pid: None,
folder,
model: session_agent.model(),
prompt: &truncated_prompt,
request_kind: ag_agent::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_agent.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,
prompt,
session_agent,
session_update_versions,
transcript,
} = input;
let assist_submission = Self::submit_utility_prompt_with_backend(
session_agent,
backend,
agent::OneShotRequest {
agent_kind: session_agent.kind(),
child_pid: Some(child_pid.as_ref()),
folder: &folder,
model: session_agent.model(),
prompt: &prompt,
request_kind: ag_agent::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_transcript_message(
&transcript,
&db,
&app_event_tx,
&session_update_versions,
&id,
SessionTranscriptMessageAppend {
kind: SessionMessageKind::AssistantAnswer,
raw_content: &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_agent.model().as_str(),
&assist_submission.stats,
)
.await
{
warn!(
session_id = id,
model = %session_agent.model().as_str(),
error = %error,
"failed to persist session usage after utility prompt"
);
}
Ok(())
}
async fn submit_utility_prompt_with_backend(
session_agent: AgentSelection,
backend: &dyn agent::AgentBackend,
request: agent::OneShotRequest<'_>,
) -> Result<agent::OneShotSubmission, SessionError> {
if agent::transport_mode(session_agent.kind()).uses_app_server() {
let app_server_client = agent::create_app_server_client(session_agent.kind(), None)
.ok_or_else(|| {
SessionError::Workflow(format!(
"{} provider did not provide an app-server client",
session_agent.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_session_and_project_refresh_events(app_event_tx, id);
}
true
}
pub(crate) async fn append_workflow_notice(
transcript: &Arc<Mutex<SessionTranscript>>,
db: &AppRepositories,
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
session_update_versions: &SessionUpdateVersionMap,
id: &str,
message: &str,
) {
Self::append_live_transcript_message(
transcript,
id,
SessionMessageKind::WorkflowNotice,
message,
);
if let Err(error) = db
.sessions()
.append_session_message(id, SessionMessageKind::WorkflowNotice, message)
.await
{
warn!(
session_id = id,
error = %error,
"failed to persist workflow notice"
);
}
Self::emit_session_updated(app_event_tx, session_update_versions, id);
}
pub(crate) async fn append_session_transcript_message(
transcript: &Arc<Mutex<SessionTranscript>>,
db: &AppRepositories,
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
session_update_versions: &SessionUpdateVersionMap,
id: &str,
message: SessionTranscriptMessageAppend<'_>,
) {
Self::append_live_transcript_message(transcript, id, message.kind, message.raw_content);
if let Err(error) = db
.sessions()
.append_session_message(id, message.kind, message.raw_content)
.await
{
warn!(
session_id = id,
error = %error,
"failed to persist session transcript message"
);
}
Self::emit_session_updated(app_event_tx, session_update_versions, id);
}
fn append_live_transcript_message(
transcript: &Arc<Mutex<SessionTranscript>>,
id: &str,
kind: SessionMessageKind,
content: &str,
) {
match transcript.lock() {
Ok(mut transcript) => transcript.append_message(kind, content),
Err(error) => {
warn!(
session_id = id,
error = %error,
"failed to lock session transcript buffer"
);
}
}
}
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_session_and_project_refresh_events(
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
session_id: &str,
) {
Self::send_app_event(
app_event_tx,
AppEvent::RefreshSessions,
Some(session_id),
"RefreshSessions",
);
Self::send_app_event(
app_event_tx,
AppEvent::RefreshProjects,
Some(session_id),
"RefreshProjects",
);
}
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 ag_agent::agent::tests::MockAgentBackend;
use ag_agent::channel::AgentRequestKind;
use ag_git::{GitError, MockGitClient};
use super::*;
use crate::app::service::AppServiceDeps;
use crate::db::AppRepositories;
use crate::domain::agent::AgentCliInfo;
use crate::domain::session_message::SessionMessage;
use crate::infra::fs;
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");
}
#[tokio::test]
async fn test_append_workflow_notice_updates_live_and_durable_workflow_transcript() {
let database = AppRepositories::in_memory().await;
insert_review_session(&database, AgentModel::Gpt55.as_str()).await;
let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel();
let transcript = Arc::new(Mutex::new(SessionTranscript::default()));
let session_update_versions = Arc::default();
SessionTaskService::append_workflow_notice(
&transcript,
&database,
&app_event_tx,
&session_update_versions,
"session-id",
"\n[Commit] No changes to commit.\n",
)
.await;
assert_eq!(
transcript
.lock()
.expect("transcript lock should not be poisoned")
.replay_text()
.expect("transcript should have replay text"),
"\n[Commit] No changes to commit.\n"
);
assert_eq!(
transcript
.lock()
.expect("transcript lock should not be poisoned")
.messages(),
&[SessionMessage::new(
0,
SessionMessageKind::WorkflowNotice,
"\n[Commit] No changes to commit.\n"
)]
);
let messages = database
.sessions()
.load_session_messages("session-id")
.await
.expect("failed to load persisted session messages");
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].kind, "workflow_notice");
assert_eq!(messages[0].content, "\n[Commit] No changes to commit.\n");
}
#[tokio::test]
async fn test_append_session_transcript_message_updates_live_and_durable_typed_transcript() {
let database = AppRepositories::in_memory().await;
insert_review_session(&database, AgentModel::Gpt55.as_str()).await;
let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel();
let transcript = Arc::new(Mutex::new(SessionTranscript::default()));
let session_update_versions = Arc::default();
SessionTaskService::append_session_transcript_message(
&transcript,
&database,
&app_event_tx,
&session_update_versions,
"session-id",
SessionTranscriptMessageAppend {
kind: SessionMessageKind::UserPrompt,
raw_content: " hello ",
},
)
.await;
assert_eq!(
transcript
.lock()
.expect("transcript lock should not be poisoned")
.replay_text()
.expect("transcript should have replay text"),
" › hello\n\n"
);
assert_eq!(
transcript
.lock()
.expect("transcript lock should not be poisoned")
.messages(),
&[SessionMessage::conversation(
0,
SessionMessageKind::UserPrompt,
"hello"
)]
);
let messages = database
.sessions()
.load_session_messages("session-id")
.await
.expect("failed to load persisted session messages");
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].kind, "user_prompt");
assert_eq!(messages[0].content, "hello");
}
#[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.5",
"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);
}
#[tokio::test]
async fn test_status_transition_from_services_updates_handle_and_persistence() {
let database = AppRepositories::in_memory().await;
insert_review_session(&database, "gpt-5.5").await;
let handles = SessionHandles::new(Status::Review);
let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel();
let services = AppServices::new_with_agent_clis(
PathBuf::from("/tmp/agentty-tests"),
Arc::new(StaticClock::new(
SystemTime::UNIX_EPOCH + Duration::from_secs(42),
)),
app_event_tx,
AppServiceDeps {
app_server_client_override: Some(crate::test_support::mock_app_server()),
available_agent_kinds: AgentKind::ALL.to_vec(),
clipboard_image_client_override: None,
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(MockGitClient::new()),
repositories: database.clone(),
review_request_client: Arc::new(ag_forge::MockReviewRequestClient::new()),
},
AgentCliInfo::from_kinds(AgentKind::ALL),
);
let status_transition = StatusTransition::from_services(&services, &handles, "session-id");
let status_updated = status_transition.apply(Status::InProgress).await;
let live_status = handles
.status
.lock()
.expect("status lock should not be poisoned")
.to_string();
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!(status_updated);
assert_eq!(live_status, Status::InProgress.to_string());
assert_eq!(session_row.status, Status::InProgress.to_string());
}
#[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("was fixed in `answer`"));
assert!(prompt.contains("leave `questions` empty"));
assert!(prompt.contains("set `summary` to null"));
}
#[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("Apply this precedence order"));
assert!(prompt.contains("`.agents/skills/`"));
assert!(!prompt.contains("`.gemini/skills/`"));
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(),
AgentSelection::new(AgentKind::Claude, AgentModel::ClaudeSonnet5),
"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(),
AgentSelection::new(AgentKind::Claude, AgentModel::ClaudeSonnet5),
"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(),
AgentSelection::new(AgentKind::Claude, AgentModel::ClaudeSonnet5),
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::Gpt55.as_str()).await;
let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel();
let transcript = Arc::new(Mutex::new(SessionTranscript::default()));
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(),
session_agent: AgentSelection::new(AgentKind::Codex, AgentModel::Gpt55),
session_update_versions: Arc::default(),
transcript: Arc::clone(&transcript),
};
SessionTaskService::handle_auto_commit(context).await;
let output_text = transcript
.lock()
.ok()
.and_then(|buffer| buffer.replay_text())
.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::Gpt55.as_str()).await;
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel();
let transcript = Arc::new(Mutex::new(SessionTranscript::default()));
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(),
session_agent: AgentSelection::new(AgentKind::Codex, AgentModel::Gpt55),
session_update_versions: Arc::default(),
transcript: Arc::clone(&transcript),
};
SessionTaskService::handle_auto_commit(context).await;
let output_text = transcript
.lock()
.ok()
.and_then(|buffer| buffer.replay_text())
.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::Gpt55.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::Gpt55.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::Gpt55.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 transcript = Arc::new(Mutex::new(SessionTranscript::default()));
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(),
session_agent: AgentSelection::new(AgentKind::Codex, AgentModel::Gpt55),
session_update_versions: Arc::default(),
transcript: Arc::clone(&transcript),
};
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 = transcript
.lock()
.ok()
.and_then(|buffer| buffer.replay_text())
.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_agent_setting_prefers_project_fast_selection() {
let database = AppRepositories::in_memory().await;
insert_review_session(&database, AgentModel::Gpt55.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::Gemini31ProPreview.as_str(),
)
.await
.expect("failed to persist default fast model");
database
.settings()
.upsert_project_setting(
project_id,
SettingName::DefaultFastAgent,
AgentKind::Antigravity.name(),
)
.await
.expect("failed to persist default fast agent");
database
.settings()
.upsert_project_setting(
project_id,
SettingName::DefaultSmartModel,
AgentModel::Gemini31ProPreview.as_str(),
)
.await
.expect("failed to persist default smart model");
let auto_commit_agent = SessionTaskService::load_auto_commit_agent_setting(
&database,
"session-id",
AgentSelection::new(AgentKind::Codex, AgentModel::Gpt55),
)
.await;
assert_eq!(
auto_commit_agent,
AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini31ProPreview)
);
}
#[tokio::test]
async fn test_load_auto_commit_agent_setting_falls_back_through_defaults() {
let database = AppRepositories::in_memory().await;
insert_review_session(&database, AgentModel::Gpt55.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");
database
.settings()
.upsert_project_setting(
project_id,
SettingName::DefaultSmartAgent,
AgentKind::Antigravity.name(),
)
.await
.expect("failed to persist default smart agent");
let smart_fallback_agent = SessionTaskService::load_auto_commit_agent_setting(
&database,
"session-id",
AgentSelection::new(AgentKind::Codex, AgentModel::Gpt55),
)
.await;
assert_eq!(
smart_fallback_agent,
AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini31ProPreview)
);
database
.settings()
.upsert_project_setting(project_id, SettingName::DefaultSmartModel, "invalid")
.await
.expect("failed to persist invalid smart model");
let session_fallback_agent = SessionTaskService::load_auto_commit_agent_setting(
&database,
"session-id",
AgentSelection::new(AgentKind::Codex, AgentModel::Gpt55),
)
.await;
assert_eq!(
session_fallback_agent,
AgentSelection::new(AgentKind::Codex, AgentModel::Gpt55)
);
}
#[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 transcript = Arc::new(Mutex::new(SessionTranscript::default()));
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(),
prompt: "Resolve conflict".to_string(),
session_agent: AgentSelection::new(AgentKind::Claude, AgentModel::ClaudeOpus48),
session_update_versions: Arc::default(),
transcript: Arc::clone(&transcript),
},
&backend,
)
.await;
assert!(
result.is_ok(),
"assist task should succeed: {:?}",
result.err()
);
let output_text = transcript
.lock()
.ok()
.and_then(|transcript| transcript.replay_text())
.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 transcript = Arc::new(Mutex::new(SessionTranscript::default()));
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(),
prompt: "Resolve conflict".to_string(),
session_agent: AgentSelection::new(AgentKind::Claude, AgentModel::ClaudeOpus48),
session_update_versions: Arc::default(),
transcript: Arc::clone(&transcript),
},
&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 = transcript
.lock()
.ok()
.and_then(|transcript| transcript.replay_text());
assert_eq!(output_text, None);
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(),
prompt: "Resolve conflict".to_string(),
session_agent: AgentSelection::new(AgentKind::Claude, AgentModel::ClaudeOpus48),
session_update_versions: Arc::default(),
transcript: Arc::new(Mutex::new(SessionTranscript::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"));
}
}