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
@@ -533,6 +533,17 @@ fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode:
|
|||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the session to summarise its own history and carry on from the summary.
|
||||||
|
*
|
||||||
|
* Nothing comes back here: a compaction takes a minute or two, and what it is doing arrives on the
|
||||||
|
* event stream like everything else -- a `compacting` status while it runs, then how much context
|
||||||
|
* it recovered. A call that waited would be a second, worse account of the same thing.
|
||||||
|
*/
|
||||||
|
fun compactSession(settings: ServerSettings, sessionId: String) {
|
||||||
|
requestFromServer(settings, "/sessions/$sessionId/compact", method = "POST") {}
|
||||||
|
}
|
||||||
|
|
||||||
fun deleteSession(settings: ServerSettings, sessionId: String) {
|
fun deleteSession(settings: ServerSettings, sessionId: String) {
|
||||||
requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {}
|
requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mark a compaction leaves in the transcript.
|
||||||
|
*
|
||||||
|
* Centred and dim, because it is a divider rather than something anybody said: everything above it
|
||||||
|
* is out of the session's context now, and that is a fact about the conversation, not a turn in it.
|
||||||
|
* It has no collapsed form -- it is already one line, and there is nothing behind it to open.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
compactionSummary(item),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to say about a compaction, given what was measured about it.
|
||||||
|
*
|
||||||
|
* The counts are the whole point when they are there -- "a million tokens became ten thousand" is
|
||||||
|
* the reader's answer to why the wait was worth it. When they are not, this says nothing about size
|
||||||
|
* rather than filling in a plausible number, and when only the size before is known it says exactly
|
||||||
|
* that much.
|
||||||
|
*
|
||||||
|
* `auto` is named because it is the case the reader did not ask for, and so the one that explains a
|
||||||
|
* session going quiet on its own. Anything else -- including a trigger this build does not
|
||||||
|
* recognise -- makes no claim about who asked, which is the honest reading of not knowing.
|
||||||
|
*/
|
||||||
|
fun compactionSummary(item: TranscriptItem.CompactedNote): String {
|
||||||
|
val what = if (item.trigger == "auto") "Compacted automatically" else "Compacted"
|
||||||
|
val pre = item.preTokens
|
||||||
|
val post = item.postTokens
|
||||||
|
return when {
|
||||||
|
pre != null && post != null -> "$what -- ${tokens(pre)} to ${tokens(post)} tokens"
|
||||||
|
pre != null -> "$what -- was ${tokens(pre)} tokens, new size not reported"
|
||||||
|
else -> what
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tokens(count: Long): String = "%,d".format(count)
|
||||||
@@ -41,6 +41,20 @@ sealed class SessionEvent {
|
|||||||
|
|
||||||
data class UsageDelta(val tokens: Long) : SessionEvent()
|
data class UsageDelta(val tokens: Long) : SessionEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A compaction that finished, and how much context it recovered.
|
||||||
|
*
|
||||||
|
* The counts are nullable because the server sends them only when it was told them: a
|
||||||
|
* compaction whose size nobody measured has to be able to say so, since a zero here would read
|
||||||
|
* as "recovered nothing" and a made-up number would read as a measurement.
|
||||||
|
*/
|
||||||
|
data class Compacted(
|
||||||
|
val preTokens: Long?,
|
||||||
|
val postTokens: Long?,
|
||||||
|
/** What asked for it, in the CLI's own word; `auto` is the one worth naming. */
|
||||||
|
val trigger: String?,
|
||||||
|
) : SessionEvent()
|
||||||
|
|
||||||
data class Error(val message: String) : SessionEvent()
|
data class Error(val message: String) : SessionEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,6 +98,12 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
"answered" -> SessionEvent.Answered(body.getString("id"), body.getString("answer"))
|
"answered" -> SessionEvent.Answered(body.getString("id"), body.getString("answer"))
|
||||||
"status" -> SessionEvent.Status(body.getString("state"))
|
"status" -> SessionEvent.Status(body.getString("state"))
|
||||||
"usageDelta" -> SessionEvent.UsageDelta(body.getLong("tokens"))
|
"usageDelta" -> SessionEvent.UsageDelta(body.getLong("tokens"))
|
||||||
|
"compacted" ->
|
||||||
|
SessionEvent.Compacted(
|
||||||
|
preTokens = if (body.has("preTokens")) body.getLong("preTokens") else null,
|
||||||
|
postTokens = if (body.has("postTokens")) body.getLong("postTokens") else null,
|
||||||
|
trigger = body.optString("trigger").ifEmpty { null },
|
||||||
|
)
|
||||||
"error" -> SessionEvent.Error(body.getString("message"))
|
"error" -> SessionEvent.Error(body.getString("message"))
|
||||||
else -> SessionEvent.Unknown(type)
|
else -> SessionEvent.Unknown(type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,19 @@ sealed class TranscriptItem {
|
|||||||
|
|
||||||
/** Placeholder row for events this build can't render (newer kinds). */
|
/** Placeholder row for events this build can't render (newer kinds). */
|
||||||
data class Note(val text: String) : TranscriptItem()
|
data class Note(val text: String) : TranscriptItem()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A compaction that happened, and what it recovered.
|
||||||
|
*
|
||||||
|
* In the transcript rather than only in the status line, because the status is gone the moment
|
||||||
|
* it finishes and this is the part worth keeping: it is the explanation for a gap in the
|
||||||
|
* conversation, and for a minute or two in which the session was busy with nothing to show.
|
||||||
|
*/
|
||||||
|
data class CompactedNote(
|
||||||
|
val preTokens: Long?,
|
||||||
|
val postTokens: Long?,
|
||||||
|
val trigger: String?,
|
||||||
|
) : TranscriptItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun foldEvent(items: List<TranscriptItem>, event: SessionEvent): List<TranscriptItem> =
|
fun foldEvent(items: List<TranscriptItem>, event: SessionEvent): List<TranscriptItem> =
|
||||||
@@ -181,6 +194,8 @@ fun foldEvent(items: List<TranscriptItem>, event: SessionEvent): List<Transcript
|
|||||||
} else {
|
} else {
|
||||||
items + TranscriptItem.ImageItem(event.ref)
|
items + TranscriptItem.ImageItem(event.ref)
|
||||||
}
|
}
|
||||||
|
is SessionEvent.Compacted ->
|
||||||
|
items + TranscriptItem.CompactedNote(event.preTokens, event.postTokens, event.trigger)
|
||||||
is SessionEvent.Unknown -> items + TranscriptItem.Note("[${event.type}]")
|
is SessionEvent.Unknown -> items + TranscriptItem.Note("[${event.type}]")
|
||||||
// Screen-level state, not transcript rows -- see SessionScreen.
|
// Screen-level state, not transcript rows -- see SessionScreen.
|
||||||
is SessionEvent.UsageDelta -> items
|
is SessionEvent.UsageDelta -> items
|
||||||
@@ -526,6 +541,15 @@ fun SessionScreen(
|
|||||||
// the paid service's own numbers, so a session on a provider with no such service
|
// the paid service's own numbers, so a session on a provider with no such service
|
||||||
// gets an honest "unavailable" rather than a hidden button -- a control that comes
|
// gets an honest "unavailable" rather than a hidden button -- a control that comes
|
||||||
// and goes makes its absence the signal, and absence cannot say why.
|
// and goes makes its absence the signal, and absence cannot say why.
|
||||||
|
// Beside the token count it acts on, which is the line to its left: compacting is
|
||||||
|
// what that number is for. Disabled rather than hidden while one is already running,
|
||||||
|
// so the button still says the session can do this and why it cannot right now.
|
||||||
|
TextButton(
|
||||||
|
onClick = { act { compactSession(settings, summary.id) } },
|
||||||
|
enabled = status != "compacting" && status != "exited",
|
||||||
|
) {
|
||||||
|
Text("Compact")
|
||||||
|
}
|
||||||
TextButton(onClick = onUsage) { Text("Usage") }
|
TextButton(onClick = onUsage) { Text("Usage") }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,6 +701,7 @@ fun SessionScreen(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
is TranscriptItem.CompactedNote -> CompactedRow(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -482,7 +482,16 @@ impl Driver for ClaudeDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn compact(&self) {
|
fn compact(&self) {
|
||||||
// Slash commands ride the normal user-message channel.
|
// A turn is in flight from here. The CLI answers `/compact` like
|
||||||
|
// any other message -- a status line, a boundary, then a `result`
|
||||||
|
// -- so a message sent meanwhile belongs in the queue's "written,
|
||||||
|
// announce when it has been read" path rather than being reported
|
||||||
|
// as read the moment it is typed.
|
||||||
|
self.queue.lock().unwrap().running = true;
|
||||||
|
// Slash commands ride the normal user-message channel. Nothing is
|
||||||
|
// emitted here: the CLI announces the compaction itself, and
|
||||||
|
// saying so first would be this side's guess standing in for its
|
||||||
|
// measurement.
|
||||||
self.send_line(
|
self.send_line(
|
||||||
json!({"type": "user", "message": {"role": "user", "content": [
|
json!({"type": "user", "message": {"role": "user", "content": [
|
||||||
{"type": "text", "text": "/compact"}
|
{"type": "text", "text": "/compact"}
|
||||||
@@ -680,10 +689,14 @@ fn translate_line(
|
|||||||
queue: &Arc<Mutex<Queue>>,
|
queue: &Arc<Mutex<Queue>>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let Ok(message) = serde_json::from_str::<Value>(line) else {
|
let Ok(message) = serde_json::from_str::<Value>(line) else {
|
||||||
tracing::warn!(
|
// By characters, not bytes: the CLI emits plenty of non-ASCII, and
|
||||||
"unparseable claude output line: {}",
|
// a byte slice that lands mid-character panics -- inside `follow`,
|
||||||
&line[..line.len().min(200)]
|
// which is the task reading this session's output, so the session
|
||||||
);
|
// would go permanently deaf with nothing on screen to say so. The
|
||||||
|
// other three truncations in this codebase (`setups.rs`,
|
||||||
|
// `translate.rs`, `import.rs`) already do it this way.
|
||||||
|
let shown: String = line.chars().take(200).collect();
|
||||||
|
tracing::warn!("unparseable claude output line: {shown}");
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
let (events, new_session_id) = {
|
let (events, new_session_id) = {
|
||||||
|
|||||||
@@ -69,14 +69,7 @@ impl Translator {
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
match message.get("type").and_then(Value::as_str) {
|
match message.get("type").and_then(Value::as_str) {
|
||||||
Some("system") => {
|
Some("system") => self.translate_system(message),
|
||||||
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("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),
|
||||||
@@ -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
|
/// Raw API streaming: only text deltas become events. Consolidated
|
||||||
/// blocks arriving later re-carry the same text, so those are skipped
|
/// blocks arriving later re-carry the same text, so those are skipped
|
||||||
/// in `translate_assistant` -- one source per fact.
|
/// 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]
|
#[test]
|
||||||
fn an_error_result_surfaces_the_message() {
|
fn an_error_result_surfaces_the_message() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
|||||||
@@ -21,7 +21,19 @@ pub type ImageRef = String;
|
|||||||
/// reconnecting is just "events after seq N" -- no separate history path
|
/// reconnecting is just "events after seq N" -- no separate history path
|
||||||
/// to drift from the live one.
|
/// to drift from the live one.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "camelCase")]
|
// `rename_all` renames the variants; `rename_all_fields` renames what is
|
||||||
|
// inside them. Both are needed and only the first is obvious: every field
|
||||||
|
// here was a single lowercase word until `pre_tokens` arrived, so a
|
||||||
|
// multi-word field went out as snake_case, the app looked for camelCase and
|
||||||
|
// found nothing, and the event still rendered -- as the "no counts were
|
||||||
|
// reported" case, which is a state it is allowed to be in. A wire mismatch
|
||||||
|
// that lands on a plausible state is invisible; anything added below with a
|
||||||
|
// two-word field would have hit the same thing.
|
||||||
|
#[serde(
|
||||||
|
tag = "type",
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase"
|
||||||
|
)]
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
/// What the user sent, written into the transcript by the manager (not
|
/// What the user sent, written into the transcript by the manager (not
|
||||||
/// by drivers) so every device renders the full conversation from the
|
/// by drivers) so every device renders the full conversation from the
|
||||||
@@ -106,6 +118,29 @@ pub enum Event {
|
|||||||
UsageDelta {
|
UsageDelta {
|
||||||
tokens: u64,
|
tokens: u64,
|
||||||
},
|
},
|
||||||
|
/// A compaction that finished, and how much context it recovered.
|
||||||
|
///
|
||||||
|
/// The counts are the point, and a spinner is not: what a reader wants
|
||||||
|
/// afterwards is that the session went from a million tokens to ten
|
||||||
|
/// thousand, which is measured rather than estimated. They are
|
||||||
|
/// optional because the record has shipped without them, and "the
|
||||||
|
/// compaction happened, we don't know by how much" is a state this
|
||||||
|
/// has to be able to say -- filling in a plausible number would make
|
||||||
|
/// it indistinguishable from one that was counted.
|
||||||
|
Compacted {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pre_tokens: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
post_tokens: Option<u64>,
|
||||||
|
/// What asked for it, in the dialect's own word -- `auto` when the
|
||||||
|
/// session compacted on its own. Carried rather than reduced to a
|
||||||
|
/// bool so an unrecognised trigger stays unrecognised: an
|
||||||
|
/// automatic compaction is the one worth naming, because it
|
||||||
|
/// explains a wait nobody asked for, and defaulting the unknown
|
||||||
|
/// case to "you asked for this" would explain it away.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
trigger: Option<String>,
|
||||||
|
},
|
||||||
Error {
|
Error {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
@@ -180,3 +215,32 @@ pub trait Driver: Send + Sync {
|
|||||||
/// [`detach`]: Driver::detach
|
/// [`detach`]: Driver::detach
|
||||||
fn stop(&self);
|
fn stop(&self);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A tripwire for the wire format, not for serde.
|
||||||
|
///
|
||||||
|
/// The app reads these names, and getting one wrong does not fail
|
||||||
|
/// loudly: a field the app cannot find reads as a field the server
|
||||||
|
/// chose not to send, which several of them are allowed to be.
|
||||||
|
#[test]
|
||||||
|
fn multi_word_fields_go_out_in_camel_case() {
|
||||||
|
let json = serde_json::to_value(Event::Compacted {
|
||||||
|
pre_tokens: Some(28719),
|
||||||
|
post_tokens: Some(1125),
|
||||||
|
trigger: Some("manual".to_string()),
|
||||||
|
})
|
||||||
|
.expect("serialize");
|
||||||
|
assert_eq!(
|
||||||
|
json,
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "compacted",
|
||||||
|
"preTokens": 28719,
|
||||||
|
"postTokens": 1125,
|
||||||
|
"trigger": "manual",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
-24
@@ -1,6 +1,6 @@
|
|||||||
//! The phase-1 fake driver: no child process, just events. It exists to
|
//! The phase-1 fake driver: no child process, just events. It exists to
|
||||||
//! prove the whole pipe -- spawn, transcript, SSE cursors, questions,
|
//! prove the whole pipe -- spawn, transcript, SSE cursors, questions,
|
||||||
//! interrupts -- before any AI is involved, and stays useful afterwards as
|
//! interrupts, compaction -- before any AI is involved, and stays useful afterwards as
|
||||||
//! a connectivity check that costs no tokens.
|
//! a connectivity check that costs no tokens.
|
||||||
//!
|
//!
|
||||||
//! Behavior: every message is echoed back as a few streamed text deltas.
|
//! Behavior: every message is echoed back as a few streamed text deltas.
|
||||||
@@ -36,6 +36,12 @@ use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus};
|
|||||||
/// stay fast.
|
/// stay fast.
|
||||||
const DELTA_DELAY: Duration = Duration::from_millis(50);
|
const DELTA_DELAY: Duration = Duration::from_millis(50);
|
||||||
|
|
||||||
|
/// How long a fake compaction takes. A real one runs for a minute or two,
|
||||||
|
/// which is too long to sit through when what is being checked is what the
|
||||||
|
/// screen does; this is long enough that the state is visible and short
|
||||||
|
/// enough to wait for.
|
||||||
|
const COMPACT_TIME: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
pub struct EchoDriver {
|
pub struct EchoDriver {
|
||||||
sink: EventSink,
|
sink: EventSink,
|
||||||
/// Whether a turn is in flight, and what arrived during it.
|
/// Whether a turn is in flight, and what arrived during it.
|
||||||
@@ -74,6 +80,28 @@ impl EchoDriver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ending a turn is also when anything held during it is taken up -- the
|
||||||
|
/// moment a real CLI would have injected it. One place, because a turn has
|
||||||
|
/// several ways to end (a reply, an interrupt, a compaction) and every one
|
||||||
|
/// of them owes the same answer.
|
||||||
|
fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<String>>, busy: &AtomicBool) {
|
||||||
|
let held = std::mem::take(&mut *queued.lock().unwrap());
|
||||||
|
for text in held {
|
||||||
|
// Announced before it is answered, in that order: a phone showing
|
||||||
|
// the message as pending needs the signal that it has been read,
|
||||||
|
// and the answer is meaningless above a message still drawn as
|
||||||
|
// waiting.
|
||||||
|
let _ = sink.send(Event::MessageTaken { text: text.clone() });
|
||||||
|
let _ = sink.send(Event::AssistantText {
|
||||||
|
delta: format!("\n(taken from the queue) You said: {text}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
busy.store(false, Ordering::SeqCst);
|
||||||
|
let _ = sink.send(Event::Status {
|
||||||
|
state: SessionStatus::Idle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
impl Driver for EchoDriver {
|
impl Driver for EchoDriver {
|
||||||
fn send_user_message(&self, text: String, _images: Vec<ImageRef>) {
|
fn send_user_message(&self, text: String, _images: Vec<ImageRef>) {
|
||||||
let sink = self.sink.clone();
|
let sink = self.sink.clone();
|
||||||
@@ -141,27 +169,7 @@ impl Driver for EchoDriver {
|
|||||||
let send = |event: Event| {
|
let send = |event: Event| {
|
||||||
let _ = sink.send(event);
|
let _ = sink.send(event);
|
||||||
};
|
};
|
||||||
// Ending a turn is also when anything held during it is taken
|
let finish = || finish_turn(&sink, &queued, &busy);
|
||||||
// up -- the moment a real CLI would have injected it. One
|
|
||||||
// place, because a turn has several ways to end and every one
|
|
||||||
// of them owes the same answer.
|
|
||||||
let finish = || {
|
|
||||||
let held = std::mem::take(&mut *queued.lock().unwrap());
|
|
||||||
for text in held {
|
|
||||||
// Announced before it is answered, in that order: a
|
|
||||||
// phone showing the message as pending needs the
|
|
||||||
// signal that it has been read, and the answer is
|
|
||||||
// meaningless above a message still drawn as waiting.
|
|
||||||
send(Event::MessageTaken { text: text.clone() });
|
|
||||||
send(Event::AssistantText {
|
|
||||||
delta: format!("\n(taken from the queue) You said: {text}"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
busy.store(false, Ordering::SeqCst);
|
|
||||||
send(Event::Status {
|
|
||||||
state: SessionStatus::Idle,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
// Echo takes a message the instant it gets one, but it says so
|
// Echo takes a message the instant it gets one, but it says so
|
||||||
// anyway: a driver that skips this leaves the phone holding a
|
// anyway: a driver that skips this leaves the phone holding a
|
||||||
// message it thinks is still queued, and the point of an echo
|
// message it thinks is still queued, and the point of an echo
|
||||||
@@ -295,9 +303,30 @@ impl Driver for EchoDriver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A compaction with nothing to compact.
|
||||||
|
///
|
||||||
|
/// The counts are invented, like everything else this driver says --
|
||||||
|
/// what is real is the shape and the order: busy, a pause long enough
|
||||||
|
/// to see, then the result. `Compacting` and `Compacted` are states a
|
||||||
|
/// screen has to draw, and the only other way to reach them is to fill
|
||||||
|
/// a real session's context and spend two minutes of somebody's
|
||||||
|
/// account getting it back.
|
||||||
fn compact(&self) {
|
fn compact(&self) {
|
||||||
self.emit(Event::Error {
|
let sink = self.sink.clone();
|
||||||
message: "echo sessions have nothing to compact".to_string(),
|
let queued = Arc::clone(&self.queued);
|
||||||
|
let busy = Arc::clone(&self.busy);
|
||||||
|
busy.store(true, Ordering::SeqCst);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = sink.send(Event::Status {
|
||||||
|
state: SessionStatus::Compacting,
|
||||||
|
});
|
||||||
|
tokio::time::sleep(COMPACT_TIME).await;
|
||||||
|
let _ = sink.send(Event::Compacted {
|
||||||
|
pre_tokens: Some(128_402),
|
||||||
|
post_tokens: Some(9_617),
|
||||||
|
trigger: Some("manual".to_string()),
|
||||||
|
});
|
||||||
|
finish_turn(&sink, &queued, &busy);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user