diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index 721b00f..897e354 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -570,13 +570,13 @@ impl Driver for ClaudeDriver { } fn clear(&self) { - // Nothing is emitted here on purpose. The CLI answers this with a - // fresh `init` carrying a new `session_id`, the reader persists - // that as the resume token, and `Event::Cleared` is emitted at - // that observation -- so the transcript records a clear that - // happened rather than one that was asked for, and the next - // launch resumes the cleared conversation with nothing here to - // keep in step. + // Nothing is emitted here on purpose: the transcript should + // record a clear that happened, not one that was asked for. The + // CLI announces it with a `conversation_reset` line, which + // `translate.rs` turns into `Event::Cleared`, and follows it with + // a fresh `init` whose new `session_id` the reader persists as + // the resume token -- so the next launch resumes the cleared + // conversation with nothing here to keep in step. self.local_command("/clear".to_string()); } @@ -790,36 +790,16 @@ fn translate_line( tracing::warn!("unparseable claude output line: {shown}"); return true; }; - let (events, new_session_id, replaced) = { + let (events, new_session_id) = { let mut state = state.lock().unwrap(); let before = state.session_id.clone(); let events = state.translate(&message); let after = state.session_id.clone(); - // A *replacement* -- an id where there already was a different - // one -- rather than merely a change. The first `init` of a - // session sets the id from nothing, which is a session starting - // and not a conversation being dropped. - let replaced = before.is_some() && before != after; - (events, if before != after { after } else { None }, replaced) + (events, if before != after { after } else { None }) }; if let Some(session_id) = new_session_id { write_resume_token(session_dir, &session_id); } - // Here rather than where `/clear` is sent, because this is the CLI - // saying it happened and that is a different claim from us having - // asked. The divider is read as a fact about the conversation -- a - // reader scrolling back takes it to mean the session no longer has - // what is above it -- so it has to be anchored to the observation, - // the same way `Compacted` is anchored to `compact_boundary` rather - // than to somebody pressing Compact. - // - // The CLI's `/clear` returns empty text and leaves no result line, so - // a fresh `init` bearing a different `session_id` is the only trace - // it leaves. That is this, and it is already being watched for in - // order to persist the resume token. - if replaced && sink.send(Event::Cleared).is_err() { - return false; - } for event in events { // Anything the CLI says after a steer was written is proof it has // been round the model again, and the steer went with it -- so @@ -1056,28 +1036,20 @@ mod tests { events } - /// A session starting is not a conversation being dropped. + /// The divider comes from the CLI announcing the reset, not from an + /// `init` arriving. /// - /// The first `init` sets the session id from nothing, which looks like - /// a change and is not a replacement -- reading it as one would open - /// every session with a divider announcing a clear that never - /// happened. + /// Measured against 2.1.237: `/clear` emits `conversation_reset`, + /// then a fresh `init` carrying a new session id. Watching the id be + /// replaced would work, but it reads the event through one of its + /// side effects; the announcement says so directly and arrives first, + /// so the divider lands above the new conversation rather than below + /// its opening line. #[test] - fn the_first_init_is_not_a_clear() { - let events = events_from_lines(&[ - r#"{"type":"system","subtype":"init","session_id":"first","tools":[],"model":"claude-haiku-4-5-20251001"}"#, - ]); - assert!(!events.contains(&Event::Cleared), "got {events:?}"); - } - - /// The divider is anchored to the CLI saying it happened, not to us - /// asking. `/clear` returns empty text and leaves no result line, so a - /// fresh `init` bearing a *different* session id is the only trace it - /// leaves -- and that trace is what records the clear. - #[test] - fn a_second_init_with_a_new_id_is_a_clear() { + fn a_conversation_reset_is_what_records_a_clear() { let events = events_from_lines(&[ r#"{"type":"system","subtype":"init","session_id":"first","tools":[],"model":"claude-haiku-4-5-20251001"}"#, + r#"{"type":"conversation_reset","session_id":"first"}"#, r#"{"type":"system","subtype":"init","session_id":"second","tools":[],"model":"claude-haiku-4-5-20251001"}"#, ]); assert_eq!( @@ -1087,15 +1059,30 @@ mod tests { ); } - /// A compaction also re-announces `init`, carrying the *same* session - /// id. That must not draw a clear on top of the compaction's own mark. + /// An `init` on its own never records a clear, whatever id it carries. + /// + /// Three ways one arrives and none of them is a cleared conversation: + /// the first init of a session, the one a compaction re-announces + /// carrying the *same* id, and the one that follows a resume. Reading + /// any of them as a clear would open sessions with a divider + /// announcing something that never happened, or draw one on top of a + /// compaction's own mark and tell the reader the conversation had been + /// dropped when it had been summarised. #[test] - fn an_init_repeating_the_same_id_is_not_a_clear() { - let events = events_from_lines(&[ - r#"{"type":"system","subtype":"init","session_id":"same","tools":[],"model":"claude-haiku-4-5-20251001"}"#, - r#"{"type":"system","subtype":"init","session_id":"same","tools":[],"model":"claude-haiku-4-5-20251001"}"#, - ]); - assert!(!events.contains(&Event::Cleared), "got {events:?}"); + fn an_init_alone_is_never_a_clear() { + for ids in [["first", "first"], ["first", "second"]] { + let events = events_from_lines(&[ + &format!( + r#"{{"type":"system","subtype":"init","session_id":"{}","tools":[],"model":"claude-haiku-4-5-20251001"}}"#, + ids[0] + ), + &format!( + r#"{{"type":"system","subtype":"init","session_id":"{}","tools":[],"model":"claude-haiku-4-5-20251001"}}"#, + ids[1] + ), + ]); + assert!(!events.contains(&Event::Cleared), "{ids:?} gave {events:?}"); + } } /// The failure this exists for: a shell's complaint ends with a blank diff --git a/server/src/session/claude/translate.rs b/server/src/session/claude/translate.rs index 89bb6c4..11d396b 100644 --- a/server/src/session/claude/translate.rs +++ b/server/src/session/claude/translate.rs @@ -97,6 +97,15 @@ impl Translator { } match message.get("type").and_then(Value::as_str) { Some("system") => self.translate_system(message), + // The CLI's own announcement that `/clear` took effect, sent + // just before the fresh `init` that carries the new + // session_id. Measured against 2.1.237 rather than inferred: + // this used to watch for the id being *replaced*, which is the + // same event seen through one of its side effects. Taking the + // announcement instead means the transcript's divider is the + // CLI saying "I did this", and it lands before the new init + // rather than after it. + Some("conversation_reset") => vec![Event::Cleared], Some("stream_event") => self.translate_stream_event(&message["event"]), Some("assistant") => self.translate_assistant(&message["message"]), Some("user") => self.translate_user(message),