# PR #1529: tui supporting /compact operation - URL: https://github.com/openai/codex/pull/1529 - Author: aibrahim-oai - Created: 2025-07-11 04:01:08 UTC - Updated: 2025-07-30 23:35:05 UTC - Changes: +228/-14, Files changed: 9, Commits: 12 ## Description - Supporting compact by sending `Op` for getting summary. - Using the summary to start new Chat with the summary as initial prompt. - Building on this [PR](https://github.com/openai/codex/pull/1527) https://github.com/user-attachments/assets/d8e36e41-b0d5-453f-864b-551314669b22 ## Full Diff ```diff diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6823a83a50..4adbdc6388 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9,6 +9,8 @@ use crate::slash_command::SlashCommand; use crate::tui; use codex_core::config::Config; use codex_core::protocol::Event; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -53,15 +55,9 @@ pub(crate) struct App<'a> { /// Stored parameters needed to instantiate the ChatWidget later, e.g., /// after dismissing the Git-repo warning. chat_args: Option, -} -/// Aggregate parameters needed to create a `ChatWidget`, as creation may be -/// deferred until after the Git warning screen is dismissed. -#[derive(Clone)] -struct ChatWidgetArgs { - config: Config, - initial_prompt: Option, - initial_images: Vec, + /// Tracks pending summarization requests for the compact feature. + pending_summarization: Option, } impl App<'_> { @@ -153,6 +149,7 @@ impl App<'_> { file_search, pending_redraw, chat_args, + pending_summarization: None, } } @@ -271,6 +268,18 @@ impl App<'_> { self.app_state = AppState::Chat { widget: new_widget }; self.app_event_tx.send(AppEvent::RequestRedraw); } + SlashCommand::Compact => { + if let AppState::Chat { widget } = &mut self.app_state { + // Submit the summarization request to the current widget + widget.submit_op(Op::SummarizeContext); + + // Set up tracking for the summary response + self.pending_summarization = Some(PendingSummarization { + summary_buffer: String::new(), + started_receiving: false, + }); + } + } SlashCommand::Quit => { break; } @@ -374,9 +383,113 @@ impl App<'_> { } fn dispatch_codex_event(&mut self, event: Event) { + // First check if we're waiting for a summarization response + if self.pending_summarization.is_some() { + self.handle_summarization_response(event); + return; + } + + // Otherwise dispatch to the current app state match &mut self.app_state { AppState::Chat { widget } => widget.handle_codex_event(event), AppState::GitWarning { .. } => {} } } + + /// Handles responses during a summarization request. + fn handle_summarization_response(&mut self, event: Event) { + match &event.msg { + EventMsg::AgentMessage(msg) => { + // Only collect messages once we've started receiving the summarization + if let Some(ref mut pending) = self.pending_summarization { + // Start collecting once we see a message that looks like a summary + if !pending.started_receiving && msg.message.contains("summarize") { + pending.started_receiving = true; + } + + if pending.started_receiving { + pending.summary_buffer.push_str(&msg.message); + pending.summary_buffer.push('\n'); + } + } + } + EventMsg::TaskComplete(_) => { + // Task is complete, now create a new widget with the summary + if let Some(pending) = self.pending_summarization.take() { + let summary = create_compact_summary_prompt(&pending.summary_buffer); + + // Create new widget with summary as initial prompt + let new_widget = Box::new(ChatWidget::new( + self.config.clone(), + self.app_event_tx.clone(), + Some(summary), + Vec::new(), + )); + self.app_state = AppState::Chat { widget: new_widget }; + self.app_event_tx.send(AppEvent::Redraw); + } + } + _ => {} + } + } +} + +/// State for tracking a pending summarization request. +struct PendingSummarization { + /// Buffer to collect the summary response. + summary_buffer: String, + /// Whether we've received the first message of the summarization response. + started_receiving: bool, +} + +/// Aggregate parameters needed to create a `ChatWidget`, as creation may be +/// deferred until after the Git warning screen is dismissed. +#[derive(Clone)] +struct ChatWidgetArgs { + config: Config, + initial_prompt: Option, + initial_images: Vec, +} + +/// Creates the initial prompt for a compacted conversation. +fn create_compact_summary_prompt(summary_text: &str) -> String { + if summary_text.trim().is_empty() { + "Previous conversation has been summarized.".to_string() + } else { + format!( + r#"This chat is a continuation of a previous conversation. After providing the summary, acknowledge that /compact command has been applied. Here is the summary of the previous conversation: + +{}"#, + summary_text.trim() + ) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn test_summary_buffer_accumulation() { + let mut buffer = String::new(); + + // Simulate the way we accumulate messages in pending_summarization + buffer.push_str("First message part"); + buffer.push('\n'); + buffer.push_str("Second message part"); + buffer.push('\n'); + buffer.push_str("Final message part"); + + let prompt = create_compact_summary_prompt(&buffer); + + // Should contain all parts + assert!(prompt.contains("First message part")); + assert!(prompt.contains("Second message part")); + assert!(prompt.contains("Final message part")); + + // Should preserve newlines in the content + let trimmed_buffer = buffer.trim(); + assert!(prompt.contains(trimmed_buffer)); + } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 77a600d304..15dd82584a 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -6,7 +6,7 @@ use ratatui::text::Line; use crate::slash_command::SlashCommand; #[allow(clippy::large_enum_variant)] -pub(crate) enum AppEvent { +pub enum AppEvent { CodexEvent(Event), /// Request a redraw which will be debounced by the [`App`]. diff --git a/codex-rs/tui/src/app_event_sender.rs b/codex-rs/tui/src/app_event_sender.rs index 9d838273ef..9752a3c415 100644 --- a/codex-rs/tui/src/app_event_sender.rs +++ b/codex-rs/tui/src/app_event_sender.rs @@ -3,18 +3,18 @@ use std::sync::mpsc::Sender; use crate::app_event::AppEvent; #[derive(Clone, Debug)] -pub(crate) struct AppEventSender { +pub struct AppEventSender { app_event_tx: Sender, } impl AppEventSender { - pub(crate) fn new(app_event_tx: Sender) -> Self { + pub fn new(app_event_tx: Sender) -> Self { Self { app_event_tx } } /// Send an event to the app event channel. If it fails, we swallow the /// error and log it. - pub(crate) fn send(&self, event: AppEvent) { + pub fn send(&self, event: AppEvent) { if let Err(e) = self.app_event_tx.send(event) { tracing::error!("failed to send event: {e}"); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index b15d81f8f5..03d71afe1d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -33,7 +33,7 @@ pub enum InputResult { None, } -pub(crate) struct ChatComposer<'a> { +pub struct ChatComposer<'a> { textarea: TextArea<'a>, active_popup: ActivePopup, app_event_tx: AppEventSender, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 4ec1ba4b3e..fd1a4a2eb4 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -14,7 +14,7 @@ use ratatui::widgets::WidgetRef; mod approval_modal_view; mod bottom_pane_view; -mod chat_composer; +pub mod chat_composer; mod chat_composer_history; mod command_popup; mod file_search_popup; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 603eb721cd..ab224ae054 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -13,6 +13,7 @@ pub enum SlashCommand { // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so // more frequently used commands should be listed first. New, + Compact, Diff, Quit, } @@ -22,6 +23,9 @@ impl SlashCommand { pub fn description(self) -> &'static str { match self { SlashCommand::New => "Start a new chat.", + SlashCommand::Compact => { + "Summarize and compact the current conversation to free up context." + } SlashCommand::Quit => "Exit the application.", SlashCommand::Diff => { "Show git diff of the working directory (including untracked files)" @@ -40,3 +44,58 @@ impl SlashCommand { pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { SlashCommand::iter().map(|c| (c.command(), c)).collect() } + +#[cfg(test)] +mod tests { + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::chat_composer::ChatComposer; + use crossterm::event::KeyCode; + use insta::assert_snapshot; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use std::sync::mpsc; + + #[test] + fn test_slash_commands() { + let (tx, _rx) = mpsc::channel(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new(true, sender); + + let mut terminal = match Terminal::new(TestBackend::new(100, 10)) { + Ok(t) => t, + Err(e) => panic!("Failed to create terminal: {e}"), + }; + + // Initial empty state + if let Err(e) = terminal.draw(|f| f.render_widget_ref(&composer, f.area())) { + panic!("Failed to draw empty composer: {e}"); + } + assert_snapshot!("empty_slash", terminal.backend()); + + // Type slash to show commands + let _ = composer.handle_key_event(crossterm::event::KeyEvent::new( + KeyCode::Char('/'), + crossterm::event::KeyModifiers::empty(), + )); + if let Err(e) = terminal.draw(|f| f.render_widget_ref(&composer, f.area())) { + panic!("Failed to draw slash commands: {e}"); + } + assert_snapshot!("slash_commands", terminal.backend()); + + // Type 'c' to filter to compact + let _ = composer.handle_key_event(crossterm::event::KeyEvent::new( + KeyCode::Char('c'), + crossterm::event::KeyModifiers::empty(), + )); + if let Err(e) = terminal.draw(|f| f.render_widget_ref(&composer, f.area())) { + panic!("Failed to draw filtered commands: {e}"); + } + assert_snapshot!("compact_filtered", terminal.backend()); + + // Select compact command - we don't check the final state since it's handled by the app layer + let _ = composer.handle_key_event(crossterm::event::KeyEvent::new( + KeyCode::Enter, + crossterm::event::KeyModifiers::empty(), + )); + } +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__compact_filtered.snap b/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__compact_filtered.snap new file mode 100644 index 0000000000..44207a832a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__compact_filtered.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/slash_command.rs +expression: terminal.backend() +--- +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" +"│/compact Summarize and compact the current conversation to free up context. │" +"╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" +"│/c │" +"│ │" +"│ │" +"│ │" +"│ │" +"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯" diff --git a/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__empty_slash.snap b/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__empty_slash.snap new file mode 100644 index 0000000000..68af93743f --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__empty_slash.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/slash_command.rs +expression: terminal.backend() +--- +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" +"│ send a message │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯" diff --git a/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__slash_commands.snap b/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__slash_commands.snap new file mode 100644 index 0000000000..257ed9fb46 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__slash_commands.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/slash_command.rs +expression: terminal.backend() +--- +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" +"│/new Start a new chat. │" +"│/compact Summarize and compact the current conversation to free up context. │" +"│/diff Show git diff of the working directory (including untracked files) │" +"│/quit Exit the application. │" +"│/toggle-mouse-mode Toggle mouse mode (enable for scrolling, disable for text selection) │" +"╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" +"│/ │" +"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯" ``` ## Review Comments ### codex-rs/tui/src/app.rs - Created: 2025-07-12 17:09:16 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202825742 ```diff @@ -21,6 +22,22 @@ use std::path::PathBuf; use std::sync::mpsc::Receiver; use std::sync::mpsc::channel; +/// Template for compact summary continuation prompt +const COMPACT_SUMMARY_TEMPLATE: &str = concat!( + "This chat is a continuation of a previous conversation. ", + "After providing the summary, acknowledge that /compact command has been applied. ", + "Here is the summary of the previous conversation:\n\n{}" ``` > Using `{}` as a placeholder in this way seems very confusing to me as a Rust person because it's not being used natively by `format!()`. Please use something like `SUMMARY_TEXT` instead so it's more obvious that something is meant to be replaced. - Created: 2025-07-12 17:11:01 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202826750 ```diff @@ -21,6 +22,22 @@ use std::path::PathBuf; use std::sync::mpsc::Receiver; use std::sync::mpsc::channel; +/// Template for compact summary continuation prompt +const COMPACT_SUMMARY_TEMPLATE: &str = concat!( + "This chat is a continuation of a previous conversation. ", + "After providing the summary, acknowledge that /compact command has been applied. ", + "Here is the summary of the previous conversation:\n\n{}" +); + +/// Creates the initial prompt for a compacted conversation +fn create_compact_summary_prompt(summary_text: &str) -> String { ``` > For small helper functions, particularly ones that are private to the file, please declare them _after_ the functions that use them. I strongly prefer declaring the "most important stuff" at the top of the file and "details" (which includes functions like this) at the top of the file. - Created: 2025-07-12 17:12:21 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202827235 ```diff @@ -21,6 +22,22 @@ use std::path::PathBuf; use std::sync::mpsc::Receiver; use std::sync::mpsc::channel; +/// Template for compact summary continuation prompt +const COMPACT_SUMMARY_TEMPLATE: &str = concat!( + "This chat is a continuation of a previous conversation. ", + "After providing the summary, acknowledge that /compact command has been applied. ", + "Here is the summary of the previous conversation:\n\n{}" +); + +/// Creates the initial prompt for a compacted conversation +fn create_compact_summary_prompt(summary_text: &str) -> String { + if summary_text.trim().is_empty() { + "Previous conversation has been summarized.".to_string() + } else { + COMPACT_SUMMARY_TEMPLATE.replace("{}", summary_text.trim()) ``` > Even better, why not just use the string directly here (again, I would recommend `r#`) with `format!()`. I see that the other places `COMPACT_SUMMARY_TEMPLATE` is used is in tests, but I'm not sure those tests should be written the way they are, so I don't think we'll need this as its own variable. - Created: 2025-07-12 17:13:46 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202827674 ```diff @@ -36,6 +53,21 @@ enum AppState<'a> { GitWarning { screen: GitWarningScreen }, } +/// State for tracking a pending summarization request ``` > Again, the previous order was more appropriate: `App` is the most important thing and should be listed before these structs. - Created: 2025-07-12 17:14:29 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202827940 ```diff @@ -49,15 +81,9 @@ pub(crate) struct App<'a> { /// Stored parameters needed to instantiate the ChatWidget later, e.g., /// after dismissing the Git-repo warning. chat_args: Option, -} -/// Aggregate parameters needed to create a `ChatWidget`, as creation may be -/// deferred until after the Git warning screen is dismissed. -#[derive(Clone)] -struct ChatWidgetArgs { - config: Config, - initial_prompt: Option, - initial_images: Vec, + /// Tracks pending summarization requests for the compact feature ``` > Please match the existing style where comments end with periods. - Created: 2025-07-12 17:16:52 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202828654 ```diff @@ -224,6 +251,40 @@ impl<'a> App<'a> { self.dispatch_scroll_event(scroll_delta); } AppEvent::CodexEvent(event) => { + // Check if we're waiting for a summarization response ``` > Is there a reason this logic isn't added to `dispatch_codex_event()` instead? Much of the reason to have the `dispatch_codex_event()` helper is to keep the length of `run()` down. In a new top-level function, there will be less indenting and the code should be easier to read, as well. - Created: 2025-07-12 17:17:31 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202828894 ```diff @@ -224,6 +251,40 @@ impl<'a> App<'a> { self.dispatch_scroll_event(scroll_delta); } AppEvent::CodexEvent(event) => { + // Check if we're waiting for a summarization response + if let Some(ref mut pending) = self.pending_summarization { + if let Event { + msg: codex_core::protocol::EventMsg::AgentMessage(ref msg), ``` > I would `use codex_core::protocol::EventMsg` so this can just be `EventMsg`. - Created: 2025-07-12 17:19:34 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202833564 ```diff @@ -357,3 +429,119 @@ impl<'a> App<'a> { } } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use codex_core::protocol::AgentMessageEvent; + use codex_core::protocol::EventMsg; + use codex_core::protocol::TaskCompleteEvent; + use pretty_assertions::assert_eq; + + #[test] + fn test_create_compact_summary_prompt_with_content() { + let summary_text = "User asked about Rust. I explained ownership and borrowing."; + let result = create_compact_summary_prompt(summary_text); + + let expected = COMPACT_SUMMARY_TEMPLATE.replace( + "{}", + "User asked about Rust. I explained ownership and borrowing.", + ); + assert_eq!(result, expected); + } + + #[test] + fn test_create_compact_summary_prompt_empty_content() { + let result = create_compact_summary_prompt(""); + assert_eq!(result, "Previous conversation has been summarized."); + + let result_whitespace = create_compact_summary_prompt(" \n\t "); + assert_eq!( + result_whitespace, + "Previous conversation has been summarized." + ); + } + + #[test] + fn test_pending_summarization_state_management() { + let mut pending = PendingSummarization { + summary_buffer: String::new(), + }; + + // Simulate collecting summary pieces + pending.summary_buffer.push_str("First part of summary"); + pending.summary_buffer.push('\n'); + pending.summary_buffer.push_str("Second part of summary"); + + let expected = "First part of summary\nSecond part of summary"; + assert_eq!(pending.summary_buffer, expected); + + // Test that create_compact_summary_prompt works with collected buffer + let prompt = create_compact_summary_prompt(&pending.summary_buffer); + assert!(prompt.contains("First part of summary")); + assert!(prompt.contains("Second part of summary")); + } + + #[test] ``` > I don't think this test provides any signal. Please remove. - Created: 2025-07-12 17:20:10 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202834695 ```diff @@ -357,3 +429,119 @@ impl<'a> App<'a> { } } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use codex_core::protocol::AgentMessageEvent; + use codex_core::protocol::EventMsg; + use codex_core::protocol::TaskCompleteEvent; + use pretty_assertions::assert_eq; + + #[test] + fn test_create_compact_summary_prompt_with_content() { + let summary_text = "User asked about Rust. I explained ownership and borrowing."; + let result = create_compact_summary_prompt(summary_text); + + let expected = COMPACT_SUMMARY_TEMPLATE.replace( + "{}", + "User asked about Rust. I explained ownership and borrowing.", + ); + assert_eq!(result, expected); + } + + #[test] + fn test_create_compact_summary_prompt_empty_content() { + let result = create_compact_summary_prompt(""); + assert_eq!(result, "Previous conversation has been summarized."); + + let result_whitespace = create_compact_summary_prompt(" \n\t "); + assert_eq!( + result_whitespace, + "Previous conversation has been summarized." + ); + } + + #[test] + fn test_pending_summarization_state_management() { + let mut pending = PendingSummarization { + summary_buffer: String::new(), + }; + + // Simulate collecting summary pieces + pending.summary_buffer.push_str("First part of summary"); + pending.summary_buffer.push('\n'); + pending.summary_buffer.push_str("Second part of summary"); + + let expected = "First part of summary\nSecond part of summary"; + assert_eq!(pending.summary_buffer, expected); + + // Test that create_compact_summary_prompt works with collected buffer + let prompt = create_compact_summary_prompt(&pending.summary_buffer); + assert!(prompt.contains("First part of summary")); + assert!(prompt.contains("Second part of summary")); + } + + #[test] + fn test_compact_summary_template_integrity() { + // Ensure the template has expected structure and placeholder + assert!(COMPACT_SUMMARY_TEMPLATE.contains("{}")); + assert!(COMPACT_SUMMARY_TEMPLATE.contains("continuation of a previous conversation")); + assert!(COMPACT_SUMMARY_TEMPLATE.contains("/compact command has been applied")); + } + + #[test] + fn test_agent_message_event_creation() { + // Test that we can create the events we expect to handle + let msg_event = EventMsg::AgentMessage(AgentMessageEvent { + message: "This is a test summary".to_string(), + }); + + if let EventMsg::AgentMessage(agent_msg) = msg_event { + assert_eq!(agent_msg.message, "This is a test summary"); + } else { + panic!("Expected AgentMessage event"); + } + + let task_complete_event = EventMsg::TaskComplete(TaskCompleteEvent { + last_agent_message: Some("Final message".to_string()), + }); + + matches!(task_complete_event, EventMsg::TaskComplete(_)); + } + + #[test] + fn test_multiline_summary_handling() { + let multiline_summary = "Line 1: User question\nLine 2: My response\nLine 3: Follow-up"; + let result = create_compact_summary_prompt(multiline_summary); + + assert!(result.contains("Line 1: User question")); + assert!(result.contains("Line 2: My response")); + assert!(result.contains("Line 3: Follow-up")); + assert!(result.contains("continuation of a previous conversation")); + } + + #[test] + fn test_summary_buffer_accumulation() { ``` > What is this test telling us? - Created: 2025-07-12 17:21:20 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202835602 ```diff @@ -357,3 +429,119 @@ impl<'a> App<'a> { } } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use codex_core::protocol::AgentMessageEvent; + use codex_core::protocol::EventMsg; + use codex_core::protocol::TaskCompleteEvent; + use pretty_assertions::assert_eq; + + #[test] + fn test_create_compact_summary_prompt_with_content() { + let summary_text = "User asked about Rust. I explained ownership and borrowing."; + let result = create_compact_summary_prompt(summary_text); + + let expected = COMPACT_SUMMARY_TEMPLATE.replace( + "{}", + "User asked about Rust. I explained ownership and borrowing.", + ); + assert_eq!(result, expected); + } + + #[test] + fn test_create_compact_summary_prompt_empty_content() { + let result = create_compact_summary_prompt(""); + assert_eq!(result, "Previous conversation has been summarized."); + + let result_whitespace = create_compact_summary_prompt(" \n\t "); + assert_eq!( + result_whitespace, + "Previous conversation has been summarized." + ); + } + + #[test] + fn test_pending_summarization_state_management() { + let mut pending = PendingSummarization { + summary_buffer: String::new(), + }; + + // Simulate collecting summary pieces + pending.summary_buffer.push_str("First part of summary"); + pending.summary_buffer.push('\n'); + pending.summary_buffer.push_str("Second part of summary"); + + let expected = "First part of summary\nSecond part of summary"; + assert_eq!(pending.summary_buffer, expected); + + // Test that create_compact_summary_prompt works with collected buffer + let prompt = create_compact_summary_prompt(&pending.summary_buffer); + assert!(prompt.contains("First part of summary")); + assert!(prompt.contains("Second part of summary")); + } + + #[test] + fn test_compact_summary_template_integrity() { + // Ensure the template has expected structure and placeholder + assert!(COMPACT_SUMMARY_TEMPLATE.contains("{}")); + assert!(COMPACT_SUMMARY_TEMPLATE.contains("continuation of a previous conversation")); + assert!(COMPACT_SUMMARY_TEMPLATE.contains("/compact command has been applied")); + } + + #[test] + fn test_agent_message_event_creation() { + // Test that we can create the events we expect to handle + let msg_event = EventMsg::AgentMessage(AgentMessageEvent { + message: "This is a test summary".to_string(), + }); + + if let EventMsg::AgentMessage(agent_msg) = msg_event { + assert_eq!(agent_msg.message, "This is a test summary"); + } else { + panic!("Expected AgentMessage event"); + } + + let task_complete_event = EventMsg::TaskComplete(TaskCompleteEvent { + last_agent_message: Some("Final message".to_string()), + }); + + matches!(task_complete_event, EventMsg::TaskComplete(_)); + } + + #[test] ``` > What is this test telling us? > > A test should help reduce risk. If the test is implemented as `format!()` as I suggested, then the risk of it failing in any of the ways this verifies seems low. - Created: 2025-07-12 17:22:08 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202835856 ```diff @@ -357,3 +429,119 @@ impl<'a> App<'a> { } } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use codex_core::protocol::AgentMessageEvent; + use codex_core::protocol::EventMsg; + use codex_core::protocol::TaskCompleteEvent; + use pretty_assertions::assert_eq; + + #[test] + fn test_create_compact_summary_prompt_with_content() { + let summary_text = "User asked about Rust. I explained ownership and borrowing."; + let result = create_compact_summary_prompt(summary_text); + + let expected = COMPACT_SUMMARY_TEMPLATE.replace( ``` > I would just `assert_eq!()` using a string literal that, yes, is a copy of `COMPACT_SUMMARY_TEMPLATE`. Again, I would use `r#`. - Created: 2025-07-12 17:27:22 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202837355 ```diff @@ -224,6 +251,40 @@ impl<'a> App<'a> { self.dispatch_scroll_event(scroll_delta); } AppEvent::CodexEvent(event) => { + // Check if we're waiting for a summarization response + if let Some(ref mut pending) = self.pending_summarization { + if let Event { + msg: codex_core::protocol::EventMsg::AgentMessage(ref msg), ``` > Why is every `AgentMessage` used as the `PendingSummarization` in this case? ### codex-rs/tui/src/slash_command.rs - Created: 2025-07-12 17:23:01 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202836083 ```diff @@ -16,6 +16,7 @@ pub enum SlashCommand { Diff, Quit, ToggleMouseMode, + Compact, ``` > As noted on line 13, enum order should be what we think frequency order is. I would list this second (after `New`). - Created: 2025-07-12 17:23:21 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202836138 ```diff @@ -30,6 +31,9 @@ impl SlashCommand { SlashCommand::Diff => { "Show git diff of the working directory (including untracked files)" } + SlashCommand::Compact => { ``` > Assuming you reorder the enum, please reorder this, as well. - Created: 2025-07-12 17:25:09 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202836629 ```diff @@ -44,3 +48,28 @@ impl SlashCommand { pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { SlashCommand::iter().map(|c| (c.command(), c)).collect() } + +#[cfg(test)] ``` > Again, I don't think this test derisks very much. It feels like it would only ever fail if we restructured how commands work, so it is unlikely to catch anything. > > An integration test that verifies `/compact` would be a better way to verify this.