Skip to main content

agent_doc_core/
replay_guard.rs

1//! # Module: replay_guard
2//!
3//! Shared shape validation for replaying assistant payloads through recovery
4//! paths such as the Codex Stop hook and `agent-doc repair`.
5//!
6//! ## Spec
7//! - `classify_replay_payload(message)` trims the candidate payload and
8//!   classifies it as empty, replayable, or blocked.
9//! - Block transcript/full-document shaped payloads that should never be
10//!   replayed automatically: agent component dumps, transcript prompt lines,
11//!   `## User` / `## Assistant` headings, multiple `### Re:` headings, or
12//!   malformed patch payloads.
13//! - Patch-bearing payloads are replayable only when `template::parse_patches`
14//!   returns at least one patch and at least one `patch:exchange` block.
15//! - Patch-bearing payloads may carry only known replay guard comments outside
16//!   patch blocks, such as `<!-- no-pending-capture -->`.
17//! - When a patch-bearing payload has unmatched text, replay is allowed only
18//!   for the narrow "plain progress commentary before the patch body" shape; in
19//!   that case the replayable payload is the extracted patch-only body.
20//!
21//! ## Agentic Contracts
22//! - The validator is conservative: ambiguous transcript-shaped payloads are
23//!   blocked instead of auto-extracted.
24//! - Replayable payloads are returned as either the trimmed original string or
25//!   an extracted patch-only body when leading commentary had to be stripped.
26//!
27//! ## Evals
28//! - `classify_empty_payload`
29//! - `classify_plain_response_body_as_replayable`
30//! - `classify_single_exchange_patch_as_replayable`
31//! - `classify_multiple_clean_patches_as_replayable`
32//! - `classify_blocks_agent_component_dump`
33//! - `classify_blocks_prompt_lines`
34//! - `classify_blocks_multiple_response_headings`
35//! - `classify_blocks_patch_payload_with_unmatched_transcript`
36//! - `classify_patch_payload_with_leading_guard_marker_as_replayable`
37//! - `classify_patch_payload_with_safe_leading_commentary_extracts_patch_body`
38
39use std::borrow::Cow;
40
41pub enum ReplayPayloadClassification<'a> {
42    Empty,
43    Replayable(Cow<'a, str>),
44    Blocked(String),
45}
46
47fn is_safe_leading_patch_commentary(prefix: &str) -> bool {
48    let trimmed = prefix.trim();
49    if trimmed.is_empty() {
50        return false;
51    }
52
53    for line in trimmed.lines() {
54        let line = line.trim();
55        if line.is_empty() {
56            continue;
57        }
58        let first = line.chars().next().unwrap_or_default();
59        let starts_numbered_item =
60            first.is_ascii_digit() && line[first.len_utf8()..].starts_with('.');
61        if line.starts_with("<!--")
62            || line.starts_with("```")
63            || line.starts_with("~~~")
64            || line.starts_with('#')
65            || line.starts_with('-')
66            || line.starts_with('*')
67            || line.starts_with('>')
68            || line.starts_with('|')
69            || starts_numbered_item
70        {
71            return false;
72        }
73    }
74
75    true
76}
77
78fn is_replay_guard_marker(line: &str) -> bool {
79    matches!(
80        line.trim(),
81        "<!-- no-pending-capture -->" | "<!-- no-pending-done-guard -->"
82    )
83}
84
85fn unmatched_is_only_replay_guard_markers(unmatched: &str) -> bool {
86    let mut saw_marker = false;
87    for line in unmatched.lines() {
88        let line = line.trim();
89        if line.is_empty() {
90            continue;
91        }
92        if !is_replay_guard_marker(line) {
93            return false;
94        }
95        saw_marker = true;
96    }
97    saw_marker
98}
99
100pub fn classify_replay_payload(message: &str) -> ReplayPayloadClassification<'_> {
101    let trimmed = message.trim();
102    if trimmed.is_empty() {
103        return ReplayPayloadClassification::Empty;
104    }
105
106    if trimmed.contains("<!-- agent:") || trimmed.contains("<!-- /agent:") {
107        return ReplayPayloadClassification::Blocked(
108            "it contained agent component markers from a full document component dump".to_string(),
109        );
110    }
111
112    let prompt_lines = trimmed
113        .lines()
114        .filter(|line| line.trim_start().starts_with('❯'))
115        .count();
116    if prompt_lines > 0 {
117        return ReplayPayloadClassification::Blocked(
118            "it contained transcript prompt lines".to_string(),
119        );
120    }
121
122    let user_headings = trimmed
123        .lines()
124        .filter(|line| {
125            let line = line.trim();
126            line == "## User" || line.starts_with("## User ")
127        })
128        .count();
129    if user_headings > 0 {
130        return ReplayPayloadClassification::Blocked(
131            "it contained `## User` transcript headings".to_string(),
132        );
133    }
134
135    let assistant_headings = trimmed
136        .lines()
137        .filter(|line| {
138            let line = line.trim();
139            line == "## Assistant" || line.starts_with("## Assistant ")
140        })
141        .count();
142    if assistant_headings > 0 {
143        return ReplayPayloadClassification::Blocked(
144            "it contained `## Assistant` transcript headings".to_string(),
145        );
146    }
147
148    let response_headings = trimmed
149        .lines()
150        .filter(|line| line.trim_start().starts_with("### Re:"))
151        .count();
152    if response_headings > 1 {
153        return ReplayPayloadClassification::Blocked(
154            "it contained multiple assistant response headings".to_string(),
155        );
156    }
157
158    let has_patch_markers = trimmed.contains("<!-- patch:") || trimmed.contains("<!-- /patch:");
159    if has_patch_markers {
160        match crate::template::parse_patches(trimmed) {
161            Ok((patches, unmatched)) => {
162                if patches.is_empty() {
163                    return ReplayPayloadClassification::Blocked(
164                        "it contained malformed patch markers without a replayable patch block"
165                            .to_string(),
166                    );
167                }
168                if !patches.iter().any(|patch| patch.name == "exchange") {
169                    return ReplayPayloadClassification::Blocked(
170                        "it did not include a `patch:exchange` closeout".to_string(),
171                    );
172                }
173                if !unmatched.trim().is_empty() {
174                    if unmatched_is_only_replay_guard_markers(&unmatched) {
175                        return ReplayPayloadClassification::Replayable(Cow::Borrowed(trimmed));
176                    }
177                    let first_patch = trimmed
178                        .find("<!-- patch:")
179                        .or_else(|| trimmed.find("<!-- replace:"));
180                    if let Some(first_patch) = first_patch {
181                        let prefix = &trimmed[..first_patch];
182                        let patch_only = trimmed[first_patch..].trim();
183                        if is_safe_leading_patch_commentary(prefix)
184                            && let Ok((patches_only, unmatched_only)) =
185                                crate::template::parse_patches(patch_only)
186                            && !patches_only.is_empty()
187                            && unmatched_only.trim().is_empty()
188                            && patches_only.iter().any(|patch| patch.name == "exchange")
189                        {
190                            return ReplayPayloadClassification::Replayable(Cow::Owned(
191                                patch_only.to_string(),
192                            ));
193                        }
194                    }
195                    if let Some(prefix) = first_patch.map(|idx| &trimmed[..idx])
196                        && is_safe_leading_patch_commentary(prefix)
197                    {
198                        return ReplayPayloadClassification::Blocked(
199                            "it contained non-patch trailing or interstitial content around patch blocks"
200                                .to_string(),
201                        );
202                    }
203                    return ReplayPayloadClassification::Blocked(
204                        "it contained extra transcript content around patch blocks".to_string(),
205                    );
206                }
207            }
208            Err(err) => {
209                return ReplayPayloadClassification::Blocked(format!(
210                    "it contained malformed patch markers: {err}"
211                ));
212            }
213        }
214    }
215
216    ReplayPayloadClassification::Replayable(Cow::Borrowed(trimmed))
217}
218
219#[cfg(test)]
220mod tests {
221    use super::{ReplayPayloadClassification, classify_replay_payload};
222
223    fn assert_replayable(payload: &str) {
224        match classify_replay_payload(payload) {
225            ReplayPayloadClassification::Replayable(actual) => {
226                assert_eq!(actual.as_ref(), payload.trim());
227            }
228            ReplayPayloadClassification::Empty => panic!("expected replayable payload, got empty"),
229            ReplayPayloadClassification::Blocked(reason) => {
230                panic!("expected replayable payload, got blocked: {reason}")
231            }
232        }
233    }
234
235    fn assert_blocked(payload: &str, needle: &str) {
236        match classify_replay_payload(payload) {
237            ReplayPayloadClassification::Blocked(reason) => {
238                assert!(
239                    reason.contains(needle),
240                    "expected reason to contain `{needle}`, got `{reason}`"
241                );
242            }
243            ReplayPayloadClassification::Empty => panic!("expected blocked payload, got empty"),
244            ReplayPayloadClassification::Replayable(actual) => {
245                panic!("expected blocked payload, got replayable: {actual}")
246            }
247        }
248    }
249
250    #[test]
251    fn classify_empty_payload() {
252        assert!(matches!(
253            classify_replay_payload("   \n"),
254            ReplayPayloadClassification::Empty
255        ));
256    }
257
258    #[test]
259    fn classify_plain_response_body_as_replayable() {
260        assert_replayable("### Re: topic — gpt-5\n\nBody\n");
261    }
262
263    #[test]
264    fn classify_single_exchange_patch_as_replayable() {
265        assert_replayable(
266            "<!-- patch:exchange -->\n### Re: topic — gpt-5\n\nBody\n<!-- /patch:exchange -->\n",
267        );
268    }
269
270    #[test]
271    fn classify_multiple_clean_patches_as_replayable() {
272        assert_replayable(
273            "<!-- patch:status -->\nDone.\n<!-- /patch:status -->\n<!-- patch:exchange -->\n### Re: topic — gpt-5\n\nBody\n<!-- /patch:exchange -->\n",
274        );
275    }
276
277    #[test]
278    fn classify_blocks_agent_component_dump() {
279        assert_blocked(
280            "<!-- agent:exchange patch=append -->\n❯ hi\n### Re: topic — gpt-5\n<!-- /agent:exchange -->\n",
281            "component",
282        );
283    }
284
285    #[test]
286    fn classify_blocks_prompt_lines() {
287        assert_blocked("❯ do #task\n### Re: topic — gpt-5\n", "prompt lines");
288    }
289
290    #[test]
291    fn classify_blocks_multiple_response_headings() {
292        assert_blocked(
293            "### Re: first — gpt-5\none\n### Re: second — gpt-5\ntwo\n",
294            "multiple assistant response headings",
295        );
296    }
297
298    #[test]
299    fn classify_blocks_patch_payload_with_unmatched_transcript() {
300        assert_blocked(
301            "<!-- patch:exchange -->\n### Re: topic — gpt-5\nBody\n<!-- /patch:exchange -->\nextra transcript",
302            "extra transcript content",
303        );
304    }
305
306    #[test]
307    fn classify_patch_payload_with_leading_guard_marker_as_replayable() {
308        assert_replayable(
309            "<!-- no-pending-capture -->\n<!-- patch:exchange -->\n### Re: topic — gpt-5\nBody\n<!-- /patch:exchange -->\n",
310        );
311    }
312
313    #[test]
314    fn classify_patch_payload_with_safe_leading_commentary_extracts_patch_body() {
315        let payload = concat!(
316            "Reviewing the current plan and repo conventions so I can turn `#next-steps` into concrete backlog items in the session document.\n",
317            "I have the plan context. Next I’m checking how this repo formats backlog items so the patch matches existing session-doc conventions instead of inventing a new shape.\n\n",
318            "<!-- patch:exchange -->\n",
319            "### Re: topic — gpt-5\n\n",
320            "Body\n",
321            "<!-- /patch:exchange -->\n\n",
322            "<!-- patch:backlog -->\n",
323            "- [ ] [#task] Follow-up\n",
324            "<!-- /patch:backlog -->\n"
325        );
326
327        match classify_replay_payload(payload) {
328            ReplayPayloadClassification::Replayable(actual) => assert_eq!(
329                actual.as_ref(),
330                concat!(
331                    "<!-- patch:exchange -->\n",
332                    "### Re: topic — gpt-5\n\n",
333                    "Body\n",
334                    "<!-- /patch:exchange -->\n",
335                    "\n",
336                    "<!-- patch:backlog -->\n",
337                    "- [ ] [#task] Follow-up\n",
338                    "<!-- /patch:backlog -->"
339                )
340            ),
341            ReplayPayloadClassification::Empty => panic!("expected replayable payload, got empty"),
342            ReplayPayloadClassification::Blocked(reason) => {
343                panic!("expected replayable payload, got blocked: {reason}")
344            }
345        }
346    }
347}