Skip to main content

ralph_workflow/prompts/
resume_note.rs

1//! Resume context note generation.
2//!
3//! Generates rich context notes for resumed sessions to help agents understand
4//! where they are in the pipeline when resuming from a checkpoint.
5
6use std::fmt::Write;
7
8use crate::checkpoint::execution_history::StepOutcome;
9use crate::checkpoint::restore::ResumeContext;
10use crate::checkpoint::state::PipelinePhase;
11
12fn append_phase_header(note: &mut String, context: &ResumeContext) {
13    match context.phase {
14        PipelinePhase::Development => {
15            writeln!(
16                note,
17                "Resuming DEVELOPMENT phase (iteration {} of {})",
18                context.iteration + 1,
19                context.total_iterations
20            )
21            .unwrap();
22        }
23        PipelinePhase::Review => {
24            writeln!(
25                note,
26                "Resuming REVIEW phase (pass {} of {})",
27                context.reviewer_pass + 1,
28                context.total_reviewer_passes
29            )
30            .unwrap();
31        }
32        _ => {
33            writeln!(note, "Resuming from phase: {}", context.phase_name()).unwrap();
34        }
35    }
36}
37
38fn append_resume_and_rebase_state(note: &mut String, context: &ResumeContext) {
39    if context.resume_count > 0 {
40        writeln!(
41            note,
42            "This session has been resumed {} time(s)",
43            context.resume_count
44        )
45        .unwrap();
46    }
47
48    if !matches!(
49        context.rebase_state,
50        crate::checkpoint::state::RebaseState::NotStarted
51    ) {
52        writeln!(note, "Rebase state: {:?}", context.rebase_state).unwrap();
53    }
54
55    note.push('\n');
56}
57
58fn append_modified_files_summary(
59    note: &mut String,
60    detail: &crate::checkpoint::execution_history::ModifiedFilesDetail,
61) {
62    let added_count = detail.added.as_ref().map_or(0, |v| v.len());
63    let modified_count = detail.modified.as_ref().map_or(0, |v| v.len());
64    let deleted_count = detail.deleted.as_ref().map_or(0, |v| v.len());
65    let total_files = added_count + modified_count + deleted_count;
66    if total_files == 0 {
67        return;
68    }
69
70    write!(note, "  Files: {total_files} changed").unwrap();
71    if added_count > 0 {
72        write!(note, " ({added_count} added)").unwrap();
73    }
74    if modified_count > 0 {
75        write!(note, " ({modified_count} modified)").unwrap();
76    }
77    if deleted_count > 0 {
78        write!(note, " ({deleted_count} deleted)").unwrap();
79    }
80    note.push('\n');
81}
82
83fn append_issues_summary(
84    note: &mut String,
85    issues: &crate::checkpoint::execution_history::IssuesSummary,
86) {
87    if issues.found == 0 && issues.fixed == 0 {
88        return;
89    }
90
91    write!(
92        note,
93        "  Issues: {} found, {} fixed",
94        issues.found, issues.fixed
95    )
96    .unwrap();
97    if let Some(ref desc) = issues.description {
98        write!(note, " ({desc})").unwrap();
99    }
100    note.push('\n');
101}
102
103fn append_recent_step(
104    note: &mut String,
105    step: &crate::checkpoint::execution_history::ExecutionStep,
106) {
107    writeln!(
108        note,
109        "- [{}] {} (iteration {}): {}",
110        step.step_type,
111        step.phase,
112        step.iteration,
113        step.outcome.brief_description()
114    )
115    .unwrap();
116
117    if let Some(ref detail) = step.modified_files_detail {
118        append_modified_files_summary(note, detail);
119    }
120
121    if let Some(ref issues) = step.issues_summary {
122        append_issues_summary(note, issues);
123    }
124
125    if let Some(ref oid) = step.git_commit_oid {
126        writeln!(note, "  Commit: {oid}").unwrap();
127    }
128}
129
130fn append_recent_activity(note: &mut String, context: &ResumeContext) {
131    let Some(ref history) = context.execution_history else {
132        return;
133    };
134    if history.steps.is_empty() {
135        return;
136    }
137
138    note.push_str("RECENT ACTIVITY:\n");
139    note.push_str("----------------\n");
140
141    let recent_steps: Vec<_> = history
142        .steps
143        .iter()
144        .rev()
145        .take(5)
146        .collect::<Vec<_>>()
147        .into_iter()
148        .rev()
149        .collect();
150
151    for step in &recent_steps {
152        append_recent_step(note, step);
153    }
154
155    note.push('\n');
156}
157
158fn append_guidance(note: &mut String, phase: PipelinePhase) {
159    note.push_str("\nGUIDANCE:\n");
160    note.push_str("--------\n");
161    match phase {
162        PipelinePhase::Development => {
163            note.push_str("Continue working on the implementation tasks from your plan.\n");
164        }
165        PipelinePhase::Review => {
166            note.push_str("Review the code changes and provide feedback.\n");
167        }
168        _ => {}
169    }
170    note.push('\n');
171}
172
173/// Generate a rich resume note from resume context.
174///
175/// Creates a detailed, context-aware note that helps agents understand
176/// where they are in the pipeline when resuming from a checkpoint.
177///
178/// The note includes:
179/// - Phase and iteration information
180/// - Recent execution history (files modified, issues found/fixed)
181/// - Git commits made during the session
182/// - Guidance on what to focus on
183#[must_use]
184pub fn generate_resume_note(context: &ResumeContext) -> String {
185    let mut note = String::from("SESSION RESUME CONTEXT\n");
186    note.push_str("====================\n\n");
187
188    append_phase_header(&mut note, context);
189    append_resume_and_rebase_state(&mut note, context);
190    append_recent_activity(&mut note, context);
191
192    note.push_str("Previous progress is preserved in git history.\n");
193    append_guidance(&mut note, context.phase);
194    note
195}
196
197/// Helper trait for brief outcome descriptions.
198pub trait BriefDescription {
199    fn brief_description(&self) -> String;
200}
201
202const PARTIAL_FIELD_MAX_CHARS: usize = 120;
203
204fn one_line_truncated(input: &str, max_chars: usize) -> String {
205    let first_line = input.lines().next().unwrap_or("").trim();
206    let mut out: String = first_line.chars().take(max_chars).collect();
207    if first_line.chars().count() > max_chars {
208        out.push_str("...(truncated)");
209    }
210    out
211}
212
213impl BriefDescription for StepOutcome {
214    fn brief_description(&self) -> String {
215        match self {
216            Self::Success {
217                files_modified,
218                output,
219                ..
220            } => output
221                .as_ref()
222                .and_then(|out| {
223                    if out.is_empty() {
224                        None
225                    } else {
226                        Some(format!("Success - {}", out.lines().next().unwrap_or("")))
227                    }
228                })
229                .or_else(|| {
230                    files_modified.as_ref().and_then(|files| {
231                        if files.is_empty() {
232                            None
233                        } else {
234                            Some(format!("Success - {} files modified", files.len()))
235                        }
236                    })
237                })
238                .unwrap_or_else(|| "Success".to_string()),
239            Self::Failure {
240                error, recoverable, ..
241            } => {
242                if *recoverable {
243                    format!("Recoverable error - {}", error.lines().next().unwrap_or(""))
244                } else {
245                    format!("Failed - {}", error.lines().next().unwrap_or(""))
246                }
247            }
248            Self::Partial {
249                completed,
250                remaining,
251                ..
252            } => {
253                let completed = one_line_truncated(completed, PARTIAL_FIELD_MAX_CHARS);
254                let remaining = one_line_truncated(remaining, PARTIAL_FIELD_MAX_CHARS);
255                format!("Partial - {completed} done, {remaining}")
256            }
257            Self::Skipped { reason } => {
258                format!("Skipped - {reason}")
259            }
260        }
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::BriefDescription;
267    use crate::checkpoint::execution_history::StepOutcome;
268
269    #[test]
270    fn test_partial_brief_description_is_single_line_and_truncated() {
271        let outcome =
272            StepOutcome::partial("done line 1\ndone line 2".to_string(), "x".repeat(1000));
273
274        let desc = outcome.brief_description();
275        assert!(
276            !desc.contains('\n'),
277            "description must be single-line: {desc}"
278        );
279        assert!(
280            desc.contains("truncated"),
281            "expected truncation marker for oversized fields: {desc}"
282        );
283        assert!(desc.len() < 300, "expected bounded output size: {desc}");
284    }
285}