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)
130        && old_phase.is_some()
131        && let Some(op) = old_phase
132    {
133        triggers.push(format!("exit~{}%{}", old_status, op));
134    }
135
136    // Exit phase (if phase changed)
137    if phase_changed && let Some(op) = old_phase {
138        triggers.push(format!("exit%{}", op));
139    }
140
141    // Exit status (if status changed)
142    if status_changed {
143        triggers.push(format!("exit~{}", old_status));
144    }
145
146    // === ENTERS (general → specific) ===
147
148    // Enter status (if status changed)
149    if status_changed {
150        triggers.push(format!("enter~{}", new_status));
151    }
152
153    // Enter phase (if phase changed)
154    if phase_changed && let Some(np) = new_phase {
155        triggers.push(format!("enter%{}", np));
156    }
157
158    // Enter combo (if either changed and has a phase)
159    if (status_changed || phase_changed)
160        && new_phase.is_some()
161        && let Some(np) = new_phase
162    {
163        triggers.push(format!("enter~{}%{}", new_status, np));
164    }
165
166    triggers
167}
168
169/// Get all prompts that should be delivered for a state transition.
170///
171/// Returns a vector of prompt strings (caller concatenates as needed).
172/// This version does NOT expand template variables - use `get_transition_prompts_with_context` for that.
173pub fn get_transition_prompts(
174    old_status: &str,
175    old_phase: Option<&str>,
176    new_status: &str,
177    new_phase: Option<&str>,
178    workflows: &WorkflowsConfig,
179) -> Vec<String> {
180    get_transition_triggers(old_status, old_phase, new_status, new_phase)
181        .iter()
182        .filter_map(|trigger| load_prompt(trigger, workflows))
183        .collect()
184}
185
186/// Get all prompts that should be delivered for a state transition, with template expansion.
187///
188/// Returns a vector of prompt strings with template variables expanded.
189pub fn get_transition_prompts_with_context(
190    old_status: &str,
191    old_phase: Option<&str>,
192    new_status: &str,
193    new_phase: Option<&str>,
194    workflows: &WorkflowsConfig,
195    ctx: &PromptContext,
196) -> Vec<String> {
197    get_transition_triggers(old_status, old_phase, new_status, new_phase)
198        .iter()
199        .filter_map(|trigger| load_prompt(trigger, workflows))
200        .map(|content| expand_prompt(&content, ctx))
201        .collect()
202}
203
204/// List all available prompt triggers from the workflows config.
205pub fn list_available_prompts(workflows: &WorkflowsConfig) -> Vec<String> {
206    workflows.list_prompt_triggers()
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_triggers_status_change_only() {
215        let triggers = get_transition_triggers("pending", None, "working", None);
216        assert_eq!(triggers, vec!["exit~pending", "enter~working"]);
217    }
218
219    #[test]
220    fn test_triggers_phase_change_only() {
221        let triggers =
222            get_transition_triggers("working", Some("diagnose"), "working", Some("review"));
223        assert_eq!(
224            triggers,
225            vec![
226                "exit~working%diagnose",
227                "exit%diagnose",
228                "enter%review",
229                "enter~working%review"
230            ]
231        );
232    }
233
234    #[test]
235    fn test_triggers_both_change() {
236        let triggers =
237            get_transition_triggers("working", Some("diagnose"), "finished", Some("review"));
238        assert_eq!(
239            triggers,
240            vec![
241                "exit~working%diagnose",
242                "exit%diagnose",
243                "exit~working",
244                "enter~finished",
245                "enter%review",
246                "enter~finished%review"
247            ]
248        );
249    }
250
251    #[test]
252    fn test_triggers_enter_phase_from_none() {
253        let triggers = get_transition_triggers("working", None, "working", Some("diagnose"));
254        assert_eq!(triggers, vec!["enter%diagnose", "enter~working%diagnose"]);
255    }
256
257    #[test]
258    fn test_triggers_exit_phase_to_none() {
259        let triggers = get_transition_triggers("working", Some("diagnose"), "working", None);
260        assert_eq!(triggers, vec!["exit~working%diagnose", "exit%diagnose"]);
261    }
262
263    #[test]
264    fn test_no_triggers_when_unchanged() {
265        let triggers =
266            get_transition_triggers("working", Some("diagnose"), "working", Some("diagnose"));
267        assert!(triggers.is_empty());
268    }
269
270    #[test]
271    fn test_expand_prompt_valid_exits() {
272        let states_config = StatesConfig::default();
273        let phases_config = PhasesConfig::default();
274        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
275
276        let template = "From {{current_status}} you can go to:\n{{valid_exits}}";
277        let result = expand_prompt(template, &ctx);
278
279        assert!(result.contains("From working you can go to:"));
280        assert!(result.contains("`completed`"));
281        assert!(result.contains("`failed`"));
282        assert!(result.contains("`pending`"));
283    }
284
285    #[test]
286    fn test_expand_prompt_current_phase() {
287        let states_config = StatesConfig::default();
288        let phases_config = PhasesConfig::default();
289
290        // With a phase
291        let ctx = PromptContext::new("working", Some("implement"), &states_config, &phases_config);
292        let template = "Phase: {{current_phase}}";
293        let result = expand_prompt(template, &ctx);
294        assert_eq!(result, "Phase: `implement`");
295
296        // Without a phase
297        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
298        let result = expand_prompt(template, &ctx);
299        assert_eq!(result, "Phase: _(none)_");
300    }
301
302    #[test]
303    fn test_expand_prompt_valid_phases() {
304        let states_config = StatesConfig::default();
305        let phases_config = PhasesConfig::default();
306        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
307
308        let template = "Phases: {{valid_phases}}";
309        let result = expand_prompt(template, &ctx);
310
311        // Should contain various default phases
312        assert!(result.contains("implement"));
313        assert!(result.contains("test"));
314        assert!(result.contains("review"));
315    }
316
317    #[test]
318    fn test_expand_prompt_terminal_state() {
319        let states_config = StatesConfig::default();
320        let phases_config = PhasesConfig::default();
321        let ctx = PromptContext::new("cancelled", None, &states_config, &phases_config);
322
323        let template = "Exits: {{valid_exits}}";
324        let result = expand_prompt(template, &ctx);
325
326        // Cancelled is a terminal state (no exits)
327        assert!(result.contains("no transitions available"));
328    }
329
330    #[test]
331    fn test_load_prompt_from_workflows() {
332        let workflows = WorkflowsConfig::default();
333
334        // Should find enter~working
335        let prompt = load_prompt("enter~working", &workflows);
336        assert!(prompt.is_some());
337        assert!(prompt.unwrap().contains("actively working"));
338
339        // Should find enter%implement
340        let prompt = load_prompt("enter%implement", &workflows);
341        assert!(prompt.is_some());
342        assert!(prompt.unwrap().contains("Implementation"));
343    }
344
345    #[test]
346    fn test_get_transition_prompts() {
347        let workflows = WorkflowsConfig::default();
348
349        let prompts = get_transition_prompts("pending", None, "working", None, &workflows);
350
351        // Should have at least the enter~working prompt
352        assert!(!prompts.is_empty());
353        assert!(prompts.iter().any(|p| p.contains("actively working")));
354    }
355
356    #[test]
357    fn test_list_available_prompts() {
358        let workflows = WorkflowsConfig::default();
359        let prompts = list_available_prompts(&workflows);
360
361        assert!(prompts.contains(&"enter~working".to_string()));
362        assert!(prompts.contains(&"exit~working".to_string()));
363        assert!(prompts.contains(&"enter%implement".to_string()));
364    }
365}