use serde_json::{json, Value};
use crate::SharedMessage;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InterruptReason {
User,
CostCap,
Restart,
Host,
Unknown,
Crash,
Driver,
}
impl InterruptReason {
pub fn marker(self) -> &'static str {
match self {
InterruptReason::User => "[Request interrupted by user]",
InterruptReason::CostCap => "[Request interrupted: session cost cap reached]",
InterruptReason::Restart => "[Request interrupted: Synaps restarted]",
InterruptReason::Host => "[Request interrupted: session stopped by the host]",
InterruptReason::Unknown => "[Request interrupted]",
InterruptReason::Crash => "[Request interrupted: Synaps stopped unexpectedly]",
InterruptReason::Driver => "[Request interrupted: session driver revoked]",
}
}
pub fn cancel_cause(self) -> Option<crate::runtime::CancelCause> {
use crate::runtime::CancelCause;
match self {
InterruptReason::User => Some(CancelCause::User),
InterruptReason::CostCap => Some(CancelCause::CostCap),
InterruptReason::Restart => Some(CancelCause::Restart),
InterruptReason::Host => Some(CancelCause::Host),
InterruptReason::Driver => Some(CancelCause::Driver),
InterruptReason::Unknown | InterruptReason::Crash => None,
}
}
pub const ALL: [InterruptReason; 7] = [
InterruptReason::User,
InterruptReason::CostCap,
InterruptReason::Restart,
InterruptReason::Host,
InterruptReason::Unknown,
InterruptReason::Crash,
InterruptReason::Driver,
];
}
pub fn is_interruption_marker(text: &str) -> bool {
InterruptReason::ALL.iter().any(|r| r.marker() == text)
}
pub fn interruption_label(text: &str) -> Option<&str> {
is_interruption_marker(text).then(|| &text[1..text.len() - 1])
}
fn marker_of(message: &Value) -> bool {
message["role"] == "user"
&& message["content"]
.as_str()
.is_some_and(is_interruption_marker)
}
pub fn append_marker(messages: &mut Vec<SharedMessage>, reason: InterruptReason) -> bool {
if messages.last().is_some_and(|m| marker_of(m)) {
return false;
}
messages.push(std::sync::Arc::new(
json!({"role": "user", "content": reason.marker()}),
));
true
}
pub fn migrate_legacy_abort_context(
messages: &mut Vec<SharedMessage>,
abort_context: &mut Option<String>,
) -> bool {
if abort_context.take().is_none() {
return false;
}
if !messages.is_empty() {
append_marker(messages, InterruptReason::Unknown);
}
true
}
pub fn recover_crashed_turn(
messages: &mut Vec<SharedMessage>,
draft: &agent_core::core::session_draft::TurnDraft,
) -> bool {
match messages.last() {
None => return false,
Some(last) if marker_of(last) || last["role"] == "assistant" => return false,
Some(_) => {}
}
if draft.base_len == messages.len() && !draft.partial_text.trim().is_empty() {
messages.push(std::sync::Arc::new(json!({
"role": "assistant",
"content": [{"type": "text", "text": draft.partial_text}]
})));
}
append_marker(messages, InterruptReason::Crash);
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
const ALL: [InterruptReason; 7] = InterruptReason::ALL;
fn user(text: &str) -> SharedMessage {
Arc::new(json!({"role": "user", "content": text}))
}
#[test]
fn every_marker_is_recognised_and_labelled() {
for r in ALL {
let m = r.marker();
assert!(is_interruption_marker(m), "{m}");
let label = interruption_label(m).unwrap();
assert!(label.starts_with("Request interrupted"), "{label}");
assert!(!label.starts_with('[') && !label.ends_with(']'));
}
}
#[test]
fn markers_carry_no_instructions_or_recap() {
for r in ALL {
let m = r.marker().to_lowercase();
for banned in ["abort context", "continue", "you ", "your ", "tool", "note"] {
assert!(!m.contains(banned), "{m:?} contains {banned:?}");
}
}
}
#[test]
fn ordinary_user_text_is_not_a_marker() {
for t in [
"",
"[Request interrupted by user] but actually do Y",
"[Request interrupted by user]\nand more",
"please [Request interrupted by user]",
"[ABORT CONTEXT — your previous response was interrupted]",
"[Request interrupted: do Y instead]",
"[Request interrupted by user] ",
] {
assert!(!is_interruption_marker(t), "{t:?}");
}
}
#[test]
fn append_is_append_only_and_deduplicated() {
let first = user("do X");
let mut msgs = vec![first.clone()];
assert!(append_marker(&mut msgs, InterruptReason::User));
assert!(
!append_marker(&mut msgs, InterruptReason::CostCap),
"no double marker"
);
assert_eq!(msgs.len(), 2);
assert!(
Arc::ptr_eq(&msgs[0], &first),
"earlier messages are never rebuilt"
);
assert_eq!(msgs[1]["content"], "[Request interrupted by user]");
}
#[test]
fn legacy_abort_context_is_dropped_and_replaced_by_the_marker() {
let mut msgs = vec![user("do X")];
let mut ctx = Some("(System note — ABORT CONTEXT: …)".to_string());
assert!(migrate_legacy_abort_context(&mut msgs, &mut ctx));
assert!(ctx.is_none());
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[1]["content"], "[Request interrupted]");
assert!(!serde_json::to_string(&msgs)
.unwrap()
.contains("ABORT CONTEXT"));
assert!(!migrate_legacy_abort_context(&mut msgs, &mut ctx));
assert_eq!(msgs.len(), 2);
}
fn draft(base_len: usize, text: &str) -> agent_core::core::session_draft::TurnDraft {
agent_core::core::session_draft::TurnDraft {
base_len,
partial_text: text.into(),
}
}
fn assistant(text: &str) -> SharedMessage {
Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": text}]}))
}
#[test]
fn crash_mid_reply_keeps_the_partial_reply_then_the_marker() {
let prompt = user("do X");
let mut msgs = vec![prompt.clone()];
assert!(recover_crashed_turn(&mut msgs, &draft(1, "Let me look")));
assert_eq!(msgs.len(), 3);
assert!(Arc::ptr_eq(&msgs[0], &prompt), "append-only");
assert_eq!(msgs[1]["role"], "assistant");
assert_eq!(msgs[1]["content"][0]["text"], "Let me look");
assert_eq!(msgs[2]["content"], InterruptReason::Crash.marker());
}
#[test]
fn crash_with_no_streamed_text_records_only_the_marker() {
for text in ["", " \n"] {
let mut msgs = vec![user("do X")];
assert!(recover_crashed_turn(&mut msgs, &draft(1, text)));
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[1]["content"], InterruptReason::Crash.marker());
}
}
#[test]
fn a_stale_draft_never_contributes_its_text() {
for base in [0, 2, 5] {
let mut msgs = vec![user("do X"), assistant("calling"), user("tool result")];
assert!(recover_crashed_turn(&mut msgs, &draft(base, "stale text")));
assert_eq!(msgs.len(), 4, "base {base}");
assert!(!serde_json::to_string(&msgs).unwrap().contains("stale text"));
assert_eq!(msgs[3]["content"], InterruptReason::Crash.marker());
}
}
#[test]
fn a_concluded_turn_is_left_alone() {
let mut done = vec![user("do X"), assistant("done")];
assert!(!recover_crashed_turn(&mut done, &draft(2, "done")));
assert_eq!(done.len(), 2);
let mut aborted = vec![user("do X"), user(InterruptReason::User.marker())];
assert!(!recover_crashed_turn(&mut aborted, &draft(1, "partial")));
assert_eq!(aborted.len(), 2);
let mut empty = Vec::new();
assert!(!recover_crashed_turn(&mut empty, &draft(0, "partial")));
assert!(empty.is_empty());
}
#[test]
fn recovery_is_idempotent_through_its_own_marker() {
let mut msgs = vec![user("do X")];
let d = draft(1, "partial");
assert!(recover_crashed_turn(&mut msgs, &d));
let once = msgs.clone();
assert!(!recover_crashed_turn(&mut msgs, &d), "second load (removal lost)");
assert_eq!(msgs, once);
}
#[test]
fn legacy_migration_without_history_just_drops_the_recap() {
let mut msgs = Vec::new();
let mut ctx = Some("recap".to_string());
assert!(migrate_legacy_abort_context(&mut msgs, &mut ctx));
assert!(msgs.is_empty() && ctx.is_none());
}
}