Show a compaction happening, and what it recovered
The Compacting status had been declared, rendered in four places, and never once emitted: no driver produced it, and the app had no control to ask for a compaction in the first place. Pressing nothing for two minutes and then quietly having less context was the whole experience. The CLI turns out to announce all of it, which was worth measuring rather than guessing at. Driven through /compact against 2.1.237 it emits a `status: "compacting"` line at the start, a `status: null` carrying `compact_result` at the end -- `"failed"` with a sentence saying why, when it does -- and then a `compact_boundary` with the token counts. The same records appear in the CLI's own transcript file with camelCase keys, which is the obvious place to read the shape off and gets every field name wrong. So none of it is inferred here. The driver writes the line and says nothing; the translator reports what the CLI reports. A failed compaction surfaces the CLI's own sentence, which is specific enough to act on. The counts are the part worth keeping afterwards, so they land in the transcript rather than only in a status that vanishes: a session that went from 128,402 tokens to 9,617 has just been given its context back. They are optional throughout, because a compaction whose size nobody reported has to be able to say so -- a zero would read as "recovered nothing". Also here, all found on the way: - `rename_all` renames variants; fields need `rename_all_fields`. Every field in Event was a single word until `pre_tokens`, which went out as snake_case, was not found by the app, and rendered as the "no counts reported" case -- a state it is allowed to be in, so nothing looked wrong. There is now a test on the wire names. - The unparseable-line warning sliced bytes, not chars, on output that is full of em dashes. A panic there kills the task reading the session's stdout, and the session goes deaf with nothing on screen. The other three truncations in the tree already did this correctly. - Echo compacts too, with invented numbers and a real shape, so this screen can be looked at without spending two minutes of somebody's account to reach the state.
This commit is contained in:
1 parent
42131c75d6
commit
5396da76c7
8 files changed
+408
-38
No files matched your search
@@ -69,14 +69,7 @@ impl Translator {
|
||||
return Vec::new();
|
||||
}
|
||||
match message.get("type").and_then(Value::as_str) {
|
||||
Some("system") => {
|
||||
if message.get("subtype").and_then(Value::as_str) == Some("init")
|
||||
&& let Some(id) = message.get("session_id").and_then(Value::as_str)
|
||||
{
|
||||
self.session_id = Some(id.to_string());
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
Some("system") => self.translate_system(message),
|
||||
Some("stream_event") => self.translate_stream_event(&message["event"]),
|
||||
Some("assistant") => self.translate_assistant(&message["message"]),
|
||||
Some("user") => self.translate_user(message),
|
||||
@@ -131,6 +124,88 @@ impl Translator {
|
||||
}
|
||||
}
|
||||
|
||||
/// The CLI's own notices: which session this is, and what it is doing
|
||||
/// that is not a turn.
|
||||
///
|
||||
/// Compaction is the whole of that second kind, and it is announced
|
||||
/// rather than inferred. Measured against CLI 2.1.237 (2026-08-29) by
|
||||
/// driving a session through `/compact`, one produces in order:
|
||||
///
|
||||
/// - `{"subtype":"status","status":"compacting"}` -- the start;
|
||||
/// - `{"subtype":"status","status":null,"compact_result":"success"}`,
|
||||
/// or `"failed"` with a `compact_error` saying why -- the end;
|
||||
/// - a fresh `init` carrying the same `session_id`;
|
||||
/// - `{"subtype":"compact_boundary","compact_metadata":{…}}` with the
|
||||
/// token counts, and only when it succeeded;
|
||||
/// - the turn's ordinary `result`, which is what returns it to idle.
|
||||
///
|
||||
/// The keys are snake_case here and camelCase in the CLI's own
|
||||
/// transcript file, which records the same events. Reading the shape
|
||||
/// off that file -- the obvious place to find one, since it is on
|
||||
/// disk -- gets every field name wrong and silently yields a
|
||||
/// compaction with no numbers in it.
|
||||
fn translate_system(&mut self, message: &Value) -> Vec<Event> {
|
||||
match message.get("subtype").and_then(Value::as_str) {
|
||||
Some("init") => {
|
||||
if let Some(id) = message.get("session_id").and_then(Value::as_str) {
|
||||
self.session_id = Some(id.to_string());
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
Some("status") => self.translate_status(message),
|
||||
Some("compact_boundary") => {
|
||||
let meta = &message["compact_metadata"];
|
||||
vec![Event::Compacted {
|
||||
pre_tokens: meta.get("pre_tokens").and_then(Value::as_u64),
|
||||
post_tokens: meta.get("post_tokens").and_then(Value::as_u64),
|
||||
trigger: meta
|
||||
.get("trigger")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
}]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `system/status` line: the CLI entering or leaving a state that is
|
||||
/// not a turn.
|
||||
///
|
||||
/// A null `status` is the leaving edge, and it carries how the thing
|
||||
/// went. Whatever it was, the turn it happened inside is still going
|
||||
/// when it ends -- the `result` has not arrived yet -- so leaving says
|
||||
/// `Running`, which is also the only place in this file that does. A
|
||||
/// state this build does not recognise is left alone rather than
|
||||
/// mapped onto the nearest one we do.
|
||||
fn translate_status(&self, message: &Value) -> Vec<Event> {
|
||||
if let Some(status) = message.get("status").and_then(Value::as_str) {
|
||||
return match status {
|
||||
"compacting" => vec![Event::Status {
|
||||
state: SessionStatus::Compacting,
|
||||
}],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
}
|
||||
let Some(result) = message.get("compact_result").and_then(Value::as_str) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
if result != "success" {
|
||||
// The CLI's own sentence, because it is specific enough to act
|
||||
// on: "Not enough messages to compact." is a complete answer.
|
||||
events.push(Event::Error {
|
||||
message: match message.get("compact_error").and_then(Value::as_str) {
|
||||
Some(why) => format!("compaction failed: {why}"),
|
||||
None => format!("compaction {result}"),
|
||||
},
|
||||
});
|
||||
}
|
||||
events.push(Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
});
|
||||
events
|
||||
}
|
||||
|
||||
/// Raw API streaming: only text deltas become events. Consolidated
|
||||
/// blocks arriving later re-carry the same text, so those are skipped
|
||||
/// in `translate_assistant` -- one source per fact.
|
||||
@@ -628,6 +703,86 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_compaction_reports_its_start_and_what_it_recovered() {
|
||||
// Real lines (trimmed) from a 2.1.237 session driven through
|
||||
// `/compact`. Note the snake_case keys -- the CLI's transcript
|
||||
// file writes the same records in camelCase.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||
let events = translate_lines(
|
||||
&mut translator,
|
||||
&[
|
||||
r#"{"type":"system","subtype":"status","status":"compacting","session_id":"s","uuid":"u1"}"#,
|
||||
r#"{"type":"system","subtype":"status","status":null,"compact_result":"success","session_id":"s","uuid":"u2"}"#,
|
||||
r#"{"type":"system","subtype":"compact_boundary","session_id":"s","uuid":"u3","compact_metadata":{"trigger":"manual","pre_tokens":28719,"post_tokens":1125,"duration_ms":17130}}"#,
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![
|
||||
Event::Status {
|
||||
state: SessionStatus::Compacting
|
||||
},
|
||||
Event::Status {
|
||||
state: SessionStatus::Running
|
||||
},
|
||||
Event::Compacted {
|
||||
pre_tokens: Some(28719),
|
||||
post_tokens: Some(1125),
|
||||
trigger: Some("manual".to_string()),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_compaction_says_why_and_leaves_the_turn_running() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||
let events = translate_lines(
|
||||
&mut translator,
|
||||
&[
|
||||
r#"{"type":"system","subtype":"status","status":"compacting","session_id":"s","uuid":"u1"}"#,
|
||||
r#"{"type":"system","subtype":"status","status":null,"compact_result":"failed","compact_error":"Not enough messages to compact.","session_id":"s","uuid":"u2"}"#,
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![
|
||||
Event::Status {
|
||||
state: SessionStatus::Compacting
|
||||
},
|
||||
Event::Error {
|
||||
message: "compaction failed: Not enough messages to compact.".to_string()
|
||||
},
|
||||
Event::Status {
|
||||
state: SessionStatus::Running
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boundary_without_counts_says_so_rather_than_inventing_them() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||
let events = translate_lines(
|
||||
&mut translator,
|
||||
&[
|
||||
r#"{"type":"system","subtype":"compact_boundary","session_id":"s","compact_metadata":{"trigger":"auto"}}"#,
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![Event::Compacted {
|
||||
pre_tokens: None,
|
||||
post_tokens: None,
|
||||
trigger: Some("auto".to_string()),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_error_result_surfaces_the_message() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
Reference in new issue
Block a user