From de85d53c73b0f61fea24beb7eca5a95b534156e5 Mon Sep 17 00:00:00 2001 From: Allen Huang Date: Sat, 21 Feb 2026 14:59:47 +0900 Subject: [PATCH] fix(channel): close orphan user turn on error and timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a channel message triggers an LLM error or idle timeout, the user turn was already appended to conversation history (line 1517) but no assistant turn was recorded. This orphan user turn caused the LLM to treat the failed request as unfinished context on subsequent messages, leading to unrelated replies (e.g., re-executing a timed-out GitHub search when the user asked about WAL checkpoints). Append a short assistant marker ("[Task failed — not continuing this request]" or "[Task timed out — ...]") in the error and timeout branches so the conversation history stays properly alternating and the LLM sees the prior request as closed. The cancel and context-overflow paths are intentionally left unchanged: cancel is superseded by a newer message, and context-overflow prompts the user to resend. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/mod.rs | 50 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 60e5d6519..9e752d221 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -1851,6 +1851,13 @@ async fn process_channel_message( " ❌ LLM error after {}ms: {e}", started_at.elapsed().as_millis() ); + // Close the orphan user turn so subsequent messages don't + // inherit this failed request as unfinished context. + append_sender_turn( + ctx.as_ref(), + &history_key, + ChatMessage::assistant("[Task failed — not continuing this request]"), + ); if let Some(channel) = target_channel.as_ref() { if let Some(ref draft_id) = draft_message_id { let _ = channel @@ -1877,6 +1884,13 @@ async fn process_channel_message( timeout_msg, started_at.elapsed().as_millis() ); + // Close the orphan user turn so subsequent messages don't + // inherit this timed-out request as unfinished context. + append_sender_turn( + ctx.as_ref(), + &history_key, + ChatMessage::assistant("[Task timed out — not continuing this request]"), + ); if let Some(channel) = target_channel.as_ref() { let error_text = "⚠️ Request timed out while waiting for the model. Please try again."; @@ -3221,6 +3235,42 @@ mod tests { assert!(normalized[1].content.contains("assistant part 2")); } + /// Verify that an orphan user turn followed by a failure-marker assistant + /// turn normalizes correctly, so the LLM sees the failed request as closed + /// and does not re-execute it on the next user message. + #[test] + fn normalize_preserves_failure_marker_after_orphan_user_turn() { + let turns = vec![ + ChatMessage::user("download something from GitHub"), + ChatMessage::assistant("[Task failed — not continuing this request]"), + ChatMessage::user("what is WAL?"), + ]; + + let normalized = normalize_cached_channel_turns(turns); + assert_eq!(normalized.len(), 3); + assert_eq!(normalized[0].role, "user"); + assert_eq!(normalized[1].role, "assistant"); + assert!(normalized[1].content.contains("Task failed")); + assert_eq!(normalized[2].role, "user"); + assert_eq!(normalized[2].content, "what is WAL?"); + } + + /// Same as above but for the timeout variant. + #[test] + fn normalize_preserves_timeout_marker_after_orphan_user_turn() { + let turns = vec![ + ChatMessage::user("run a long task"), + ChatMessage::assistant("[Task timed out — not continuing this request]"), + ChatMessage::user("next question"), + ]; + + let normalized = normalize_cached_channel_turns(turns); + assert_eq!(normalized.len(), 3); + assert_eq!(normalized[1].role, "assistant"); + assert!(normalized[1].content.contains("Task timed out")); + assert_eq!(normalized[2].content, "next question"); + } + #[test] fn compact_sender_history_keeps_recent_truncated_messages() { let mut histories = HashMap::new();