Skip to main content

agent_engine/engine/
interrupt.rs

1//! How an interrupted turn is recorded in conversation history.
2//!
3//! When a turn is cancelled, the engine publishes the REAL partial history —
4//! the assistant's partial message (text / signed thinking / completed tool
5//! calls), every completed tool round, and a canceled `tool_result` for any
6//! call that did not finish. The frontend adopts that history verbatim and
7//! then appends ONE short, factual user message: the interruption marker.
8//!
9//! This replaces the old "ABORT CONTEXT" recap, which re-described the
10//! model's own reasoning and tool calls as text inside the NEXT user message.
11//! A user turn that impersonates the model's output reads as a prompt
12//! injection, and current models refuse it. The marker carries no
13//! instructions and claims nothing about what the model did — the history
14//! already says that, in the provider's own message format.
15//!
16//! Prompt caching: the marker is only ever APPENDED after the adopted
17//! history, never folded into or edited onto an existing message, so every
18//! byte the provider already cached stays identical.
19
20use serde_json::{json, Value};
21
22use crate::SharedMessage;
23
24/// Why a turn was interrupted. Selects the marker text only.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum InterruptReason {
27    /// Esc / Cancel command / quitting the client mid-turn.
28    User,
29    /// The host's session cost cap tripped mid-turn.
30    CostCap,
31    /// The daemon checkpointed the session to restart (`daemon reload`).
32    Restart,
33    /// Host-side teardown: `daemon stop`, a host checkpoint request, an
34    /// error ending the session.
35    Host,
36    /// A session persisted by an older Synaps with an `abort_context`
37    /// recap: the turn was interrupted, the reason is not recorded.
38    Unknown,
39    /// The process died with a turn open (crash, `kill -9`, power loss):
40    /// found on load via the in-flight turn draft (`session_draft`).
41    Crash,
42    /// A plugin session driver was running the turn and was revoked (the
43    /// user took over, competing work arrived, the driver failed a check).
44    /// The revocation reason itself goes to clients (`DriverRevoked`), never
45    /// into history.
46    Driver,
47}
48
49impl InterruptReason {
50    /// The exact marker text appended to history.
51    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    /// How a canceled `tool_result` of this turn words the cancel. `None`
64    /// for reasons that are never a live cancel (found on load).
65    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    /// Every reason, i.e. every marker this build writes or recognises.
78    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
89/// True iff `text` is EXACTLY one of the interruption markers. Clients use
90/// this to render the marker as an "interrupted" line instead of a user
91/// bubble (the daemon's `DisplayItem` has no dedicated variant: adding one
92/// would break older clients, whose `DisplayItem` has no `#[serde(other)]`).
93/// Exact, not a prefix: a user message that merely starts like a marker is
94/// the user's, and is shown (and deduplicated) as such.
95pub fn is_interruption_marker(text: &str) -> bool {
96    InterruptReason::ALL.iter().any(|r| r.marker() == text)
97}
98
99/// `"[Request interrupted by user]"` → `"Request interrupted by user"`, for
100/// display. `None` for anything that is not a marker.
101pub 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
112/// Append the marker for `reason` unless the history already ends with a
113/// marker (a second interruption with no turn in between records nothing
114/// new). Returns whether a message was appended.
115pub 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
125/// Load-time migration for sessions saved before this change. A non-`None`
126/// `abort_context` means the saved history ends at the interrupted turn and
127/// the recap was waiting to be prepended to the next user message. The recap
128/// is DISCARDED (it is exactly the injection-shaped text this module
129/// replaces) and the marker is appended in its place.
130///
131/// Returns whether the session was migrated. Idempotent: `abort_context` is
132/// taken, so a second call is a no-op.
133pub 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    // A recap with nothing to attach to (empty history) is simply dropped.
141    if !messages.is_empty() {
142        append_marker(messages, InterruptReason::Unknown);
143    }
144    true
145}
146
147/// Fold an in-flight turn draft left by a process that died mid-turn
148/// (`agent_core::core::session_draft`) into the loaded history.
149///
150/// The saved history is valid (it is only ever saved at round boundaries),
151/// so recovery only APPENDS — the cached prefix is untouched:
152/// - the history already ends with an interruption marker → the turn was
153///   already concluded (aborted, or recovered before): nothing to do;
154/// - it ends with an assistant message → the turn completed (its final
155///   history was saved) and only the draft's removal was lost: nothing to
156///   do;
157/// - otherwise the turn was cut off: the partial reply is appended as a real
158///   assistant message — only when the draft belongs to exactly this
159///   history (`base_len`), never a stale one — then the `Crash` marker.
160///
161/// Returns whether `messages` changed. Either way the caller removes the
162/// draft once the result is saved.
163pub fn recover_crashed_turn(
164    messages: &mut Vec<SharedMessage>,
165    draft: &agent_core::core::session_draft::TurnDraft,
166) -> bool {
167    match messages.last() {
168        // Nothing to attach to: the prompt checkpoint never landed.
169        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            // Shaped like a marker, but not one this build writes: the
224            // user's own text (it used to match a prefix rule).
225            "[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        // Idempotent.
261        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        // The round the text belonged to was committed (history grew past
301        // base_len) or its save never landed (history shorter): marker only.
302        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        // Completed (final reply saved) — only the draft removal was lost.
314        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        // Aborted (marker saved) — same.
318        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        // Empty history — nothing to attach to.
322        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}