agent_doc_core/
replay_guard.rs1use 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}