Skip to main content

task_graph_mcp/prompts/
mod.rs

1//! Transition prompts system.
2//!
3//! Loads and delivers prompts when tasks transition between states/phases.
4//! Prompts are defined in `workflows.yaml` with the following structure:
5//!
6//! - State prompts: `states.<state>.prompts.enter` / `states.<state>.prompts.exit`
7//! - Phase prompts: `phases.<phase>.prompts.enter` / `phases.<phase>.prompts.exit`
8//! - Combo prompts: `combos.<state>+<phase>.enter` / `combos.<state>+<phase>.exit`
9//!
10//! Trigger naming convention:
11//! - `enter~{status}` - entering a status (any phase)
12//! - `exit~{status}` - exiting a status (any phase)
13//! - `enter%{phase}` - entering a phase (any status)
14//! - `exit%{phase}` - exiting a phase (any status)
15//! - `enter~{status}%{phase}` - entering specific status+phase combo
16//! - `exit~{status}%{phase}` - exiting specific status+phase combo
17//!
18//! Template variables are expanded in prompts:
19//! - `{{valid_exits}}` - valid states to transition to from current state
20//! - `{{current_phase}}` - current phase if set
21//! - `{{valid_phases}}` - list of valid phases that can be set
22//! - `{{current_status}}` - current status name
23
24use crate::config::workflows::WorkflowsConfig;
25use crate::config::{PhasesConfig, StatesConfig};
26
27/// Context for expanding template variables in prompts.
28#[derive(Debug, Clone)]
29pub struct PromptContext<'a> {
30    /// Current status of the task
31    pub status: &'a str,
32    /// Current phase of the task (if any)
33    pub phase: Option<&'a str>,
34    /// States configuration for looking up valid transitions
35    pub states_config: &'a StatesConfig,
36    /// Phases configuration for listing valid phases
37    pub phases_config: &'a PhasesConfig,
38}
39
40impl<'a> PromptContext<'a> {
41    /// Create a new prompt context.
42    pub fn new(
43        status: &'a str,
44        phase: Option<&'a str>,
45        states_config: &'a StatesConfig,
46        phases_config: &'a PhasesConfig,
47    ) -> Self {
48        Self {
49            status,
50            phase,
51            states_config,
52            phases_config,
53        }
54    }
55}
56
57/// Load a prompt by trigger name from WorkflowsConfig.
58///
59/// Returns None if no prompt exists for this trigger.
60pub fn load_prompt(trigger: &str, workflows: &WorkflowsConfig) -> Option<String> {
61    workflows.get_prompt(trigger).map(|s| s.to_string())
62}
63
64/// Expand template variables in a prompt string.
65///
66/// Supported variables:
67/// - `{{valid_exits}}` - markdown list of valid exit states
68/// - `{{current_phase}}` - current phase or "(none)" if not set
69/// - `{{valid_phases}}` - comma-separated list of valid phases
70/// - `{{current_status}}` - current status name
71pub fn expand_prompt(content: &str, ctx: &PromptContext) -> String {
72    let mut result = content.to_string();
73
74    // Expand {{current_status}}
75    result = result.replace("{{current_status}}", ctx.status);
76
77    // Expand {{valid_exits}}
78    if result.contains("{{valid_exits}}") {
79        let exits = ctx.states_config.get_exits(ctx.status);
80        let exits_md = if exits.is_empty() {
81            "- _(no transitions available - terminal state)_".to_string()
82        } else {
83            exits
84                .iter()
85                .map(|s| format!("- `{}`", s))
86                .collect::<Vec<_>>()
87                .join("\n")
88        };
89        result = result.replace("{{valid_exits}}", &exits_md);
90    }
91
92    // Expand {{current_phase}}
93    if result.contains("{{current_phase}}") {
94        let phase_str = ctx
95            .phase
96            .map(|p| format!("`{}`", p))
97            .unwrap_or_else(|| "_(none)_".to_string());
98        result = result.replace("{{current_phase}}", &phase_str);
99    }
100
101    // Expand {{valid_phases}}
102    if result.contains("{{valid_phases}}") {
103        let mut phases: Vec<&str> = ctx.phases_config.phase_names();
104        phases.sort();
105        let phases_str = phases.join(", ");
106        result = result.replace("{{valid_phases}}", &phases_str);
107    }
108
109    result
110}
111
112/// Get the list of triggers that should fire for a state transition.
113///
114/// Order: exits (specific → general), then enters (general → specific)
115pub fn get_transition_triggers(
116    old_status: &str,
117    old_phase: Option<&str>,
118    new_status: &str,
119    new_phase: Option<&str>,
120) -> Vec<String> {
121    let mut triggers = Vec::new();
122
123    let status_changed = old_status != new_status;
124    let phase_changed = old_phase != new_phase;
125
126    // === EXITS (specific → general) ===
127
128    // Exit combo (if either changed and had a phase)
129    if (status_changed || phase_changed) && old_phase.is_some() {
130        if let Some(op) = old_phase {
131            triggers.push(format!("exit~{}%{}", old_status, op));
132        }
133    }
134
135    // Exit phase (if phase changed)
136    if phase_changed {
137        if let Some(op) = old_phase {
138            triggers.push(format!("exit%{}", op));
139        }
140    }
141
142    // Exit status (if status changed)
143    if status_changed {
144        triggers.push(format!("exit~{}", old_status));
145    }
146
147    // === ENTERS (general → specific) ===
148
149    // Enter status (if status changed)
150    if status_changed {
151        triggers.push(format!("enter~{}", new_status));
152    }
153
154    // Enter phase (if phase changed)
155    if phase_changed {
156        if let Some(np) = new_phase {
157            triggers.push(format!("enter%{}", np));
158        }
159    }
160
161    // Enter combo (if either changed and has a phase)
162    if (status_changed || phase_changed) && new_phase.is_some() {
163        if let Some(np) = new_phase {
164            triggers.push(format!("enter~{}%{}", new_status, np));
165        }
166    }
167
168    triggers
169}
170
171/// Get all prompts that should be delivered for a state transition.
172///
173/// Returns a vector of prompt strings (caller concatenates as needed).
174/// This version does NOT expand template variables - use `get_transition_prompts_with_context` for that.
175pub fn get_transition_prompts(
176    old_status: &str,
177    old_phase: Option<&str>,
178    new_status: &str,
179    new_phase: Option<&str>,
180    workflows: &WorkflowsConfig,
181) -> Vec<String> {
182    get_transition_triggers(old_status, old_phase, new_status, new_phase)
183        .iter()
184        .filter_map(|trigger| load_prompt(trigger, workflows))
185        .collect()
186}
187
188/// Get all prompts that should be delivered for a state transition, with template expansion.
189///
190/// Returns a vector of prompt strings with template variables expanded.
191pub fn get_transition_prompts_with_context(
192    old_status: &str,
193    old_phase: Option<&str>,
194    new_status: &str,
195    new_phase: Option<&str>,
196    workflows: &WorkflowsConfig,
197    ctx: &PromptContext,
198) -> Vec<String> {
199    get_transition_triggers(old_status, old_phase, new_status, new_phase)
200        .iter()
201        .filter_map(|trigger| load_prompt(trigger, workflows))
202        .map(|content| expand_prompt(&content, ctx))
203        .collect()
204}
205
206/// List all available prompt triggers from the workflows config.
207pub fn list_available_prompts(workflows: &WorkflowsConfig) -> Vec<String> {
208    workflows.list_prompt_triggers()
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_triggers_status_change_only() {
217        let triggers = get_transition_triggers("pending", None, "working", None);
218        assert_eq!(triggers, vec!["exit~pending", "enter~working"]);
219    }
220
221    #[test]
222    fn test_triggers_phase_change_only() {
223        let triggers =
224            get_transition_triggers("working", Some("diagnose"), "working", Some("review"));
225        assert_eq!(
226            triggers,
227            vec![
228                "exit~working%diagnose",
229                "exit%diagnose",
230                "enter%review",
231                "enter~working%review"
232            ]
233        );
234    }
235
236    #[test]
237    fn test_triggers_both_change() {
238        let triggers =
239            get_transition_triggers("working", Some("diagnose"), "finished", Some("review"));
240        assert_eq!(
241            triggers,
242            vec![
243                "exit~working%diagnose",
244                "exit%diagnose",
245                "exit~working",
246                "enter~finished",
247                "enter%review",
248                "enter~finished%review"
249            ]
250        );
251    }
252
253    #[test]
254    fn test_triggers_enter_phase_from_none() {
255        let triggers = get_transition_triggers("working", None, "working", Some("diagnose"));
256        assert_eq!(triggers, vec!["enter%diagnose", "enter~working%diagnose"]);
257    }
258
259    #[test]
260    fn test_triggers_exit_phase_to_none() {
261        let triggers = get_transition_triggers("working", Some("diagnose"), "working", None);
262        assert_eq!(triggers, vec!["exit~working%diagnose", "exit%diagnose"]);
263    }
264
265    #[test]
266    fn test_no_triggers_when_unchanged() {
267        let triggers =
268            get_transition_triggers("working", Some("diagnose"), "working", Some("diagnose"));
269        assert!(triggers.is_empty());
270    }
271
272    #[test]
273    fn test_expand_prompt_valid_exits() {
274        let states_config = StatesConfig::default();
275        let phases_config = PhasesConfig::default();
276        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
277
278        let template = "From {{current_status}} you can go to:\n{{valid_exits}}";
279        let result = expand_prompt(template, &ctx);
280
281        assert!(result.contains("From working you can go to:"));
282        assert!(result.contains("`completed`"));
283        assert!(result.contains("`failed`"));
284        assert!(result.contains("`pending`"));
285    }
286
287    #[test]
288    fn test_expand_prompt_current_phase() {
289        let states_config = StatesConfig::default();
290        let phases_config = PhasesConfig::default();
291
292        // With a phase
293        let ctx = PromptContext::new("working", Some("implement"), &states_config, &phases_config);
294        let template = "Phase: {{current_phase}}";
295        let result = expand_prompt(template, &ctx);
296        assert_eq!(result, "Phase: `implement`");
297
298        // Without a phase
299        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
300        let result = expand_prompt(template, &ctx);
301        assert_eq!(result, "Phase: _(none)_");
302    }
303
304    #[test]
305    fn test_expand_prompt_valid_phases() {
306        let states_config = StatesConfig::default();
307        let phases_config = PhasesConfig::default();
308        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
309
310        let template = "Phases: {{valid_phases}}";
311        let result = expand_prompt(template, &ctx);
312
313        // Should contain various default phases
314        assert!(result.contains("implement"));
315        assert!(result.contains("test"));
316        assert!(result.contains("review"));
317    }
318
319    #[test]
320    fn test_expand_prompt_terminal_state() {
321        let states_config = StatesConfig::default();
322        let phases_config = PhasesConfig::default();
323        let ctx = PromptContext::new("cancelled", None, &states_config, &phases_config);
324
325        let template = "Exits: {{valid_exits}}";
326        let result = expand_prompt(template, &ctx);
327
328        // Cancelled is a terminal state (no exits)
329        assert!(result.contains("no transitions available"));
330    }
331
332    #[test]
333    fn test_load_prompt_from_workflows() {
334        let workflows = WorkflowsConfig::default();
335
336        // Should find enter~working
337        let prompt = load_prompt("enter~working", &workflows);
338        assert!(prompt.is_some());
339        assert!(prompt.unwrap().contains("actively working"));
340
341        // Should find enter%implement
342        let prompt = load_prompt("enter%implement", &workflows);
343        assert!(prompt.is_some());
344        assert!(prompt.unwrap().contains("Implementation"));
345    }
346
347    #[test]
348    fn test_get_transition_prompts() {
349        let workflows = WorkflowsConfig::default();
350
351        let prompts = get_transition_prompts("pending", None, "working", None, &workflows);
352
353        // Should have at least the enter~working prompt
354        assert!(!prompts.is_empty());
355        assert!(prompts.iter().any(|p| p.contains("actively working")));
356    }
357
358    #[test]
359    fn test_list_available_prompts() {
360        let workflows = WorkflowsConfig::default();
361        let prompts = list_available_prompts(&workflows);
362
363        assert!(prompts.contains(&"enter~working".to_string()));
364        assert!(prompts.contains(&"exit~working".to_string()));
365        assert!(prompts.contains(&"enter%implement".to_string()));
366    }
367}