From 3c4939f57c115200ccf5f6cd22111014b45ca3e5 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 16 Oct 2026 14:39:03 +0800 Subject: [PATCH] feat(compaction): stream the summary into the chat as it generates /compact and auto-compaction showed only a spinner until the whole summary arrived, which can take tens of seconds on a long session. compact_session now streams the summary's text to the frontend as Event::SummaryToken through a SummaryStream sink, which passes retry notices through and drops the reply's thinking. When any text has streamed, an overflow retry, a failed or empty summary, or a dropped future (cancellation) emits Event::SummaryDiscarded before the event that reports the outcome. A summary whose compact boundary fails to save is discarded the same way, and a saved one ends in SessionCompacted as before. The TUI renders the summary in flight below the chat under a "Summarizing the conversation..." header, laid out like the CompactedBlock it becomes, so success swaps it for the final block in the same layout. A discard removes it. The stdio frontends ignore both events. --- crates/oxide-code/src/agent.rs | 41 ++- .../oxide-code/src/agent/compact_boundary.rs | 18 +- crates/oxide-code/src/agent/compaction.rs | 236 ++++++++++++++++-- crates/oxide-code/src/driver/stdio.rs | 4 + crates/oxide-code/src/engine.rs | 92 ++++++- crates/oxide-code/src/protocol.rs | 10 + crates/oxide-code/src/tui/app.rs | 67 +++++ crates/oxide-code/src/tui/components/chat.rs | 130 +++++++++- .../tui/components/chat/blocks/compacted.rs | 84 ++++--- .../src/tui/components/chat/stack.rs | 11 + ...tion_cancel_drops_the_partial_summary.snap | 20 ++ ...on_streams_the_summary_below_the_chat.snap | 20 ++ ...ess_leaves_only_the_compacted_summary.snap | 20 ++ docs/design/agent/auto-compaction.md | 4 +- docs/design/slash/compact.md | 22 +- docs/design/tui/welcome.md | 2 +- 16 files changed, 682 insertions(+), 99 deletions(-) create mode 100644 crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_compaction_cancel_drops_the_partial_summary.snap create mode 100644 crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_compaction_streams_the_summary_below_the_chat.snap create mode 100644 crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_compaction_success_leaves_only_the_compacted_summary.snap diff --git a/crates/oxide-code/src/agent.rs b/crates/oxide-code/src/agent.rs index 4a845b77..aa870214 100644 --- a/crates/oxide-code/src/agent.rs +++ b/crates/oxide-code/src/agent.rs @@ -379,7 +379,15 @@ pub(crate) async fn auto_compact_if_needed( sink.emit(Event::AutoCompactionStarted, "auto-compaction-started"); let summary = match await_unless_aborted( - compaction::compact_session(client, auto.prompt, auto.tools, messages, None, auto.spent), + compaction::compact_session( + client, + auto.prompt, + auto.tools, + messages, + None, + sink, + auto.spent, + ), ops, pending, ) @@ -1612,10 +1620,18 @@ mod tests { assert!(!compacted); assert_eq!(failures, 1); assert!(matches!(&messages[0].content[0], ContentBlock::Text { text } if text == "hi")); + let events = sink.events(); assert!( - sink.events() - .iter() - .any(|event| matches!(event, Event::Error(message) if message.contains("Session write failed"))) + matches!( + events.as_slice(), + [ + Event::AutoCompactionStarted, + Event::SummaryToken(summary), + Event::SummaryDiscarded, + Event::Error(message), + ] if summary == "auto summary" && message.starts_with("Session write failed") + ), + "the unsaved summary is discarded before the write failure: {events:?}", ); } @@ -1786,6 +1802,7 @@ mod tests { ), text_turn("auto summary"), ]); + let sink = CapturingSink::new(); let tracker = FileTracker::default(); let mut messages = vec![ Message::user("hi"), @@ -1799,7 +1816,7 @@ mod tests { &client, &session, &mut messages, - &CapturingSink::new(), + &sink, &mut inert_ops(), &mut Vec::new(), Some(&mut AutoCompact { @@ -1852,6 +1869,20 @@ mod tests { assert!( matches!(&messages[0].content[0], ContentBlock::Text { text } if text.contains("auto summary")) ); + let events = sink.events(); + assert!( + matches!( + events.as_slice(), + [ + Event::AutoCompactionStarted, + Event::SummaryToken(overflowed), + Event::SummaryDiscarded, + Event::SummaryToken(retried), + Event::SessionCompacted { automatic: true, .. }, + ] if overflowed == "partial" && retried == "auto summary" + ), + "the overflowed summary is discarded before the retry streams: {events:?}", + ); } // ── agent_turn ── diff --git a/crates/oxide-code/src/agent/compact_boundary.rs b/crates/oxide-code/src/agent/compact_boundary.rs index 704011e4..bb0ece60 100644 --- a/crates/oxide-code/src/agent/compact_boundary.rs +++ b/crates/oxide-code/src/agent/compact_boundary.rs @@ -6,7 +6,8 @@ use crate::message::Message; use crate::protocol::{Event, EventSink}; use crate::session::handle::SessionHandle; -/// Persists a compact boundary and swaps the live transcript to the synthetic summary root. +/// Persists a compact boundary and swaps the live transcript to the synthetic summary root. A +/// failed write discards the streamed summary and leaves the transcript unchanged. pub(crate) async fn replace_session_with_summary( session: &SessionHandle, file_tracker: &FileTracker, @@ -20,8 +21,9 @@ pub(crate) async fn replace_session_with_summary( let outcome = session .compact(summary.clone(), instructions.clone(), synthetic.clone()) .await; - sink.session_write_error(outcome.failure.as_deref()); - if outcome.failure.is_some() { + if let Some(failure) = outcome.failure.as_deref() { + sink.emit(Event::SummaryDiscarded, "summary-discarded"); + sink.session_write_error(Some(failure)); return false; } @@ -120,10 +122,14 @@ mod tests { matches!(&messages[3].content[0], ContentBlock::Text { text } if text == "found it") ); assert_eq!(tracker.snapshot_all().len(), 1); + let events = sink.events(); assert!( - sink.events() - .iter() - .any(|event| matches!(event, Event::Error(message) if message.contains("Session write failed"))) + matches!( + events.as_slice(), + [Event::SummaryDiscarded, Event::Error(message)] + if message.contains("Session write failed") + ), + "the unsaved summary is discarded before the write error: {events:?}", ); } diff --git a/crates/oxide-code/src/agent/compaction.rs b/crates/oxide-code/src/agent/compaction.rs index 45bda11a..858f18a8 100644 --- a/crates/oxide-code/src/agent/compaction.rs +++ b/crates/oxide-code/src/agent/compaction.rs @@ -5,10 +5,11 @@ //! so it reads the conversation's prompt cache and passes earlier thinking blocks back unchanged. A //! summary that overflows the context window, as an HTTP 400 `prompt is too long` or a //! `model_context_window_exceeded` stop, retries up to three times without the oldest rounds or -//! any thinking block, which misses the cache. Session mutation happens in the driver after the -//! summary succeeds. +//! any thinking block, which misses the cache. The summary's text streams to the frontend as it +//! generates. Session mutation happens in the driver after the summary succeeds. use std::iter; +use std::sync::atomic::{AtomicBool, Ordering}; use anyhow::{Result, bail}; use indoc::{formatdoc, indoc}; @@ -62,8 +63,12 @@ const SUMMARY_PREFIX: &str = indoc! {r" "}; /// Drives the summarization request and returns the trimmed summary. `prompt` and `tools` must be -/// the ones the conversation's own requests send. `spent` receives the usage of every request, -/// even when the summary fails or the caller drops the future. +/// the ones the conversation's own requests send. `spent` receives the usage of every request, even +/// when the summary fails or the caller drops the future. +/// +/// The summary streams to `sink` as [`Event::SummaryToken`]s. A retry, a failure, or a dropped +/// future ends them with [`Event::SummaryDiscarded`]. On success the streamed text stays up, and +/// the caller must end it with [`Event::SessionCompacted`] or [`Event::SummaryDiscarded`]. /// /// Errors when the transcript is too short to compact, when a request fails, or when the reply is /// empty or stopped before the summary finished, including when the last retry still overflows. @@ -73,6 +78,7 @@ pub(crate) async fn compact_session( tools: &[ToolDefinition], transcript: &[Message], instructions: Option<&str>, + sink: &dyn EventSink, spent: &mut Vec, ) -> Result { if transcript.len() < MIN_MESSAGES_FOR_COMPACT { @@ -99,11 +105,12 @@ pub(crate) async fn compact_session( let user_context = Some(prompt.user_context.as_str()); let rubric = Message::user(build_user_message(instructions)); let request: Vec = transcript.iter().chain([&rubric]).cloned().collect(); + let stream = SummaryStream::new(sink); let mut rx = client.stream_message(&request, §ions, user_context, tools)?; let mut kept = transcript.to_vec(); let mut retries = 0; let response = loop { - let result = collect_summary(rx, spent).await; + let result = collect_summary(rx, &stream, spent).await; let smaller = overflow_tokens(&result) .filter(|_| retries < MAX_OVERFLOW_RETRIES) .and_then(|overflow| { @@ -117,6 +124,7 @@ pub(crate) async fn compact_session( }; retries += 1; kept = smaller; + stream.discard(); let request = retry_request(&kept, &rubric); rx = client.stream_one_shot(&request, §ions, user_context, tools)?; }; @@ -147,19 +155,69 @@ pub(crate) async fn compact_session( if trimmed.is_empty() { bail!("compaction returned empty summary"); } + stream.keep(); Ok(trimmed.to_owned()) } /// Collects one summarization reply, adding its usage to `spent` even when it fails. async fn collect_summary( rx: mpsc::Receiver>, + stream: &SummaryStream<'_>, spent: &mut Vec, ) -> Result { let mut usage = RequestUsage { total: spent, request: None, }; - super::collect_response(rx, &QuietSink, &mut usage.request).await + super::collect_response(rx, stream, &mut usage.request).await +} + +/// Forwards the summary's text and retry notices to the frontend and drops the rest of the reply, +/// which belongs to no visible turn. Dropping it discards the streamed text, so a cancelled +/// compaction clears it. +struct SummaryStream<'a> { + sink: &'a dyn EventSink, + streamed: AtomicBool, +} + +impl<'a> SummaryStream<'a> { + fn new(sink: &'a dyn EventSink) -> Self { + Self { + sink, + streamed: AtomicBool::new(false), + } + } + + /// Tells the frontend to drop the summary text streamed so far, if any. + fn discard(&self) { + if self.streamed.swap(false, Ordering::Relaxed) { + self.sink.emit(Event::SummaryDiscarded, "summary-discarded"); + } + } + + /// Leaves the streamed summary for the compact boundary to commit or discard. + fn keep(mut self) { + *self.streamed.get_mut() = false; + } +} + +impl Drop for SummaryStream<'_> { + fn drop(&mut self) { + self.discard(); + } +} + +impl EventSink for SummaryStream<'_> { + fn send(&self, event: Event) -> Result<()> { + match event { + Event::StreamToken(text) => { + self.streamed.store(true, Ordering::Relaxed); + self.sink.send(Event::SummaryToken(text)) + } + Event::Retrying(_) => self.sink.send(event), + _ => Ok(()), + } + } } /// Adds one request's usage to the running total on drop, so a failed or cancelled request still @@ -263,15 +321,6 @@ fn retry_request(kept: &[Message], rubric: &Message) -> Vec { .collect() } -/// Discards the summary's stream tokens, which belong to no visible turn. -struct QuietSink; - -impl EventSink for QuietSink { - fn send(&self, _event: Event) -> Result<()> { - Ok(()) - } -} - /// Composes the rubric plus optional user-supplied focus instructions. fn build_user_message(instructions: Option<&str>) -> String { match instructions.map(str::trim).filter(|s| !s.is_empty()) { @@ -313,6 +362,7 @@ pub(crate) fn strip_synthetic_post_compact_prefix(message: &mut Message) -> bool #[cfg(test)] mod tests { + use std::pin::pin; use std::sync::{Arc, Mutex}; use serde_json::json; @@ -323,6 +373,7 @@ mod tests { use crate::client::anthropic::Client; use crate::client::anthropic::testing::{api_key, sse_body, test_client}; use crate::message::Role; + use crate::protocol::CapturingSink; // ── compact_session ── @@ -370,15 +421,30 @@ mod tests { } async fn summarize(client: &Client, transcript: &[Message]) -> Result { - compact_session( + summarize_streaming(client, transcript).await.0 + } + + /// Summarizes `transcript` like [`summarize`], also returning the events the summary streamed. + async fn summarize_streaming( + client: &Client, + transcript: &[Message], + ) -> (Result, Vec) { + let sink = CapturingSink::new(); + let summary = compact_session( client, &parent_prompt(), &[], transcript, None, + &sink, &mut Vec::new(), ) - .await + .await; + (summary, sink.events()) + } + + fn token(text: &str) -> Event { + Event::SummaryToken(text.to_owned()) } fn fake_transcript() -> Vec { @@ -445,8 +511,13 @@ mod tests { .await; let client = test_client(server.uri(), api_key(), "claude-haiku-5-5"); - let summary = summarize(&client, &fake_transcript()).await.unwrap(); - assert_eq!(summary, "fixed login bug"); + let (summary, events) = summarize_streaming(&client, &fake_transcript()).await; + assert_eq!(summary.unwrap(), "fixed login bug"); + assert_eq!( + events, + [token(" fixed"), token(" login bug \n")], + "the kept summary streams untrimmed and is not discarded", + ); } #[tokio::test] @@ -470,8 +541,9 @@ mod tests { .await; let client = test_client(server.uri(), api_key(), "claude-haiku-5-5"); - let summary = summarize(&client, &fake_transcript()).await.unwrap(); - assert_eq!(summary, "fixed login bug"); + let (summary, events) = summarize_streaming(&client, &fake_transcript()).await; + assert_eq!(summary.unwrap(), "fixed login bug"); + assert_eq!(events, [token("fixed login bug")]); } #[tokio::test] @@ -512,6 +584,7 @@ mod tests { &tools, &transcript, Some("focus on auth"), + &CapturingSink::new(), &mut Vec::new(), ) .await @@ -614,6 +687,7 @@ mod tests { let server = MockServer::start().await; mount_overflow_then(&server, streamed_summary_body("trimmed summary")).await; let client = test_client(server.uri(), api_key(), "claude-haiku-5-5"); + let sink = CapturingSink::new(); let mut spent = Vec::new(); let summary = compact_session( @@ -622,12 +696,22 @@ mod tests { &[], &overflowing_transcript(), None, + &sink, &mut spent, ) .await .unwrap(); assert_eq!(summary, "trimmed summary"); + assert_eq!( + sink.events(), + [ + token("partial"), + Event::SummaryDiscarded, + token("trimmed summary") + ], + "the retry starts the summary over", + ); let requests = server.received_requests().await.unwrap(); assert_eq!(requests.len(), 2); let mut retry: serde_json::Value = serde_json::from_slice(&requests[1].body).unwrap(); @@ -708,9 +792,14 @@ mod tests { let client = test_client(server.uri(), api_key(), "claude-haiku-5-5"); let transcript = heavy_rounds(4); - let summary = summarize(&client, &transcript).await.unwrap(); + let (summary, events) = summarize_streaming(&client, &transcript).await; - assert_eq!(summary, "trimmed summary"); + assert_eq!(summary.unwrap(), "trimmed summary"); + assert_eq!( + events, + [token("trimmed summary")], + "a rejection streams nothing to discard", + ); let requests = server.received_requests().await.unwrap(); assert_eq!(requests.len(), 2); // Freeing the 5,000-token overflow plus the summary reserve takes the first five messages. @@ -791,6 +880,7 @@ mod tests { ) .await; let client = test_client(server.uri(), api_key(), "claude-haiku-5-5"); + let sink = CapturingSink::new(); let mut spent = Vec::new(); let err = compact_session( @@ -799,6 +889,7 @@ mod tests { &[], &overflowing_transcript(), None, + &sink, &mut spent, ) .await @@ -823,6 +914,11 @@ mod tests { [TokenUsage::new(100, 7); 3], "every request is billed" ); + assert_eq!( + sink.events(), + vec![vec![token("partial"), Event::SummaryDiscarded]; 3].concat(), + "each retry and the failure discard what streamed", + ); } #[tokio::test] @@ -873,10 +969,10 @@ mod tests { mount_summary(&server, streamed_summary_body(" \n ")).await; let client = test_client(server.uri(), api_key(), "claude-haiku-5-5"); - let err = summarize(&client, &fake_transcript()) - .await - .expect_err("empty summary must error"); + let (summary, events) = summarize_streaming(&client, &fake_transcript()).await; + let err = summary.expect_err("empty summary must error"); assert!(format!("{err:#}").contains("empty summary")); + assert_eq!(events, [token(" \n "), Event::SummaryDiscarded]); } #[tokio::test] @@ -896,6 +992,7 @@ mod tests { ) .await; let client = test_client(server.uri(), api_key(), "claude-haiku-5-5"); + let sink = CapturingSink::new(); let mut spent = Vec::new(); let err = compact_session( @@ -904,6 +1001,7 @@ mod tests { &[], &fake_transcript(), None, + &sink, &mut spent, ) .await @@ -914,6 +1012,92 @@ mod tests { "{stop_reason}: {err:#}" ); assert_eq!(spent, [TokenUsage::new(100, 7)], "{stop_reason}"); + assert_eq!( + sink.events(), + [token("partial"), Event::SummaryDiscarded], + "{stop_reason}", + ); + } + } + + #[tokio::test] + async fn compact_session_dropped_mid_summary_discards_what_streamed() { + let (tx, rx) = mpsc::channel(1); + tx.send(Ok(ResponseEvent::Text("partial".to_owned()))) + .await + .unwrap(); + let client = HeldStreamClient(Mutex::new(Some(rx))); + let (prompt, transcript) = (parent_prompt(), fake_transcript()); + let sink = CapturingSink::new(); + let mut spent = Vec::new(); + + { + let mut compaction = pin!(compact_session( + &client, + &prompt, + &[], + &transcript, + None, + &sink, + &mut spent, + )); + assert!(futures::poll!(compaction.as_mut()).is_pending()); + assert_eq!(sink.events(), [token("partial")]); + } + + assert_eq!(sink.events(), [token("partial"), Event::SummaryDiscarded]); + } + + #[tokio::test] + async fn compact_session_passes_retry_notices_through() { + let notice = "HTTP 529 overloaded_error. Retrying in 1s (retry 1 of 5)."; + let (tx, rx) = mpsc::channel(3); + for event in [ + ResponseEvent::Retrying(notice.to_owned()), + ResponseEvent::Text("Recap".to_owned()), + ResponseEvent::Completed(Response { + content: vec![ContentBlock::Text { + text: "Recap".to_owned(), + }], + ..Response::default() + }), + ] { + tx.send(Ok(event)).await.unwrap(); + } + let client = HeldStreamClient(Mutex::new(Some(rx))); + let sink = CapturingSink::new(); + + let summary = compact_session( + &client, + &parent_prompt(), + &[], + &fake_transcript(), + None, + &sink, + &mut Vec::new(), + ) + .await + .unwrap(); + + assert_eq!(summary, "Recap"); + assert_eq!( + sink.events(), + [Event::Retrying(notice.to_owned()), token("Recap")], + ); + } + + /// Replies with a stream the test feeds, which stays open while the test holds its sender. + struct HeldStreamClient(Mutex>>>); + + impl Wire for HeldStreamClient { + fn stream_message( + &self, + _messages: &[Message], + _system_sections: &[&str], + _user_context: Option<&str>, + _tools: &[ToolDefinition], + ) -> Result>> { + Ok(self.0.lock().unwrap().take().expect("one request")) } } diff --git a/crates/oxide-code/src/driver/stdio.rs b/crates/oxide-code/src/driver/stdio.rs index 4c5c11d8..42834eb9 100644 --- a/crates/oxide-code/src/driver/stdio.rs +++ b/crates/oxide-code/src/driver/stdio.rs @@ -156,6 +156,8 @@ impl StdioSink { Event::PromptDrained(_) | Event::UsageUpdated(_) | Event::AutoCompactionStarted + | Event::SummaryToken(_) + | Event::SummaryDiscarded | Event::SessionTitleUpdated { .. } | Event::SessionRolled { .. } | Event::SessionResumed { .. } @@ -474,6 +476,8 @@ mod tests { for event in [ Event::PromptDrained("queued".to_owned()), Event::AutoCompactionStarted, + Event::SummaryToken("Recap".to_owned()), + Event::SummaryDiscarded, Event::SessionTitleUpdated { session_id: "sid".to_owned(), title: "New title".to_owned(), diff --git a/crates/oxide-code/src/engine.rs b/crates/oxide-code/src/engine.rs index 32e45a8a..92ce86f8 100644 --- a/crates/oxide-code/src/engine.rs +++ b/crates/oxide-code/src/engine.rs @@ -361,6 +361,7 @@ impl Engine { &tools, &self.messages, instructions.as_deref(), + sink, &mut spent, ), ops, @@ -803,7 +804,7 @@ fn format_drift_warning(drifted: &[PathBuf]) -> String { mod tests { use std::time::Duration; - use indoc::indoc; + use indoc::{formatdoc, indoc}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use wiremock::matchers::{body_partial_json, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -814,7 +815,8 @@ mod tests { }; use super::*; use crate::client::anthropic::testing::{ - api_key, completion_body, sse_response, test_client, test_config, text_stream_body, + api_key, completion_body, sse_body, sse_response, test_client, test_config, + text_stream_body, }; use crate::config::{AutoCompactionConfig, CompactionConfig, PromptCacheTtl}; use crate::file_tracker::testing::seed_full_read; @@ -1929,6 +1931,7 @@ mod tests { assert!(engine.prompt_unrecorded); let events = sink.events(); let [ + Event::SummaryToken(streamed), Event::SessionCompacted { automatic: false, instructions: Some(focus), @@ -1937,8 +1940,9 @@ mod tests { Event::UsageUpdated(usage), ] = events.as_slice() else { - panic!("expected the compaction, then its cost: {events:?}"); + panic!("expected the streamed summary, the compaction, then its cost: {events:?}"); }; + assert_eq!(streamed, "manual summary"); assert_eq!(focus, "focus"); assert_eq!(usage.context_tokens, None); assert!((usage.estimated_cost_usd.unwrap() - (1.23 + 0.006)).abs() < 1e-12); @@ -1983,7 +1987,9 @@ mod tests { [ Event::TurnFailed(short), Event::Cancelled, + Event::SummaryToken(_), Event::Error(usage_write), + Event::SummaryDiscarded, Event::Error(boundary_write), Event::UsageUpdated(UsageSnapshot { context_tokens: None, @@ -2003,6 +2009,73 @@ mod tests { .await; } + #[tokio::test] + async fn compact_cancelled_mid_summary_discards_it_before_the_cancel() { + with_pinned_home(async { + let base_url = serve_stalled_summary("partial").await; + let dir = tempfile::tempdir().unwrap(); + let mut engine = engine(base_url, &test_store(dir.path()), dir.path()).await; + engine.messages = transcript(); + let sink = CapturingSink::new(); + let (ops_tx, mut ops) = mpsc::channel(1); + let interrupt_once_streaming = async { + while sink.events().is_empty() { + tokio::task::yield_now().await; + } + ops_tx.send(Op::Interrupt).await.unwrap(); + }; + + let (end, ()) = tokio::join!( + engine.compact(&sink, &mut ops, None), + interrupt_once_streaming, + ); + + assert_eq!(end, TurnEnd::Cancelled); + assert_eq!(engine.messages.len(), 4); + let events = sink.events(); + assert!( + matches!( + events.as_slice(), + [ + Event::SummaryToken(partial), + Event::SummaryDiscarded, + Event::UsageUpdated(_), + Event::Cancelled, + ] if partial == "partial" + ), + "{events:?}", + ); + }) + .await; + } + + /// Serves one summary that streams `text` and then stalls with the connection open. Returns + /// the base URL. + async fn serve_stalled_summary(text: &str) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let body = sse_body(&[ + serde_json::json!({"type": "message_start", "message": {"id": "msg_1", "model": MODEL, "usage": {"input_tokens": 1_000, "output_tokens": 0}}}), + serde_json::json!({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}), + serde_json::json!({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text}}), + ]); + let response = formatdoc! {" + HTTP/1.1 200 OK\r + content-type: text/event-stream\r + content-length: {declared}\r + \r + {body}", + declared = body.len() + 1, + }; + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + _ = socket.read(&mut [0; 1024]).await.unwrap(); + socket.write_all(response.as_bytes()).await.unwrap(); + std::future::pending::<()>().await; + }); + base_url + } + #[tokio::test] async fn compact_failure_bills_and_shows_the_summary_cost() { with_pinned_home(async { @@ -2034,9 +2107,18 @@ mod tests { engine.total_estimated_cost_usd, ); let events = sink.events(); - let [Event::UsageUpdated(usage), Event::TurnFailed(msg)] = events.as_slice() else { - panic!("expected the cost update before the failure: {events:?}"); + let [ + Event::SummaryToken(partial), + Event::SummaryDiscarded, + Event::UsageUpdated(usage), + Event::TurnFailed(msg), + ] = events.as_slice() + else { + panic!( + "expected the discarded summary and its cost before the failure: {events:?}" + ); }; + assert_eq!(partial, "partial"); assert_eq!(usage.context_tokens, Some(50_000)); assert!((usage.estimated_cost_usd.unwrap() - expected).abs() < 1e-12); assert!( diff --git a/crates/oxide-code/src/protocol.rs b/crates/oxide-code/src/protocol.rs index 6d4cd434..bb496279 100644 --- a/crates/oxide-code/src/protocol.rs +++ b/crates/oxide-code/src/protocol.rs @@ -135,6 +135,12 @@ pub(crate) enum Event { /// Automatic compaction started before the submitted prompt runs. The frontend shows compacting /// status while the summarizer request streams. AutoCompactionStarted, + /// Compaction summary chunk to append to the in-flight summary. [`Self::SessionCompacted`] or + /// [`Self::SummaryDiscarded`] ends that summary. + SummaryToken(String), + /// The summary streamed so far was dropped, because a retry starts it over or the compaction + /// failed or was cancelled. Precedes the event that reports the failure or cancellation. + SummaryDiscarded, /// The session title changed, from the background generator or an [`Op::Rename`]. SessionTitleUpdated { session_id: String, title: String }, /// An [`Op::Clear`] or [`Op::Archive`] rolled the session, making a new session UUID active. @@ -509,6 +515,8 @@ mod tests { Event::UsageUpdated(_) => "usage_updated", Event::Cancelled => "cancelled", Event::AutoCompactionStarted => "auto_compaction_started", + Event::SummaryToken(_) => "summary_token", + Event::SummaryDiscarded => "summary_discarded", Event::SessionTitleUpdated { .. } => "session_title_updated", Event::SessionRolled { .. } => "session_rolled", Event::SessionResumed { .. } => "session_resumed", @@ -548,6 +556,8 @@ mod tests { }), Event::Cancelled, Event::AutoCompactionStarted, + Event::SummaryToken("Recap".to_owned()), + Event::SummaryDiscarded, Event::SessionTitleUpdated { session_id: "s1".to_owned(), title: "Title".to_owned(), diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index 2e29cc6b..ce501743 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -519,6 +519,8 @@ impl App { self.set_active_status(Status::Compacting); self.input.set_enabled(false); } + Event::SummaryToken(token) => self.chat.append_summary_token(&token), + Event::SummaryDiscarded => self.chat.discard_summary(), Event::SessionTitleUpdated { session_id, title } => { if session_id == self.session_info.session_id { self.status_bar.set_title(Some(title)); @@ -4011,6 +4013,71 @@ mod tests { insta::assert_snapshot!(render_app(&mut app, 60, 8)); } + const COMPACT_SUMMARY: &str = indoc::indoc! {" + ## Recap + + - Fixed the token refresh in the login flow. + - Next: add a regression test. + "}; + + /// An app running `/compact` after a short exchange, with the summary streamed up to its last + /// bullet. + fn app_streaming_a_summary() -> (App, mpsc::Receiver, mpsc::UnboundedSender) { + let (mut app, rx, event_tx) = test_app(None); + app.chat.push_user_message("Fix the login bug.".to_owned()); + app.chat.append_stream_token("Fixed the token refresh."); + app.chat.commit_streaming(); + app.dispatch_action(Action::Submit("/compact".to_owned())); + let streamed = &COMPACT_SUMMARY[..COMPACT_SUMMARY.find("- Next").unwrap()]; + app.handle_engine_event(Event::SummaryToken(streamed.to_owned())); + (app, rx, event_tx) + } + + #[test] + fn draw_frame_compaction_streams_the_summary_below_the_chat() { + let (mut app, _rx, _event_tx) = app_streaming_a_summary(); + insta::assert_snapshot!(render_app(&mut app, 60, 16)); + } + + #[test] + fn draw_frame_compaction_success_leaves_only_the_compacted_summary() { + let (mut app, _rx, _event_tx) = app_streaming_a_summary(); + app.handle_engine_event(Event::SessionCompacted { + summary: COMPACT_SUMMARY.trim().to_owned(), + pre_count: 3, + instructions: None, + automatic: false, + }); + insta::assert_snapshot!(render_app(&mut app, 60, 16)); + } + + #[test] + fn draw_frame_compaction_cancel_drops_the_partial_summary() { + let (mut app, _rx, _event_tx) = app_streaming_a_summary(); + app.handle_engine_event(Event::SummaryDiscarded); + app.handle_engine_event(Event::Cancelled); + insta::assert_snapshot!(render_app(&mut app, 60, 16)); + } + + #[test] + fn draw_frame_compaction_failure_drops_the_partial_summary_and_shows_the_error() { + let (mut app, _rx, _event_tx) = app_streaming_a_summary(); + app.handle_engine_event(Event::SummaryDiscarded); + app.handle_engine_event(Event::TurnFailed( + "Compaction failed: the summary hit the max_tokens output limit before it finished" + .to_owned(), + )); + + let text = rendered_text(&mut app, 60, 16); + assert!( + text.contains("Compaction failed: the summary hit the max_tokens"), + "{text}" + ); + assert!(!text.contains("Summarizing"), "{text}"); + assert!(!text.contains("Recap"), "{text}"); + assert!(text.contains("Fixed the token refresh."), "{text}"); + } + #[test] fn draw_frame_auto_scroll_on_hides_jump_overlay() { let (mut app, _rx, _event_tx) = test_app(None); diff --git a/crates/oxide-code/src/tui/components/chat.rs b/crates/oxide-code/src/tui/components/chat.rs index 2d152ad0..88d087e8 100644 --- a/crates/oxide-code/src/tui/components/chat.rs +++ b/crates/oxide-code/src/tui/components/chat.rs @@ -31,6 +31,8 @@ pub(crate) struct ChatView { streaming: Option, thinking_buffer: String, + /// The compaction summary streaming in, which shows below the chat until compaction ends. + summary_buffer: String, /// Top row of the last paint. scroll_offset: usize, @@ -64,10 +66,11 @@ impl RenderSettings { } } -/// Thinking and streaming-tail lines, rendered every frame since their buffers keep growing. +/// Thinking, streaming-tail, and summary rows, rendered every frame as their buffers grow. struct LiveLines { thinking: Vec>, streaming_tail: Vec>, + summary: Vec>, } /// Where a frame paints the stack, and the rows that must be rendered at the current width and @@ -92,6 +95,7 @@ impl ChatView { blocks: Vec::new(), streaming: None, thinking_buffer: String::new(), + summary_buffer: String::new(), scroll_offset: 0, pending_scroll: 0, viewport_height: 0, @@ -166,6 +170,22 @@ impl ChatView { self.thinking_buffer.push_str(token); } + /// Appends a token to the compaction summary in flight. + pub(crate) fn append_summary_token(&mut self, token: &str) { + if self.summary_buffer.is_empty() { + self.bump_paused_counter(); + } + self.summary_buffer.push_str(token); + } + + /// Drops the compaction summary in flight, which then no longer counts as new content. + pub(crate) fn discard_summary(&mut self) { + if !self.summary_buffer.is_empty() && !self.auto_scroll { + self.new_content_since_pause = self.new_content_since_pause.saturating_sub(1); + } + self.summary_buffer.clear(); + } + /// Finalizes the streaming buffer into a committed block. /// /// Pending thinking is flushed first so a turn that produced only thinking still leaves an @@ -272,6 +292,7 @@ impl ChatView { self.blocks.clear(); self.streaming = None; self.thinking_buffer.clear(); + self.summary_buffer.clear(); self.scroll_offset = 0; self.pending_scroll = 0; self.auto_scroll = true; @@ -495,9 +516,15 @@ impl ChatView { let streaming_tail = self.streaming.as_ref().map_or_else(Vec::new, |s| { s.render_tail(&ctx, self.streaming_continues_turn()) }); + let summary = if self.summary_buffer.is_empty() { + Vec::new() + } else { + CompactedBlock::render_streaming(&self.summary_buffer, &ctx) + }; LiveLines { thinking, streaming_tail, + summary, } } @@ -508,12 +535,7 @@ impl ChatView { stack.push_block(cached); } - if !live.thinking.is_empty() { - if stack.last_has_width() { - stack.push_blank(); - } - stack.push(&live.thinking); - } + stack.push_separated(&live.thinking); if let Some(streaming) = &self.streaming { if !self.streaming_continues_turn() && stack.last_has_width() { @@ -523,6 +545,7 @@ impl ChatView { stack.push(&live.streaming_tail); } + stack.push_separated(&live.summary); stack } @@ -540,9 +563,12 @@ impl ChatView { .is_some_and(|b| b.block().continues_assistant_turn()) } - /// True when the chat has no content for the user to see (committed, streaming, or thinking). + /// True when the chat shows nothing: no committed block, reply, thinking, or summary. pub(crate) fn is_empty(&self) -> bool { - self.blocks.is_empty() && self.streaming.is_none() && self.thinking_buffer.is_empty() + self.blocks.is_empty() + && self.streaming.is_none() + && self.thinking_buffer.is_empty() + && self.summary_buffer.is_empty() } } @@ -610,6 +636,14 @@ mod tests { stack.slice(0, stack.height()) } + /// Stacks every row at `width` as plain text. + fn all_rows(chat: &mut ChatView, width: u16) -> Vec { + all_lines(chat, width) + .iter() + .map(ToString::to_string) + .collect() + } + /// Stacks every row at width 80 and joins them for substring assertions. fn all_text(chat: &mut ChatView) -> String { all_lines(chat, 80) @@ -1279,6 +1313,75 @@ mod tests { ); } + // ── append_summary_token ── + + #[test] + fn append_summary_token_streams_the_summary_a_blank_row_below_the_chat() { + let mut chat = test_chat(); + chat.push_tool_call(call("$", "ls")); + let chat_rows = all_rows(&mut chat, 80); + chat.auto_scroll = false; + + chat.append_summary_token("Fixed "); + chat.append_summary_token("the bug."); + + let summary: Vec = + CompactedBlock::render_streaming("Fixed the bug.", &chat.settings.ctx(80)) + .iter() + .map(ToString::to_string) + .collect(); + assert_eq!( + all_rows(&mut chat, 80), + [chat_rows, vec![String::new()], summary].concat(), + ); + assert_eq!( + chat.new_content_since_pause(), + 1, + "the summary counts as one new block while paused", + ); + } + + // ── discard_summary ── + + #[test] + fn discard_summary_drops_the_streamed_summary_so_a_retry_starts_over() { + let mut chat = test_chat(); + chat.push_tool_call(call("$", "ls")); + let chat_rows = all_rows(&mut chat, 80); + chat.append_summary_token("First attempt."); + + chat.discard_summary(); + assert_eq!(all_rows(&mut chat, 80), chat_rows); + + chat.append_summary_token("Second attempt."); + let text = all_text(&mut chat); + assert!( + text.contains("Second attempt.") && !text.contains("First attempt."), + "{text}", + ); + } + + #[test] + fn discard_summary_stops_counting_the_summary_as_new_content_while_paused() { + let mut chat = test_chat(); + chat.auto_scroll = false; + chat.push_tool_call(call("$", "ls")); + chat.append_summary_token("First attempt."); + assert_eq!(chat.new_content_since_pause(), 2); + + chat.discard_summary(); + assert_eq!(chat.new_content_since_pause(), 1); + chat.discard_summary(); + assert_eq!( + chat.new_content_since_pause(), + 1, + "a second discard has no summary left to stop counting", + ); + + chat.append_summary_token("Second attempt."); + assert_eq!(chat.new_content_since_pause(), 2); + } + // ── commit_streaming ── #[test] @@ -1940,6 +2043,7 @@ mod tests { chat.push_tool_call(call("$", "ls")); chat.append_stream_token("partial reply"); chat.append_thinking_token("considering"); + chat.append_summary_token("recap"); render_chat(&mut chat, 80, 24); chat.scroll_offset = 25; chat.pending_scroll = -3; @@ -1951,6 +2055,7 @@ mod tests { assert_eq!(chat.pending_call_count(), 0); assert!(chat.streaming.is_none()); assert_eq!(chat.thinking_buffer, ""); + assert_eq!(chat.summary_buffer, ""); assert_eq!((chat.scroll_offset, chat.pending_scroll), (0, 0)); assert_eq!(chat.painted, SlotTops::default()); assert!(chat.auto_scroll); @@ -2811,6 +2916,13 @@ mod tests { assert!(!chat.is_empty()); } + #[test] + fn is_empty_false_with_a_streaming_summary() { + let mut chat = test_chat(); + chat.append_summary_token("recap"); + assert!(!chat.is_empty()); + } + // ── rows_between ── #[test] diff --git a/crates/oxide-code/src/tui/components/chat/blocks/compacted.rs b/crates/oxide-code/src/tui/components/chat/blocks/compacted.rs index f67491da..102dbfd8 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks/compacted.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks/compacted.rs @@ -1,4 +1,4 @@ -//! Post-`/compact` boundary block. +//! Post-`/compact` boundary block, and the summary streaming in before it. use ratatui::style::Modifier; use ratatui::text::{Line, Span}; @@ -9,6 +9,9 @@ use crate::tui::glyphs::TOOL_BORDER_PREFIX; use crate::tui::markdown::render_markdown; use crate::tui::wrap::wrap_line; +/// Header over a summary still streaming in. +const STREAMING_HEADER: &str = "Summarizing the conversation..."; + /// Boundary marker rendered after `/compact`. pub(crate) struct CompactedBlock { header: String, @@ -33,49 +36,58 @@ impl CompactedBlock { summary: summary.into(), } } + + /// Renders the summary of a compaction in flight, laid out like the boundary it becomes. + pub(crate) fn render_streaming(summary: &str, ctx: &RenderCtx<'_>) -> Vec> { + render_boundary(STREAMING_HEADER, summary, ctx) + } } impl ChatBlock for CompactedBlock { fn render(&self, ctx: &RenderCtx<'_>) -> Vec> { - let bar_style = ctx.theme.accent(); - let header_style = bar_style.add_modifier(Modifier::BOLD); - let body_style = ctx.theme.text(); - let width = usize::from(ctx.width); - let bar_width = TOOL_BORDER_PREFIX.width(); - let md_width = width.saturating_sub(bar_width); - let cont_prefix = bar_continuation_prefix(TOOL_BORDER_PREFIX, bar_style); - - let mut out: Vec> = Vec::new(); - - let header_line = Line::from(vec![ - Span::styled(TOOL_BORDER_PREFIX.to_owned(), bar_style), - Span::styled(self.header.clone(), header_style), - ]); - out.extend(wrap_line(header_line, width, bar_width, Some(&cont_prefix))); - - if !self.summary.trim().is_empty() { - // Bar-only gutter row separates header from body, mirroring AssistantThinking's - // layout so the boundary reads as one visual unit. - out.push(Line::from(Span::styled( - TOOL_BORDER_PREFIX.to_owned(), - bar_style, - ))); - let rendered = render_markdown(&self.summary, ctx.theme, md_width); - for line in rendered.lines { - let mut prefixed = prepend_markdown_prefix(line, TOOL_BORDER_PREFIX, bar_style); - // Markdown render may set Line::style for code blocks; preserve that and only - // patch the body spans to the chat text style when no fg is set. - for span in &mut prefixed.spans[1..] { - if span.style.fg.is_none() { - span.style = span.style.patch(body_style); - } + render_boundary(&self.header, &self.summary, ctx) + } +} + +fn render_boundary(header: &str, summary: &str, ctx: &RenderCtx<'_>) -> Vec> { + let bar_style = ctx.theme.accent(); + let header_style = bar_style.add_modifier(Modifier::BOLD); + let body_style = ctx.theme.text(); + let width = usize::from(ctx.width); + let bar_width = TOOL_BORDER_PREFIX.width(); + let md_width = width.saturating_sub(bar_width); + let cont_prefix = bar_continuation_prefix(TOOL_BORDER_PREFIX, bar_style); + + let mut out: Vec> = Vec::new(); + + let header_line = Line::from(vec![ + Span::styled(TOOL_BORDER_PREFIX.to_owned(), bar_style), + Span::styled(header.to_owned(), header_style), + ]); + out.extend(wrap_line(header_line, width, bar_width, Some(&cont_prefix))); + + if !summary.trim().is_empty() { + // Bar-only gutter row separates header from body, mirroring AssistantThinking's + // layout so the boundary reads as one visual unit. + out.push(Line::from(Span::styled( + TOOL_BORDER_PREFIX.to_owned(), + bar_style, + ))); + let rendered = render_markdown(summary, ctx.theme, md_width); + for line in rendered.lines { + let mut prefixed = prepend_markdown_prefix(line, TOOL_BORDER_PREFIX, bar_style); + // Markdown render may set Line::style for code blocks; preserve that and only + // patch the body spans to the chat text style when no fg is set. + for span in &mut prefixed.spans[1..] { + if span.style.fg.is_none() { + span.style = span.style.patch(body_style); } - out.push(prefixed); } + out.push(prefixed); } - - out } + + out } #[cfg(test)] diff --git a/crates/oxide-code/src/tui/components/chat/stack.rs b/crates/oxide-code/src/tui/components/chat/stack.rs index 90e07b06..59201c6f 100644 --- a/crates/oxide-code/src/tui/components/chat/stack.rs +++ b/crates/oxide-code/src/tui/components/chat/stack.rs @@ -183,6 +183,17 @@ impl<'a> Stack<'a> { self.push(&BLANK); } + /// Stacks live rows a blank row below any content above them. + pub(super) fn push_separated(&mut self, lines: &'a [Line<'static>]) { + if lines.is_empty() { + return; + } + if self.last_has_width() { + self.push_blank(); + } + self.push(lines); + } + /// Whether the bottom row has visible width, so the next block needs a separator. pub(super) fn last_has_width(&self) -> bool { self.runs.last().is_some_and(|run| last_has_width(run)) diff --git a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_compaction_cancel_drops_the_partial_summary.snap b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_compaction_cancel_drops_the_partial_summary.snap new file mode 100644 index 00000000..1ee6bd4e --- /dev/null +++ b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_compaction_cancel_drops_the_partial_summary.snap @@ -0,0 +1,20 @@ +--- +source: crates/oxide-code/src/tui/app.rs +expression: "render_app(&mut app, 60, 16)" +--- +" ~/test │ main │ test-model (high) │ Ready " +"────────────────────────────────────────────────────────────" +"❯ Fix the login bug. " +" " +"◉ Fixed the token refresh. " +" " +"❯ /compact " +" " +"(interrupted) " +" " +" " +" " +" " +"────────────────────────────────────────────────────────────" +"❯ " +"────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_compaction_streams_the_summary_below_the_chat.snap b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_compaction_streams_the_summary_below_the_chat.snap new file mode 100644 index 00000000..ca10ccd1 --- /dev/null +++ b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_compaction_streams_the_summary_below_the_chat.snap @@ -0,0 +1,20 @@ +--- +source: crates/oxide-code/src/tui/app.rs +expression: "render_app(&mut app, 60, 16)" +--- +" test-model (high) │ ⣷ Compacting · Esc to interrupt " +"────────────────────────────────────────────────────────────" +"❯ Fix the login bug. " +" " +"◉ Fixed the token refresh. " +" " +"❯ /compact " +" " +"▎ Summarizing the conversation... " +"▎ " +"▎ ## Recap " +"▎ " +"▎ - Fixed the token refresh in the login flow. " +"────────────────────────────────────────────────────────────" +"❯ " +"────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_compaction_success_leaves_only_the_compacted_summary.snap b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_compaction_success_leaves_only_the_compacted_summary.snap new file mode 100644 index 00000000..cb97a6a8 --- /dev/null +++ b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_compaction_success_leaves_only_the_compacted_summary.snap @@ -0,0 +1,20 @@ +--- +source: crates/oxide-code/src/tui/app.rs +expression: "render_app(&mut app, 60, 16)" +--- +" ~/test │ main │ test-model (high) │ Ready " +"────────────────────────────────────────────────────────────" +"▎ Compacted 3 messages → 1 summary. " +"▎ " +"▎ ## Recap " +"▎ " +"▎ - Fixed the token refresh in the login flow. " +"▎ - Next: add a regression test. " +" " +" " +" " +" " +" " +"────────────────────────────────────────────────────────────" +"❯ " +"────────────────────────────────────────────────────────────" diff --git a/docs/design/agent/auto-compaction.md b/docs/design/agent/auto-compaction.md index 3fc1c6fb..699bd37e 100644 --- a/docs/design/agent/auto-compaction.md +++ b/docs/design/agent/auto-compaction.md @@ -64,9 +64,9 @@ Explicit thresholds snap into a usable range. Token values below `50_000` rise t 2. `Engine` stores that usage as the pending automatic trigger signal. 3. When the next prompt arrives, `Engine::auto_compact` checks the stored usage before `submit_prompt` records the prompt. 4. If the total crosses the threshold, it calls the same compact driver used by `/compact`. -5. `auto_compact_if_needed` emits `AutoCompactionStarted` so the TUI can show compaction status while the summarizer runs. +5. `auto_compact_if_needed` emits `AutoCompactionStarted` so the TUI can show compaction status while the summarizer runs, and the summary streams into the chat as it does for `/compact`. 6. On success, `compact_boundary` persists the compact boundary, clears the file tracker, replaces `messages` with the synthetic post-compact message, and emits `SessionCompacted`. -7. On failure, `auto_compact_if_needed` increments the failure counter and emits an `Error`, and `submit_prompt` records the new prompt against the unchanged transcript. +7. On failure, the streamed summary is discarded, `auto_compact_if_needed` increments the failure counter and emits an `Error`, and `submit_prompt` records the new prompt against the unchanged transcript. The failure counter lives on `Engine`. Three consecutive automatic failures disable further automatic attempts until a successful compaction, a model or effort change, `/clear`, or `/resume` resets the counter. Manual `/compact` does not consult it. diff --git a/docs/design/slash/compact.md b/docs/design/slash/compact.md index 0e108da8..3fef0c91 100644 --- a/docs/design/slash/compact.md +++ b/docs/design/slash/compact.md @@ -10,7 +10,8 @@ Companions: [commands.md](commands.md), [session/persistence.md](../session/pers `agent/compaction` owns the summarization request: -- `compact_session` forks the live conversation. It sends the conversation's own system prompt, user context, tools, and transcript, appends the rubric as the last user message, and reads the reply through the agent loop's `collect_response` with a sink that discards the tokens. +- `compact_session` forks the live conversation. It sends the conversation's own system prompt, user context, tools, and transcript, appends the rubric as the last user message, and reads the reply through the agent loop's `collect_response`. +- Its `SummaryStream` sink forwards the reply's text as `Event::SummaryToken`, passes retry notices through, and drops its thinking. A retry, a failure, or a dropped future emits `Event::SummaryDiscarded` when any text has streamed. - A request the API rejects with HTTP 400 `prompt is too long`, or a reply that stops on `model_context_window_exceeded`, gets up to three retries. Each retry copies the transcript without thinking blocks and drops its oldest API rounds until it frees the overflow the error reports plus about 20k tokens of output room for the summary. - It rejects a reply cut short by `max_tokens`, a refusal, a tool call, a truncated stream, or an overflow that outlasts the retries or leaves only the newest round, and otherwise returns the trimmed summary. - The driver is its own module so the request shape is testable in isolation. @@ -26,11 +27,11 @@ Companions: [commands.md](commands.md), [session/persistence.md](../session/pers `Engine::compact` in `engine.rs` coordinates compaction: - On success, it hands the summary to `agent/compact_boundary`'s `replace_session_with_summary`, which calls `session.compact(...)`, clears the file tracker, replaces the in-memory `Vec` with the synthetic continuation, and emits `Event::SessionCompacted { summary, pre_count, instructions, automatic }` with `automatic: false`. -- Failure paths (stream error, unfinished or empty summary, too-few-messages guard) emit `Event::TurnFailed` and leave the transcript unchanged. -- Cancellation routes through the existing cancel infrastructure and emits `Event::Cancelled` like a regular turn, and a quit ends the compaction without a terminal event. +- Failure paths (stream error, unfinished or empty summary, too-few-messages guard) emit `Event::TurnFailed` and leave the transcript unchanged. A summary whose boundary write fails is discarded before the failure is reported. +- Cancellation routes through the existing cancel infrastructure and emits `Event::Cancelled` like a regular turn, and a quit ends the compaction without a terminal event. Dropping the summarization future discards the streamed summary first. - Each summary request is recorded as a `usage` entry, and its estimated cost joins the session total whether the summary succeeds, fails, or is cancelled. An `Event::UsageUpdated` then shows the new total, after `SessionCompacted` on success and before the terminal event otherwise. -The TUI's `App::apply_session_compacted` clears the chat, pushes a single `CompactedBlock` with a count header and markdown summary body, keeps queued prompts for the normal idle drain, clears the modal stack, and resumes idle after a manual compact. Resume renders the same boundary block and skips the synthetic root in visible chat. +While the summary streams, the TUI shows it below the chat under a `Summarizing the conversation...` header, laid out like the `CompactedBlock` it becomes, and `Event::SummaryDiscarded` removes it. `App::apply_session_compacted` clears the chat, pushes a single `CompactedBlock` with a count header and markdown summary body, keeps queued prompts for the normal idle drain, clears the modal stack, and resumes idle after a manual compact. Resume renders the same boundary block and skips the synthetic root in visible chat. ## Design Decisions @@ -61,7 +62,7 @@ The TUI's `App::apply_session_compacted` clears the chat, pushes a single `Compa 11. **Keep queued prompts.** Unlike `/resume` (queued prompts belong to the source thread and get dropped), `/compact` _preserves_ the user's intent. Queued prompts continue to make sense after compaction, and dropping them would surprise the user. `/clear` also keeps queued prompts, though it starts a new session. -12. **Render the summary once it completes.** The spinner runs during compaction, then `Event::SessionCompacted` arrives once with the full summary. Streaming the summary is tracked in [#186](https://github.com/hakula139/oxide-code/issues/186). +12. **Stream the summary as it generates.** A long session's summary can take tens of seconds, and streaming it shows progress and lets the user judge it early. The engine streams the text and decides its fate, so every frontend sees one protocol: `SummaryToken` chunks end in `SessionCompacted` or `SummaryDiscarded`. A discard precedes the event that reports a failure or cancellation, and a retry discards before streaming again, so a frontend never shows two attempts at once. The stdio frontends skip the stream and keep their one-line boundary notice. 13. **Refuse mid-turn (`classify = Mutating`).** All three reference CLIs treat manual compaction as a between-turns operation. `Mutating` is the existing oxide-code lever for that. The slash registry refuses if a turn is in flight, and the user retries after the in-flight reply finishes. @@ -83,9 +84,11 @@ The TUI's `App::apply_session_compacted` clears the chat, pushes a single `Compa - **`Op::Compact { instructions }`**: `Option`. Empty or whitespace input becomes `None` at the slash boundary so `Engine::compact` doesn't repeat the trim. +- **`Event::SummaryToken(text)`**, **`Event::SummaryDiscarded`**: The summary in flight and its discard. Manual and automatic compaction emit both, and the TUI renders the stream below the chat with `CompactedBlock::render_streaming`. + - **`Event::SessionCompacted { summary, pre_count, instructions, automatic }`**: Emitted post-success. `summary` is the rendered body, `pre_count` is for the count header, `instructions` becomes the header's `(focus: ...)` suffix, and `automatic` is false for `/compact` and true for auto-compaction. The TUI repaints from it, and `StdioSink` prints a `Compacted N messages into summary` line to stderr (`Auto-compacted` when automatic). -- **`agent::compaction::compact_session`**: Async fn taking the client, the conversation's `PromptParts` and tool definitions, the transcript, optional instructions, and a `&mut Vec` that receives the usage of every request it sends, even when the summary fails or the caller drops the future. Returns the trimmed summary or an error. +- **`agent::compaction::compact_session`**: Async fn taking the client, the conversation's `PromptParts` and tool definitions, the transcript, optional instructions, the event sink the summary streams to, and a `&mut Vec` that receives the usage of every request it sends, even when the summary fails or the caller drops the future. Returns the trimmed summary or an error. - **`SUMMARIZATION_USER_RUBRIC`**, **`SUMMARY_PREFIX`**: Two `&'static str` constants. The rubric is the terse list (intent, decisions, code paths touched, current state, next step) and ends by asking for text only. The prefix is the next-turn framing prepended to the synthetic message. @@ -97,7 +100,7 @@ The TUI's `App::apply_session_compacted` clears the chat, pushes a single `Compa - **`session::handle::compact`**: Async API. Sends `SessionCmd::Compact`, awaits the ack, and returns `CompactOutcome { pre_count, failure }`. Like `archive`, it reports a failure whenever the command or its ack cannot reach the writer task. The caller clears the file tracker only after the boundary flush succeeds. -- **`CompactedBlock`**: Chat block with an accent-bar surface, a `Compacted N messages → 1 summary.` header (with a `(focus: ...)` suffix when instructions were given), and the rendered summary markdown body. No footer. Reuses the existing markdown renderer. +- **`CompactedBlock`**: Chat block with an accent-bar surface, a `Compacted N messages → 1 summary.` header (with a `(focus: ...)` suffix when instructions were given), and the rendered summary markdown body. No footer. Reuses the existing markdown renderer. `CompactedBlock::render_streaming` renders the summary in flight under a `Summarizing the conversation...` header. - **`apply_session_compacted` (TUI)**: Clears the chat, pushes a single `CompactedBlock`, keeps queued prompts for the normal idle drain, clears the modal stack, and resumes idle unless the compaction was automatic. @@ -119,11 +122,11 @@ A `pre_compact` hook is tracked in [#168](https://github.com/hakula139/oxide-cod - `crates/oxide-code/src/agent.rs`: `await_unless_aborted` races the summarization against cancel and quit, and `collect_response` reads its reply the way it reads a turn's. - `crates/oxide-code/src/agent/compact_boundary.rs`: `replace_session_with_summary`, shared by manual and automatic compaction. -- `crates/oxide-code/src/agent/compaction.rs`: `compact_session` driver, the context-window retries (`drop_oldest_rounds`, `retry_request`), and the `SUMMARIZATION_USER_RUBRIC` / `SUMMARY_PREFIX` constants. +- `crates/oxide-code/src/agent/compaction.rs`: `compact_session` driver, the `SummaryStream` sink, the context-window retries (`drop_oldest_rounds`, `retry_request`), and the `SUMMARIZATION_USER_RUBRIC` / `SUMMARY_PREFIX` constants. - `crates/oxide-code/src/client.rs`: `PromptTooLong`, the wire-neutral rejection that carries the reported overflow. - `crates/oxide-code/src/client/anthropic/transport.rs`: `prompt_too_long_message`, which types an HTTP 400 `prompt is too long` as `PromptTooLong`. - `crates/oxide-code/src/engine.rs`: `Engine::compact` beside `Engine::resume`, and session-cost accounting. -- `crates/oxide-code/src/protocol.rs`: `Op::Compact`, `Event::SessionCompacted`. +- `crates/oxide-code/src/protocol.rs`: `Op::Compact`, `Event::SummaryToken`, `Event::SummaryDiscarded`, `Event::SessionCompacted`. - `crates/oxide-code/src/session/actor.rs`: `SessionCmd::Compact` handler. - `crates/oxide-code/src/session/entry.rs`: `Entry::Compact` variant. - `crates/oxide-code/src/session/handle.rs`: `SessionHandle::compact`, `CompactOutcome`. @@ -132,4 +135,5 @@ A `pre_compact` hook is tracked in [#168](https://github.com/hakula139/oxide-cod - `crates/oxide-code/src/slash/compact.rs`: `CompactCmd`. - `crates/oxide-code/src/slash/registry.rs`: `BUILT_INS` entry `&CompactCmd`. - `crates/oxide-code/src/tui/app.rs`: `apply_session_compacted`. +- `crates/oxide-code/src/tui/components/chat.rs`: `ChatView::append_summary_token` and `ChatView::discard_summary`. - `crates/oxide-code/src/tui/components/chat/blocks/compacted.rs`: `CompactedBlock`. diff --git a/docs/design/tui/welcome.md b/docs/design/tui/welcome.md index a3ddad62..2a4ad72e 100644 --- a/docs/design/tui/welcome.md +++ b/docs/design/tui/welcome.md @@ -14,7 +14,7 @@ The welcome answers three questions: `tui/components/welcome` is a stateless renderer (`paint`) plus a small data snapshot (`WelcomeSnapshot`) derived from `&LiveSessionInfo`. Painted by `App::draw_frame` into the chat region when: -- `chat.is_empty()` (no blocks, no streaming, no thinking buffer), AND +- `chat.is_empty()` (no blocks, no streaming, no thinking buffer, no compaction summary), AND - `session_info.config.show_welcome` resolves to true (default: true). `ChatView` keeps its own empty-state branch, which returns an empty `Text`, and `App` paints the welcome into that space. The "is the chat empty?" predicate stays where the data lives.