diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 41dee07..67842e2 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -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) { requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {} } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt new file mode 100644 index 0000000..7c24db4 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt @@ -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) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt index f1e6608..eaf547a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt @@ -41,6 +41,20 @@ sealed class 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() /** @@ -84,6 +98,12 @@ fun parseSeqEvent(json: String): SeqEvent { "answered" -> SessionEvent.Answered(body.getString("id"), body.getString("answer")) "status" -> SessionEvent.Status(body.getString("state")) "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")) else -> SessionEvent.Unknown(type) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 0575940..5db0dda 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -110,6 +110,19 @@ sealed class TranscriptItem { /** Placeholder row for events this build can't render (newer kinds). */ 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, event: SessionEvent): List = @@ -181,6 +194,8 @@ fun foldEvent(items: List, event: SessionEvent): List + items + TranscriptItem.CompactedNote(event.preTokens, event.postTokens, event.trigger) is SessionEvent.Unknown -> items + TranscriptItem.Note("[${event.type}]") // Screen-level state, not transcript rows -- see SessionScreen. 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 // 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. + // 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") } } @@ -677,6 +701,7 @@ fun SessionScreen( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + is TranscriptItem.CompactedNote -> CompactedRow(item) } } } diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index 6a18b4a..0ca7db9 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -482,7 +482,16 @@ impl Driver for ClaudeDriver { } 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( json!({"type": "user", "message": {"role": "user", "content": [ {"type": "text", "text": "/compact"} @@ -680,10 +689,14 @@ fn translate_line( queue: &Arc>, ) -> bool { let Ok(message) = serde_json::from_str::(line) else { - tracing::warn!( - "unparseable claude output line: {}", - &line[..line.len().min(200)] - ); + // By characters, not bytes: the CLI emits plenty of non-ASCII, and + // a byte slice that lands mid-character panics -- inside `follow`, + // 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; }; let (events, new_session_id) = { diff --git a/server/src/session/claude/translate.rs b/server/src/session/claude/translate.rs index ddb2667..0bd0087 100644 --- a/server/src/session/claude/translate.rs +++ b/server/src/session/claude/translate.rs @@ -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 { + 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 { + 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"); diff --git a/server/src/session/driver.rs b/server/src/session/driver.rs index a35c8fa..11607ad 100644 --- a/server/src/session/driver.rs +++ b/server/src/session/driver.rs @@ -21,7 +21,19 @@ pub type ImageRef = String; /// reconnecting is just "events after seq N" -- no separate history path /// to drift from the live one. #[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 { /// What the user sent, written into the transcript by the manager (not /// by drivers) so every device renders the full conversation from the @@ -106,6 +118,29 @@ pub enum Event { UsageDelta { 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + post_tokens: Option, + /// 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, + }, Error { message: String, }, @@ -180,3 +215,32 @@ pub trait Driver: Send + Sync { /// [`detach`]: Driver::detach 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", + }) + ); + } +} diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index ba75d4c..b82651d 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -1,6 +1,6 @@ //! The phase-1 fake driver: no child process, just events. It exists to //! 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. //! //! 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. 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 { sink: EventSink, /// 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>, 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 { fn send_user_message(&self, text: String, _images: Vec) { let sink = self.sink.clone(); @@ -141,27 +169,7 @@ impl Driver for EchoDriver { let send = |event: Event| { let _ = sink.send(event); }; - // 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 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, - }); - }; + let finish = || finish_turn(&sink, &queued, &busy); // Echo takes a message the instant it gets one, but it says so // anyway: a driver that skips this leaves the phone holding a // 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) { - self.emit(Event::Error { - message: "echo sessions have nothing to compact".to_string(), + let sink = self.sink.clone(); + 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); }); }