diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e282e924fc..a57055ba31 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -99,6 +99,7 @@ pub enum CodexErrorInfo { Unauthorized, BadRequest, ThreadRollbackFailed, + ContextClearFailed, SandboxError, /// The response SSE stream disconnected in the middle of a turn before completion. ResponseStreamDisconnected { @@ -130,6 +131,7 @@ impl From for CodexErrorInfo { CoreCodexErrorInfo::Unauthorized => CodexErrorInfo::Unauthorized, CoreCodexErrorInfo::BadRequest => CodexErrorInfo::BadRequest, CoreCodexErrorInfo::ThreadRollbackFailed => CodexErrorInfo::ThreadRollbackFailed, + CoreCodexErrorInfo::ContextClearFailed => CodexErrorInfo::ContextClearFailed, CoreCodexErrorInfo::SandboxError => CodexErrorInfo::SandboxError, CoreCodexErrorInfo::ResponseStreamDisconnected { http_status_code } => { CodexErrorInfo::ResponseStreamDisconnected { http_status_code } diff --git a/codex-rs/codex-api/src/requests/chat.rs b/codex-rs/codex-api/src/requests/chat.rs index 5c16a5fb58..bf168207a8 100644 --- a/codex-rs/codex-api/src/requests/chat.rs +++ b/codex-rs/codex-api/src/requests/chat.rs @@ -75,6 +75,7 @@ impl<'a> ChatRequestBuilder<'a> { ResponseItem::WebSearchCall { .. } => {} ResponseItem::GhostSnapshot { .. } => {} ResponseItem::Compaction { .. } => {} + ResponseItem::ContextCleared => last_emitted_role = None, } } @@ -282,6 +283,10 @@ impl<'a> ChatRequestBuilder<'a> { ResponseItem::GhostSnapshot { .. } => { continue; } + ResponseItem::ContextCleared => { + last_assistant_text = None; + continue; + } ResponseItem::Reasoning { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::Other diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9fc2bae165..d4f0ac58a0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1560,7 +1560,7 @@ impl Session { if let Some(replacement) = &compacted.replacement_history { history.replace(replacement.clone()); } else { - let user_messages = collect_user_messages(history.raw_items()); + let user_messages = collect_user_messages(history.active_items()); let rebuilt = compact::build_compacted_history( self.build_initial_context(turn_context).await, &user_messages, @@ -2184,6 +2184,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::ThreadRollback { num_turns } => { handlers::thread_rollback(&sess, sub.id.clone(), num_turns).await; } + Op::ClearContext => { + handlers::clear_context(&sess, sub.id.clone()).await; + } Op::RunUserShellCommand { command } => { handlers::run_user_shell_command( &sess, @@ -2232,7 +2235,9 @@ mod handlers { use crate::tasks::UndoTask; use crate::tasks::UserShellCommandTask; use codex_protocol::custom_prompts::CustomPrompt; + use codex_protocol::models::ResponseItem; use codex_protocol::protocol::CodexErrorInfo; + use codex_protocol::protocol::ContextClearedEvent; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -2672,6 +2677,40 @@ mod handlers { .await; } + pub async fn clear_context(sess: &Arc, sub_id: String) { + let has_active_turn = { sess.active_turn.lock().await.is_some() }; + if has_active_turn { + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message: "Cannot clear context while a turn is in progress.".to_string(), + codex_error_info: Some(CodexErrorInfo::ContextClearFailed), + }), + }) + .await; + return; + } + + let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; + let initial_context = sess.build_initial_context(turn_context.as_ref()).await; + + let mut items = Vec::with_capacity(initial_context.len() + 1); + items.push(ResponseItem::ContextCleared); + items.extend(initial_context); + + sess.record_into_history(&items, turn_context.as_ref()) + .await; + sess.persist_rollout_response_items(&items).await; + sess.send_event_raw_flushed(Event { + id: turn_context.sub_id.clone(), + msg: EventMsg::ContextCleared(ContextClearedEvent), + }) + .await; + sess.recompute_token_usage(turn_context.as_ref()).await; + sess.send_raw_response_items(turn_context.as_ref(), &items) + .await; + } + pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; sess.services @@ -3909,6 +3948,69 @@ mod tests { assert_eq!(initial_context, history.raw_items()); } + #[tokio::test] + async fn clear_context_appends_marker_and_initial_context() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; + + let initial_context = sess.build_initial_context(tc.as_ref()).await; + sess.record_into_history(&initial_context, tc.as_ref()) + .await; + + let turn = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "turn user".to_string(), + }], + end_turn: None, + }, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "turn assistant".to_string(), + }], + end_turn: None, + }, + ]; + sess.record_into_history(&turn, tc.as_ref()).await; + + handlers::clear_context(&sess, "sub-1".to_string()).await; + wait_for_context_cleared(&rx).await; + + let mut expected = Vec::new(); + expected.extend(initial_context.clone()); + expected.extend(turn.clone()); + expected.push(ResponseItem::ContextCleared); + expected.extend(initial_context.clone()); + + let history = sess.clone_history().await; + assert_eq!(expected, history.raw_items()); + assert_eq!(initial_context, history.active_items()); + } + + #[tokio::test] + async fn clear_context_fails_when_turn_in_progress() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; + + let initial_context = sess.build_initial_context(tc.as_ref()).await; + sess.record_into_history(&initial_context, tc.as_ref()) + .await; + + *sess.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + handlers::clear_context(&sess, "sub-1".to_string()).await; + + let error_event = wait_for_context_clear_failed(&rx).await; + assert_eq!( + error_event.codex_error_info, + Some(CodexErrorInfo::ContextClearFailed) + ); + + let history = sess.clone_history().await; + assert_eq!(initial_context, history.raw_items()); + } + #[tokio::test] async fn set_rate_limits_retains_previous_credits() { let codex_home = tempfile::tempdir().expect("create temp dir"); @@ -4206,6 +4308,44 @@ mod tests { } } + async fn wait_for_context_cleared( + rx: &async_channel::Receiver, + ) -> crate::protocol::ContextClearedEvent { + let deadline = StdDuration::from_secs(2); + let start = std::time::Instant::now(); + loop { + let remaining = deadline.saturating_sub(start.elapsed()); + let evt = tokio::time::timeout(remaining, rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); + match evt.msg { + EventMsg::ContextCleared(payload) => return payload, + _ => continue, + } + } + } + + async fn wait_for_context_clear_failed(rx: &async_channel::Receiver) -> ErrorEvent { + let deadline = StdDuration::from_secs(2); + let start = std::time::Instant::now(); + loop { + let remaining = deadline.saturating_sub(start.elapsed()); + let evt = tokio::time::timeout(remaining, rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); + match evt.msg { + EventMsg::Error(payload) + if payload.codex_error_info == Some(CodexErrorInfo::ContextClearFailed) => + { + return payload; + } + _ => continue, + } + } + } + fn text_block(s: &str) -> ContentBlock { ContentBlock::TextContent(TextContent { annotations: None, diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index c365d2cfda..0a370ce681 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -171,7 +171,7 @@ async fn run_compact_task_inner( } let history_snapshot = sess.clone_history().await; - let history_items = history_snapshot.raw_items(); + let history_items = history_snapshot.active_items(); let summary_suffix = get_last_assistant_message_from_turn(history_items).unwrap_or_default(); let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}"); let user_messages = collect_user_messages(history_items); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index aaa7fc68a7..529351bc42 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -44,7 +44,7 @@ async fn run_remote_compact_task_inner_impl( // Required to keep `/undo` available after compaction let ghost_snapshots: Vec = history - .raw_items() + .active_items() .iter() .filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. })) .cloned() diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 080b701d42..4342f7144d 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -71,6 +71,9 @@ impl ContextManager { /// Returns the history prepared for sending to the model. This applies a proper /// normalization and drop un-suited items. pub(crate) fn for_prompt(mut self) -> Vec { + if let Some(clear_idx) = last_context_cleared_index(&self.items) { + self.items.drain(..=clear_idx); + } self.normalize_history(); self.items .retain(|item| !matches!(item, ResponseItem::GhostSnapshot { .. })); @@ -82,6 +85,12 @@ impl ContextManager { &self.items } + /// Returns the effective items after the most recent context clear marker. + pub(crate) fn active_items(&self) -> &[ResponseItem] { + let start_idx = first_active_index(&self.items); + &self.items[start_idx..] + } + // Estimate token usage using byte-based heuristics from the truncation helpers. // This is a coarse lower bound, not a tokenizer-accurate count. pub(crate) fn estimate_token_count(&self, turn_context: &TurnContext) -> Option { @@ -92,7 +101,7 @@ impl ContextManager { let base_instructions = model_info.get_model_instructions(personality); let base_tokens = i64::try_from(approx_token_count(&base_instructions)).unwrap_or(i64::MAX); - let items_tokens = self.items.iter().fold(0i64, |acc, item| { + let items_tokens = self.active_items().iter().fold(0i64, |acc, item| { acc + match item { ResponseItem::GhostSnapshot { .. } => 0, ResponseItem::Reasoning { @@ -117,10 +126,10 @@ impl ContextManager { } pub(crate) fn remove_first_item(&mut self) { - if !self.items.is_empty() { - // Remove the oldest item (front of the list). Items are ordered from - // oldest → newest, so index 0 is the first entry recorded. - let removed = self.items.remove(0); + let active_start = first_active_index(&self.items); + if active_start < self.items.len() { + // Remove the oldest *effective* item after the most recent clear marker. + let removed = self.items.remove(active_start); // If the removed item participates in a call/output pair, also remove // its corresponding counterpart to keep the invariants intact without // running a full normalization pass. @@ -135,12 +144,14 @@ impl ContextManager { /// Replace image content in the last turn if it originated from a tool output. /// Returns true when a tool image was replaced, false otherwise. pub(crate) fn replace_last_turn_images(&mut self, placeholder: &str) -> bool { - let Some(index) = self.items.iter().rposition(|item| { + let start_idx = first_active_index(&self.items); + let Some(rel_index) = self.items[start_idx..].iter().rposition(|item| { matches!(item, ResponseItem::FunctionCallOutput { .. }) || matches!(item, ResponseItem::Message { role, .. } if role == "user") }) else { return false; }; + let index = start_idx + rel_index; match &mut self.items[index] { ResponseItem::FunctionCallOutput { output, .. } => { @@ -179,7 +190,11 @@ impl ContextManager { } let snapshot = self.items.clone(); - let user_positions = user_message_positions(&snapshot); + let active_start = first_active_index(&snapshot); + let prefix = snapshot[..active_start].to_vec(); + let active = snapshot[active_start..].to_vec(); + + let user_positions = user_message_positions(&active); let Some(&first_user_idx) = user_positions.first() else { self.replace(snapshot); return; @@ -192,7 +207,9 @@ impl ContextManager { user_positions[user_positions.len() - n_from_end] }; - self.replace(snapshot[..cut_idx].to_vec()); + let mut new_items = prefix; + new_items.extend(active[..cut_idx].iter().cloned()); + self.replace(new_items); } pub(crate) fn update_token_info( @@ -208,17 +225,17 @@ impl ContextManager { } fn get_non_last_reasoning_items_tokens(&self) -> usize { + let active_start = first_active_index(&self.items); + let active_items = &self.items[active_start..]; // get reasoning items excluding all the ones after the last user message - let Some(last_user_index) = self - .items + let Some(last_user_index) = active_items .iter() .rposition(|item| matches!(item, ResponseItem::Message { role, .. } if role == "user")) else { return 0usize; }; - let total_reasoning_bytes = self - .items + let total_reasoning_bytes = active_items .iter() .take(last_user_index) .filter_map(|item| { @@ -301,13 +318,14 @@ impl ContextManager { | ResponseItem::CustomToolCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::GhostSnapshot { .. } + | ResponseItem::ContextCleared | ResponseItem::Other => item.clone(), } } } /// API messages include every non-system item (user/assistant messages, reasoning, -/// tool calls, tool outputs, shell calls, and web-search calls). +/// tool calls, tool outputs, shell calls, web-search calls, and context markers). fn is_api_message(message: &ResponseItem) -> bool { match message { ResponseItem::Message { role, .. } => role.as_str() != "system", @@ -318,12 +336,23 @@ fn is_api_message(message: &ResponseItem) -> bool { | ResponseItem::LocalShellCall { .. } | ResponseItem::Reasoning { .. } | ResponseItem::WebSearchCall { .. } - | ResponseItem::Compaction { .. } => true, + | ResponseItem::Compaction { .. } + | ResponseItem::ContextCleared => true, ResponseItem::GhostSnapshot { .. } => false, ResponseItem::Other => false, } } +fn last_context_cleared_index(items: &[ResponseItem]) -> Option { + items + .iter() + .rposition(|item| matches!(item, ResponseItem::ContextCleared)) +} + +fn first_active_index(items: &[ResponseItem]) -> usize { + last_context_cleared_index(items).map_or(0, |idx| idx.saturating_add(1)) +} + fn estimate_reasoning_length(encoded_len: usize) -> usize { encoded_len .saturating_mul(3) diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 2d4a79cc22..62385a9e0b 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -28,7 +28,8 @@ pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool { | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::GhostSnapshot { .. } - | ResponseItem::Compaction { .. } => true, + | ResponseItem::Compaction { .. } + | ResponseItem::ContextCleared => true, ResponseItem::Other => false, } } @@ -43,6 +44,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::AgentReasoningRawContent(_) | EventMsg::TokenCount(_) | EventMsg::ContextCompacted(_) + | EventMsg::ContextCleared(_) | EventMsg::EnteredReviewMode(_) | EventMsg::ExitedReviewMode(_) | EventMsg::ThreadRolledBack(_) diff --git a/codex-rs/core/src/rollout/truncation.rs b/codex-rs/core/src/rollout/truncation.rs index e6a84628fc..15847ea335 100644 --- a/codex-rs/core/src/rollout/truncation.rs +++ b/codex-rs/core/src/rollout/truncation.rs @@ -14,9 +14,10 @@ use codex_protocol::protocol::RolloutItem; /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` /// whose parsed turn item is `TurnItem::UserMessage`. /// -/// Rollouts can contain `ThreadRolledBack` markers. Those markers indicate that the -/// last N user turns were removed from the effective thread history; we apply them here so -/// indexing uses the post-rollback history rather than the raw stream. +/// Rollouts can contain context reset markers (e.g., `ThreadRolledBack`, +/// `ContextCleared`). Those markers indicate that older user turns no longer +/// contribute to the effective thread history; we apply them here so indexing +/// uses the post-reset history rather than the raw stream. pub(crate) fn user_message_positions_in_rollout(items: &[RolloutItem]) -> Vec { let mut user_positions = Vec::new(); for (idx, item) in items.iter().enumerate() { @@ -34,6 +35,9 @@ pub(crate) fn user_message_positions_in_rollout(items: &[RolloutItem]) -> Vec { + user_positions.clear(); + } _ => {} } } @@ -75,6 +79,7 @@ mod tests { use assert_matches::assert_matches; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemReasoningSummary; + use codex_protocol::protocol::ContextClearedEvent; use codex_protocol::protocol::ThreadRolledBackEvent; use pretty_assertions::assert_eq; @@ -188,6 +193,26 @@ mod tests { ); } + #[test] + fn truncates_rollout_from_start_respects_context_cleared_marker() { + let rollout_items = vec![ + RolloutItem::ResponseItem(user_msg("u1")), + RolloutItem::ResponseItem(assistant_msg("a1")), + RolloutItem::EventMsg(EventMsg::ContextCleared(ContextClearedEvent)), + RolloutItem::ResponseItem(user_msg("u2")), + RolloutItem::ResponseItem(assistant_msg("a2")), + RolloutItem::ResponseItem(user_msg("u3")), + RolloutItem::ResponseItem(assistant_msg("a3")), + ]; + + let truncated = truncate_rollout_before_nth_user_message_from_start(&rollout_items, 1); + let expected = rollout_items[..5].to_vec(); + assert_eq!( + serde_json::to_value(&truncated).unwrap(), + serde_json::to_value(&expected).unwrap() + ); + } + #[tokio::test] async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/tasks/undo.rs b/codex-rs/core/src/tasks/undo.rs index bb3b6d3152..6880cdecb2 100644 --- a/codex-rs/core/src/tasks/undo.rs +++ b/codex-rs/core/src/tasks/undo.rs @@ -66,19 +66,20 @@ impl SessionTask for UndoTask { let history = sess.clone_history().await; let mut items = history.raw_items().to_vec(); + let active_start = items.len().saturating_sub(history.active_items().len()); let mut completed = UndoCompletedEvent { success: false, message: None, }; let Some((idx, ghost_commit)) = - items + items[active_start..] .iter() .enumerate() .rev() - .find_map(|(idx, item)| match item { + .find_map(|(rel_idx, item)| match item { ResponseItem::GhostSnapshot { ghost_commit } => { - Some((idx, ghost_commit.clone())) + Some((active_start + rel_idx, ghost_commit.clone())) } _ => None, }) diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index f5579008b2..2be382d43d 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -68,6 +68,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `Op::UserTurn` – Any input from the user to kick off a `Turn` - `Op::UserInput` – Legacy form of user input - `Op::Interrupt` – Interrupts a running turn + - `Op::ClearContext` – Reset the conversation back to the initial context boundary - `Op::ExecApproval` – Approve or deny code execution - `Op::UserInputAnswer` – Provide answers for a `request_user_input` tool call - `Op::ListSkills` – Request skills for one or more cwd values (optionally `force_reload`) @@ -76,6 +77,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `EventMsg::AgentMessage` – Messages from the `Model` - `EventMsg::ExecApprovalRequest` – Request approval from user to execute a command - `EventMsg::RequestUserInput` – Request user input for a tool call + - `EventMsg::ContextCleared` – Signals that prior conversation history should be cleared - `EventMsg::TurnComplete` – A turn completed successfully - `EventMsg::Error` – A turn stopped with an error - `EventMsg::Warning` – A non-fatal warning that the client should surface to the user diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index c4a5c27d9c..377ab60ad0 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -607,6 +607,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::UndoCompleted(_) | EventMsg::UndoStarted(_) | EventMsg::ThreadRolledBack(_) + | EventMsg::ContextCleared(_) | EventMsg::RequestUserInput(_) => {} } CodexStatus::Running diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index b400b4cc79..5a415dfe84 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -361,6 +361,7 @@ async fn run_codex_tool_session_inner( | EventMsg::ExitedReviewMode(_) | EventMsg::RequestUserInput(_) | EventMsg::ContextCompacted(_) + | EventMsg::ContextCleared(_) | EventMsg::ThreadRolledBack(_) | EventMsg::CollabAgentSpawnBegin(_) | EventMsg::CollabAgentSpawnEnd(_) diff --git a/codex-rs/otel/src/traces/otel_manager.rs b/codex-rs/otel/src/traces/otel_manager.rs index 7ce411970c..46f3baf446 100644 --- a/codex-rs/otel/src/traces/otel_manager.rs +++ b/codex-rs/otel/src/traces/otel_manager.rs @@ -503,6 +503,7 @@ impl OtelManager { ResponseItem::WebSearchCall { .. } => "web_search_call".into(), ResponseItem::GhostSnapshot { .. } => "ghost_snapshot".into(), ResponseItem::Compaction { .. } => "compaction".into(), + ResponseItem::ContextCleared => "context_cleared".into(), ResponseItem::Other => "other".into(), } } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 57241bb8b6..4abc65e31e 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -163,6 +163,7 @@ pub enum ResponseItem { GhostSnapshot { ghost_commit: GhostCommit, }, + ContextCleared, #[serde(alias = "compaction_summary")] Compaction { encrypted_content: String, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7bf3c90eab..2e15dedb1a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -265,6 +265,13 @@ pub enum Op { /// responsible for undoing any edits on disk. ThreadRollback { num_turns: u32 }, + /// Request Codex to clear the effective context while keeping initial context. + /// + /// This is append-only: a context-cleared marker is recorded alongside a + /// fresh copy of the initial context, and prompt construction ignores the + /// earlier conversation history. + ClearContext, + /// Request a code review from the agent. Review { review_request: ReviewRequest }, @@ -675,6 +682,9 @@ pub enum EventMsg { /// Conversation history was compacted (either automatically or manually). ContextCompacted(ContextCompactedEvent), + /// Conversation context was cleared back to the initial context. + ContextCleared(ContextClearedEvent), + /// Conversation history was rolled back by dropping the last N user turns. ThreadRolledBack(ThreadRolledBackEvent), @@ -927,6 +937,7 @@ pub enum CodexErrorInfo { http_status_code: Option, }, ThreadRollbackFailed, + ContextClearFailed, Other, } @@ -1066,6 +1077,9 @@ pub struct WarningEvent { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ContextCompactedEvent; +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct ContextClearedEvent; + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnCompleteEvent { pub last_agent_message: Option, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1d53f5d70d..58de3b6b93 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -821,13 +821,17 @@ impl App { Ok(()) } - fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> { + fn reset_transcript_state(&mut self) { self.overlay = None; self.transcript_cells.clear(); self.deferred_history_lines.clear(); self.has_emitted_history_lines = false; self.backtrack = BacktrackState::default(); self.backtrack_render_pending = false; + } + + fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> { + self.reset_transcript_state(); tui.terminal.clear_scrollback()?; tui.terminal.clear()?; Ok(()) @@ -2050,12 +2054,32 @@ impl App { self.chat_widget.handle_codex_event(event); } + fn reset_for_context_clear(&mut self) { + self.reset_transcript_state(); + if let Some(thread_id) = self + .active_thread_id + .clone() + .or_else(|| self.chat_widget.thread_id()) + { + let session_configured = self.session_configured_for_thread(thread_id); + self.chat_widget.reset_for_context_clear(session_configured); + } + } + fn handle_codex_event_replay(&mut self, event: Event) { + if matches!(&event.msg, EventMsg::ContextCleared(_)) { + self.reset_for_context_clear(); + } self.handle_backtrack_event(&event.msg); self.chat_widget.handle_codex_event_replay(event); } fn handle_active_thread_event(&mut self, tui: &mut tui::Tui, event: Event) -> Result<()> { + if matches!(&event.msg, EventMsg::ContextCleared(_)) { + self.reset_for_context_clear(); + tui.terminal.clear_scrollback()?; + tui.terminal.clear()?; + } self.handle_codex_event_now(event); if self.backtrack_render_pending { tui.frame_requester().schedule_frame(); diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 256355dd05..5335e8961e 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -454,6 +454,7 @@ impl App { pub(crate) fn handle_backtrack_event(&mut self, event: &EventMsg) { match event { EventMsg::ThreadRolledBack(_) => self.finish_pending_backtrack(), + EventMsg::ContextCleared(_) => self.backtrack.pending_rollback = None, EventMsg::Error(ErrorEvent { codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed), .. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ce87b33eed..ae656d8af9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -763,6 +763,58 @@ impl ChatWidget { } } + pub(crate) fn reset_for_context_clear( + &mut self, + event: codex_core::protocol::SessionConfiguredEvent, + ) { + self.active_cell = None; + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + self.stream_controller = None; + self.running_commands.clear(); + self.suppressed_exec_calls.clear(); + self.last_unified_wait = None; + self.unified_exec_wait_streak = None; + self.task_complete_pending = false; + self.clear_unified_exec_processes(); + self.agent_turn_running = false; + self.update_task_running_state(); + self.interrupts = InterruptManager::default(); + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.retry_status_header = None; + self.queued_user_messages.clear(); + self.pending_notification = None; + self.needs_final_message_separator = false; + self.had_work_activity = false; + self.saw_plan_update_this_turn = false; + self.last_separator_elapsed_secs = None; + self.last_rendered_width.set(None); + self.clear_token_usage(); + self.refresh_queued_user_messages(); + + self.bottom_pane + .set_history_metadata(event.history_log_id, event.history_entry_count); + self.thread_id = Some(event.session_id); + self.forked_from = event.forked_from_id; + self.current_rollout_path = event.rollout_path.clone(); + let model_for_header = event.model.clone(); + let reasoning_effort = event + .reasoning_effort + .clone() + .or_else(|| self.current_collaboration_mode.reasoning_effort()); + self.session_header.set_model(&model_for_header); + self.current_collaboration_mode = self.current_collaboration_mode.with_updates( + Some(model_for_header.clone()), + Some(reasoning_effort), + None, + ); + self.refresh_model_display(); + let session_info_cell = + history_cell::new_session_info(&self.config, &model_for_header, event, false); + self.apply_session_info_cell(session_info_cell); + self.request_redraw(); + } + fn set_skills(&mut self, skills: Option>) { self.bottom_pane.set_skills(skills); } @@ -3025,7 +3077,7 @@ impl ChatWidget { EventMsg::CollabWaitingEnd(ev) => self.on_collab_event(collab::waiting_end(ev)), EventMsg::CollabCloseBegin(_) => {} EventMsg::CollabCloseEnd(ev) => self.on_collab_event(collab::close_end(ev)), - EventMsg::ThreadRolledBack(_) => {} + EventMsg::ThreadRolledBack(_) | EventMsg::ContextCleared(_) => {} EventMsg::RawResponseItem(_) | EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_)