1use crate::config::workflows::WorkflowsConfig;
25use crate::config::{PhasesConfig, StatesConfig};
26use serde::Serialize;
27
28#[derive(Debug, Clone, Serialize, PartialEq)]
37pub struct AttributedPrompt {
38 pub text: String,
39 pub source: String,
40}
41
42#[derive(Debug, Clone)]
48pub struct PromptContext<'a> {
49 pub status: &'a str,
51 pub phase: Option<&'a str>,
53 pub states_config: &'a StatesConfig,
55 pub phases_config: &'a PhasesConfig,
57 pub task_id: Option<&'a str>,
59 pub task_title: Option<&'a str>,
61 pub task_priority: Option<i32>,
63 pub task_tags: Option<&'a [String]>,
65 pub agent_id: Option<&'a str>,
67 pub agent_role: Option<&'a str>,
69 pub agent_tags: Option<&'a [String]>,
71 pub task_level: Option<&'a str>,
73 pub child_count: Option<usize>,
75}
76
77impl<'a> PromptContext<'a> {
78 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 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 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 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
142pub fn load_prompt(trigger: &str, workflows: &WorkflowsConfig) -> Option<String> {
146 workflows.get_prompt(trigger).map(|s| s.to_string())
147}
148
149pub fn expand_prompt(content: &str, ctx: &PromptContext) -> String {
170 let mut result = content.to_string();
171
172 result = result.replace("{{current_status}}", ctx.status);
176
177 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 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 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 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 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 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
290pub 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 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 if phase_changed && let Some(op) = old_phase {
316 triggers.push(format!("exit%{}", op));
317 }
318
319 if status_changed {
321 triggers.push(format!("exit~{}", old_status));
322 }
323
324 if status_changed {
328 triggers.push(format!("enter~{}", new_status));
329 }
330
331 if phase_changed && let Some(np) = new_phase {
333 triggers.push(format!("enter%{}", np));
334 }
335
336 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
347pub 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
364pub 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
382fn trigger_to_source(trigger: &str) -> String {
389 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 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
412pub 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
435pub 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 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 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 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 assert!(result.contains("no transitions available"));
559 }
560
561 #[test]
562 fn test_load_prompt_from_workflows() {
563 let workflows = WorkflowsConfig::default();
564
565 let prompt = load_prompt("enter~working", &workflows);
567 assert!(prompt.is_some());
568 assert!(prompt.unwrap().contains("actively working"));
569
570 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 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 #[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 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 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 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 #[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 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 if !attributed.is_empty() {
803 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}