use std::time::{Duration, Instant};
use crate::domain::process::{
ActivityState, AgentActivitySource, AgentProtocol, Process, ProcessKind,
};
pub(super) const OUTPUT_IDLE_TIMEOUT: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum ActivityEvidence {
#[default]
Unscheduled,
Recent(Instant),
ExplicitProgress,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct ActivityTracker {
strategy: AgentActivitySource,
evidence: ActivityEvidence,
last_title: Option<String>,
}
impl ActivityTracker {
pub(super) fn for_process(process: &Process) -> Self {
Self {
strategy: if *process.kind() == ProcessKind::Agent {
process
.agent_tool()
.map_or(AgentActivitySource::Output, |tool| tool.activity_source())
} else {
AgentActivitySource::Output
},
evidence: ActivityEvidence::Unscheduled,
last_title: None,
}
}
pub(super) fn observe_output(&mut self, now: Instant) -> Option<ActivityState> {
if self.strategy != AgentActivitySource::Output {
return None;
}
if self.evidence != ActivityEvidence::ExplicitProgress {
self.evidence = ActivityEvidence::Recent(now);
}
Some(ActivityState::Working)
}
pub(super) fn observe_title(&mut self, now: Instant, title: String) -> Option<ActivityState> {
if self.strategy != AgentActivitySource::Title
|| self.last_title.as_deref() == Some(title.as_str())
{
return None;
}
self.last_title = Some(title);
if self.evidence != ActivityEvidence::ExplicitProgress {
self.evidence = ActivityEvidence::Recent(now);
}
Some(ActivityState::Working)
}
pub(super) fn observe_progress(&mut self, active: bool) -> ActivityState {
if active {
self.evidence = ActivityEvidence::ExplicitProgress;
ActivityState::Working
} else {
self.evidence = ActivityEvidence::Unscheduled;
ActivityState::AwaitingInput
}
}
pub(super) fn observe_attention(&mut self) -> ActivityState {
self.evidence = ActivityEvidence::Unscheduled;
ActivityState::AwaitingInput
}
pub(super) fn reset(&mut self) {
self.evidence = ActivityEvidence::Unscheduled;
self.last_title = None;
}
pub(super) fn deadline(&self) -> Option<Instant> {
match self.evidence {
ActivityEvidence::Recent(observed_at) => Some(observed_at + OUTPUT_IDLE_TIMEOUT),
ActivityEvidence::Unscheduled | ActivityEvidence::ExplicitProgress => None,
}
}
pub(super) fn expire(&mut self, now: Instant) -> Option<ActivityState> {
if self.deadline().is_some_and(|deadline| deadline <= now) {
self.evidence = ActivityEvidence::Unscheduled;
Some(ActivityState::Idle)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{
process::{AgentTool, ProcessOrigin},
value::{PaneId, ProcessName},
};
fn agent(tool: AgentTool) -> Process {
Process::builder()
.id(PaneId::new(1))
.name(ProcessName::try_new(tool.to_string()).unwrap())
.kind(ProcessKind::Agent)
.agent_tool(Some(tool))
.origin(ProcessOrigin::Session)
.build()
}
#[test]
fn ordinary_output_expires_to_idle() {
let observed_at = Instant::now();
let mut tracker = ActivityTracker::default();
assert_eq!(
tracker.observe_output(observed_at),
Some(ActivityState::Working)
);
assert_eq!(
tracker.expire(observed_at + OUTPUT_IDLE_TIMEOUT),
Some(ActivityState::Idle)
);
assert_eq!(tracker.evidence, ActivityEvidence::Unscheduled);
}
#[test]
fn title_strategy_ignores_output_and_observes_title_changes() {
let observed_at = Instant::now();
let mut tracker = ActivityTracker::for_process(&agent(AgentTool::Codex));
assert_eq!(tracker.observe_output(observed_at), None);
assert_eq!(
tracker.observe_title(observed_at, "working".to_string()),
Some(ActivityState::Working)
);
assert_eq!(
tracker.observe_title(observed_at, "working".to_string()),
None
);
}
#[test]
fn explicit_progress_has_no_provider_deadline() {
let mut tracker = ActivityTracker::default();
tracker.observe_progress(true);
tracker.observe_output(Instant::now());
assert_eq!(tracker.evidence, ActivityEvidence::ExplicitProgress);
assert!(tracker.deadline().is_none());
assert_eq!(tracker.expire(Instant::now() + OUTPUT_IDLE_TIMEOUT), None);
}
#[test]
fn attention_clears_an_output_deadline() {
let mut tracker = ActivityTracker::default();
tracker.observe_output(Instant::now());
assert_eq!(tracker.observe_attention(), ActivityState::AwaitingInput);
assert_eq!(tracker.evidence, ActivityEvidence::Unscheduled);
}
}