mirror of
https://github.com/openai/codex.git
synced 2026-02-02 15:03:38 +00:00
Compare commits
26 Commits
easong/clo
...
compact_cm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c4a8a38cb | ||
|
|
c85369db78 | ||
|
|
d9c45b5347 | ||
|
|
00fba9047c | ||
|
|
31c09e08e1 | ||
|
|
5626a47042 | ||
|
|
5568c191d8 | ||
|
|
1b7fea5396 | ||
|
|
b86cb8f642 | ||
|
|
4005e3708a | ||
|
|
a026e1e41c | ||
|
|
005511d1dc | ||
|
|
2bc78ea18b | ||
|
|
12722251d4 | ||
|
|
184abe9f12 | ||
|
|
ccac930606 | ||
|
|
3e74a0d173 | ||
|
|
c1bc12ab01 | ||
|
|
80c5891740 | ||
|
|
f30e25aa11 | ||
|
|
133ad67ce0 | ||
|
|
f8d6e97450 | ||
|
|
99df99d006 | ||
|
|
f77fab3d2d | ||
|
|
f12ee08378 | ||
|
|
658d69e1a4 |
@@ -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 user’s 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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
41
codex-rs/core/tests/summarize_context.rs
Normal file
41
codex-rs/core/tests/summarize_context.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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╯"
|
||||
@@ -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╯"
|
||||
@@ -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╯"
|
||||
Reference in New Issue
Block a user