Compare commits

...

26 Commits

Author SHA1 Message Date
aibrahim-oai
2c4a8a38cb Merge branch 'summary_op' into compact_cmd 2025-07-30 16:18:15 -07:00
Ahmed Ibrahim
c85369db78 compact 2025-07-30 16:14:19 -07:00
aibrahim-oai
d9c45b5347 Merge branch 'summary_op' into compact_cmd 2025-07-30 15:48:17 -07:00
Ahmed Ibrahim
00fba9047c new prompt 2025-07-30 15:48:01 -07:00
aibrahim-oai
31c09e08e1 Merge branch 'summary_op' into compact_cmd 2025-07-30 15:36:20 -07:00
Ahmed Ibrahim
5626a47042 revive 2025-07-30 15:08:44 -07:00
aibrahim-oai
5568c191d8 Merge branch 'main' into summary_op 2025-07-30 15:02:38 -07:00
aibrahim-oai
1b7fea5396 Merge branch 'summary_op' into compact_cmd 2025-07-21 20:20:58 -07:00
aibrahim-oai
b86cb8f642 Merge branch 'main' into summary_op 2025-07-21 20:20:52 -07:00
aibrahim-oai
4005e3708a Merge branch 'summary_op' into compact_cmd 2025-07-16 22:31:42 -07:00
aibrahim-oai
a026e1e41c Merge branch 'main' into summary_op 2025-07-16 22:30:24 -07:00
aibrahim-oai
005511d1dc Merge branch 'summary_op' into compact_cmd 2025-07-14 15:36:44 -07:00
aibrahim-oai
2bc78ea18b Merge branch 'main' into summary_op 2025-07-14 15:35:30 -07:00
aibrahim-oai
12722251d4 Merge branch 'summary_op' into compact_cmd 2025-07-14 15:35:07 -07:00
Ahmed Ibrahim
184abe9f12 adressing reviews 2025-07-14 15:32:52 -07:00
aibrahim-oai
ccac930606 Merge branch 'main' into compact_cmd 2025-07-14 15:26:04 -07:00
Ahmed Ibrahim
3e74a0d173 progress
review

review

warnings

addressing reviews

Reset codex-rs/core/ to match origin/summary_op

Reset codex-rs/core/ to match origin/main

restore
2025-07-12 12:58:19 -07:00
Ahmed Ibrahim
c1bc12ab01 adding tests 2025-07-12 12:37:48 -07:00
aibrahim-oai
80c5891740 Merge branch 'main' into summary_op 2025-07-12 11:32:33 -07:00
Ahmed Ibrahim
f30e25aa11 warnings 2025-07-12 11:26:22 -07:00
Ahmed Ibrahim
133ad67ce0 review 2025-07-11 17:26:02 -07:00
Ahmed Ibrahim
f8d6e97450 review 2025-07-11 17:23:45 -07:00
Ahmed Ibrahim
99df99d006 progress 2025-07-11 16:19:55 -07:00
Ahmed Ibrahim
f77fab3d2d adding tests 2025-07-10 20:53:07 -07:00
Ahmed Ibrahim
f12ee08378 tui compact 2025-07-10 20:21:48 -07:00
Ahmed Ibrahim
658d69e1a4 add summary operation 2025-07-10 19:37:50 -07:00
13 changed files with 347 additions and 15 deletions

View File

