1use serde_json::{json, Value};
21
22use crate::SharedMessage;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum InterruptReason {
27 User,
29 CostCap,
31 Restart,
33 Host,
36 Unknown,
39 Crash,
42 Driver,
47}
48
49impl InterruptReason {
50 pub fn marker(self) -> &'static str {
52 match self {
53 InterruptReason::User => "[Request interrupted by user]",
54 InterruptReason::CostCap => "[Request interrupted: session cost cap reached]",
55 InterruptReason::Restart => "[Request interrupted: Synaps restarted]",
56 InterruptReason::Host => "[Request interrupted: session stopped by the host]",
57 InterruptReason::Unknown => "[Request interrupted]",
58 InterruptReason::Crash => "[Request interrupted: Synaps stopped unexpectedly]",
59 InterruptReason::Driver => "[Request interrupted: session driver revoked]",
60 }
61 }
62
63 pub fn cancel_cause(self) -> Option<crate::runtime::CancelCause> {
66 use crate::runtime::CancelCause;
67 match self {
68 InterruptReason::User => Some(CancelCause::User),
69 InterruptReason::CostCap => Some(CancelCause::CostCap),
70 InterruptReason::Restart => Some(CancelCause::Restart),
71 InterruptReason::Host => Some(CancelCause::Host),
72 InterruptReason::Driver => Some(CancelCause::Driver),
73 InterruptReason::Unknown | InterruptReason::Crash => None,
74 }
75 }
76
77 pub const ALL: [InterruptReason; 7] = [
79 InterruptReason::User,
80 InterruptReason::CostCap,
81 InterruptReason::Restart,
82 InterruptReason::Host,
83 InterruptReason::Unknown,
84 InterruptReason::Crash,
85 InterruptReason::Driver,
86 ];
87}
88
89pub fn is_interruption_marker(text: &str) -> bool {
96 InterruptReason::ALL.iter().any(|r| r.marker() == text)
97}
98
99pub fn interruption_label(text: &str) -> Option<&str> {
102 is_interruption_marker(text).then(|| &text[1..text.len() - 1])
103}
104
105fn marker_of(message: &Value) -> bool {
106 message["role"] == "user"
107 && message["content"]
108 .as_str()
109 .is_some_and(is_interruption_marker)
110}
111
112pub fn append_marker(messages: &mut Vec<SharedMessage>, reason: InterruptReason) -> bool {
116 if messages.last().is_some_and(|m| marker_of(m)) {
117 return false;
118 }
119 messages.push(std::sync::Arc::new(
120 json!({"role": "user", "content": reason.marker()}),
121 ));
122 true
123}
124
125pub fn migrate_legacy_abort_context(
134 messages: &mut Vec<SharedMessage>,
135 abort_context: &mut Option<String>,
136) -> bool {
137 if abort_context.take().is_none() {
138 return false;
139 }
140 if !messages.is_empty() {
142 append_marker(messages, InterruptReason::Unknown);
143 }
144 true
145}
146
147pub fn recover_crashed_turn(
164 messages: &mut Vec<SharedMessage>,
165 draft: &agent_core::core::session_draft::TurnDraft,
166) -> bool {
167 match messages.last() {
168 None => return false,
170 Some(last) if marker_of(last) || last["role"] == "assistant" => return false,
171 Some(_) => {}
172 }
173 if draft.base_len == messages.len() && !draft.partial_text.trim().is_empty() {
174 messages.push(std::sync::Arc::new(json!({
175 "role": "assistant",
176 "content": [{"type": "text", "text": draft.partial_text}]
177 })));
178 }
179 append_marker(messages, InterruptReason::Crash);
180 true
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use std::sync::Arc;
187
188 const ALL: [InterruptReason; 7] = InterruptReason::ALL;
189
190 fn user(text: &str) -> SharedMessage {
191 Arc::new(json!({"role": "user", "content": text}))
192 }
193
194 #[test]
195 fn every_marker_is_recognised_and_labelled() {
196 for r in ALL {
197 let m = r.marker();
198 assert!(is_interruption_marker(m), "{m}");
199 let label = interruption_label(m).unwrap();
200 assert!(label.starts_with("Request interrupted"), "{label}");
201 assert!(!label.starts_with('[') && !label.ends_with(']'));
202 }
203 }
204
205 #[test]
206 fn markers_carry_no_instructions_or_recap() {
207 for r in ALL {
208 let m = r.marker().to_lowercase();
209 for banned in ["abort context", "continue", "you ", "your ", "tool", "note"] {
210 assert!(!m.contains(banned), "{m:?} contains {banned:?}");
211 }
212 }
213 }
214
215 #[test]
216 fn ordinary_user_text_is_not_a_marker() {
217 for t in [
218 "",
219 "[Request interrupted by user] but actually do Y",
220 "[Request interrupted by user]\nand more",
221 "please [Request interrupted by user]",
222 "[ABORT CONTEXT — your previous response was interrupted]",
223 "[Request interrupted: do Y instead]",
226 "[Request interrupted by user] ",
227 ] {
228 assert!(!is_interruption_marker(t), "{t:?}");
229 }
230 }
231
232 #[test]
233 fn append_is_append_only_and_deduplicated() {
234 let first = user("do X");
235 let mut msgs = vec![first.clone()];
236 assert!(append_marker(&mut msgs, InterruptReason::User));
237 assert!(
238 !append_marker(&mut msgs, InterruptReason::CostCap),
239 "no double marker"
240 );
241 assert_eq!(msgs.len(), 2);
242 assert!(
243 Arc::ptr_eq(&msgs[0], &first),
244 "earlier messages are never rebuilt"
245 );
246 assert_eq!(msgs[1]["content"], "[Request interrupted by user]");
247 }
248
249 #[test]
250 fn legacy_abort_context_is_dropped_and_replaced_by_the_marker() {
251 let mut msgs = vec![user("do X")];
252 let mut ctx = Some("(System note — ABORT CONTEXT: …)".to_string());
253 assert!(migrate_legacy_abort_context(&mut msgs, &mut ctx));
254 assert!(ctx.is_none());
255 assert_eq!(msgs.len(), 2);
256 assert_eq!(msgs[1]["content"], "[Request interrupted]");
257 assert!(!serde_json::to_string(&msgs)
258 .unwrap()
259 .contains("ABORT CONTEXT"));
260 assert!(!migrate_legacy_abort_context(&mut msgs, &mut ctx));
262 assert_eq!(msgs.len(), 2);
263 }
264
265 fn draft(base_len: usize, text: &str) -> agent_core::core::session_draft::TurnDraft {
266 agent_core::core::session_draft::TurnDraft {
267 base_len,
268 partial_text: text.into(),
269 }
270 }
271
272 fn assistant(text: &str) -> SharedMessage {
273 Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": text}]}))
274 }
275
276 #[test]
277 fn crash_mid_reply_keeps_the_partial_reply_then_the_marker() {
278 let prompt = user("do X");
279 let mut msgs = vec![prompt.clone()];
280 assert!(recover_crashed_turn(&mut msgs, &draft(1, "Let me look")));
281 assert_eq!(msgs.len(), 3);
282 assert!(Arc::ptr_eq(&msgs[0], &prompt), "append-only");
283 assert_eq!(msgs[1]["role"], "assistant");
284 assert_eq!(msgs[1]["content"][0]["text"], "Let me look");
285 assert_eq!(msgs[2]["content"], InterruptReason::Crash.marker());
286 }
287
288 #[test]
289 fn crash_with_no_streamed_text_records_only_the_marker() {
290 for text in ["", " \n"] {
291 let mut msgs = vec![user("do X")];
292 assert!(recover_crashed_turn(&mut msgs, &draft(1, text)));
293 assert_eq!(msgs.len(), 2);
294 assert_eq!(msgs[1]["content"], InterruptReason::Crash.marker());
295 }
296 }
297
298 #[test]
299 fn a_stale_draft_never_contributes_its_text() {
300 for base in [0, 2, 5] {
303 let mut msgs = vec![user("do X"), assistant("calling"), user("tool result")];
304 assert!(recover_crashed_turn(&mut msgs, &draft(base, "stale text")));
305 assert_eq!(msgs.len(), 4, "base {base}");
306 assert!(!serde_json::to_string(&msgs).unwrap().contains("stale text"));
307 assert_eq!(msgs[3]["content"], InterruptReason::Crash.marker());
308 }
309 }
310
311 #[test]
312 fn a_concluded_turn_is_left_alone() {
313 let mut done = vec![user("do X"), assistant("done")];
315 assert!(!recover_crashed_turn(&mut done, &draft(2, "done")));
316 assert_eq!(done.len(), 2);
317 let mut aborted = vec![user("do X"), user(InterruptReason::User.marker())];
319 assert!(!recover_crashed_turn(&mut aborted, &draft(1, "partial")));
320 assert_eq!(aborted.len(), 2);
321 let mut empty = Vec::new();
323 assert!(!recover_crashed_turn(&mut empty, &draft(0, "partial")));
324 assert!(empty.is_empty());
325 }
326
327 #[test]
328 fn recovery_is_idempotent_through_its_own_marker() {
329 let mut msgs = vec![user("do X")];
330 let d = draft(1, "partial");
331 assert!(recover_crashed_turn(&mut msgs, &d));
332 let once = msgs.clone();
333 assert!(!recover_crashed_turn(&mut msgs, &d), "second load (removal lost)");
334 assert_eq!(msgs, once);
335 }
336
337 #[test]
338 fn legacy_migration_without_history_just_drops_the_recap() {
339 let mut msgs = Vec::new();
340 let mut ctx = Some("recap".to_string());
341 assert!(migrate_legacy_abort_context(&mut msgs, &mut ctx));
342 assert!(msgs.is_empty() && ctx.is_none());
343 }
344}