1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4#[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#[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#[derive(Serialize, Deserialize, Clone, Debug)]
63pub struct AgentPhaseTransition {
64 pub from: AgentPhase,
65 pub to: AgentPhase,
66 pub state: AgentPhaseState,
67}
68
69#[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 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
109pub 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
129pub 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
143pub 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 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 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 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}