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};
26use serde::Serialize;
27
28/// A prompt string paired with its source attribution.
29///
30/// The `source` field indicates where the prompt originated from:
31/// - `"state:<name>"` - state enter/exit prompt (e.g., `"state:working"`)
32/// - `"phase:<name>"` - phase enter/exit prompt (e.g., `"phase:implement"`)
33/// - `"combo:<state>+<phase>"` - state+phase combo prompt (e.g., `"combo:working+implement"`)
34/// - `"role:<name>"` - role-specific prompt (e.g., `"role:worker"`)
35/// - `"workflow"` - base workflow prompt (fallback)
36#[derive(Debug, Clone, Serialize, PartialEq)]
37pub struct AttributedPrompt {
38    pub text: String,
39    pub source: String,
40}
41
42/// Context for expanding template variables in prompts.
43///
44/// Provides both workflow context (status, phase, valid transitions) and
45/// situational context (task metadata, agent identity) for rich prompt
46/// template expansion.
47#[derive(Debug, Clone)]
48pub struct PromptContext<'a> {
49    /// Current status of the task
50    pub status: &'a str,
51    /// Current phase of the task (if any)
52    pub phase: Option<&'a str>,
53    /// States configuration for looking up valid transitions
54    pub states_config: &'a StatesConfig,
55    /// Phases configuration for listing valid phases
56    pub phases_config: &'a PhasesConfig,
57    /// Task ID (if available)
58    pub task_id: Option<&'a str>,
59    /// Task title (if available)
60    pub task_title: Option<&'a str>,
61    /// Task priority (if available)
62    pub task_priority: Option<i32>,
63    /// Task tags (if available)
64    pub task_tags: Option<&'a [String]>,
65    /// Agent/worker ID (if available)
66    pub agent_id: Option<&'a str>,
67    /// Agent's matched role name (if available)
68    pub agent_role: Option<&'a str>,
69    /// Agent's tags (if available)
70    pub agent_tags: Option<&'a [String]>,
71    /// Task's hierarchy level extracted from level:* tags (if available)
72    pub task_level: Option<&'a str>,
73    /// Number of direct children (contains deps) of the task (if available)
74    pub child_count: Option<usize>,
75}
76
77impl<'a> PromptContext<'a> {
78    /// Create a new prompt context with workflow information only.
79    ///
80    /// For backwards compatibility -- callers that don't have task/agent
81    /// info can use this constructor. Use `with_task()` and `with_agent()`
82    /// to add situational context.
83    pub fn new(
84        status: &'a str,
85        phase: Option<&'a str>,
86        states_config: &'a StatesConfig,
87        phases_config: &'a PhasesConfig,
88    ) -> Self {
89        Self {
90            status,
91            phase,
92            states_config,
93            phases_config,
94            task_id: None,
95            task_title: None,
96            task_priority: None,
97            task_tags: None,
98            agent_id: None,
99            agent_role: None,
100            agent_tags: None,
101            task_level: None,
102            child_count: None,
103        }
104    }
105
106    /// Add task context to the prompt context.
107    pub fn with_task(
108        mut self,
109        id: &'a str,
110        title: &'a str,
111        priority: i32,
112        tags: &'a [String],
113    ) -> Self {
114        self.task_id = Some(id);
115        self.task_title = Some(title);
116        self.task_priority = Some(priority);
117        self.task_tags = Some(tags);
118        self
119    }
120
121    /// Add hierarchy level and child count context.
122    pub fn with_level(mut self, level: Option<&'a str>, child_count: Option<usize>) -> Self {
123        self.task_level = level;
124        self.child_count = child_count;
125        self
126    }
127
128    /// Add agent context to the prompt context.
129    pub fn with_agent(
130        mut self,
131        agent_id: &'a str,
132        role: Option<&'a str>,
133        tags: &'a [String],
134    ) -> Self {
135        self.agent_id = Some(agent_id);
136        self.agent_role = role;
137        self.agent_tags = Some(tags);
138        self
139    }
140}
141
142/// Load a prompt by trigger name from WorkflowsConfig.
143///
144/// Returns None if no prompt exists for this trigger.
145pub fn load_prompt(trigger: &str, workflows: &WorkflowsConfig) -> Option<String> {
146    workflows.get_prompt(trigger).map(|s| s.to_string())
147}
148
149/// Expand template variables in a prompt string.
150///
151/// Supported variables:
152///
153/// **Workflow context:**
154/// - `{{valid_exits}}` - markdown list of valid exit states
155/// - `{{current_phase}}` - current phase or "(none)" if not set
156/// - `{{valid_phases}}` - comma-separated list of valid phases
157/// - `{{current_status}}` - current status name
158///
159/// **Task context** (available when task info is provided):
160/// - `{{task_id}}` - task identifier
161/// - `{{task_title}}` - task title
162/// - `{{task_priority}}` - task priority (0-10)
163/// - `{{task_tags}}` - comma-separated task tags
164///
165/// **Agent context** (available when agent info is provided):
166/// - `{{agent_id}}` - agent/worker identifier
167/// - `{{agent_role}}` - matched role name or "(none)"
168/// - `{{agent_tags}}` - comma-separated agent tags
169pub fn expand_prompt(content: &str, ctx: &PromptContext) -> String {
170    let mut result = content.to_string();
171
172    // === Workflow context ===
173
174    // Expand {{current_status}}
175    result = result.replace("{{current_status}}", ctx.status);
176
177    // Expand {{valid_exits}}
178    if result.contains("{{valid_exits}}") {
179        let exits = ctx.states_config.get_exits(ctx.status);
180        let exits_md = if exits.is_empty() {
181            "- _(no transitions available - terminal state)_".to_string()
182        } else {
183            exits
184                .iter()
185                .map(|s| format!("- `{}`", s))
186                .collect::<Vec<_>>()
187                .join("\n")
188        };
189        result = result.replace("{{valid_exits}}", &exits_md);
190    }
191
192    // Expand {{current_phase}}
193    if result.contains("{{current_phase}}") {
194        let phase_str = ctx
195            .phase
196            .map(|p| format!("`{}`", p))
197            .unwrap_or_else(|| "_(none)_".to_string());
198        result = result.replace("{{current_phase}}", &phase_str);
199    }
200
201    // Expand {{valid_phases}}
202    if result.contains("{{valid_phases}}") {
203        let mut phases: Vec<&str> = ctx.phases_config.phase_names();
204        phases.sort();
205        let phases_str = phases.join(", ");
206        result = result.replace("{{valid_phases}}", &phases_str);
207    }
208
209    // === Task context ===
210
211    if result.contains("{{task_id}}") {
212        let val = ctx.task_id.unwrap_or("_unknown_");
213        result = result.replace("{{task_id}}", val);
214    }
215
216    if result.contains("{{task_title}}") {
217        let val = ctx.task_title.unwrap_or("_untitled_");
218        result = result.replace("{{task_title}}", val);
219    }
220
221    if result.contains("{{task_priority}}") {
222        let val = ctx
223            .task_priority
224            .map(|p| p.to_string())
225            .unwrap_or_else(|| "_unset_".to_string());
226        result = result.replace("{{task_priority}}", &val);
227    }
228
229    if result.contains("{{task_tags}}") {
230        let val = ctx
231            .task_tags
232            .map(|tags| {
233                if tags.is_empty() {
234                    "_(none)_".to_string()
235                } else {
236                    tags.join(", ")
237                }
238            })
239            .unwrap_or_else(|| "_(none)_".to_string());
240        result = result.replace("{{task_tags}}", &val);
241    }
242
243    // === Hierarchy context ===
244
245    if result.contains("{{task_level}}") {
246        let val = ctx.task_level.unwrap_or("_unset_");
247        result = result.replace("{{task_level}}", val);
248    }
249
250    if result.contains("{{child_count}}") {
251        let val = ctx
252            .child_count
253            .map(|c| c.to_string())
254            .unwrap_or_else(|| "_unknown_".to_string());
255        result = result.replace("{{child_count}}", &val);
256    }
257
258    // === Agent context ===
259
260    if result.contains("{{agent_id}}") {
261        let val = ctx.agent_id.unwrap_or("_unknown_");
262        result = result.replace("{{agent_id}}", val);
263    }
264
265    if result.contains("{{agent_role}}") {
266        let val = ctx
267            .agent_role
268            .map(|r| format!("`{}`", r))
269            .unwrap_or_else(|| "_(none)_".to_string());
270        result = result.replace("{{agent_role}}", &val);
271    }
272
273    if result.contains("{{agent_tags}}") {
274        let val = ctx
275            .agent_tags
276            .map(|tags| {
277                if tags.is_empty() {
278                    "_(none)_".to_string()
279                } else {
280                    tags.join(", ")
281                }
282            })
283            .unwrap_or_else(|| "_(none)_".to_string());
284        result = result.replace("{{agent_tags}}", &val);
285    }
286
287    result
288}
289
290/// Get the list of triggers that should fire for a state transition.
291///
292/// Order: exits (specific → general), then enters (general → specific)
293pub fn get_transition_triggers(
294    old_status: &str,
295    old_phase: Option<&str>,
296    new_status: &str,
297    new_phase: Option<&str>,
298) -> Vec<String> {
299    let mut triggers = Vec::new();
300
301    let status_changed = old_status != new_status;
302    let phase_changed = old_phase != new_phase;
303
304    // === EXITS (specific → general) ===
305
306    // Exit combo (if either changed and had a phase)
307    if (status_changed || phase_changed)
308        && old_phase.is_some()
309        && let Some(op) = old_phase
310    {
311        triggers.push(format!("exit~{}%{}", old_status, op));
312    }
313
314    // Exit phase (if phase changed)
315    if phase_changed && let Some(op) = old_phase {
316        triggers.push(format!("exit%{}", op));
317    }
318
319    // Exit status (if status changed)
320    if status_changed {
321        triggers.push(format!("exit~{}", old_status));
322    }
323
324    // === ENTERS (general → specific) ===
325
326    // Enter status (if status changed)
327    if status_changed {
328        triggers.push(format!("enter~{}", new_status));
329    }
330
331    // Enter phase (if phase changed)
332    if phase_changed && let Some(np) = new_phase {
333        triggers.push(format!("enter%{}", np));
334    }
335
336    // Enter combo (if either changed and has a phase)
337    if (status_changed || phase_changed)
338        && new_phase.is_some()
339        && let Some(np) = new_phase
340    {
341        triggers.push(format!("enter~{}%{}", new_status, np));
342    }
343
344    triggers
345}
346
347/// Get all prompts that should be delivered for a state transition.
348///
349/// Returns a vector of prompt strings (caller concatenates as needed).
350/// This version does NOT expand template variables - use `get_transition_prompts_with_context` for that.
351pub fn get_transition_prompts(
352    old_status: &str,
353    old_phase: Option<&str>,
354    new_status: &str,
355    new_phase: Option<&str>,
356    workflows: &WorkflowsConfig,
357) -> Vec<String> {
358    get_transition_triggers(old_status, old_phase, new_status, new_phase)
359        .iter()
360        .filter_map(|trigger| load_prompt(trigger, workflows))
361        .collect()
362}
363
364/// Get all prompts that should be delivered for a state transition, with template expansion.
365///
366/// Returns a vector of prompt strings with template variables expanded.
367pub fn get_transition_prompts_with_context(
368    old_status: &str,
369    old_phase: Option<&str>,
370    new_status: &str,
371    new_phase: Option<&str>,
372    workflows: &WorkflowsConfig,
373    ctx: &PromptContext,
374) -> Vec<String> {
375    get_transition_triggers(old_status, old_phase, new_status, new_phase)
376        .iter()
377        .filter_map(|trigger| load_prompt(trigger, workflows))
378        .map(|content| expand_prompt(&content, ctx))
379        .collect()
380}
381
382/// Derive a human-readable source label from a prompt trigger name.
383///
384/// Trigger naming convention:
385/// - `enter~working` / `exit~working` -> `"state:working"`
386/// - `enter%implement` / `exit%implement` -> `"phase:implement"`
387/// - `enter~working%implement` / `exit~working%implement` -> `"combo:working+implement"`
388fn trigger_to_source(trigger: &str) -> String {
389    // Phase-only triggers: enter%phase / exit%phase
390    if let Some(phase) = trigger.strip_prefix("enter%") {
391        return format!("phase:{}", phase);
392    }
393    if let Some(phase) = trigger.strip_prefix("exit%") {
394        return format!("phase:{}", phase);
395    }
396
397    // State or combo triggers: enter~state / exit~state / enter~state%phase / exit~state%phase
398    let rest = trigger
399        .strip_prefix("enter~")
400        .or_else(|| trigger.strip_prefix("exit~"))
401        .unwrap_or(trigger);
402
403    if let Some(idx) = rest.find('%') {
404        let state = &rest[..idx];
405        let phase = &rest[idx + 1..];
406        format!("combo:{}+{}", state, phase)
407    } else {
408        format!("state:{}", rest)
409    }
410}
411
412/// Get all prompts with source attribution for a state transition, with template expansion.
413///
414/// Returns a vector of `AttributedPrompt` structs containing both the expanded
415/// prompt text and a source label indicating where the prompt came from.
416pub fn get_transition_prompts_attributed(
417    old_status: &str,
418    old_phase: Option<&str>,
419    new_status: &str,
420    new_phase: Option<&str>,
421    workflows: &WorkflowsConfig,
422    ctx: &PromptContext,
423) -> Vec<AttributedPrompt> {
424    get_transition_triggers(old_status, old_phase, new_status, new_phase)
425        .iter()
426        .filter_map(|trigger| {
427            load_prompt(trigger, workflows).map(|content| AttributedPrompt {
428                text: expand_prompt(&content, ctx),
429                source: trigger_to_source(trigger),
430            })
431        })
432        .collect()
433}
434
435/// List all available prompt triggers from the workflows config.
436pub fn list_available_prompts(workflows: &WorkflowsConfig) -> Vec<String> {
437    workflows.list_prompt_triggers()
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_triggers_status_change_only() {
446        let triggers = get_transition_triggers("pending", None, "working", None);
447        assert_eq!(triggers, vec!["exit~pending", "enter~working"]);
448    }
449
450    #[test]
451    fn test_triggers_phase_change_only() {
452        let triggers =
453            get_transition_triggers("working", Some("diagnose"), "working", Some("review"));
454        assert_eq!(
455            triggers,
456            vec![
457                "exit~working%diagnose",
458                "exit%diagnose",
459                "enter%review",
460                "enter~working%review"
461            ]
462        );
463    }
464
465    #[test]
466    fn test_triggers_both_change() {
467        let triggers =
468            get_transition_triggers("working", Some("diagnose"), "finished", Some("review"));
469        assert_eq!(
470            triggers,
471            vec![
472                "exit~working%diagnose",
473                "exit%diagnose",
474                "exit~working",
475                "enter~finished",
476                "enter%review",
477                "enter~finished%review"
478            ]
479        );
480    }
481
482    #[test]
483    fn test_triggers_enter_phase_from_none() {
484        let triggers = get_transition_triggers("working", None, "working", Some("diagnose"));
485        assert_eq!(triggers, vec!["enter%diagnose", "enter~working%diagnose"]);
486    }
487
488    #[test]
489    fn test_triggers_exit_phase_to_none() {
490        let triggers = get_transition_triggers("working", Some("diagnose"), "working", None);
491        assert_eq!(triggers, vec!["exit~working%diagnose", "exit%diagnose"]);
492    }
493
494    #[test]
495    fn test_no_triggers_when_unchanged() {
496        let triggers =
497            get_transition_triggers("working", Some("diagnose"), "working", Some("diagnose"));
498        assert!(triggers.is_empty());
499    }
500
501    #[test]
502    fn test_expand_prompt_valid_exits() {
503        let states_config = StatesConfig::default();
504        let phases_config = PhasesConfig::default();
505        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
506
507        let template = "From {{current_status}} you can go to:\n{{valid_exits}}";
508        let result = expand_prompt(template, &ctx);
509
510        assert!(result.contains("From working you can go to:"));
511        assert!(result.contains("`completed`"));
512        assert!(result.contains("`failed`"));
513        assert!(result.contains("`pending`"));
514    }
515
516    #[test]
517    fn test_expand_prompt_current_phase() {
518        let states_config = StatesConfig::default();
519        let phases_config = PhasesConfig::default();
520
521        // With a phase
522        let ctx = PromptContext::new("working", Some("implement"), &states_config, &phases_config);
523        let template = "Phase: {{current_phase}}";
524        let result = expand_prompt(template, &ctx);
525        assert_eq!(result, "Phase: `implement`");
526
527        // Without a phase
528        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
529        let result = expand_prompt(template, &ctx);
530        assert_eq!(result, "Phase: _(none)_");
531    }
532
533    #[test]
534    fn test_expand_prompt_valid_phases() {
535        let states_config = StatesConfig::default();
536        let phases_config = PhasesConfig::default();
537        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
538
539        let template = "Phases: {{valid_phases}}";
540        let result = expand_prompt(template, &ctx);
541
542        // Should contain various default phases
543        assert!(result.contains("implement"));
544        assert!(result.contains("test"));
545        assert!(result.contains("review"));
546    }
547
548    #[test]
549    fn test_expand_prompt_terminal_state() {
550        let states_config = StatesConfig::default();
551        let phases_config = PhasesConfig::default();
552        let ctx = PromptContext::new("cancelled", None, &states_config, &phases_config);
553
554        let template = "Exits: {{valid_exits}}";
555        let result = expand_prompt(template, &ctx);
556
557        // Cancelled is a terminal state (no exits)
558        assert!(result.contains("no transitions available"));
559    }
560
561    #[test]
562    fn test_load_prompt_from_workflows() {
563        let workflows = WorkflowsConfig::default();
564
565        // Should find enter~working
566        let prompt = load_prompt("enter~working", &workflows);
567        assert!(prompt.is_some());
568        assert!(prompt.unwrap().contains("actively working"));
569
570        // Should find enter%implement
571        let prompt = load_prompt("enter%implement", &workflows);
572        assert!(prompt.is_some());
573        assert!(prompt.unwrap().contains("Implementation"));
574    }
575
576    #[test]
577    fn test_get_transition_prompts() {
578        let workflows = WorkflowsConfig::default();
579
580        let prompts = get_transition_prompts("pending", None, "working", None, &workflows);
581
582        // Should have at least the enter~working prompt
583        assert!(!prompts.is_empty());
584        assert!(prompts.iter().any(|p| p.contains("actively working")));
585    }
586
587    #[test]
588    fn test_list_available_prompts() {
589        let workflows = WorkflowsConfig::default();
590        let prompts = list_available_prompts(&workflows);
591
592        assert!(prompts.contains(&"enter~working".to_string()));
593        assert!(prompts.contains(&"exit~working".to_string()));
594        assert!(prompts.contains(&"enter%implement".to_string()));
595    }
596
597    // === Tests for context-sensitive template variables ===
598
599    #[test]
600    fn test_expand_prompt_task_context() {
601        let states_config = StatesConfig::default();
602        let phases_config = PhasesConfig::default();
603        let tags = vec!["backend".to_string(), "api".to_string()];
604        let ctx = PromptContext::new("working", Some("implement"), &states_config, &phases_config)
605            .with_task("fix-auth-bug", "Fix authentication bypass", 8, &tags);
606
607        let template = "Working on {{task_id}}: {{task_title}} (priority {{task_priority}}, tags: {{task_tags}})";
608        let result = expand_prompt(template, &ctx);
609
610        assert_eq!(
611            result,
612            "Working on fix-auth-bug: Fix authentication bypass (priority 8, tags: backend, api)"
613        );
614    }
615
616    #[test]
617    fn test_expand_prompt_task_context_empty_tags() {
618        let states_config = StatesConfig::default();
619        let phases_config = PhasesConfig::default();
620        let tags: Vec<String> = vec![];
621        let ctx = PromptContext::new("working", None, &states_config, &phases_config).with_task(
622            "my-task",
623            "Some task",
624            5,
625            &tags,
626        );
627
628        let template = "Tags: {{task_tags}}";
629        let result = expand_prompt(template, &ctx);
630
631        assert_eq!(result, "Tags: _(none)_");
632    }
633
634    #[test]
635    fn test_expand_prompt_task_context_missing() {
636        let states_config = StatesConfig::default();
637        let phases_config = PhasesConfig::default();
638        // No with_task() call - should use fallbacks
639        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
640
641        let template = "Task: {{task_id}} / {{task_title}} / {{task_priority}} / {{task_tags}}";
642        let result = expand_prompt(template, &ctx);
643
644        assert_eq!(result, "Task: _unknown_ / _untitled_ / _unset_ / _(none)_");
645    }
646
647    #[test]
648    fn test_expand_prompt_agent_context() {
649        let states_config = StatesConfig::default();
650        let phases_config = PhasesConfig::default();
651        let agent_tags = vec!["worker".to_string(), "implement".to_string()];
652        let ctx = PromptContext::new("working", None, &states_config, &phases_config).with_agent(
653            "worker-21",
654            Some("worker"),
655            &agent_tags,
656        );
657
658        let template = "Agent {{agent_id}} (role: {{agent_role}}, tags: {{agent_tags}})";
659        let result = expand_prompt(template, &ctx);
660
661        assert_eq!(
662            result,
663            "Agent worker-21 (role: `worker`, tags: worker, implement)"
664        );
665    }
666
667    #[test]
668    fn test_expand_prompt_agent_context_no_role() {
669        let states_config = StatesConfig::default();
670        let phases_config = PhasesConfig::default();
671        let agent_tags = vec!["generic".to_string()];
672        let ctx = PromptContext::new("working", None, &states_config, &phases_config).with_agent(
673            "worker-5",
674            None,
675            &agent_tags,
676        );
677
678        let template = "Role: {{agent_role}}";
679        let result = expand_prompt(template, &ctx);
680
681        assert_eq!(result, "Role: _(none)_");
682    }
683
684    #[test]
685    fn test_expand_prompt_agent_context_missing() {
686        let states_config = StatesConfig::default();
687        let phases_config = PhasesConfig::default();
688        // No with_agent() call
689        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
690
691        let template = "{{agent_id}} / {{agent_role}} / {{agent_tags}}";
692        let result = expand_prompt(template, &ctx);
693
694        assert_eq!(result, "_unknown_ / _(none)_ / _(none)_");
695    }
696
697    #[test]
698    fn test_expand_prompt_combined_context() {
699        let states_config = StatesConfig::default();
700        let phases_config = PhasesConfig::default();
701        let task_tags = vec!["design".to_string()];
702        let agent_tags = vec!["worker".to_string(), "design".to_string()];
703        let ctx = PromptContext::new("working", Some("design"), &states_config, &phases_config)
704            .with_task(
705                "prompt-guidance",
706                "Context-sensitive prompts",
707                7,
708                &task_tags,
709            )
710            .with_agent("worker-21", Some("worker"), &agent_tags);
711
712        let template = "{{agent_id}} is working on {{task_id}} in phase {{current_phase}} with status {{current_status}}";
713        let result = expand_prompt(template, &ctx);
714
715        assert_eq!(
716            result,
717            "worker-21 is working on prompt-guidance in phase `design` with status working"
718        );
719    }
720
721    #[test]
722    fn test_prompt_context_builder_pattern() {
723        let states_config = StatesConfig::default();
724        let phases_config = PhasesConfig::default();
725        let task_tags = vec![];
726        let agent_tags = vec!["worker".to_string()];
727
728        // Verify builder pattern works correctly
729        let ctx = PromptContext::new("pending", None, &states_config, &phases_config)
730            .with_task("t1", "Title", 5, &task_tags)
731            .with_agent("w1", Some("worker"), &agent_tags);
732
733        assert_eq!(ctx.task_id, Some("t1"));
734        assert_eq!(ctx.task_title, Some("Title"));
735        assert_eq!(ctx.task_priority, Some(5));
736        assert_eq!(ctx.agent_id, Some("w1"));
737        assert_eq!(ctx.agent_role, Some("worker"));
738    }
739
740    // === Tests for source attribution ===
741
742    #[test]
743    fn test_trigger_to_source_state() {
744        assert_eq!(trigger_to_source("enter~working"), "state:working");
745        assert_eq!(trigger_to_source("exit~pending"), "state:pending");
746    }
747
748    #[test]
749    fn test_trigger_to_source_phase() {
750        assert_eq!(trigger_to_source("enter%implement"), "phase:implement");
751        assert_eq!(trigger_to_source("exit%review"), "phase:review");
752    }
753
754    #[test]
755    fn test_trigger_to_source_combo() {
756        assert_eq!(
757            trigger_to_source("enter~working%implement"),
758            "combo:working+implement"
759        );
760        assert_eq!(
761            trigger_to_source("exit~working%review"),
762            "combo:working+review"
763        );
764    }
765
766    #[test]
767    fn test_get_transition_prompts_attributed() {
768        let workflows = WorkflowsConfig::default();
769        let states_config: StatesConfig = (&workflows).into();
770        let phases_config: PhasesConfig = (&workflows).into();
771        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
772
773        let attributed =
774            get_transition_prompts_attributed("pending", None, "working", None, &workflows, &ctx);
775
776        // Should have at least the enter~working prompt
777        assert!(!attributed.is_empty());
778        assert!(
779            attributed
780                .iter()
781                .any(|p| p.text.contains("actively working") && p.source == "state:working")
782        );
783    }
784
785    #[test]
786    fn test_attributed_prompts_phase_change() {
787        let workflows = WorkflowsConfig::default();
788        let states_config: StatesConfig = (&workflows).into();
789        let phases_config: PhasesConfig = (&workflows).into();
790        let ctx = PromptContext::new("working", Some("implement"), &states_config, &phases_config);
791
792        let attributed = get_transition_prompts_attributed(
793            "working",
794            None,
795            "working",
796            Some("implement"),
797            &workflows,
798            &ctx,
799        );
800
801        // Should have prompts for entering implement phase
802        if !attributed.is_empty() {
803            // If there's an implement phase prompt, it should be attributed to phase:implement
804            for p in &attributed {
805                assert!(
806                    p.source.starts_with("phase:")
807                        || p.source.starts_with("combo:")
808                        || p.source.starts_with("state:"),
809                    "source should have a valid prefix, got: {}",
810                    p.source
811                );
812            }
813        }
814    }
815}