@@ -829,6 +829,79 @@ async fn submission_loop(
}
});
}
Op::SummarizeContext => {
let sess = match sess.as_ref() {
Some(sess) => sess,
None => {
send_no_session_event(sub.id).await;
continue;
}
};
// Create a summarization request as user input
const SUMMARIZATION_PROMPT: &str = r#"
You are the component that compacts a long coding session log into a structured memory object.
This memory will become the ONLY reference for continuing the task.
All critical facts, user intentions, tool results, and file operations must be captured.
Omit filler talk and commentary. Do not invent information; use "none" if evidence is missing.
Output ONLY the XML object below. No extra text.
<project_memory>
<mission>
<!-- One concise line describing the users main goal. -->
</mission>
<essentials>
<!-- Bullet-like facts the agent must retain: commands, APIs, paths, configs, tickets, rules. -->
<!-- Example:
- Build cmd: `npm run build`
- Repo branch: `feature/auth-refactor`
- API version: v2
-->
</essentials>
<workspace>
<!-- Record file interactions and key observations. -->
<!-- Example:
- CREATED: `tests/login.test.ts` initial test
- MODIFIED: `src/auth.ts` swapped jwt library
- DELETED: none
-->
</workspace>
<activity_log>
<!-- Key actions and tool outputs in the recent session. -->
<!-- Example:
- Ran `npm test` 1 failure in `User.test.ts`
- Queried `grep 'oldAPI'` 2 matches
-->
</activity_log>
<next_steps>
<!-- Stepwise plan; mark status. -->
<!-- Example:
1. [DONE] Identify old API usage
2. [NEXT] Refactor `auth.ts` to new API
3. [TODO] Update tests
-->
</next_steps>
</project_memory>
"#;
let summarization_prompt = vec![InputItem::Text {
text: SUMMARIZATION_PROMPT.to_string(),
}];
// Attempt to inject input into current task
if let Err(items) = sess.inject_input(summarization_prompt) {
run_task(sess.clone(), sub.id, items).await;
// only keep the last input item and clear the rest
let mut pending_input = sess.state.lock().unwrap().pending_input.clone();
pending_input.truncate(1);
sess.state.lock().unwrap().pending_input = pending_input;
}
}
Op::Shutdown => {
info!("Shutting down Codex instance");

View File

@@ -121,6 +121,10 @@ pub enum Op {
/// Request a single history entry identified by `log_id` + `offset`.
GetHistoryEntryRequest { offset: usize, log_id: u64 },
/// Request the agent to summarize the current conversation context.
/// The agent will use its existing context (either conversation history or previous response id)
/// to generate a summary which will be returned as an AgentMessage event.
SummarizeContext,
/// Request to shut down codex instance.
Shutdown,
}

View File

@@ -0,0 +1,41 @@
#![expect(clippy::unwrap_used, clippy::expect_used)]
//! Tests for the `Op::SummarizeContext` operation added to verify that
//! summarization requests are properly handled and injected as user input.
use std::time::Duration;
use codex_core::Codex;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use core_test_support::load_default_config_for_test;
use tempfile::TempDir;
use tokio::time::timeout;
/// Helper function to set up a codex session and wait for it to be configured
async fn setup_configured_codex_session() -> Codex {
let codex_home = TempDir::new().unwrap();
let config = load_default_config_for_test(&codex_home);
let codex_conversation = codex_core::codex_wrapper::init_codex(config).await.unwrap();
codex_conversation.codex
}
#[tokio::test]
async fn test_summarize_context_spawns_new_agent_task() {
// Test the specific behavior: when there's no current task,
// SummarizeContext should spawn a new AgentTask with the summarization prompt
let codex = setup_configured_codex_session().await;
// At this point, there should be no current task running
let _sub_id = codex.submit(Op::SummarizeContext).await.unwrap();
let event = timeout(Duration::from_secs(5), codex.next_event())
.await
.expect("timeout waiting for task started event")
.expect("codex closed");
assert!(
matches!(event.msg, EventMsg::TaskStarted),
"Expected TaskStarted when no current task exists - should spawn new AgentTask"
);
}

View File

@@ -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<ChatWidgetArgs>,
}
/// 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<String>,
initial_images: Vec<PathBuf>,
/// Tracks pending summarization requests for the compact feature.
pending_summarization: Option<PendingSummarization>,
}
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<String>,
initial_images: Vec<PathBuf>,
}
/// 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));
}
}

View File

@@ -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`].

View File

@@ -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<AppEvent>,
}
impl AppEventSender {
pub(crate) fn new(app_event_tx: Sender<AppEvent>) -> Self {
pub fn new(app_event_tx: Sender<AppEvent>) -> 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}");
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -158,7 +158,7 @@ impl ChatWidget<'_> {
self.bottom_pane.handle_paste(text);
}
fn add_to_history(&mut self, cell: HistoryCell) {
pub(crate) fn add_to_history(&mut self, cell: HistoryCell) {
self.app_event_tx
.send(AppEvent::InsertHistory(cell.plain_lines()));
}

View File

@@ -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(),
));
}
}

View File

@@ -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╯"

View File

@@ -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╯"

View File

@@ -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╯"