Skip to main content

edda_core/
agent_phase.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4/// The lifecycle phase an agent is currently in.
5#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
6#[serde(rename_all = "snake_case")]
7pub enum AgentPhase {
8    Triage,
9    Research,
10    Plan,
11    Implement,
12    Review,
13}
14
15impl fmt::Display for AgentPhase {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        match self {
18            Self::Triage => write!(f, "triage"),
19            Self::Research => write!(f, "research"),
20            Self::Plan => write!(f, "plan"),
21            Self::Implement => write!(f, "implement"),
22            Self::Review => write!(f, "review"),
23        }
24    }
25}
26
27impl std::str::FromStr for AgentPhase {
28    type Err = String;
29
30    fn from_str(s: &str) -> Result<Self, Self::Err> {
31        match s {
32            "triage" => Ok(Self::Triage),
33            "research" => Ok(Self::Research),
34            "plan" => Ok(Self::Plan),
35            "implement" => Ok(Self::Implement),
36            "review" => Ok(Self::Review),
37            other => Err(format!("unknown agent phase: {other}")),
38        }
39    }
40}
41
42/// Snapshot of a single agent's detected phase.
43#[derive(Serialize, Deserialize, Clone, Debug)]
44pub struct AgentPhaseState {
45    pub phase: AgentPhase,
46    pub session_id: String,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub label: Option<String>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub issue: Option<u64>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub pr: Option<u64>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub branch: Option<String>,
55    pub confidence: f32,
56    pub detected_at: String,
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub signals: Vec<String>,
59}
60
61/// A phase transition (from -> to) with the new state.
62#[derive(Serialize, Deserialize, Clone, Debug)]
63pub struct AgentPhaseTransition {
64    pub from: AgentPhase,
65    pub to: AgentPhase,
66    pub state: AgentPhaseState,
67}
68
69/// Aggregated view of all active agents' phases.
70#[derive(Serialize, Deserialize, Clone, Debug)]
71pub struct AgentPhaseMap {
72    pub agents: Vec<AgentPhaseState>,
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    pub stale: Vec<AgentPhaseState>,
75    pub summary: String,
76}
77
78impl AgentPhaseMap {
79    /// Build an AgentPhaseMap from active and stale agent states.
80    pub fn from_agents(agents: Vec<AgentPhaseState>, stale: Vec<AgentPhaseState>) -> Self {
81        let summary = Self::build_summary(&agents);
82        Self {
83            agents,
84            stale,
85            summary,
86        }
87    }
88
89    fn build_summary(agents: &[AgentPhaseState]) -> String {
90        if agents.is_empty() {
91            return "no active agents".to_string();
92        }
93        let parts: Vec<String> = agents
94            .iter()
95            .map(|a| {
96                let id = a.label.as_deref().unwrap_or(&a.session_id);
97                let context = match (a.issue, a.pr) {
98                    (_, Some(pr)) => format!(" PR #{pr}"),
99                    (Some(issue), _) => format!(" #{issue}"),
100                    _ => String::new(),
101                };
102                format!("{id} {}{context}", a.phase)
103            })
104            .collect();
105        format!("{} active: {}", agents.len(), parts.join(", "))
106    }
107}
108
109/// Suggestion for the agent based on current phase.
110pub fn phase_suggestion(phase: &AgentPhase, issue: Option<u64>, pr: Option<u64>) -> String {
111    match phase {
112        AgentPhase::Triage => "/issue-scan or /issue-create".to_string(),
113        AgentPhase::Research => match issue {
114            Some(id) => format!("/deep-research {id}"),
115            None => "/deep-research".to_string(),
116        },
117        AgentPhase::Plan => match issue {
118            Some(id) => format!("/deep-plan {id}"),
119            None => "/deep-plan".to_string(),
120        },
121        AgentPhase::Implement => "/issue-action".to_string(),
122        AgentPhase::Review => match pr {
123            Some(id) => format!("/pr-review {id}"),
124            None => "/pr-review".to_string(),
125        },
126    }
127}
128
129/// Format a phase nudge line for hook injection.
130pub fn format_phase_nudge(state: &AgentPhaseState) -> String {
131    let context = match (state.issue, state.pr) {
132        (_, Some(pr)) => format!(" (PR #{pr})"),
133        (Some(issue), _) => format!(" (#{issue})"),
134        _ => String::new(),
135    };
136    let suggestion = phase_suggestion(&state.phase, state.issue, state.pr);
137    format!(
138        "-> AgentPhase: {}{context}. Suggested: {suggestion}",
139        state.phase
140    )
141}
142
143/// Generate a mobile-friendly context summary within a character budget.
144pub fn mobile_context_summary(
145    state: &AgentPhaseState,
146    decisions: &[String],
147    commits: &[String],
148    budget_chars: usize,
149) -> String {
150    let mut parts: Vec<String> = Vec::new();
151
152    // Phase header (always included)
153    let header = match (state.issue, state.pr) {
154        (_, Some(pr)) => format!("{} PR #{pr}", state.phase),
155        (Some(issue), _) => format!("{} #{issue}", state.phase),
156        _ => state.phase.to_string(),
157    };
158    parts.push(header);
159
160    // Phase-specific content priority
161    match state.phase {
162        AgentPhase::Research | AgentPhase::Triage => {
163            for d in decisions.iter().take(2) {
164                parts.push(d.clone());
165            }
166        }
167        AgentPhase::Plan => {
168            for d in decisions.iter().take(1) {
169                parts.push(d.clone());
170            }
171            for c in commits.iter().take(1) {
172                parts.push(c.clone());
173            }
174        }
175        AgentPhase::Implement => {
176            for c in commits.iter().take(2) {
177                parts.push(c.clone());
178            }
179            for d in decisions.iter().take(1) {
180                parts.push(d.clone());
181            }
182        }
183        AgentPhase::Review => {
184            for c in commits.iter().take(2) {
185                parts.push(c.clone());
186            }
187        }
188    }
189
190    let joined = parts.join("; ");
191    if joined.len() <= budget_chars {
192        joined
193    } else {
194        // Find a char boundary at or before the target length to avoid panicking on multi-byte chars
195        let target = budget_chars.saturating_sub(3);
196        let boundary = joined
197            .char_indices()
198            .take_while(|(i, _)| *i <= target)
199            .last()
200            .map(|(i, _)| i)
201            .unwrap_or(0);
202        let mut truncated = joined[..boundary].to_string();
203        truncated.push_str("...");
204        truncated
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    fn sample_state() -> AgentPhaseState {
213        AgentPhaseState {
214            phase: AgentPhase::Implement,
215            session_id: "sess-abc".to_string(),
216            label: Some("feature-worker".to_string()),
217            issue: Some(45),
218            pr: None,
219            branch: Some("feat/auth-45".to_string()),
220            confidence: 0.85,
221            detected_at: "2026-02-27T10:00:00Z".to_string(),
222            signals: vec!["branch feat/auth-45 created".to_string()],
223        }
224    }
225
226    #[test]
227    fn agent_phase_display_roundtrip() {
228        for phase in [
229            AgentPhase::Triage,
230            AgentPhase::Research,
231            AgentPhase::Plan,
232            AgentPhase::Implement,
233            AgentPhase::Review,
234        ] {
235            let s = phase.to_string();
236            let parsed: AgentPhase = s.parse().unwrap();
237            assert_eq!(phase, parsed);
238        }
239    }
240
241    #[test]
242    fn agent_phase_from_str_unknown() {
243        let result: Result<AgentPhase, _> = "unknown".parse();
244        assert!(result.is_err());
245    }
246
247    #[test]
248    fn agent_phase_state_serde_roundtrip() {
249        let state = sample_state();
250        let json = serde_json::to_string(&state).unwrap();
251        let parsed: AgentPhaseState = serde_json::from_str(&json).unwrap();
252        assert_eq!(parsed.phase, AgentPhase::Implement);
253        assert_eq!(parsed.session_id, "sess-abc");
254        assert_eq!(parsed.label.as_deref(), Some("feature-worker"));
255        assert_eq!(parsed.issue, Some(45));
256        assert_eq!(parsed.confidence, 0.85);
257    }
258
259    #[test]
260    fn agent_phase_state_serde_minimal() {
261        let json = r#"{"phase":"triage","session_id":"s1","confidence":0.3,"detected_at":"2026-01-01T00:00:00Z"}"#;
262        let state: AgentPhaseState = serde_json::from_str(json).unwrap();
263        assert_eq!(state.phase, AgentPhase::Triage);
264        assert!(state.label.is_none());
265        assert!(state.issue.is_none());
266        assert!(state.signals.is_empty());
267    }
268
269    #[test]
270    fn agent_phase_transition_serde_roundtrip() {
271        let t = AgentPhaseTransition {
272            from: AgentPhase::Research,
273            to: AgentPhase::Implement,
274            state: sample_state(),
275        };
276        let json = serde_json::to_string(&t).unwrap();
277        let parsed: AgentPhaseTransition = serde_json::from_str(&json).unwrap();
278        assert_eq!(parsed.from, AgentPhase::Research);
279        assert_eq!(parsed.to, AgentPhase::Implement);
280    }
281
282    #[test]
283    fn agent_phase_map_serde_roundtrip() {
284        let map = AgentPhaseMap::from_agents(vec![sample_state()], vec![]);
285        let json = serde_json::to_string(&map).unwrap();
286        let parsed: AgentPhaseMap = serde_json::from_str(&json).unwrap();
287        assert_eq!(parsed.agents.len(), 1);
288        assert!(parsed.stale.is_empty());
289        assert!(parsed.summary.contains("1 active"));
290    }
291
292    #[test]
293    fn agent_phase_map_summary_empty() {
294        let map = AgentPhaseMap::from_agents(vec![], vec![]);
295        assert_eq!(map.summary, "no active agents");
296    }
297
298    #[test]
299    fn agent_phase_map_summary_multiple() {
300        let s1 = sample_state();
301        let mut s2 = sample_state();
302        s2.session_id = "sess-def".to_string();
303        s2.label = None;
304        s2.phase = AgentPhase::Review;
305        s2.pr = Some(53);
306
307        let map = AgentPhaseMap::from_agents(vec![s1, s2], vec![]);
308        assert!(map.summary.contains("2 active"));
309        assert!(map.summary.contains("feature-worker implement #45"));
310        assert!(map.summary.contains("sess-def review PR #53"));
311    }
312
313    #[test]
314    fn phase_suggestion_with_issue() {
315        let s = phase_suggestion(&AgentPhase::Research, Some(45), None);
316        assert_eq!(s, "/deep-research 45");
317    }
318
319    #[test]
320    fn phase_suggestion_with_pr() {
321        let s = phase_suggestion(&AgentPhase::Review, None, Some(53));
322        assert_eq!(s, "/pr-review 53");
323    }
324
325    #[test]
326    fn format_phase_nudge_output() {
327        let state = sample_state();
328        let nudge = format_phase_nudge(&state);
329        assert!(nudge.starts_with("-> AgentPhase: implement (#45)"));
330        assert!(nudge.contains("/issue-action"));
331    }
332
333    #[test]
334    fn mobile_context_summary_within_budget() {
335        let state = sample_state();
336        let decisions = vec!["db.engine=sqlite".to_string()];
337        let commits = vec!["feat: add auth".to_string()];
338        let summary = mobile_context_summary(&state, &decisions, &commits, 200);
339        assert!(summary.len() <= 200);
340        assert!(summary.contains("implement #45"));
341    }
342
343    #[test]
344    fn mobile_context_summary_truncates() {
345        let state = sample_state();
346        let decisions =
347            vec!["a very long decision description that takes up lots of space".to_string()];
348        let commits = vec!["a very long commit message that also takes space".to_string()];
349        let summary = mobile_context_summary(&state, &decisions, &commits, 50);
350        assert!(summary.len() <= 50);
351        assert!(summary.ends_with("..."));
352    }
353
354    #[test]
355    fn agent_phase_serde_snake_case() {
356        let json = serde_json::to_string(&AgentPhase::Implement).unwrap();
357        assert_eq!(json, "\"implement\"");
358        let parsed: AgentPhase = serde_json::from_str("\"research\"").unwrap();
359        assert_eq!(parsed, AgentPhase::Research);
360    }
361}