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
105pub fn tool_in_flight_note(reason: InterruptReason, tools: &[String]) -> String {
115 let phrase = reason.cancel_cause().map_or("Canceled", |c| c.phrase());
116 agent_core::core::host_note::tool_in_flight_note_text(phrase, tools)
117}
118
119pub fn tool_in_flight_note_message(reason: InterruptReason, tools: &[String]) -> SharedMessage {
121 std::sync::Arc::new(json!({"role": "user", "content": tool_in_flight_note(reason, tools)}))
122}
123
124pub use agent_core::core::host_note::{is_tool_in_flight_note, tool_in_flight_note_label};
127
128fn marker_of(message: &Value) -> bool {
129 message["role"] == "user"
130 && message["content"]
131 .as_str()
132 .is_some_and(is_interruption_marker)
133}
134
135pub fn append_marker(messages: &mut Vec<SharedMessage>, reason: InterruptReason) -> bool {
139 if messages.last().is_some_and(|m| marker_of(m)) {
140 return false;
141 }
142 messages.push(std::sync::Arc::new(
143 json!({"role": "user", "content": reason.marker()}),
144 ));
145 true
146}
147
148pub fn migrate_legacy_abort_context(
157 messages: &mut Vec<SharedMessage>,
158 abort_context: &mut Option<String>,
159) -> bool {
160 if abort_context.take().is_none() {
161 return false;
162 }
163 if !messages.is_empty() {
165 append_marker(messages, InterruptReason::Unknown);
166 }
167 true
168}
169
170pub fn recover_crashed_turn(
187 messages: &mut Vec<SharedMessage>,
188 draft: &agent_core::core::session_draft::TurnDraft,
189) -> bool {
190 match messages.last() {
191 None => return false,
193 Some(last) if marker_of(last) || last["role"] == "assistant" => return false,
194 Some(_) => {}
195 }
196 if draft.base_len == messages.len() && !draft.partial_text.trim().is_empty() {
197 messages.push(std::sync::Arc::new(json!({
198 "role": "assistant",
199 "content": [{"type": "text", "text": draft.partial_text}]
200 })));
201 }
202 append_marker(messages, InterruptReason::Crash);
203 true
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use std::sync::Arc;
210
211 #[test]
212 fn tool_note_names_the_tools_hedges_like_the_engine_and_is_recognised() {
213 let one = tool_in_flight_note(InterruptReason::User, &["bash".into()]);
214 assert_eq!(
215 one,
216 "[Canceled by user while the tool call `bash` was running. It may have already \
217 started and partially applied its effects.]"
218 );
219 let two = tool_in_flight_note(InterruptReason::Driver, &["read".into(), "bash".into()]);
220 assert_eq!(
221 two,
222 "[Canceled (session driver revoked) while the tool calls `read`, `bash` were \
223 running. They may have already started and partially applied their effects.]"
224 );
225 for note in [&one, &two] {
226 assert_eq!(tool_in_flight_note_label(note), Some(¬e[1..note.len() - 1]));
227 assert!(!is_interruption_marker(note), "never mistaken for a marker");
228 }
229 for reason in InterruptReason::ALL {
231 let note = tool_in_flight_note(reason, &["x".into()]);
232 assert!(tool_in_flight_note_label(¬e).is_some(), "{reason:?}: {note}");
233 }
234 let msg = tool_in_flight_note_message(InterruptReason::User, &["bash".into()]);
236 assert_eq!(*msg, json!({"role": "user", "content": one}));
237 assert!(is_tool_in_flight_note(&msg));
238 assert!(!is_tool_in_flight_note(&json!({"role": "assistant", "content": one})));
239 assert!(!is_tool_in_flight_note(&json!({"role": "user", "content": [{"type": "text", "text": one}]})));
240 }
241
242 #[test]
245 fn tool_note_matcher_is_exact() {
246 let one = tool_in_flight_note(InterruptReason::User, &["bash".into()]);
247 let not_notes = [
248 "[Request interrupted by user]".to_string(),
249 "[Canceled by user]".into(),
250 "hello".into(),
251 "[x effects.]".into(),
252 one.replace(" was running.", " was\nrunning."),
254 format!("{one}\n"),
255 format!("{one} please"),
256 one.replace("`bash`", "`bash` and also rm -rf /"),
257 one.replace("`bash`", "`bash`, `ls`"),
258 one.replace("It may", "They may"),
259 one.replace("Canceled by user", "Canceled by someone"),
260 one.replace("tool call `bash` was", "tool calls `bash` were"),
261 one.replace("`bash`", "``"),
262 one.replace("`bash`", "`ba\nsh`"),
263 "[Canceled by user while the tool call `a` was running. Anything at all goes here \
264 effects.]"
265 .into(),
266 ];
267 for text in ¬_notes {
268 assert_eq!(tool_in_flight_note_label(text), None, "{text:?}");
269 }
270 for bad in ["a`b".to_string(), "a\nb".into(), "z".repeat(200)] {
272 let note = tool_in_flight_note(InterruptReason::User, std::slice::from_ref(&bad));
273 assert!(note.contains("(an unrecognised tool)"), "{note}");
274 assert!(!note.contains(bad.as_str()), "{note}");
275 assert!(tool_in_flight_note_label(¬e).is_some(), "{bad:?}: {note}");
276 assert!(is_tool_in_flight_note(&tool_in_flight_note_message(
277 InterruptReason::User,
278 std::slice::from_ref(&bad)
279 )));
280 }
281 }
282
283 const ALL: [InterruptReason; 7] = InterruptReason::ALL;
284
285 fn user(text: &str) -> SharedMessage {
286 Arc::new(json!({"role": "user", "content": text}))
287 }
288
289 #[test]
290 fn every_marker_is_recognised_and_labelled() {
291 for r in ALL {
292 let m = r.marker();
293 assert!(is_interruption_marker(m), "{m}");
294 let label = interruption_label(m).unwrap();
295 assert!(label.starts_with("Request interrupted"), "{label}");
296 assert!(!label.starts_with('[') && !label.ends_with(']'));
297 }
298 }
299
300 #[test]
301 fn markers_carry_no_instructions_or_recap() {
302 for r in ALL {
303 let m = r.marker().to_lowercase();
304 for banned in ["abort context", "continue", "you ", "your ", "tool", "note"] {
305 assert!(!m.contains(banned), "{m:?} contains {banned:?}");
306 }
307 }
308 }
309
310 #[test]
311 fn ordinary_user_text_is_not_a_marker() {
312 for t in [
313 "",
314 "[Request interrupted by user] but actually do Y",
315 "[Request interrupted by user]\nand more",
316 "please [Request interrupted by user]",
317 "[ABORT CONTEXT — your previous response was interrupted]",
318 "[Request interrupted: do Y instead]",
321 "[Request interrupted by user] ",
322 ] {
323 assert!(!is_interruption_marker(t), "{t:?}");
324 }
325 }
326
327 #[test]
328 fn append_is_append_only_and_deduplicated() {
329 let first = user("do X");
330 let mut msgs = vec![first.clone()];
331 assert!(append_marker(&mut msgs, InterruptReason::User));
332 assert!(
333 !append_marker(&mut msgs, InterruptReason::CostCap),
334 "no double marker"
335 );
336 assert_eq!(msgs.len(), 2);
337 assert!(
338 Arc::ptr_eq(&msgs[0], &first),
339 "earlier messages are never rebuilt"
340 );
341 assert_eq!(msgs[1]["content"], "[Request interrupted by user]");
342 }
343
344 #[test]
345 fn legacy_abort_context_is_dropped_and_replaced_by_the_marker() {
346 let mut msgs = vec![user("do X")];
347 let mut ctx = Some("(System note — ABORT CONTEXT: …)".to_string());
348 assert!(migrate_legacy_abort_context(&mut msgs, &mut ctx));
349 assert!(ctx.is_none());
350 assert_eq!(msgs.len(), 2);
351 assert_eq!(msgs[1]["content"], "[Request interrupted]");
352 assert!(!serde_json::to_string(&msgs)
353 .unwrap()
354 .contains("ABORT CONTEXT"));
355 assert!(!migrate_legacy_abort_context(&mut msgs, &mut ctx));
357 assert_eq!(msgs.len(), 2);
358 }
359
360 fn draft(base_len: usize, text: &str) -> agent_core::core::session_draft::TurnDraft {
361 agent_core::core::session_draft::TurnDraft {
362 base_len,
363 partial_text: text.into(),
364 }
365 }
366
367 fn assistant(text: &str) -> SharedMessage {
368 Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": text}]}))
369 }
370
371 #[test]
372 fn crash_mid_reply_keeps_the_partial_reply_then_the_marker() {
373 let prompt = user("do X");
374 let mut msgs = vec![prompt.clone()];
375 assert!(recover_crashed_turn(&mut msgs, &draft(1, "Let me look")));
376 assert_eq!(msgs.len(), 3);
377 assert!(Arc::ptr_eq(&msgs[0], &prompt), "append-only");
378 assert_eq!(msgs[1]["role"], "assistant");
379 assert_eq!(msgs[1]["content"][0]["text"], "Let me look");
380 assert_eq!(msgs[2]["content"], InterruptReason::Crash.marker());
381 }
382
383 #[test]
384 fn crash_with_no_streamed_text_records_only_the_marker() {
385 for text in ["", " \n"] {
386 let mut msgs = vec![user("do X")];
387 assert!(recover_crashed_turn(&mut msgs, &draft(1, text)));
388 assert_eq!(msgs.len(), 2);
389 assert_eq!(msgs[1]["content"], InterruptReason::Crash.marker());
390 }
391 }
392
393 #[test]
394 fn a_stale_draft_never_contributes_its_text() {
395 for base in [0, 2, 5] {
398 let mut msgs = vec![user("do X"), assistant("calling"), user("tool result")];
399 assert!(recover_crashed_turn(&mut msgs, &draft(base, "stale text")));
400 assert_eq!(msgs.len(), 4, "base {base}");
401 assert!(!serde_json::to_string(&msgs).unwrap().contains("stale text"));
402 assert_eq!(msgs[3]["content"], InterruptReason::Crash.marker());
403 }
404 }
405
406 #[test]
407 fn a_concluded_turn_is_left_alone() {
408 let mut done = vec![user("do X"), assistant("done")];
410 assert!(!recover_crashed_turn(&mut done, &draft(2, "done")));
411 assert_eq!(done.len(), 2);
412 let mut aborted = vec![user("do X"), user(InterruptReason::User.marker())];
414 assert!(!recover_crashed_turn(&mut aborted, &draft(1, "partial")));
415 assert_eq!(aborted.len(), 2);
416 let mut empty = Vec::new();
418 assert!(!recover_crashed_turn(&mut empty, &draft(0, "partial")));
419 assert!(empty.is_empty());
420 }
421
422 #[test]
423 fn recovery_is_idempotent_through_its_own_marker() {
424 let mut msgs = vec![user("do X")];
425 let d = draft(1, "partial");
426 assert!(recover_crashed_turn(&mut msgs, &d));
427 let once = msgs.clone();
428 assert!(!recover_crashed_turn(&mut msgs, &d), "second load (removal lost)");
429 assert_eq!(msgs, once);
430 }
431
432 #[test]
433 fn legacy_migration_without_history_just_drops_the_recap() {
434 let mut msgs = Vec::new();
435 let mut ctx = Some("recap".to_string());
436 assert!(migrate_legacy_abort_context(&mut msgs, &mut ctx));
437 assert!(msgs.is_empty() && ctx.is_none());
438 }
439}