Take the CLI's word for a clear instead of inferring it

Bryan reported no divider when clearing. It was not this code -- the
backend serving him started at 17:06, three hours before `Event::Cleared`
existed, so it has no such event to send and `/clear` reaches it as an
unrecognised passthrough. Verified against a current build: the event is
recorded.

Probing the CLI to establish that turned up something better than what
was here. `/clear` in stream-json mode emits a dedicated
`conversation_reset` line and *then* a fresh `init` with the new session
id -- so watching the id be replaced, which is what this did, was reading
the event through one of its side effects. The announcement says it
directly, and it arrives first, so the divider now lands above the new
conversation rather than after its opening line.

That also removes the reasoning the previous commit needed about which id
changes count. There is one signal now instead of an inference with two
exceptions, and the test that used to pin those exceptions became
`an_init_alone_is_never_a_clear`, which covers all three ways an init
arrives: a session's first, the one a compaction re-announces with the
same id, and the one following a resume.

The resume token still follows the id, unchanged -- one CLI event with
two observable effects, and each half now reads the half it needs.

Verified end to end against a real claude-cli session: message, /clear,
message, and the transcript reads userMessage / assistantText / cleared /
userMessage, in that order. 74 tests, clippy and rustfmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-29 20:39:21 -04:00
1 parent 71067275e2
commit 894180de77
2 files changed
+51 -55

No files matched your search

+42 -55
View File
@@ -570,13 +570,13 @@ impl Driver for ClaudeDriver {
} }
fn clear(&self) { fn clear(&self) {
// Nothing is emitted here on purpose. The CLI answers this with a // Nothing is emitted here on purpose: the transcript should
// fresh `init` carrying a new `session_id`, the reader persists // record a clear that happened, not one that was asked for. The
// that as the resume token, and `Event::Cleared` is emitted at // CLI announces it with a `conversation_reset` line, which
// that observation -- so the transcript records a clear that // `translate.rs` turns into `Event::Cleared`, and follows it with
// happened rather than one that was asked for, and the next // a fresh `init` whose new `session_id` the reader persists as
// launch resumes the cleared conversation with nothing here to // the resume token -- so the next launch resumes the cleared
// keep in step. // conversation with nothing here to keep in step.
self.local_command("/clear".to_string()); self.local_command("/clear".to_string());
} }
@@ -790,36 +790,16 @@ fn translate_line(
tracing::warn!("unparseable claude output line: {shown}"); tracing::warn!("unparseable claude output line: {shown}");
return true; return true;
}; };
let (events, new_session_id, replaced) = { let (events, new_session_id) = {
let mut state = state.lock().unwrap(); let mut state = state.lock().unwrap();
let before = state.session_id.clone(); let before = state.session_id.clone();
let events = state.translate(&message); let events = state.translate(&message);
let after = state.session_id.clone(); let after = state.session_id.clone();
// A *replacement* -- an id where there already was a different (events, if before != after { after } else { None })
// 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)
}; };
if let Some(session_id) = new_session_id { if let Some(session_id) = new_session_id {
write_resume_token(session_dir, &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 { for event in events {
// Anything the CLI says after a steer was written is proof it has // 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 // been round the model again, and the steer went with it -- so
@@ -1056,28 +1036,20 @@ mod tests {
events 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 /// Measured against 2.1.237: `/clear` emits `conversation_reset`,
/// a change and is not a replacement -- reading it as one would open /// then a fresh `init` carrying a new session id. Watching the id be
/// every session with a divider announcing a clear that never /// replaced would work, but it reads the event through one of its
/// happened. /// 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] #[test]
fn the_first_init_is_not_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"}"#,
]);
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() {
let events = events_from_lines(&[ let events = events_from_lines(&[
r#"{"type":"system","subtype":"init","session_id":"first","tools":[],"model":"claude-haiku-4-5-20251001"}"#, 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"}"#, r#"{"type":"system","subtype":"init","session_id":"second","tools":[],"model":"claude-haiku-4-5-20251001"}"#,
]); ]);
assert_eq!( assert_eq!(
@@ -1087,15 +1059,30 @@ mod tests {
); );
} }
/// A compaction also re-announces `init`, carrying the *same* session /// An `init` on its own never records a clear, whatever id it carries.
/// id. That must not draw a clear on top of the compaction's own mark. ///
/// 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] #[test]
fn an_init_repeating_the_same_id_is_not_a_clear() { fn an_init_alone_is_never_a_clear() {
let events = events_from_lines(&[ for ids in [["first", "first"], ["first", "second"]] {
r#"{"type":"system","subtype":"init","session_id":"same","tools":[],"model":"claude-haiku-4-5-20251001"}"#, let events = events_from_lines(&[
r#"{"type":"system","subtype":"init","session_id":"same","tools":[],"model":"claude-haiku-4-5-20251001"}"#, &format!(
]); r#"{{"type":"system","subtype":"init","session_id":"{}","tools":[],"model":"claude-haiku-4-5-20251001"}}"#,
assert!(!events.contains(&Event::Cleared), "got {events:?}"); 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 /// The failure this exists for: a shell's complaint ends with a blank
+9
View File
@@ -97,6 +97,15 @@ impl Translator {
} }
match message.get("type").and_then(Value::as_str) { match message.get("type").and_then(Value::as_str) {
Some("system") => self.translate_system(message), 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("stream_event") => self.translate_stream_event(&message["event"]),
Some("assistant") => self.translate_assistant(&message["message"]), Some("assistant") => self.translate_assistant(&message["message"]),
Some("user") => self.translate_user(message), Some("user") => self.translate_user(message),