mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Generate automatic and manual conversation recaps (#40705)
## Why Users returning to a conversation need a short summary of its recent progress and next steps. ## What changed - Generate a recap for eligible idle, unfocused conversations and add it to the transcript. - Add `/recap` to request the same summary on demand, with visible progress and actionable errors. - Keep recap requests bounded and isolated, reject stale results, and retry only automatic failures. ## Testing - Cover automatic and manual generation, request eligibility, failure handling, loading-state cleanup, and temporary-thread configuration. GitOrigin-RevId: 926a88d6996d67a42809ce2c4091b13fca882b52
This commit is contained in:
1 parent
40ba7da7b4
commit
6988d390b3
17 files changed
+831
-38
No files matched your search
@@ -6,6 +6,7 @@
|
|||||||
use super::resize_reflow::trailing_run_start;
|
use super::resize_reflow::trailing_run_start;
|
||||||
use super::session_lifecycle::ThreadAttachPresentation;
|
use super::session_lifecycle::ThreadAttachPresentation;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::app_event::RecapTrigger;
|
||||||
use crate::app_event::ThreadTitleDestination;
|
use crate::app_event::ThreadTitleDestination;
|
||||||
use crate::app_server_session::ForkGoalContinuation;
|
use crate::app_server_session::ForkGoalContinuation;
|
||||||
use crate::app_server_session::UnsupportedLegacyPermissionProfile;
|
use crate::app_server_session::UnsupportedLegacyPermissionProfile;
|
||||||
@@ -2897,11 +2898,71 @@ impl App {
|
|||||||
AppEvent::KeymapCleared { context, action } => {
|
AppEvent::KeymapCleared { context, action } => {
|
||||||
self.apply_keymap_clear(context, action).await;
|
self.apply_keymap_clear(context, action).await;
|
||||||
}
|
}
|
||||||
|
AppEvent::GenerateRecap { thread_id } => {
|
||||||
|
if self.current_displayed_thread_id() == Some(thread_id) {
|
||||||
|
if self.chat_widget.is_user_turn_pending_or_running() {
|
||||||
|
self.chat_widget.add_error_message(
|
||||||
|
"Wait for the current task to finish before running /recap.".to_string(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
self.request_recap(app_server, thread_id, RecapTrigger::Manual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
AppEvent::CheckRecap { thread_id } => {
|
AppEvent::CheckRecap { thread_id } => {
|
||||||
// The inference layer stacked on this change consumes the scheduled check.
|
if self.current_displayed_thread_id() == Some(thread_id)
|
||||||
let _ = thread_id;
|
&& !self.chat_widget.is_user_turn_pending_or_running()
|
||||||
|
&& self.recap.should_generate(std::time::Instant::now())
|
||||||
|
{
|
||||||
|
self.request_recap(app_server, thread_id, RecapTrigger::Automatic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppEvent::RecapStarted {
|
||||||
|
thread_id,
|
||||||
|
request_id,
|
||||||
|
trigger,
|
||||||
|
completed_turn_count,
|
||||||
|
turn_revision,
|
||||||
|
history,
|
||||||
|
result,
|
||||||
|
} => {
|
||||||
|
self.handle_recap_started(
|
||||||
|
app_server,
|
||||||
|
recap::RecapRequest {
|
||||||
|
thread_id,
|
||||||
|
request_id,
|
||||||
|
trigger,
|
||||||
|
completed_turn_count,
|
||||||
|
turn_revision,
|
||||||
|
},
|
||||||
|
history,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
AppEvent::RecapGenerated {
|
||||||
|
thread_id,
|
||||||
|
request_id,
|
||||||
|
trigger,
|
||||||
|
temporary_thread_id,
|
||||||
|
completed_turn_count,
|
||||||
|
turn_revision,
|
||||||
|
result,
|
||||||
|
} => {
|
||||||
|
self.temporary_structured_requests.remove(&temporary_thread_id);
|
||||||
|
if let Some(cell) = self.handle_generated_recap(
|
||||||
|
recap::RecapRequest {
|
||||||
|
thread_id,
|
||||||
|
request_id,
|
||||||
|
trigger,
|
||||||
|
completed_turn_count,
|
||||||
|
turn_revision,
|
||||||
|
},
|
||||||
|
temporary_thread_id,
|
||||||
|
result,
|
||||||
|
) {
|
||||||
|
self.insert_history_cell(tui, Box::new(cell));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
AppEvent::RecapStarted { .. } | AppEvent::RecapGenerated { .. } => {}
|
|
||||||
}
|
}
|
||||||
Ok(AppRunControl::Continue)
|
Ok(AppRunControl::Continue)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
//! Determines when an unfocused conversation is ready for an automatic recap.
|
//! Determines when an unfocused conversation is ready for an automatic recap.
|
||||||
|
|
||||||
// The stacked event-dispatch change activates the generation methods.
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use super::App;
|
use super::App;
|
||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
|
use crate::app_event::RecapTrigger;
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
use crate::app_server_session::AppServerSession;
|
use crate::app_server_session::AppServerSession;
|
||||||
use crate::history_cell::AgentMarkdownCell;
|
use crate::history_cell::AgentMarkdownCell;
|
||||||
use crate::history_cell::AgentMessageCell;
|
use crate::history_cell::AgentMessageCell;
|
||||||
use crate::history_cell::HistoryCell;
|
use crate::history_cell::HistoryCell;
|
||||||
use crate::history_cell::ThreadRecapHistoryCell;
|
use crate::history_cell::ThreadRecapHistoryCell;
|
||||||
|
use crate::history_cell::ThreadRecapLoadingCell;
|
||||||
use crate::history_cell::UserHistoryCell;
|
use crate::history_cell::UserHistoryCell;
|
||||||
|
use crate::pager_overlay::Overlay;
|
||||||
use crate::temporary_structured_request::TemporaryStructuredThreadOptions;
|
use crate::temporary_structured_request::TemporaryStructuredThreadOptions;
|
||||||
use crate::temporary_structured_request::run_temporary_structured_turn;
|
use crate::temporary_structured_request::run_temporary_structured_turn;
|
||||||
use crate::temporary_structured_request::start_temporary_thread;
|
use crate::temporary_structured_request::start_temporary_thread;
|
||||||
@@ -36,6 +36,9 @@ pub(super) const RECAP_DELAY: Duration = Duration::from_secs(/*secs*/ 3 * 60);
|
|||||||
const RECAP_HISTORY_MAX_TURNS: usize = 8;
|
const RECAP_HISTORY_MAX_TURNS: usize = 8;
|
||||||
const RECAP_MAX_CHARS: usize = 320;
|
const RECAP_MAX_CHARS: usize = 320;
|
||||||
const RECAP_RETRY_DELAY: Duration = Duration::from_secs(/*secs*/ 30);
|
const RECAP_RETRY_DELAY: Duration = Duration::from_secs(/*secs*/ 30);
|
||||||
|
const MANUAL_RECAP_FAILURE_MESSAGE: &str = "Could not generate a recap. Please try again.";
|
||||||
|
const MANUAL_RECAP_IN_PROGRESS_MESSAGE: &str = "A recap is already being generated.";
|
||||||
|
const MANUAL_RECAP_EMPTY_HISTORY_MESSAGE: &str = "There is no conversation history to recap.";
|
||||||
const RECAP_PROMPT_PREFIX: &str = concat!(
|
const RECAP_PROMPT_PREFIX: &str = concat!(
|
||||||
"Write a brief catch-up for a user returning to this Codex task. ",
|
"Write a brief catch-up for a user returning to this Codex task. ",
|
||||||
"In at most 40 words and one or two plain-text sentences, explain the ",
|
"In at most 40 words and one or two plain-text sentences, explain the ",
|
||||||
@@ -46,7 +49,7 @@ const RECAP_PROMPT_PREFIX: &str = concat!(
|
|||||||
"of inventing more work. Use the user's language; omit greetings, ",
|
"of inventing more work. Use the user's language; omit greetings, ",
|
||||||
"markdown, lists, and tool chatter.\n\nRecent conversation:\n",
|
"markdown, lists, and tool chatter.\n\nRecent conversation:\n",
|
||||||
);
|
);
|
||||||
const RECAP_PROMPT_MAX_BYTES: usize = 900;
|
pub(super) const RECAP_PROMPT_MAX_BYTES: usize = 900;
|
||||||
|
|
||||||
fn render_recap_message(role: &str, content: &str, max_bytes: usize) -> Option<String> {
|
fn render_recap_message(role: &str, content: &str, max_bytes: usize) -> Option<String> {
|
||||||
let prefix = format!("{role}: ");
|
let prefix = format!("{role}: ");
|
||||||
@@ -167,24 +170,79 @@ fn parse_recap(response: &str) -> Option<String> {
|
|||||||
pub(super) struct RecapRequest {
|
pub(super) struct RecapRequest {
|
||||||
pub(super) thread_id: ThreadId,
|
pub(super) thread_id: ThreadId,
|
||||||
pub(super) request_id: Uuid,
|
pub(super) request_id: Uuid,
|
||||||
|
pub(super) trigger: RecapTrigger,
|
||||||
pub(super) completed_turn_count: usize,
|
pub(super) completed_turn_count: usize,
|
||||||
pub(super) turn_revision: usize,
|
pub(super) turn_revision: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub(super) fn request_recap(&mut self, app_server: &AppServerSession, thread_id: ThreadId) {
|
fn clear_recap_request(&mut self, trigger: RecapTrigger) {
|
||||||
|
self.recap.clear_in_flight_request();
|
||||||
|
|
||||||
|
if !matches!(trigger, RecapTrigger::Manual) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.chat_widget.clear_recap_loading();
|
||||||
|
|
||||||
|
let Some(index) = self
|
||||||
|
.transcript_cells
|
||||||
|
.iter()
|
||||||
|
.rposition(|cell| cell.as_any().is::<ThreadRecapLoadingCell>())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.transcript_cells.remove(index);
|
||||||
|
if let Some(Overlay::Transcript(overlay)) = &mut self.overlay {
|
||||||
|
overlay.replace_cells(self.transcript_cells.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_or_report_recap_failure(&mut self, request: RecapRequest) {
|
||||||
|
match request.trigger {
|
||||||
|
RecapTrigger::Automatic => self.recap.schedule_retry(
|
||||||
|
request.thread_id,
|
||||||
|
self.app_event_tx.clone(),
|
||||||
|
request.turn_revision,
|
||||||
|
),
|
||||||
|
RecapTrigger::Manual => self
|
||||||
|
.chat_widget
|
||||||
|
.add_error_message(MANUAL_RECAP_FAILURE_MESSAGE.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn request_recap(
|
||||||
|
&mut self,
|
||||||
|
app_server: &AppServerSession,
|
||||||
|
thread_id: ThreadId,
|
||||||
|
trigger: RecapTrigger,
|
||||||
|
) {
|
||||||
if self.recap.in_flight_request.is_some() {
|
if self.recap.in_flight_request.is_some() {
|
||||||
|
if matches!(trigger, RecapTrigger::Manual) {
|
||||||
|
self.chat_widget
|
||||||
|
.add_error_message(MANUAL_RECAP_IN_PROGRESS_MESSAGE.to_string());
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let history = recap_history(&self.transcript_cells);
|
let history = recap_history(&self.transcript_cells);
|
||||||
if history.is_empty() {
|
if history.is_empty() {
|
||||||
|
if matches!(trigger, RecapTrigger::Manual) {
|
||||||
|
self.chat_widget
|
||||||
|
.add_error_message(MANUAL_RECAP_EMPTY_HISTORY_MESSAGE.to_string());
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if matches!(trigger, RecapTrigger::Manual) {
|
||||||
|
self.chat_widget.show_recap_loading();
|
||||||
|
}
|
||||||
|
|
||||||
let request = RecapRequest {
|
let request = RecapRequest {
|
||||||
thread_id,
|
thread_id,
|
||||||
request_id: Uuid::new_v4(),
|
request_id: Uuid::new_v4(),
|
||||||
|
trigger,
|
||||||
completed_turn_count: self.recap.completed_turns,
|
completed_turn_count: self.recap.completed_turns,
|
||||||
turn_revision: self.recap.turn_revision,
|
turn_revision: self.recap.turn_revision,
|
||||||
};
|
};
|
||||||
@@ -219,6 +277,7 @@ impl App {
|
|||||||
event_sender.send(AppEvent::RecapStarted {
|
event_sender.send(AppEvent::RecapStarted {
|
||||||
thread_id: request.thread_id,
|
thread_id: request.thread_id,
|
||||||
request_id: request.request_id,
|
request_id: request.request_id,
|
||||||
|
trigger: request.trigger,
|
||||||
completed_turn_count: request.completed_turn_count,
|
completed_turn_count: request.completed_turn_count,
|
||||||
turn_revision: request.turn_revision,
|
turn_revision: request.turn_revision,
|
||||||
history,
|
history,
|
||||||
@@ -227,6 +286,7 @@ impl App {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.recap.in_flight_request_id = Some(request.request_id);
|
self.recap.in_flight_request_id = Some(request.request_id);
|
||||||
|
self.recap.in_flight_trigger = Some(request.trigger);
|
||||||
self.recap.in_flight_request = Some(task);
|
self.recap.in_flight_request = Some(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,10 +300,12 @@ impl App {
|
|||||||
let RecapRequest {
|
let RecapRequest {
|
||||||
thread_id,
|
thread_id,
|
||||||
request_id,
|
request_id,
|
||||||
|
trigger,
|
||||||
completed_turn_count,
|
completed_turn_count,
|
||||||
turn_revision,
|
turn_revision,
|
||||||
} = request;
|
} = request;
|
||||||
let is_current_request = self.recap.in_flight_request_id == Some(request_id);
|
let is_current_request = self.recap.in_flight_request_id == Some(request_id)
|
||||||
|
&& self.recap.in_flight_trigger == Some(trigger);
|
||||||
if is_current_request {
|
if is_current_request {
|
||||||
self.recap.in_flight_request.take();
|
self.recap.in_flight_request.take();
|
||||||
}
|
}
|
||||||
@@ -252,34 +314,37 @@ impl App {
|
|||||||
Ok(temporary_thread_id) => temporary_thread_id,
|
Ok(temporary_thread_id) => temporary_thread_id,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
if is_current_request {
|
if is_current_request {
|
||||||
self.recap.in_flight_request_id = None;
|
self.clear_recap_request(trigger);
|
||||||
tracing::warn!(%thread_id, %error, "failed to start thread recap request");
|
tracing::warn!(%thread_id, %error, "failed to start thread recap request");
|
||||||
self.recap
|
self.retry_or_report_recap_failure(request);
|
||||||
.schedule_retry(thread_id, self.app_event_tx.clone(), turn_revision);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let trigger_is_eligible = match trigger {
|
||||||
|
RecapTrigger::Automatic => self.recap.should_generate(Instant::now()),
|
||||||
|
RecapTrigger::Manual => true,
|
||||||
|
};
|
||||||
|
|
||||||
let request_is_fresh = is_current_request
|
let request_is_fresh = is_current_request
|
||||||
&& self.current_displayed_thread_id() == Some(thread_id)
|
&& self.current_displayed_thread_id() == Some(thread_id)
|
||||||
&& !self.chat_widget.is_user_turn_pending_or_running()
|
&& !self.chat_widget.is_user_turn_pending_or_running()
|
||||||
&& self.recap.completed_turns == completed_turn_count
|
&& self.recap.completed_turns == completed_turn_count
|
||||||
&& self.recap.turn_revision == turn_revision
|
&& self.recap.turn_revision == turn_revision
|
||||||
&& self.recap.should_generate(Instant::now());
|
&& trigger_is_eligible;
|
||||||
let Ok(temporary_thread_id) = ThreadId::from_string(&temporary_thread_id_text) else {
|
let Ok(temporary_thread_id) = ThreadId::from_string(&temporary_thread_id_text) else {
|
||||||
if is_current_request {
|
if is_current_request {
|
||||||
self.recap.in_flight_request_id = None;
|
self.clear_recap_request(trigger);
|
||||||
tracing::warn!(%thread_id, "thread recap request returned an invalid thread ID");
|
tracing::warn!(%thread_id, "thread recap request returned an invalid thread ID");
|
||||||
self.recap
|
self.retry_or_report_recap_failure(request);
|
||||||
.schedule_retry(thread_id, self.app_event_tx.clone(), turn_revision);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if !request_is_fresh {
|
if !request_is_fresh {
|
||||||
if is_current_request {
|
if is_current_request {
|
||||||
self.recap.in_flight_request_id = None;
|
self.clear_recap_request(trigger);
|
||||||
}
|
}
|
||||||
let request_handle = app_server.request_handle();
|
let request_handle = app_server.request_handle();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -309,6 +374,7 @@ impl App {
|
|||||||
event_sender.send(AppEvent::RecapGenerated {
|
event_sender.send(AppEvent::RecapGenerated {
|
||||||
thread_id,
|
thread_id,
|
||||||
request_id,
|
request_id,
|
||||||
|
trigger,
|
||||||
temporary_thread_id,
|
temporary_thread_id,
|
||||||
completed_turn_count,
|
completed_turn_count,
|
||||||
turn_revision,
|
turn_revision,
|
||||||
@@ -329,18 +395,18 @@ impl App {
|
|||||||
let RecapRequest {
|
let RecapRequest {
|
||||||
thread_id,
|
thread_id,
|
||||||
request_id,
|
request_id,
|
||||||
|
trigger,
|
||||||
completed_turn_count,
|
completed_turn_count,
|
||||||
turn_revision,
|
turn_revision,
|
||||||
} = request;
|
} = request;
|
||||||
if self.recap.in_flight_request_id != Some(request_id)
|
if self.recap.in_flight_request_id != Some(request_id)
|
||||||
|
|| self.recap.in_flight_trigger != Some(trigger)
|
||||||
|| self.recap.in_flight_thread_id != Some(temporary_thread_id)
|
|| self.recap.in_flight_thread_id != Some(temporary_thread_id)
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.recap.in_flight_request_id = None;
|
self.clear_recap_request(trigger);
|
||||||
self.recap.in_flight_thread_id = None;
|
|
||||||
self.recap.in_flight_request.take();
|
|
||||||
|
|
||||||
if self.current_displayed_thread_id() != Some(thread_id)
|
if self.current_displayed_thread_id() != Some(thread_id)
|
||||||
|| self.chat_widget.is_user_turn_pending_or_running()
|
|| self.chat_widget.is_user_turn_pending_or_running()
|
||||||
@@ -354,8 +420,7 @@ impl App {
|
|||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
let Some(recap) = parse_recap(&response) else {
|
let Some(recap) = parse_recap(&response) else {
|
||||||
tracing::warn!(%thread_id, "generated thread recap was invalid");
|
tracing::warn!(%thread_id, "generated thread recap was invalid");
|
||||||
self.recap
|
self.retry_or_report_recap_failure(request);
|
||||||
.schedule_retry(thread_id, self.app_event_tx.clone(), turn_revision);
|
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -364,8 +429,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(%thread_id, %error, "failed to generate thread recap");
|
tracing::warn!(%thread_id, %error, "failed to generate thread recap");
|
||||||
self.recap
|
self.retry_or_report_recap_failure(request);
|
||||||
.schedule_retry(thread_id, self.app_event_tx.clone(), turn_revision);
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,11 +471,19 @@ pub(super) struct RecapState {
|
|||||||
scheduled_check: Option<JoinHandle<()>>,
|
scheduled_check: Option<JoinHandle<()>>,
|
||||||
retry_revision: Option<usize>,
|
retry_revision: Option<usize>,
|
||||||
in_flight_request_id: Option<Uuid>,
|
in_flight_request_id: Option<Uuid>,
|
||||||
|
in_flight_trigger: Option<RecapTrigger>,
|
||||||
in_flight_thread_id: Option<ThreadId>,
|
in_flight_thread_id: Option<ThreadId>,
|
||||||
in_flight_request: Option<JoinHandle<()>>,
|
in_flight_request: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RecapState {
|
impl RecapState {
|
||||||
|
fn clear_in_flight_request(&mut self) {
|
||||||
|
self.in_flight_request_id = None;
|
||||||
|
self.in_flight_trigger = None;
|
||||||
|
self.in_flight_thread_id = None;
|
||||||
|
self.in_flight_request.take();
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn seed_from_turns(&mut self, turns: &[Turn], now: Instant) {
|
pub(super) fn seed_from_turns(&mut self, turns: &[Turn], now: Instant) {
|
||||||
self.seed_from_progress(RecapProgress::from_turns(turns), now);
|
self.seed_from_progress(RecapProgress::from_turns(turns), now);
|
||||||
}
|
}
|
||||||
@@ -456,9 +528,9 @@ impl RecapState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.retry_revision = None;
|
self.retry_revision = None;
|
||||||
self.in_flight_request_id = None;
|
if self.in_flight_trigger == Some(RecapTrigger::Automatic) {
|
||||||
self.in_flight_thread_id = None;
|
self.clear_in_flight_request();
|
||||||
self.in_flight_request.take();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn note_turn_finished(&mut self, status: &TurnStatus, now: Instant) {
|
pub(super) fn note_turn_finished(&mut self, status: &TurnStatus, now: Instant) {
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ use super::recap_history;
|
|||||||
use super::recap_prompt;
|
use super::recap_prompt;
|
||||||
use crate::app::test_support::make_test_app;
|
use crate::app::test_support::make_test_app;
|
||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
|
use crate::app_event::RecapTrigger;
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
use crate::history_cell::AgentMarkdownCell;
|
use crate::history_cell::AgentMarkdownCell;
|
||||||
use crate::history_cell::AgentMessageCell;
|
use crate::history_cell::AgentMessageCell;
|
||||||
use crate::history_cell::HistoryCell;
|
use crate::history_cell::HistoryCell;
|
||||||
use crate::history_cell::PlainHistoryCell;
|
use crate::history_cell::PlainHistoryCell;
|
||||||
use crate::history_cell::ThreadRecapHistoryCell;
|
use crate::history_cell::ThreadRecapHistoryCell;
|
||||||
|
use crate::history_cell::ThreadRecapLoadingCell;
|
||||||
use crate::history_cell::UserHistoryCell;
|
use crate::history_cell::UserHistoryCell;
|
||||||
use codex_app_server_protocol::Turn;
|
use codex_app_server_protocol::Turn;
|
||||||
use codex_app_server_protocol::TurnStatus;
|
use codex_app_server_protocol::TurnStatus;
|
||||||
@@ -275,6 +277,43 @@ async fn regaining_focus_cancels_scheduled_check() {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regaining_focus_preserves_only_manual_in_flight_request() {
|
||||||
|
let request_id = Uuid::new_v4();
|
||||||
|
let thread_id = ThreadId::new();
|
||||||
|
let mut state = RecapState::default();
|
||||||
|
state.in_flight_request_id = Some(request_id);
|
||||||
|
state.in_flight_trigger = Some(RecapTrigger::Manual);
|
||||||
|
state.in_flight_thread_id = Some(thread_id);
|
||||||
|
|
||||||
|
state.note_focus_gained();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
state.in_flight_request_id,
|
||||||
|
state.in_flight_trigger,
|
||||||
|
state.in_flight_thread_id,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Some(request_id),
|
||||||
|
Some(RecapTrigger::Manual),
|
||||||
|
Some(thread_id),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
state.in_flight_trigger = Some(RecapTrigger::Automatic);
|
||||||
|
state.note_focus_gained();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
state.in_flight_request_id,
|
||||||
|
state.in_flight_trigger,
|
||||||
|
state.in_flight_thread_id,
|
||||||
|
),
|
||||||
|
(None, None, None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread", start_paused = true)]
|
#[tokio::test(flavor = "current_thread", start_paused = true)]
|
||||||
async fn scheduled_check_fires_at_recap_deadline() {
|
async fn scheduled_check_fires_at_recap_deadline() {
|
||||||
let thread_id = ThreadId::new();
|
let thread_id = ThreadId::new();
|
||||||
@@ -537,6 +576,7 @@ fn track_in_flight_recap(app: &mut App, thread_id: ThreadId) -> (RecapRequest, T
|
|||||||
let request = RecapRequest {
|
let request = RecapRequest {
|
||||||
thread_id,
|
thread_id,
|
||||||
request_id: Uuid::new_v4(),
|
request_id: Uuid::new_v4(),
|
||||||
|
trigger: RecapTrigger::Automatic,
|
||||||
completed_turn_count: 3,
|
completed_turn_count: 3,
|
||||||
turn_revision: 3,
|
turn_revision: 3,
|
||||||
};
|
};
|
||||||
@@ -544,6 +584,7 @@ fn track_in_flight_recap(app: &mut App, thread_id: ThreadId) -> (RecapRequest, T
|
|||||||
app.recap.completed_turns = request.completed_turn_count;
|
app.recap.completed_turns = request.completed_turn_count;
|
||||||
app.recap.turn_revision = request.turn_revision;
|
app.recap.turn_revision = request.turn_revision;
|
||||||
app.recap.in_flight_request_id = Some(request.request_id);
|
app.recap.in_flight_request_id = Some(request.request_id);
|
||||||
|
app.recap.in_flight_trigger = Some(request.trigger);
|
||||||
app.recap.in_flight_thread_id = Some(temporary_thread_id);
|
app.recap.in_flight_thread_id = Some(temporary_thread_id);
|
||||||
app.recap.in_flight_request = Some(tokio::spawn(async {}));
|
app.recap.in_flight_request = Some(tokio::spawn(async {}));
|
||||||
(request, temporary_thread_id)
|
(request, temporary_thread_id)
|
||||||
@@ -618,6 +659,39 @@ async fn newer_terminal_turn_invalidates_generated_recap() {
|
|||||||
assert!(app.recap.in_flight_request.is_none());
|
assert!(app.recap.in_flight_request.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread", start_paused = true)]
|
||||||
|
async fn manual_recap_failure_does_not_schedule_retry() {
|
||||||
|
let thread_id = ThreadId::new();
|
||||||
|
let mut app = app_with_visible_thread(thread_id).await;
|
||||||
|
let (mut request, temporary_thread_id) = track_in_flight_recap(&mut app, thread_id);
|
||||||
|
request.trigger = RecapTrigger::Manual;
|
||||||
|
app.recap.in_flight_trigger = Some(RecapTrigger::Manual);
|
||||||
|
app.transcript_cells
|
||||||
|
.push(Arc::new(ThreadRecapLoadingCell::new(
|
||||||
|
/*animations_enabled*/ false,
|
||||||
|
)));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.handle_generated_recap(
|
||||||
|
request,
|
||||||
|
temporary_thread_id,
|
||||||
|
Err("temporary failure".to_string()),
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
tokio::time::advance(RECAP_RETRY_DELAY).await;
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
|
||||||
|
assert_eq!(app.recap.retry_revision, None);
|
||||||
|
assert!(app.recap.scheduled_check.is_none());
|
||||||
|
assert!(
|
||||||
|
!app.transcript_cells
|
||||||
|
.iter()
|
||||||
|
.any(|cell| cell.as_any().is::<ThreadRecapLoadingCell>())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread", start_paused = true)]
|
#[tokio::test(flavor = "current_thread", start_paused = true)]
|
||||||
async fn recap_failure_retries_once_for_the_same_turn_revision() {
|
async fn recap_failure_retries_once_for_the_same_turn_revision() {
|
||||||
let thread_id = ThreadId::new();
|
let thread_id = ThreadId::new();
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ mod patch_approval_tests;
|
|||||||
mod permission_shortcuts_tests;
|
mod permission_shortcuts_tests;
|
||||||
mod plugin_catalog;
|
mod plugin_catalog;
|
||||||
mod rate_limits;
|
mod rate_limits;
|
||||||
|
#[path = "tests/recap_generation_tests.rs"]
|
||||||
|
mod recap_generation;
|
||||||
mod safety_buffering;
|
mod safety_buffering;
|
||||||
#[path = "tests/session_lifecycle_requests.rs"]
|
#[path = "tests/session_lifecycle_requests.rs"]
|
||||||
mod session_lifecycle_requests;
|
mod session_lifecycle_requests;
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::app::session_lifecycle::ThreadAttachPresentation;
|
||||||
|
use crate::app::tests::session_lifecycle_requests::recorded_params;
|
||||||
|
use crate::app::tests::session_lifecycle_requests::start_recording_remote_app_server;
|
||||||
|
use crate::app_event::RecapTrigger;
|
||||||
|
use core_test_support::responses;
|
||||||
|
use core_test_support::responses::ev_assistant_message;
|
||||||
|
use core_test_support::responses::ev_completed;
|
||||||
|
use core_test_support::responses::ev_response_created;
|
||||||
|
use core_test_support::streaming_sse::StreamingSseChunk;
|
||||||
|
use core_test_support::streaming_sse::start_streaming_sse_server;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
const MODEL: &str = "gpt-5.2";
|
||||||
|
const MODEL_PROVIDER_ID: &str = "recap-generation-test";
|
||||||
|
|
||||||
|
fn render_chat_widget(app: &App) -> String {
|
||||||
|
let area = Rect::new(
|
||||||
|
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 24,
|
||||||
|
);
|
||||||
|
let mut buffer = Buffer::empty(area);
|
||||||
|
app.chat_widget.render(area, &mut buffer);
|
||||||
|
(area.y..area.bottom())
|
||||||
|
.map(|y| {
|
||||||
|
(area.x..area.right())
|
||||||
|
.map(|x| buffer[(x, y)].symbol())
|
||||||
|
.collect::<String>()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_eligible_recap(app: &mut App, thread_id: ThreadId) {
|
||||||
|
app.active_thread_id = Some(thread_id);
|
||||||
|
app.transcript_cells
|
||||||
|
.push(Arc::new(crate::history_cell::UserHistoryCell {
|
||||||
|
message: "Finish the recap implementation".to_string(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
local_image_paths: Vec::new(),
|
||||||
|
remote_image_urls: Vec::new(),
|
||||||
|
}));
|
||||||
|
let ready_at = Instant::now() - recap::RECAP_DELAY;
|
||||||
|
app.recap.note_focus_lost(ready_at);
|
||||||
|
for _ in 0..3 {
|
||||||
|
app.recap
|
||||||
|
.note_turn_finished(&TurnStatus::Completed, ready_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn recap_generation_uses_bounded_structured_request_and_inserts_result() -> Result<()> {
|
||||||
|
let chunks = [
|
||||||
|
ev_response_created("recap-response"),
|
||||||
|
ev_assistant_message(
|
||||||
|
"recap-message",
|
||||||
|
r#"{"recap":"Finished parsing. Next: run focused tests."}"#,
|
||||||
|
),
|
||||||
|
ev_completed("recap-response"),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|event| StreamingSseChunk {
|
||||||
|
gate: None,
|
||||||
|
body: responses::sse(vec![event]),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let (model_server, _completions) = start_streaming_sse_server(vec![chunks]).await;
|
||||||
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||||
|
let codex_home = tempdir()?;
|
||||||
|
std::fs::write(
|
||||||
|
codex_home.path().join("config.toml"),
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
model = "{MODEL}"
|
||||||
|
model_provider = "{MODEL_PROVIDER_ID}"
|
||||||
|
|
||||||
|
[model_providers.{MODEL_PROVIDER_ID}]
|
||||||
|
name = "Recap generation test"
|
||||||
|
base_url = "{}/v1"
|
||||||
|
wire_api = "responses"
|
||||||
|
request_max_retries = 0
|
||||||
|
stream_max_retries = 0
|
||||||
|
"#,
|
||||||
|
model_server.uri()
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
||||||
|
app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs());
|
||||||
|
app.config.model = Some(MODEL.to_string());
|
||||||
|
app.config.model_provider_id = MODEL_PROVIDER_ID.to_string();
|
||||||
|
app.config.model_provider = ModelProviderInfo {
|
||||||
|
name: "Recap generation test".to_string(),
|
||||||
|
base_url: Some(format!("{}/v1", model_server.uri())),
|
||||||
|
request_max_retries: Some(0),
|
||||||
|
stream_max_retries: Some(0),
|
||||||
|
..ModelProviderInfo::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||||
|
let started = app_server.start_thread(&app.config).await?;
|
||||||
|
let thread_id = started.session.thread_id;
|
||||||
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||||
|
app.replace_chat_widget_with_app_server_thread(
|
||||||
|
&mut tui,
|
||||||
|
started,
|
||||||
|
ThreadAttachPresentation::SessionLineage,
|
||||||
|
/*initial_user_message*/ None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
while app_event_rx.try_recv().is_ok() {}
|
||||||
|
|
||||||
|
prepare_eligible_recap(&mut app, thread_id);
|
||||||
|
|
||||||
|
app.handle_event(
|
||||||
|
&mut tui,
|
||||||
|
&mut app_server,
|
||||||
|
AppEvent::CheckRecap { thread_id },
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let started_event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv())
|
||||||
|
.await?
|
||||||
|
.expect("recap start event");
|
||||||
|
assert!(matches!(started_event, AppEvent::RecapStarted { .. }));
|
||||||
|
app.handle_event(&mut tui, &mut app_server, started_event)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let generated_event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
event = app_event_rx.recv() => {
|
||||||
|
let event = event.expect("app event stream");
|
||||||
|
if matches!(event, AppEvent::RecapGenerated { .. }) {
|
||||||
|
break Ok::<_, color_eyre::Report>(event);
|
||||||
|
}
|
||||||
|
app.handle_event(&mut tui, &mut app_server, event).await?;
|
||||||
|
}
|
||||||
|
event = app_server.next_event() => {
|
||||||
|
let event = event.expect("app-server event stream");
|
||||||
|
app.handle_app_server_event(&app_server, event).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
app.handle_event(&mut tui, &mut app_server, generated_event)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let recap = app
|
||||||
|
.transcript_cells
|
||||||
|
.last()
|
||||||
|
.and_then(|cell| {
|
||||||
|
cell.as_any()
|
||||||
|
.downcast_ref::<crate::history_cell::ThreadRecapHistoryCell>()
|
||||||
|
})
|
||||||
|
.expect("recap history cell");
|
||||||
|
assert_eq!(
|
||||||
|
recap
|
||||||
|
.raw_lines()
|
||||||
|
.iter()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
"Conversation recap",
|
||||||
|
"Finished parsing. Next: run focused tests.",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let model_requests = model_server.requests().await;
|
||||||
|
assert_eq!(model_requests.len(), 1);
|
||||||
|
let request: Value = serde_json::from_slice(&model_requests[0])?;
|
||||||
|
let prompt = request["input"]
|
||||||
|
.as_array()
|
||||||
|
.and_then(|items| items.iter().rfind(|item| item["role"] == "user"))
|
||||||
|
.and_then(|item| item["content"].as_array())
|
||||||
|
.and_then(|content| content.iter().find(|item| item["type"] == "input_text"))
|
||||||
|
.and_then(|item| item["text"].as_str())
|
||||||
|
.expect("recap prompt");
|
||||||
|
assert!(
|
||||||
|
prompt.contains("Finish the recap implementation"),
|
||||||
|
"prompt: {prompt}\nrequest: {request}"
|
||||||
|
);
|
||||||
|
assert!(prompt.len() <= recap::RECAP_PROMPT_MAX_BYTES);
|
||||||
|
assert_eq!(request["text"]["format"]["type"], "json_schema");
|
||||||
|
assert_eq!(request["tools"], serde_json::json!([]));
|
||||||
|
|
||||||
|
app_server.shutdown().await?;
|
||||||
|
model_server.shutdown().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn manual_recap_bypasses_automatic_eligibility_and_reports_failure() -> Result<()> {
|
||||||
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||||
|
let thread_id = ThreadId::new();
|
||||||
|
app.active_thread_id = Some(thread_id);
|
||||||
|
app.transcript_cells
|
||||||
|
.push(Arc::new(crate::history_cell::UserHistoryCell {
|
||||||
|
message: "Summarize this conversation".to_string(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
local_image_paths: Vec::new(),
|
||||||
|
remote_image_urls: Vec::new(),
|
||||||
|
}));
|
||||||
|
let (mut app_server, _requests, proxy) = start_recording_remote_app_server(&app.config).await?;
|
||||||
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||||
|
|
||||||
|
app.handle_event(
|
||||||
|
&mut tui,
|
||||||
|
&mut app_server,
|
||||||
|
AppEvent::GenerateRecap { thread_id },
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(
|
||||||
|
render_chat_widget(&app).contains("Generating conversation recap…"),
|
||||||
|
"manual recap should show progress while generation is in flight"
|
||||||
|
);
|
||||||
|
let started_event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv())
|
||||||
|
.await?
|
||||||
|
.expect("manual recap start event");
|
||||||
|
let (request, temporary_thread_id) = match &started_event {
|
||||||
|
AppEvent::RecapStarted {
|
||||||
|
thread_id: requested_thread_id,
|
||||||
|
request_id,
|
||||||
|
trigger,
|
||||||
|
completed_turn_count,
|
||||||
|
turn_revision,
|
||||||
|
result: Ok(temporary_thread_id),
|
||||||
|
..
|
||||||
|
} if *requested_thread_id == thread_id && *trigger == RecapTrigger::Manual => (
|
||||||
|
recap::RecapRequest {
|
||||||
|
thread_id: *requested_thread_id,
|
||||||
|
request_id: *request_id,
|
||||||
|
trigger: *trigger,
|
||||||
|
completed_turn_count: *completed_turn_count,
|
||||||
|
turn_revision: *turn_revision,
|
||||||
|
},
|
||||||
|
ThreadId::from_string(temporary_thread_id).expect("temporary recap thread ID"),
|
||||||
|
),
|
||||||
|
other => panic!("expected a started manual recap, got {other:?}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
app.handle_event(&mut tui, &mut app_server, started_event)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
assert_eq!(app.temporary_structured_requests.len(), 1);
|
||||||
|
assert!(
|
||||||
|
app.handle_generated_recap(
|
||||||
|
request,
|
||||||
|
temporary_thread_id,
|
||||||
|
Err("temporary failure".to_string()),
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!render_chat_widget(&app).contains("Generating conversation recap…"),
|
||||||
|
"manual recap progress should clear when generation finishes"
|
||||||
|
);
|
||||||
|
|
||||||
|
let cell = match app_event_rx.try_recv() {
|
||||||
|
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||||||
|
other => panic!("expected visible manual recap error, got {other:?}"),
|
||||||
|
};
|
||||||
|
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
|
||||||
|
assert!(
|
||||||
|
rendered.contains("Could not generate a recap. Please try again."),
|
||||||
|
"rendered error: {rendered:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
app_server.shutdown().await?;
|
||||||
|
proxy.await??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recap_generation_uses_remote_workspace_cwd() -> Result<()> {
|
||||||
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||||
|
let remote_cwd = if cfg!(windows) {
|
||||||
|
PathBuf::from(r"C:\remote\project")
|
||||||
|
} else {
|
||||||
|
PathBuf::from("/srv/remote/project")
|
||||||
|
};
|
||||||
|
let (app_server, requests, proxy) = start_recording_remote_app_server(&app.config).await?;
|
||||||
|
let mut app_server = app_server.with_remote_cwd_override(Some(remote_cwd.clone()));
|
||||||
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||||
|
app.transcript_cells
|
||||||
|
.push(Arc::new(crate::history_cell::UserHistoryCell {
|
||||||
|
message: "Finish the recap implementation".to_string(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
local_image_paths: Vec::new(),
|
||||||
|
remote_image_urls: Vec::new(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.request_recap(&app_server, ThreadId::new(), RecapTrigger::Automatic);
|
||||||
|
let started_event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv())
|
||||||
|
.await?
|
||||||
|
.expect("recap start event");
|
||||||
|
app.handle_event(&mut tui, &mut app_server, started_event)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let starts = recorded_params(&requests, "thread/start");
|
||||||
|
assert_eq!(starts.len(), 1);
|
||||||
|
assert_eq!(starts[0]["cwd"], remote_cwd.to_string_lossy().into_owned());
|
||||||
|
assert_eq!(starts[0]["ephemeral"], true);
|
||||||
|
assert_eq!(starts[0]["environments"], serde_json::json!([]));
|
||||||
|
|
||||||
|
app_server.shutdown().await?;
|
||||||
|
proxy.await??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn temporary_recap_threads_disable_memories_and_remote_mcp_servers() -> Result<()> {
|
||||||
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||||
|
let codex_home = tempdir()?;
|
||||||
|
std::fs::write(
|
||||||
|
codex_home.path().join("config.toml"),
|
||||||
|
"[mcp_servers.filesystem]\ncommand = 'true'\n",
|
||||||
|
)?;
|
||||||
|
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
||||||
|
app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs());
|
||||||
|
let (app_server, requests, proxy) = start_recording_remote_app_server(&app.config).await?;
|
||||||
|
|
||||||
|
let config = app.chat_widget.config_ref();
|
||||||
|
let options = crate::temporary_structured_request::TemporaryStructuredThreadOptions {
|
||||||
|
model: app.chat_widget.current_model().to_string(),
|
||||||
|
model_provider: config.model_provider_id.clone(),
|
||||||
|
cwd: config.cwd.display().to_string(),
|
||||||
|
active_permission_profile: config
|
||||||
|
.permissions
|
||||||
|
.active_permission_profile()
|
||||||
|
.map(|profile| profile.id),
|
||||||
|
mcp_server_names: Vec::new(),
|
||||||
|
};
|
||||||
|
crate::temporary_structured_request::start_temporary_thread(
|
||||||
|
&app_server.request_handle(),
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let starts = recorded_params(&requests, "thread/start");
|
||||||
|
assert_eq!(starts.len(), 1);
|
||||||
|
assert_eq!(starts[0]["config"]["features.memories"], false);
|
||||||
|
assert!(starts[0]["config"].get("features.memory_tool").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
starts[0]["config"]["mcp_servers"]["filesystem"]["enabled"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
app_server.shutdown().await?;
|
||||||
|
proxy.await??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recap_check_rejects_a_non_displayed_thread() -> Result<()> {
|
||||||
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||||
|
let displayed_thread_id = ThreadId::new();
|
||||||
|
prepare_eligible_recap(&mut app, displayed_thread_id);
|
||||||
|
while app_event_rx.try_recv().is_ok() {}
|
||||||
|
let (mut app_server, requests, proxy) = start_recording_remote_app_server(&app.config).await?;
|
||||||
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||||
|
|
||||||
|
app.handle_event(
|
||||||
|
&mut tui,
|
||||||
|
&mut app_server,
|
||||||
|
AppEvent::CheckRecap {
|
||||||
|
thread_id: ThreadId::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
|
||||||
|
assert!(app_event_rx.try_recv().is_err());
|
||||||
|
assert_eq!(
|
||||||
|
recorded_params(&requests, "thread/start"),
|
||||||
|
Vec::<Value>::new()
|
||||||
|
);
|
||||||
|
|
||||||
|
app_server.shutdown().await?;
|
||||||
|
proxy.await??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recap_check_rejects_a_running_turn() -> Result<()> {
|
||||||
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||||
|
let thread_id = ThreadId::new();
|
||||||
|
prepare_eligible_recap(&mut app, thread_id);
|
||||||
|
app.chat_widget
|
||||||
|
.handle_thread_session(test_thread_session(thread_id, app.config.cwd.to_path_buf()));
|
||||||
|
app.chat_widget.handle_server_notification(
|
||||||
|
turn_started_notification(thread_id, "active-turn"),
|
||||||
|
/*replay_kind*/ None,
|
||||||
|
);
|
||||||
|
while app_event_rx.try_recv().is_ok() {}
|
||||||
|
let (mut app_server, requests, proxy) = start_recording_remote_app_server(&app.config).await?;
|
||||||
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||||
|
|
||||||
|
app.handle_event(
|
||||||
|
&mut tui,
|
||||||
|
&mut app_server,
|
||||||
|
AppEvent::CheckRecap { thread_id },
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
|
||||||
|
assert!(app_event_rx.try_recv().is_err());
|
||||||
|
assert_eq!(
|
||||||
|
recorded_params(&requests, "thread/start"),
|
||||||
|
Vec::<Value>::new()
|
||||||
|
);
|
||||||
|
|
||||||
|
app_server.shutdown().await?;
|
||||||
|
proxy.await??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -37,8 +37,8 @@ use tokio::task::JoinHandle;
|
|||||||
use tokio_tungstenite::accept_async;
|
use tokio_tungstenite::accept_async;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
type RecordedRequests = Arc<Mutex<Vec<JSONRPCRequest>>>;
|
pub(super) type RecordedRequests = Arc<Mutex<Vec<JSONRPCRequest>>>;
|
||||||
type RecordingAppServer = (AppServerSession, RecordedRequests, JoinHandle<Result<()>>);
|
pub(super) type RecordingAppServer = (AppServerSession, RecordedRequests, JoinHandle<Result<()>>);
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum HistoryCapabilities {
|
enum HistoryCapabilities {
|
||||||
@@ -75,6 +75,20 @@ pub(super) async fn start_recording_app_server(
|
|||||||
HistoryCapabilities::Current,
|
HistoryCapabilities::Current,
|
||||||
blocked_thread_list,
|
blocked_thread_list,
|
||||||
failed_thread_name,
|
failed_thread_name,
|
||||||
|
crate::app_server_session::ThreadParamsMode::Embedded,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn start_recording_remote_app_server(
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<RecordingAppServer> {
|
||||||
|
start_recording_app_server_with_history(
|
||||||
|
config,
|
||||||
|
HistoryCapabilities::Current,
|
||||||
|
/*blocked_thread_list*/ None,
|
||||||
|
/*failed_thread_name*/ None,
|
||||||
|
crate::app_server_session::ThreadParamsMode::Remote,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -85,6 +99,7 @@ async fn start_recording_app_server_with_history(
|
|||||||
history_capabilities: HistoryCapabilities,
|
history_capabilities: HistoryCapabilities,
|
||||||
mut blocked_thread_list: Option<(ThreadId, oneshot::Sender<()>, oneshot::Receiver<()>)>,
|
mut blocked_thread_list: Option<(ThreadId, oneshot::Sender<()>, oneshot::Receiver<()>)>,
|
||||||
failed_thread_name: Option<&'static str>,
|
failed_thread_name: Option<&'static str>,
|
||||||
|
thread_params_mode: crate::app_server_session::ThreadParamsMode,
|
||||||
) -> Result<RecordingAppServer> {
|
) -> Result<RecordingAppServer> {
|
||||||
let state_db =
|
let state_db =
|
||||||
crate::init_state_db_for_app_server_target(config, &crate::AppServerTarget::Embedded)
|
crate::init_state_db_for_app_server_target(config, &crate::AppServerTarget::Embedded)
|
||||||
@@ -299,11 +314,7 @@ async fn start_recording_app_server_with_history(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
AppServerSession::new(
|
AppServerSession::new(app_server, thread_params_mode).with_startup_config(config),
|
||||||
app_server,
|
|
||||||
crate::app_server_session::ThreadParamsMode::Embedded,
|
|
||||||
)
|
|
||||||
.with_startup_config(config),
|
|
||||||
requests,
|
requests,
|
||||||
proxy,
|
proxy,
|
||||||
))
|
))
|
||||||
@@ -330,7 +341,7 @@ fn create_history_rollout(
|
|||||||
Ok(ThreadId::from_string(&thread_id)?)
|
Ok(ThreadId::from_string(&thread_id)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn recorded_params(requests: &RecordedRequests, method: &str) -> Vec<serde_json::Value> {
|
pub(super) fn recorded_params(requests: &RecordedRequests, method: &str) -> Vec<serde_json::Value> {
|
||||||
requests
|
requests
|
||||||
.lock()
|
.lock()
|
||||||
.expect("request recorder lock")
|
.expect("request recorder lock")
|
||||||
@@ -930,6 +941,7 @@ async fn older_external_server_starts_without_unsupported_dynamic_tools_or_histo
|
|||||||
HistoryCapabilities::LegacyDynamicToolsAndHistory,
|
HistoryCapabilities::LegacyDynamicToolsAndHistory,
|
||||||
/*blocked_thread_list*/ None,
|
/*blocked_thread_list*/ None,
|
||||||
/*failed_thread_name*/ None,
|
/*failed_thread_name*/ None,
|
||||||
|
crate::app_server_session::ThreadParamsMode::Embedded,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -1762,6 +1774,7 @@ async fn remote_legacy_history_start_negotiates_once_for_resume_and_fork() -> Re
|
|||||||
HistoryCapabilities::LegacyOnly,
|
HistoryCapabilities::LegacyOnly,
|
||||||
/*blocked_thread_list*/ None,
|
/*blocked_thread_list*/ None,
|
||||||
/*failed_thread_name*/ None,
|
/*failed_thread_name*/ None,
|
||||||
|
crate::app_server_session::ThreadParamsMode::Embedded,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -1852,6 +1865,7 @@ async fn remote_legacy_history_start_retries_unsupported_paginated_variant() ->
|
|||||||
HistoryCapabilities::LegacyOnlyUnsupportedVariant,
|
HistoryCapabilities::LegacyOnlyUnsupportedVariant,
|
||||||
/*blocked_thread_list*/ None,
|
/*blocked_thread_list*/ None,
|
||||||
/*failed_thread_name*/ None,
|
/*failed_thread_name*/ None,
|
||||||
|
crate::app_server_session::ThreadParamsMode::Embedded,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -1882,6 +1896,7 @@ async fn assert_remote_legacy_history_retry(request: LegacyHistoryRequest) -> Re
|
|||||||
HistoryCapabilities::LegacyOnly,
|
HistoryCapabilities::LegacyOnly,
|
||||||
/*blocked_thread_list*/ None,
|
/*blocked_thread_list*/ None,
|
||||||
/*failed_thread_name*/ None,
|
/*failed_thread_name*/ None,
|
||||||
|
crate::app_server_session::ThreadParamsMode::Embedded,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -1947,6 +1962,7 @@ async fn paginated_fork_survives_post_response_hydration_failure() -> Result<()>
|
|||||||
HistoryCapabilities::ForkHydrationFails,
|
HistoryCapabilities::ForkHydrationFails,
|
||||||
/*blocked_thread_list*/ None,
|
/*blocked_thread_list*/ None,
|
||||||
/*failed_thread_name*/ None,
|
/*failed_thread_name*/ None,
|
||||||
|
crate::app_server_session::ThreadParamsMode::Embedded,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,13 @@ pub(crate) enum ThreadTitleDestination {
|
|||||||
RenameSuggestion { request_id: Uuid },
|
RenameSuggestion { request_id: Uuid },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Identifies the policy that initiated a recap request.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum RecapTrigger {
|
||||||
|
Automatic,
|
||||||
|
Manual,
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::large_enum_variant)]
|
#[allow(clippy::large_enum_variant)]
|
||||||
#[derive(Debug, IntoStaticStr)]
|
#[derive(Debug, IntoStaticStr)]
|
||||||
pub(crate) enum AppEvent {
|
pub(crate) enum AppEvent {
|
||||||
@@ -1326,16 +1333,21 @@ pub(crate) enum AppEvent {
|
|||||||
action: String,
|
action: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Generate a recap for the displayed idle thread at the user's request.
|
||||||
|
GenerateRecap {
|
||||||
|
thread_id: ThreadId,
|
||||||
|
},
|
||||||
|
|
||||||
/// Recheck whether an unfocused thread is ready for an automatic recap.
|
/// Recheck whether an unfocused thread is ready for an automatic recap.
|
||||||
CheckRecap {
|
CheckRecap {
|
||||||
thread_id: ThreadId,
|
thread_id: ThreadId,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Deliver the result of starting an automatic recap's temporary thread.
|
/// Deliver the result of starting a recap's temporary thread.
|
||||||
#[allow(dead_code)]
|
|
||||||
RecapStarted {
|
RecapStarted {
|
||||||
thread_id: ThreadId,
|
thread_id: ThreadId,
|
||||||
request_id: Uuid,
|
request_id: Uuid,
|
||||||
|
trigger: RecapTrigger,
|
||||||
completed_turn_count: usize,
|
completed_turn_count: usize,
|
||||||
turn_revision: usize,
|
turn_revision: usize,
|
||||||
history: String,
|
history: String,
|
||||||
@@ -1343,10 +1355,10 @@ pub(crate) enum AppEvent {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/// Deliver the generated recap from a temporary structured turn.
|
/// Deliver the generated recap from a temporary structured turn.
|
||||||
#[allow(dead_code)]
|
|
||||||
RecapGenerated {
|
RecapGenerated {
|
||||||
thread_id: ThreadId,
|
thread_id: ThreadId,
|
||||||
request_id: Uuid,
|
request_id: Uuid,
|
||||||
|
trigger: RecapTrigger,
|
||||||
temporary_thread_id: ThreadId,
|
temporary_thread_id: ThreadId,
|
||||||
completed_turn_count: usize,
|
completed_turn_count: usize,
|
||||||
turn_revision: usize,
|
turn_revision: usize,
|
||||||
|
|||||||
+1
@@ -23,6 +23,7 @@ expression: commands
|
|||||||
/app - continue this session in the Desktop app
|
/app - continue this session in the Desktop app
|
||||||
/init - create an AGENTS.md file with instructions for Codex
|
/init - create an AGENTS.md file with instructions for Codex
|
||||||
/compact - summarize conversation to prevent hitting the context limit
|
/compact - summarize conversation to prevent hitting the context limit
|
||||||
|
/recap - summarize the current conversation now
|
||||||
/agents - view and switch between all active agent sessions
|
/agents - view and switch between all active agent sessions
|
||||||
/side - start a side conversation in an ephemeral fork
|
/side - start a side conversation in an ephemeral fork
|
||||||
/copy - copy the last response, code block, or quote
|
/copy - copy the last response, code block, or quote
|
||||||
|
|||||||
@@ -401,6 +401,7 @@ use self::rate_limits::RateLimitWarningState;
|
|||||||
use self::rate_limits::app_server_rate_limit_error_kind;
|
use self::rate_limits::app_server_rate_limit_error_kind;
|
||||||
pub(crate) use self::rate_limits::fallback_limit_label;
|
pub(crate) use self::rate_limits::fallback_limit_label;
|
||||||
use self::rate_limits::is_app_server_cyber_policy_error;
|
use self::rate_limits::is_app_server_cyber_policy_error;
|
||||||
|
mod recap;
|
||||||
mod reset_credits;
|
mod reset_credits;
|
||||||
pub(crate) use self::rate_limits::limit_label_for_window;
|
pub(crate) use self::rate_limits::limit_label_for_window;
|
||||||
mod reasoning_shortcuts;
|
mod reasoning_shortcuts;
|
||||||
|
|||||||
@@ -377,6 +377,11 @@ impl ChatWidget {
|
|||||||
// App-event submissions are handled serially, and turn/start can wait on remote work.
|
// App-event submissions are handled serially, and turn/start can wait on remote work.
|
||||||
// Queue the optimistic prompt first so the user's input is visible while that happens.
|
// Queue the optimistic prompt first so the user's input is visible while that happens.
|
||||||
// Direct submissions do not share that queue, so keep their existing failure behavior.
|
// Direct submissions do not share that queue, so keep their existing failure behavior.
|
||||||
|
if render_in_history {
|
||||||
|
// Do not let a transient manual-recap progress cell become permanent terminal
|
||||||
|
// scrollback when the new user prompt flushes the active history cell.
|
||||||
|
self.clear_recap_loading();
|
||||||
|
}
|
||||||
let render_before_submit =
|
let render_before_submit =
|
||||||
render_in_history && matches!(&self.codex_op_target, CodexOpTarget::AppEvent);
|
render_in_history && matches!(&self.codex_op_target, CodexOpTarget::AppEvent);
|
||||||
if render_before_submit {
|
if render_before_submit {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//! Manual recap progress presentation for `ChatWidget`.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl ChatWidget {
|
||||||
|
pub(crate) fn show_recap_loading(&mut self) {
|
||||||
|
self.flush_answer_stream_with_separator();
|
||||||
|
self.flush_active_cell();
|
||||||
|
self.transcript.active_cell = Some(Box::new(history_cell::ThreadRecapLoadingCell::new(
|
||||||
|
self.config.animations,
|
||||||
|
)));
|
||||||
|
self.bump_active_cell_revision();
|
||||||
|
self.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clear_recap_loading(&mut self) {
|
||||||
|
let Some(active) = self.transcript.active_cell.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !active.as_any().is::<history_cell::ThreadRecapLoadingCell>() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.transcript.active_cell = None;
|
||||||
|
self.bump_active_cell_revision();
|
||||||
|
self.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -273,6 +273,16 @@ impl ChatWidget {
|
|||||||
self.input_queue.user_turn_pending_start = true;
|
self.input_queue.user_turn_pending_start = true;
|
||||||
self.app_event_tx.compact();
|
self.app_event_tx.compact();
|
||||||
}
|
}
|
||||||
|
SlashCommand::Recap => {
|
||||||
|
let Some(thread_id) = self.thread_id else {
|
||||||
|
self.add_error_message(
|
||||||
|
"Session is still starting; try /recap again in a moment.".to_string(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.app_event_tx
|
||||||
|
.send(AppEvent::GenerateRecap { thread_id });
|
||||||
|
}
|
||||||
SlashCommand::Review => {
|
SlashCommand::Review => {
|
||||||
self.open_review_popup();
|
self.open_review_popup();
|
||||||
if self.mcp_startup_status.is_some() {
|
if self.mcp_startup_status.is_some() {
|
||||||
@@ -1139,6 +1149,7 @@ impl ChatWidget {
|
|||||||
| SlashCommand::Diff
|
| SlashCommand::Diff
|
||||||
| SlashCommand::App
|
| SlashCommand::App
|
||||||
| SlashCommand::Rename
|
| SlashCommand::Rename
|
||||||
|
| SlashCommand::Recap
|
||||||
| SlashCommand::TestApproval => QueueDrain::Continue,
|
| SlashCommand::TestApproval => QueueDrain::Continue,
|
||||||
SlashCommand::Cd => match self.thread_id {
|
SlashCommand::Cd => match self.thread_id {
|
||||||
Some(thread_id) if self.can_change_working_directory(thread_id) => QueueDrain::Stop,
|
Some(thread_id) if self.can_change_working_directory(thread_id) => QueueDrain::Stop,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::app_event::ConnectorsSnapshot;
|
use crate::app_event::ConnectorsSnapshot;
|
||||||
|
use crate::history_cell::ThreadRecapLoadingCell;
|
||||||
use codex_protocol::models::ManagedFileSystemPermissions;
|
use codex_protocol::models::ManagedFileSystemPermissions;
|
||||||
use codex_protocol::permissions::FileSystemAccessMode;
|
use codex_protocol::permissions::FileSystemAccessMode;
|
||||||
use codex_protocol::permissions::FileSystemPath;
|
use codex_protocol::permissions::FileSystemPath;
|
||||||
@@ -32,6 +33,25 @@ fn assert_hidden_shell_payload_is_literal(op: Result<Op, TryRecvError>, payload:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn user_submission_does_not_commit_recap_loading_to_history() {
|
||||||
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
chat.thread_id = Some(ThreadId::new());
|
||||||
|
chat.show_recap_loading();
|
||||||
|
while rx.try_recv().is_ok() {}
|
||||||
|
|
||||||
|
chat.submit_user_message(UserMessage::from("Continue with the task"));
|
||||||
|
|
||||||
|
let mut saw_user_message = false;
|
||||||
|
while let Ok(event) = rx.try_recv() {
|
||||||
|
if let AppEvent::InsertHistoryCell(cell) = event {
|
||||||
|
assert!(!cell.as_any().is::<ThreadRecapLoadingCell>());
|
||||||
|
saw_user_message |= cell.as_any().is::<UserHistoryCell>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(saw_user_message);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn hidden_shell_paste_submits_literal_prompt() {
|
async fn hidden_shell_paste_submits_literal_prompt() {
|
||||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
|||||||
@@ -177,6 +177,22 @@ async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
|
|||||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn slash_recap_requests_generation_for_current_thread() {
|
||||||
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
let thread_id = ThreadId::new();
|
||||||
|
chat.thread_id = Some(thread_id);
|
||||||
|
|
||||||
|
chat.dispatch_command(SlashCommand::Recap);
|
||||||
|
|
||||||
|
assert_matches!(
|
||||||
|
rx.try_recv(),
|
||||||
|
Ok(AppEvent::GenerateRecap {
|
||||||
|
thread_id: requested_thread_id,
|
||||||
|
}) if requested_thread_id == thread_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn queued_slash_compact_dispatches_after_active_turn() {
|
async fn queued_slash_compact_dispatches_after_active_turn() {
|
||||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
|||||||
@@ -230,6 +230,52 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
|
|||||||
PlainHistoryCell { lines }
|
PlainHistoryCell { lines }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct ThreadRecapLoadingCell {
|
||||||
|
start_time: Instant,
|
||||||
|
animations_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThreadRecapLoadingCell {
|
||||||
|
pub(crate) fn new(animations_enabled: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
start_time: Instant::now(),
|
||||||
|
animations_enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistoryCell for ThreadRecapLoadingCell {
|
||||||
|
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||||
|
vec![
|
||||||
|
vec![
|
||||||
|
activity_indicator(
|
||||||
|
Some(self.start_time),
|
||||||
|
MotionMode::from_animations_enabled(self.animations_enabled),
|
||||||
|
ReducedMotionIndicator::StaticBullet,
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|| "•".dim()),
|
||||||
|
" ".into(),
|
||||||
|
"Generating conversation recap".bold(),
|
||||||
|
"…".dim(),
|
||||||
|
]
|
||||||
|
.into(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||||
|
vec![Line::from("Generating conversation recap...")]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transcript_animation_tick(&self) -> Option<u64> {
|
||||||
|
if !self.animations_enabled {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((self.start_time.elapsed().as_millis() / 50) as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(test), allow(dead_code))]
|
#[cfg_attr(not(test), allow(dead_code))]
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct ThreadRecapHistoryCell {
|
pub(crate) struct ThreadRecapHistoryCell {
|
||||||
|
|||||||
@@ -1420,6 +1420,14 @@ fn mcp_inventory_loading_without_animations_is_stable() {
|
|||||||
assert_eq!(first, vec!["• Loading MCP inventory…".to_string()]);
|
assert_eq!(first, vec!["• Loading MCP inventory…".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thread_recap_loading_without_animations_snapshot() {
|
||||||
|
let cell = ThreadRecapLoadingCell::new(/*animations_enabled*/ false);
|
||||||
|
let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n");
|
||||||
|
|
||||||
|
insta::assert_snapshot!(rendered, @"• Generating conversation recap…");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn completed_mcp_tool_call_success_snapshot() {
|
fn completed_mcp_tool_call_success_snapshot() {
|
||||||
let invocation = McpInvocation {
|
let invocation = McpInvocation {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ pub enum SlashCommand {
|
|||||||
App,
|
App,
|
||||||
Init,
|
Init,
|
||||||
Compact,
|
Compact,
|
||||||
|
Recap,
|
||||||
Plan,
|
Plan,
|
||||||
Goal,
|
Goal,
|
||||||
Agents,
|
Agents,
|
||||||
@@ -90,6 +91,7 @@ impl SlashCommand {
|
|||||||
SlashCommand::New => "start a new chat during a conversation",
|
SlashCommand::New => "start a new chat during a conversation",
|
||||||
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
||||||
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
||||||
|
SlashCommand::Recap => "summarize the current conversation now",
|
||||||
SlashCommand::Review => "review my current changes and find issues",
|
SlashCommand::Review => "review my current changes and find issues",
|
||||||
SlashCommand::Rename => "rename the current thread",
|
SlashCommand::Rename => "rename the current thread",
|
||||||
SlashCommand::Resume => "resume a saved chat",
|
SlashCommand::Resume => "resume a saved chat",
|
||||||
@@ -210,6 +212,7 @@ impl SlashCommand {
|
|||||||
| SlashCommand::Fork
|
| SlashCommand::Fork
|
||||||
| SlashCommand::Init
|
| SlashCommand::Init
|
||||||
| SlashCommand::Compact
|
| SlashCommand::Compact
|
||||||
|
| SlashCommand::Recap
|
||||||
| SlashCommand::Export
|
| SlashCommand::Export
|
||||||
| SlashCommand::Keymap
|
| SlashCommand::Keymap
|
||||||
| SlashCommand::Vim
|
| SlashCommand::Vim
|
||||||
|
|||||||
Reference in new issue
Block a user