use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use ag_git::GitClient;
use tokio::sync::mpsc;
use crate::app::AppEvent;
use crate::app::service::SessionUpdateVersionMap;
use crate::app::session::{RunAgentAssistTaskInput, SessionError, SessionTaskService};
use crate::domain::agent::AgentSelection;
use crate::domain::session_message::SessionTranscript;
use crate::domain::transcript_notice::TranscriptNotice;
use crate::infra::db::AppRepositories;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) struct AssistPolicy {
pub(super) max_attempts: usize,
pub(super) max_identical_failure_streak: usize,
}
pub(super) struct AssistContext {
pub(super) app_event_tx: mpsc::UnboundedSender<AppEvent>,
pub(super) child_pid: Arc<Mutex<Option<u32>>>,
pub(super) db: AppRepositories,
pub(super) folder: PathBuf,
pub(super) git_client: Arc<dyn GitClient>,
pub(super) id: String,
pub(super) session_agent: AgentSelection,
pub(super) session_update_versions: SessionUpdateVersionMap,
pub(super) transcript: Arc<Mutex<SessionTranscript>>,
}
pub(super) struct FailureTracker {
max_identical_failure_streak: usize,
previous_fingerprint: String,
streak: usize,
}
impl FailureTracker {
pub(super) fn new(max_identical_failure_streak: usize) -> Self {
Self {
max_identical_failure_streak,
previous_fingerprint: String::new(),
streak: 0,
}
}
pub(super) fn observe(&mut self, fingerprint: &str) -> bool {
let normalized_fingerprint = fingerprint.trim().to_ascii_lowercase();
if normalized_fingerprint.is_empty() {
self.previous_fingerprint.clear();
self.streak = 0;
return false;
}
if self.previous_fingerprint == normalized_fingerprint {
self.streak += 1;
} else {
self.previous_fingerprint = normalized_fingerprint;
self.streak = 1;
}
self.streak > self.max_identical_failure_streak
}
}
pub(super) fn format_detail_lines(detail: &str) -> String {
detail
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| format!("- {line}"))
.collect::<Vec<_>>()
.join("\n")
}
pub(super) async fn append_assist_header(
context: &AssistContext,
notice: TranscriptNotice,
assist_attempt: usize,
max_assist_attempts: usize,
assist_action: &str,
detail: &str,
) {
let assist_header = notice.format(format!(
"Attempt {assist_attempt}/{max_assist_attempts}. {assist_action}\n{detail}"
));
SessionTaskService::append_workflow_notice(
&context.transcript,
&context.db,
&context.app_event_tx,
&context.session_update_versions,
&context.id,
&assist_header,
)
.await;
}
pub(super) async fn run_agent_assist(
context: &AssistContext,
prompt: &str,
) -> Result<(), SessionError> {
SessionTaskService::run_agent_assist_task(RunAgentAssistTaskInput {
app_event_tx: context.app_event_tx.clone(),
child_pid: Arc::clone(&context.child_pid),
db: context.db.clone(),
folder: context.folder.clone(),
id: context.id.clone(),
prompt: prompt.to_string(),
session_agent: context.session_agent,
session_update_versions: context.session_update_versions.clone(),
transcript: Arc::clone(&context.transcript),
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_failure_tracker_observe_exceeds_after_identical_streak_limit() {
let mut tracker = FailureTracker::new(2);
let first_exceeded = tracker.observe("same");
let second_exceeded = tracker.observe("same");
let third_exceeded = tracker.observe("same");
assert!(!first_exceeded);
assert!(!second_exceeded);
assert!(third_exceeded);
}
#[test]
fn test_failure_tracker_observe_resets_streak_for_new_fingerprint() {
let mut tracker = FailureTracker::new(2);
let _ = tracker.observe("same");
let _ = tracker.observe("same");
let exceeded = tracker.observe("other");
assert!(!exceeded);
}
#[test]
fn test_failure_tracker_observe_normalizes_case_and_whitespace() {
let mut tracker = FailureTracker::new(1);
let first_exceeded = tracker.observe(" Same Failure ");
let second_exceeded = tracker.observe("same failure");
assert!(!first_exceeded);
assert!(second_exceeded);
}
#[test]
fn test_failure_tracker_observe_empty_fingerprint_resets_streak() {
let mut tracker = FailureTracker::new(1);
let _ = tracker.observe("same");
let empty_exceeded = tracker.observe(" ");
let next_exceeded = tracker.observe("same");
assert!(!empty_exceeded);
assert!(!next_exceeded);
}
#[test]
fn test_format_detail_lines_returns_bulleted_non_empty_lines() {
let detail = "line one\n\nline two";
let formatted = format_detail_lines(detail);
assert_eq!(formatted, "- line one\n- line two");
}
#[test]
fn test_format_detail_lines_trims_lines_and_returns_empty_for_blank_detail() {
let detail = " line one \n\tline two\t";
let blank_detail = " \n\n\t";
let formatted = format_detail_lines(detail);
let blank_formatted = format_detail_lines(blank_detail);
assert_eq!(formatted, "- line one\n- line two");
assert_eq!(blank_formatted, "");
}
}