Add a TUI setting to disable automatic recaps (#42101)

## What changed

- Add the `tui.auto_recap` configuration option, enabled by default.
- When disabled, cancel scheduled automatic recap checks, reject automatic
  requests, and discard pending automatic results without retrying.
- Keep manual `/recap` requests available regardless of the setting.

## Testing

- Cover configuration defaults and command-line overrides.
- Cover scheduling cancellation, pending request cleanup, discarded results,
  and manual recaps while automatic recaps are disabled.

GitOrigin-RevId: 7d5323f58a2c84e60683a25a8c77653097efe6e2
This commit is contained in:
Felipe Coury
2026-09-01 17:09:13 +00:00
committed by copyberry
parent b6ab99ed87
commit 8ea297ff60
14 changed files with 229 additions and 47 deletions
+1 -2
View File
@@ -833,8 +833,7 @@ impl App {
self.recap.note_focus_lost(now);
if let Some(thread_id) = thread_id {
self.recap
.schedule_check(thread_id, self.app_event_tx.clone(), now);
self.schedule_recap_check(thread_id, now);
}
}
TuiEvent::FocusGained => {
+37 -23
View File
@@ -1,4 +1,5 @@
//! Determines when an unfocused conversation is ready for an automatic recap.
//! The TUI opt-out suppresses automatic scheduling and requests, but not manual `/recap`.
use std::sync::Arc;
use std::time::Duration;
@@ -176,6 +177,33 @@ pub(super) struct RecapRequest {
}
impl App {
fn recap_trigger_enabled(&self, trigger: RecapTrigger) -> bool {
match trigger {
RecapTrigger::Automatic => self.config.tui_auto_recap,
RecapTrigger::Manual => true,
}
}
pub(super) fn schedule_recap_check(&mut self, thread_id: ThreadId, now: Instant) {
if let Some(task) = self.recap.scheduled_check.take() {
task.abort();
}
if !self.config.tui_auto_recap {
return;
}
let Some(deadline) = self.recap.next_check_deadline() else {
return;
};
let delay = deadline.saturating_duration_since(now);
let app_event_tx = self.app_event_tx.clone();
self.recap.scheduled_check = Some(tokio::spawn(async move {
tokio::time::sleep(delay).await;
app_event_tx.send(AppEvent::CheckRecap { thread_id });
}));
}
pub(super) fn clear_recap_request(&mut self, trigger: RecapTrigger) {
self.recap.clear_in_flight_request();
@@ -200,6 +228,9 @@ impl App {
}
fn retry_or_report_recap_failure(&mut self, request: RecapRequest) {
if !self.recap_trigger_enabled(request.trigger) {
return;
}
match request.trigger {
RecapTrigger::Automatic => self.recap.schedule_retry(
request.thread_id,
@@ -218,6 +249,9 @@ impl App {
thread_id: ThreadId,
trigger: RecapTrigger,
) {
if !self.recap_trigger_enabled(trigger) {
return;
}
if self.recap.in_flight_request.is_some() {
if matches!(trigger, RecapTrigger::Manual) {
self.chat_widget
@@ -332,6 +366,7 @@ impl App {
&& !self.chat_widget.is_user_turn_pending_or_running()
&& self.recap.completed_turns == completed_turn_count
&& self.recap.turn_revision == turn_revision
&& self.recap_trigger_enabled(trigger)
&& trigger_is_eligible;
let Ok(temporary_thread_id) = ThreadId::from_string(&temporary_thread_id_text) else {
if is_current_request {
@@ -408,7 +443,8 @@ impl App {
self.clear_recap_request(trigger);
if self.current_displayed_thread_id() != Some(thread_id)
if !self.recap_trigger_enabled(trigger)
|| self.current_displayed_thread_id() != Some(thread_id)
|| self.chat_widget.is_user_turn_pending_or_running()
|| self.recap.completed_turns != completed_turn_count
|| self.recap.turn_revision != turn_revision
@@ -541,28 +577,6 @@ impl RecapState {
self.last_turn_finished_at = Some(now);
}
pub(super) fn schedule_check(
&mut self,
thread_id: ThreadId,
app_event_tx: AppEventSender,
now: Instant,
) {
if let Some(task) = self.scheduled_check.take() {
task.abort();
}
let Some(deadline) = self.next_check_deadline() else {
return;
};
let delay = deadline.saturating_duration_since(now);
self.scheduled_check = Some(tokio::spawn(async move {
tokio::time::sleep(delay).await;
app_event_tx.send(AppEvent::CheckRecap { thread_id });
}));
}
pub(super) fn schedule_retry(
&mut self,
thread_id: ThreadId,
+61 -11
View File
@@ -318,14 +318,15 @@ fn regaining_focus_preserves_only_manual_in_flight_request() {
async fn scheduled_check_fires_at_recap_deadline() {
let thread_id = ThreadId::new();
let now = Instant::now();
let mut state = RecapState::default();
let mut app = make_test_app().await;
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(event_tx);
state.note_focus_lost(now);
app.recap.note_focus_lost(now);
for _ in 0..3 {
state.note_turn_finished(&TurnStatus::Completed, now);
app.recap.note_turn_finished(&TurnStatus::Completed, now);
}
state.schedule_check(thread_id, AppEventSender::new(event_tx), now);
app.schedule_recap_check(thread_id, now);
tokio::task::yield_now().await;
tokio::time::advance(RECAP_DELAY - Duration::from_secs(/*secs*/ 1)).await;
@@ -346,22 +347,24 @@ async fn scheduled_check_fires_at_recap_deadline() {
async fn rescheduling_check_cancels_the_earlier_timer() {
let thread_id = ThreadId::new();
let first_turn = Instant::now();
let mut state = RecapState::default();
let mut app = make_test_app().await;
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
let app_event_tx = AppEventSender::new(event_tx);
app.app_event_tx = AppEventSender::new(event_tx);
state.note_focus_lost(first_turn);
app.recap.note_focus_lost(first_turn);
for _ in 0..3 {
state.note_turn_finished(&TurnStatus::Completed, first_turn);
app.recap
.note_turn_finished(&TurnStatus::Completed, first_turn);
}
state.schedule_check(thread_id, app_event_tx.clone(), first_turn);
app.schedule_recap_check(thread_id, first_turn);
tokio::task::yield_now().await;
let elapsed = Duration::from_secs(/*secs*/ 1);
tokio::time::advance(elapsed).await;
let later_turn = first_turn + elapsed;
state.note_turn_finished(&TurnStatus::Completed, later_turn);
state.schedule_check(thread_id, app_event_tx, later_turn);
app.recap
.note_turn_finished(&TurnStatus::Completed, later_turn);
app.schedule_recap_check(thread_id, later_turn);
tokio::task::yield_now().await;
tokio::time::advance(RECAP_DELAY - elapsed).await;
@@ -378,6 +381,32 @@ async fn rescheduling_check_cancels_the_earlier_timer() {
));
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn auto_recap_opt_out_cancels_scheduling_after_restored_progress() {
let thread_id = ThreadId::new();
let now = Instant::now();
let mut app = make_test_app().await;
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(event_tx);
app.recap.note_focus_lost(now);
let progress = RecapProgress {
completed_turns: 3,
last_recapped_turn_count: None,
};
app.recap.seed_from_progress(progress, now);
app.schedule_recap_check(thread_id, now);
tokio::task::yield_now().await;
app.config.tui_auto_recap = false;
app.schedule_recap_check(thread_id, now);
tokio::time::advance(RECAP_DELAY).await;
tokio::task::yield_now().await;
assert!(app.recap.scheduled_check.is_none());
assert!(matches!(event_rx.try_recv(), Err(TryRecvError::Empty)));
assert_eq!(app.recap.progress(), progress);
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn dropping_recap_state_cancels_timer() {
let mut state = RecapState::default();
@@ -620,6 +649,27 @@ async fn generated_recap_is_returned_for_synchronous_insertion() {
);
}
#[tokio::test]
async fn auto_recap_opt_out_discards_results_without_retrying() {
let thread_id = ThreadId::new();
let mut app = app_with_visible_thread(thread_id).await;
for result in [
Ok(serde_json::json!({ "recap": "obsolete" }).to_string()),
Err("temporary failure".to_string()),
] {
let (request, temporary_thread_id) = track_in_flight_recap(&mut app, thread_id);
app.config.tui_auto_recap = false;
assert!(
app.handle_generated_recap(request, temporary_thread_id, result)
.is_none()
);
assert!(app.recap.in_flight_request.is_none());
assert!(app.recap.scheduled_check.is_none());
assert_eq!(app.recap.last_recapped_turn_count, None);
}
}
#[tokio::test]
async fn obsolete_recap_result_does_not_clear_the_current_request() {
let thread_id = ThreadId::new();
+1 -2
View File
@@ -327,8 +327,7 @@ impl App {
self.chat_widget.pause_unavailable_thread();
self.chat_widget.add_info_message("This conversation is unavailable. Its cached transcript and draft remain here; input is paused. Open the agent picker or return to the parent to continue.".into(), /*hint*/ None);
} else {
self.recap
.schedule_check(id, self.app_event_tx.clone(), Instant::now());
self.schedule_recap_check(id, Instant::now());
}
} else {
self.active_thread_id = None;
+1 -2
View File
@@ -620,8 +620,7 @@ impl App {
}
let now = Instant::now();
self.recap.seed_from_progress(recap_progress, now);
self.recap
.schedule_check(thread_id, self.app_event_tx.clone(), now);
self.schedule_recap_check(thread_id, now);
self.render_thread_snapshot(tui, app_server, thread_id, snapshot, !is_replay_only)?;
if is_replay_only {
@@ -180,8 +180,7 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
app.recap
.note_turn_finished(&TurnStatus::Completed, before_disconnect);
}
app.recap
.schedule_check(id, app.app_event_tx.clone(), Instant::now());
app.schedule_recap_check(id, Instant::now());
let old_sender = app.app_event_tx.clone();
let connected = reconnect(
app.app_server_target.clone(),
@@ -191,8 +191,9 @@ stream_max_retries = 0
}
#[tokio::test]
async fn manual_recap_bypasses_automatic_eligibility_and_reports_failure() -> Result<()> {
async fn manual_recap_works_when_auto_recap_disabled() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.config.tui_auto_recap = false;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
app.transcript_cells
@@ -205,6 +206,8 @@ async fn manual_recap_bypasses_automatic_eligibility_and_reports_failure() -> Re
let (mut app_server, _requests, proxy) = start_recording_remote_app_server(&app.config).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
// An automatic request must not occupy the slot needed by a manual request.
app.request_recap(&app_server, thread_id, RecapTrigger::Automatic);
app.handle_event(
&mut tui,
&mut app_server,
@@ -295,6 +298,72 @@ async fn manual_recap_bypasses_automatic_eligibility_and_reports_failure() -> Re
Ok(())
}
#[tokio::test]
async fn auto_recap_opt_out_blocks_requests_and_cleans_up_pending_start() -> 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);
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.config.tui_auto_recap = false;
app.schedule_recap_check(thread_id, Instant::now());
app.request_recap(&app_server, thread_id, RecapTrigger::Automatic);
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()
);
let rendered = app
.transcript_cells
.iter()
.flat_map(|cell| cell.display_lines(/*width*/ 80))
.collect::<Vec<_>>();
app.config.tui_auto_recap = true;
app.request_recap(&app_server, thread_id, RecapTrigger::Automatic);
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.config.tui_auto_recap = false;
app.handle_event(&mut tui, &mut app_server, started_event)
.await?;
tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async {
while recorded_params(&requests, "thread/unsubscribe").is_empty() {
tokio::task::yield_now().await;
}
})
.await?;
// Finish the unsubscribe round trip before shutting down the recording proxy.
app_server
.thread_loaded_list(codex_app_server_protocol::ThreadLoadedListParams {
cursor: None,
limit: None,
})
.await?;
assert_eq!(
recorded_params(&requests, "turn/start"),
Vec::<Value>::new()
);
assert!(app.temporary_structured_requests.is_empty());
app_server.shutdown().await?;
proxy.await??;
insta::assert_snapshot!(
"auto_recap_disabled_transcript",
lines_to_single_string(&rendered)
);
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;
@@ -0,0 +1,6 @@
---
source: tui/src/app/tests/recap_generation_tests.rs
expression: lines_to_single_string(&rendered)
---
› Finish the recap implementation
+2 -4
View File
@@ -973,11 +973,10 @@ impl App {
&& let ServerNotification::TurnCompleted(notification) = &notification
{
let now = Instant::now();
let app_event_tx = self.app_event_tx.clone();
self.recap
.note_turn_finished(&notification.turn.status, now);
self.recap.schedule_check(thread_id, app_event_tx, now);
self.schedule_recap_check(thread_id, now);
}
let misalignment_policy_violation =
match &notification {
@@ -1340,10 +1339,9 @@ impl App {
.send(AppEvent::BeginInitialHistoryReplayBuffer);
}
let now = Instant::now();
let app_event_tx = self.app_event_tx.clone();
self.recap.seed_from_turns(&turns, now);
self.recap.schedule_check(thread_id, app_event_tx, now);
self.schedule_recap_check(thread_id, now);
self.chat_widget
.replay_thread_turns(turns, ReplayKind::ResumeInitialMessages);