mirror of
https://github.com/openai/codex.git
synced 2026-10-31 19:28:49 +00:00
tui: show rollback turn-context diffs
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
co-authored by
Codex
parent
af3e7742f4
commit
9803d01715
@@ -1491,7 +1491,10 @@ mod tests {
|
||||
message: "A2".into(),
|
||||
phase: None,
|
||||
}),
|
||||
EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
|
||||
EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: None,
|
||||
}),
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "Third".into(),
|
||||
images: None,
|
||||
@@ -1574,7 +1577,10 @@ mod tests {
|
||||
message: "A2".into(),
|
||||
phase: None,
|
||||
}),
|
||||
EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 99 }),
|
||||
EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 99,
|
||||
rolled_back_to_turn_context: None,
|
||||
}),
|
||||
];
|
||||
|
||||
let items = events
|
||||
|
||||
@@ -199,6 +199,12 @@ pub(crate) struct PreviousTurnSettings {
|
||||
pub(crate) realtime_active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct AppliedRolloutReconstruction {
|
||||
previous_turn_settings: Option<PreviousTurnSettings>,
|
||||
surviving_turn_context_item: Option<TurnContextItem>,
|
||||
}
|
||||
|
||||
use crate::exec_policy::ExecPolicyUpdateError;
|
||||
use crate::feedback_tags;
|
||||
use crate::file_watcher::FileWatcher;
|
||||
@@ -941,9 +947,11 @@ impl TurnContext {
|
||||
current_date: self.current_date.clone(),
|
||||
timezone: self.timezone.clone(),
|
||||
approval_policy: self.approval_policy.value(),
|
||||
approvals_reviewer: self.config.approvals_reviewer,
|
||||
sandbox_policy: self.sandbox_policy.get().clone(),
|
||||
network: self.turn_context_network_item(),
|
||||
model: self.model_info.slug.clone(),
|
||||
service_tier: self.config.service_tier,
|
||||
personality: self.personality,
|
||||
collaboration_mode: Some(self.collaboration_mode.clone()),
|
||||
realtime_active: Some(self.realtime_active),
|
||||
@@ -2076,9 +2084,10 @@ impl Session {
|
||||
}
|
||||
InitialHistory::Resumed(resumed_history) => {
|
||||
let rollout_items = resumed_history.history;
|
||||
self.apply_rollout_reconstruction(&turn_context, &rollout_items)
|
||||
let reconstructed_rollout = self
|
||||
.apply_rollout_reconstruction(&turn_context, &rollout_items)
|
||||
.await;
|
||||
let previous_turn_settings = self.previous_turn_settings().await;
|
||||
let previous_turn_settings = reconstructed_rollout.previous_turn_settings;
|
||||
|
||||
// If resuming, warn when the last recorded model differs from the current one.
|
||||
let curr: &str = turn_context.model_info.slug.as_str();
|
||||
@@ -2153,15 +2162,25 @@ impl Session {
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
rollout_items: &[RolloutItem],
|
||||
) {
|
||||
) -> AppliedRolloutReconstruction {
|
||||
let reconstructed_rollout = self
|
||||
.reconstruct_history_from_rollout(turn_context, rollout_items)
|
||||
.await;
|
||||
let previous_turn_settings = reconstructed_rollout
|
||||
.reference_turn_context_state
|
||||
.previous_turn_settings();
|
||||
let surviving_turn_context_item = reconstructed_rollout
|
||||
.reference_turn_context_state
|
||||
.latest_turn_context_item();
|
||||
let mut state = self.state.lock().await;
|
||||
state.replace_history_with_reference_turn_context_state(
|
||||
reconstructed_rollout.history,
|
||||
reconstructed_rollout.reference_turn_context_state,
|
||||
);
|
||||
AppliedRolloutReconstruction {
|
||||
previous_turn_settings,
|
||||
surviving_turn_context_item,
|
||||
}
|
||||
}
|
||||
|
||||
fn last_token_info_from_rollout(rollout_items: &[RolloutItem]) -> Option<TokenUsageInfo> {
|
||||
@@ -5068,19 +5087,26 @@ mod handlers {
|
||||
}
|
||||
};
|
||||
|
||||
let rollback_event = ThreadRolledBackEvent { num_turns };
|
||||
let rollback_msg = EventMsg::ThreadRolledBack(rollback_event.clone());
|
||||
let rollback_msg = EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns,
|
||||
rolled_back_to_turn_context: None,
|
||||
});
|
||||
let replay_items = initial_history
|
||||
.get_rollout_items()
|
||||
.into_iter()
|
||||
.chain(std::iter::once(RolloutItem::EventMsg(rollback_msg.clone())))
|
||||
.collect::<Vec<_>>();
|
||||
let reconstructed_rollout = sess
|
||||
.apply_rollout_reconstruction(turn_context.as_ref(), replay_items.as_slice())
|
||||
.await;
|
||||
sess.recompute_token_usage(turn_context.as_ref()).await;
|
||||
let rollback_msg = EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns,
|
||||
rolled_back_to_turn_context: reconstructed_rollout.surviving_turn_context_item,
|
||||
});
|
||||
sess.persist_rollout_items(&[RolloutItem::EventMsg(rollback_msg.clone())])
|
||||
.await;
|
||||
sess.flush_rollout().await;
|
||||
sess.apply_rollout_reconstruction(turn_context.as_ref(), replay_items.as_slice())
|
||||
.await;
|
||||
sess.recompute_token_usage(turn_context.as_ref()).await;
|
||||
|
||||
sess.deliver_event_raw(Event {
|
||||
id: turn_context.sub_id.clone(),
|
||||
|
||||
@@ -45,9 +45,11 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
approvals_reviewer: turn_context.config.approvals_reviewer,
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
model: previous_model.to_string(),
|
||||
service_tier: turn_context.config.service_tier,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
@@ -84,9 +86,11 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
approvals_reviewer: turn_context.config.approvals_reviewer,
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
model: previous_model.to_string(),
|
||||
service_tier: turn_context.config.service_tier,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
@@ -215,7 +219,10 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
|
||||
codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 },
|
||||
codex_protocol::protocol::ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: None,
|
||||
},
|
||||
)),
|
||||
];
|
||||
|
||||
@@ -313,7 +320,10 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
|
||||
)),
|
||||
RolloutItem::ResponseItem(turn_two_user),
|
||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
|
||||
codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 },
|
||||
codex_protocol::protocol::ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: None,
|
||||
},
|
||||
)),
|
||||
];
|
||||
|
||||
@@ -438,7 +448,10 @@ async fn reconstruct_history_rollback_backfills_surviving_turn_context_from_olde
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
|
||||
codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 },
|
||||
codex_protocol::protocol::ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: None,
|
||||
},
|
||||
)),
|
||||
];
|
||||
|
||||
@@ -565,7 +578,10 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
|
||||
codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 },
|
||||
codex_protocol::protocol::ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: None,
|
||||
},
|
||||
)),
|
||||
];
|
||||
|
||||
@@ -632,7 +648,10 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
|
||||
codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 99 },
|
||||
codex_protocol::protocol::ThreadRolledBackEvent {
|
||||
num_turns: 99,
|
||||
rolled_back_to_turn_context: None,
|
||||
},
|
||||
)),
|
||||
];
|
||||
|
||||
@@ -702,7 +721,10 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
|
||||
codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 },
|
||||
codex_protocol::protocol::ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: None,
|
||||
},
|
||||
)),
|
||||
];
|
||||
|
||||
@@ -771,7 +793,10 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
|
||||
replacement_history: Some(Vec::new()),
|
||||
}),
|
||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
|
||||
codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 },
|
||||
codex_protocol::protocol::ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: None,
|
||||
},
|
||||
)),
|
||||
];
|
||||
|
||||
@@ -934,9 +959,11 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
approvals_reviewer: turn_context.config.approvals_reviewer,
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
model: previous_model.to_string(),
|
||||
service_tier: turn_context.config.service_tier,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
@@ -1006,9 +1033,11 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
approvals_reviewer: turn_context.config.approvals_reviewer,
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
model: previous_model.to_string(),
|
||||
service_tier: turn_context.config.service_tier,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
@@ -1035,9 +1064,11 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
approvals_reviewer: turn_context.config.approvals_reviewer,
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
model: previous_model.to_string(),
|
||||
service_tier: turn_context.config.service_tier,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
@@ -1141,9 +1172,11 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
approvals_reviewer: turn_context.config.approvals_reviewer,
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
model: current_model.to_string(),
|
||||
service_tier: turn_context.config.service_tier,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
@@ -1243,9 +1276,11 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
approvals_reviewer: turn_context.config.approvals_reviewer,
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
model: previous_model.to_string(),
|
||||
service_tier: turn_context.config.service_tier,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
@@ -1387,9 +1422,11 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
approvals_reviewer: turn_context.config.approvals_reviewer,
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
model: previous_model.to_string(),
|
||||
service_tier: turn_context.config.service_tier,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
|
||||
@@ -1043,9 +1043,11 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
approvals_reviewer: turn_context.config.approvals_reviewer,
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
model: previous_model.to_string(),
|
||||
service_tier: turn_context.config.service_tier,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
@@ -1284,6 +1286,12 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context
|
||||
handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await;
|
||||
let rollback_event = wait_for_thread_rolled_back(&rx).await;
|
||||
assert_eq!(rollback_event.num_turns, 1);
|
||||
assert_eq!(
|
||||
serde_json::to_value(rollback_event.rolled_back_to_turn_context)
|
||||
.expect("serialize rollback turn context"),
|
||||
serde_json::to_value(Some(first_context_item.clone()))
|
||||
.expect("serialize expected rollback turn context")
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sess.clone_history().await.raw_items(),
|
||||
@@ -1385,7 +1393,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
|
||||
.await;
|
||||
sess.replace_history(
|
||||
vec![assistant_message("stale history")],
|
||||
Some(first_context_item),
|
||||
Some(first_context_item.clone()),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1395,6 +1403,126 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
|
||||
|
||||
assert_eq!(sess.clone_history().await.raw_items(), compacted_history);
|
||||
assert!(sess.reference_context_item().await.is_none());
|
||||
assert_eq!(
|
||||
serde_json::to_value(rollback_event.rolled_back_to_turn_context)
|
||||
.expect("serialize rollback turn context"),
|
||||
serde_json::to_value(Some(first_context_item))
|
||||
.expect("serialize expected rollback turn context")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_rollback_emits_surviving_turn_context_when_compaction_clears_reference_baseline() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
attach_rollout_recorder(&sess).await;
|
||||
|
||||
let first_context_item = tc.to_turn_context_item();
|
||||
let first_turn_id = first_context_item
|
||||
.turn_id
|
||||
.clone()
|
||||
.expect("turn context should have turn_id");
|
||||
let compact_turn_id = "compact-turn".to_string();
|
||||
let rolled_back_turn_id = "rolled-back-turn".to_string();
|
||||
let compacted_context_item = TurnContextItem {
|
||||
turn_id: Some(compact_turn_id.clone()),
|
||||
model: "compacted-model".to_string(),
|
||||
..first_context_item.clone()
|
||||
};
|
||||
let compacted_history = vec![
|
||||
user_message("turn 1 user"),
|
||||
assistant_message("turn 1 assistant"),
|
||||
user_message("summary after compaction"),
|
||||
];
|
||||
|
||||
sess.persist_rollout_items(&[
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: first_turn_id.clone(),
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "turn 1 user".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
})),
|
||||
RolloutItem::TurnContext(first_context_item.clone()),
|
||||
RolloutItem::ResponseItem(user_message("turn 1 user")),
|
||||
RolloutItem::ResponseItem(assistant_message("turn 1 assistant")),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: first_turn_id,
|
||||
last_agent_message: None,
|
||||
})),
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: compact_turn_id.clone(),
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "turn 2 user".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
})),
|
||||
RolloutItem::TurnContext(compacted_context_item.clone()),
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: "summary after compaction".to_string(),
|
||||
replacement_history: Some(compacted_history.clone()),
|
||||
}),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: compact_turn_id,
|
||||
last_agent_message: None,
|
||||
})),
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: rolled_back_turn_id.clone(),
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "turn 3 user".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
})),
|
||||
RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some(rolled_back_turn_id.clone()),
|
||||
model: "rolled-back-model".to_string(),
|
||||
..first_context_item.clone()
|
||||
}),
|
||||
RolloutItem::ResponseItem(user_message("turn 3 user")),
|
||||
RolloutItem::ResponseItem(assistant_message("turn 3 assistant")),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: rolled_back_turn_id,
|
||||
last_agent_message: None,
|
||||
})),
|
||||
])
|
||||
.await;
|
||||
|
||||
handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await;
|
||||
let rollback_event = wait_for_thread_rolled_back(&rx).await;
|
||||
|
||||
assert_eq!(rollback_event.num_turns, 1);
|
||||
assert_eq!(
|
||||
serde_json::to_value(rollback_event.rolled_back_to_turn_context)
|
||||
.expect("serialize rollback turn context"),
|
||||
serde_json::to_value(Some(compacted_context_item))
|
||||
.expect("serialize expected rollback turn context")
|
||||
);
|
||||
assert_eq!(sess.clone_history().await.raw_items(), compacted_history);
|
||||
assert_eq!(
|
||||
sess.previous_turn_settings().await,
|
||||
Some(PreviousTurnSettings {
|
||||
model: "compacted-model".to_string(),
|
||||
realtime_active: Some(tc.realtime_active),
|
||||
})
|
||||
);
|
||||
assert!(sess.reference_context_item().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::features::Feature;
|
||||
use chrono::TimeZone;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -480,9 +481,11 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
network: None,
|
||||
model: "test-model".to_string(),
|
||||
service_tier: None,
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
realtime_active: None,
|
||||
|
||||
@@ -102,6 +102,7 @@ fn truncates_rollout_from_start_applies_thread_rollback_markers() {
|
||||
RolloutItem::ResponseItem(assistant_msg("a2")),
|
||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: None,
|
||||
})),
|
||||
RolloutItem::ResponseItem(user_msg("u3")),
|
||||
RolloutItem::ResponseItem(assistant_msg("a3")),
|
||||
|
||||
@@ -32,9 +32,11 @@ fn resume_history(
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: config.approvals_reviewer,
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
model: previous_model.to_string(),
|
||||
service_tier: config.service_tier,
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
realtime_active: None,
|
||||
|
||||
@@ -2457,10 +2457,15 @@ pub struct TurnContextItem {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timezone: Option<String>,
|
||||
pub approval_policy: AskForApproval,
|
||||
#[serde(default)]
|
||||
pub approvals_reviewer: ApprovalsReviewer,
|
||||
pub sandbox_policy: SandboxPolicy,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub network: Option<TurnContextNetworkItem>,
|
||||
pub model: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub service_tier: Option<ServiceTier>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub personality: Option<Personality>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -2750,6 +2755,10 @@ pub struct UndoCompletedEvent {
|
||||
pub struct ThreadRolledBackEvent {
|
||||
/// Number of user turns that were removed from context.
|
||||
pub num_turns: u32,
|
||||
/// Historical turn context for the turn that remains selected after rollback.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub rolled_back_to_turn_context: Option<TurnContextItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
@@ -4253,6 +4262,8 @@ mod tests {
|
||||
|
||||
assert_eq!(item.trace_id, None);
|
||||
assert_eq!(item.network, None);
|
||||
assert_eq!(item.approvals_reviewer, ApprovalsReviewer::User);
|
||||
assert_eq!(item.service_tier, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4265,12 +4276,14 @@ mod tests {
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
network: Some(TurnContextNetworkItem {
|
||||
allowed_domains: vec!["api.example.com".to_string()],
|
||||
denied_domains: vec!["blocked.example.com".to_string()],
|
||||
}),
|
||||
model: "gpt-5".to_string(),
|
||||
service_tier: None,
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
realtime_active: None,
|
||||
@@ -4293,6 +4306,17 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_rolled_back_event_deserializes_without_turn_context() -> Result<()> {
|
||||
let event: ThreadRolledBackEvent = serde_json::from_value(json!({
|
||||
"num_turns": 2,
|
||||
}))?;
|
||||
|
||||
assert_eq!(event.num_turns, 2);
|
||||
assert!(event.rolled_back_to_turn_context.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize Event to verify that its JSON representation has the expected
|
||||
/// amount of nesting.
|
||||
#[test]
|
||||
|
||||
@@ -268,9 +268,11 @@ mod tests {
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
network: None,
|
||||
model: "gpt-5".to_string(),
|
||||
service_tier: None,
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
realtime_active: None,
|
||||
@@ -306,9 +308,11 @@ mod tests {
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
network: None,
|
||||
model: "gpt-5".to_string(),
|
||||
service_tier: None,
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
realtime_active: None,
|
||||
|
||||
+15
-2
@@ -6215,6 +6215,7 @@ guardian_approval = true
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
turn_context: None,
|
||||
}) as Arc<dyn HistoryCell>
|
||||
};
|
||||
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
||||
@@ -6981,6 +6982,7 @@ guardian_approval = true
|
||||
text_elements,
|
||||
local_image_paths,
|
||||
remote_image_urls,
|
||||
turn_context: None,
|
||||
}) as Arc<dyn HistoryCell>
|
||||
};
|
||||
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
||||
@@ -7112,6 +7114,7 @@ guardian_approval = true
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
turn_context: None,
|
||||
}) as Arc<dyn HistoryCell>];
|
||||
app.chat_widget
|
||||
.set_composer_text("stale draft".to_string(), Vec::new(), Vec::new());
|
||||
@@ -7170,6 +7173,7 @@ guardian_approval = true
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: vec![data_image_url.clone()],
|
||||
turn_context: None,
|
||||
}) as Arc<dyn HistoryCell>];
|
||||
|
||||
app.apply_backtrack_rollback(BacktrackSelection {
|
||||
@@ -7237,7 +7241,10 @@ guardian_approval = true
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
}),
|
||||
EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
|
||||
EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: None,
|
||||
}),
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "third prompt".to_string(),
|
||||
images: None,
|
||||
@@ -7327,7 +7334,10 @@ guardian_approval = true
|
||||
// Simulate a live rollback arriving before queued replay inserts are drained.
|
||||
app.handle_codex_event_now(Event {
|
||||
id: "live-rollback".to_string(),
|
||||
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
|
||||
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: None,
|
||||
}),
|
||||
});
|
||||
|
||||
let mut saw_rollback = false;
|
||||
@@ -7370,6 +7380,7 @@ guardian_approval = true
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
turn_context: None,
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(
|
||||
vec![Line::from("after first")],
|
||||
@@ -7380,6 +7391,7 @@ guardian_approval = true
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
turn_context: None,
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(
|
||||
vec![Line::from("after second")],
|
||||
@@ -7515,6 +7527,7 @@ guardian_approval = true
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
turn_context: None,
|
||||
}) as Arc<dyn HistoryCell>];
|
||||
app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone()));
|
||||
app.deferred_history_lines = vec![Line::from("stale buffered line")];
|
||||
|
||||
@@ -338,13 +338,21 @@ impl App {
|
||||
fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) {
|
||||
if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) {
|
||||
self.backtrack.nth_user_message = nth_user_message;
|
||||
let highlight_context_lines = self
|
||||
.transcript_cells
|
||||
.get(cell_idx)
|
||||
.and_then(|cell| cell.as_any().downcast_ref::<UserHistoryCell>())
|
||||
.and_then(|cell| cell.turn_context.as_ref())
|
||||
.and_then(|snapshot| {
|
||||
snapshot.diff_lines(&self.chat_widget.current_turn_context_snapshot())
|
||||
});
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
t.set_highlight_cell(Some(cell_idx));
|
||||
t.set_highlight_cell(Some(cell_idx), highlight_context_lines);
|
||||
}
|
||||
} else {
|
||||
self.backtrack.nth_user_message = usize::MAX;
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
t.set_highlight_cell(None);
|
||||
t.set_highlight_cell(None, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -671,6 +679,7 @@ mod tests {
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
turn_context: None,
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true))
|
||||
as Arc<dyn HistoryCell>,
|
||||
@@ -690,6 +699,7 @@ mod tests {
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
turn_context: None,
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("after")], false))
|
||||
as Arc<dyn HistoryCell>,
|
||||
@@ -721,6 +731,7 @@ mod tests {
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
turn_context: None,
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("between")], false))
|
||||
as Arc<dyn HistoryCell>,
|
||||
@@ -729,6 +740,7 @@ mod tests {
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
turn_context: None,
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false))
|
||||
as Arc<dyn HistoryCell>,
|
||||
@@ -775,6 +787,7 @@ mod tests {
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
turn_context: None,
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(
|
||||
vec![Line::from("after first")],
|
||||
@@ -785,6 +798,7 @@ mod tests {
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
turn_context: None,
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(
|
||||
vec![Line::from("after second")],
|
||||
@@ -813,6 +827,7 @@ mod tests {
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
turn_context: None,
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("after")], false))
|
||||
as Arc<dyn HistoryCell>,
|
||||
|
||||
@@ -138,6 +138,7 @@ use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::TurnDiffEvent;
|
||||
use codex_protocol::protocol::UndoCompletedEvent;
|
||||
use codex_protocol::protocol::UndoStartedEvent;
|
||||
@@ -269,6 +270,7 @@ use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES;
|
||||
use crate::status_indicator_widget::StatusDetailsCapitalization;
|
||||
use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::turn_context::TurnContextSnapshot;
|
||||
mod interrupts;
|
||||
use self::interrupts::InterruptManager;
|
||||
mod agent;
|
||||
@@ -5014,6 +5016,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
let effective_mode = self.effective_collaboration_mode();
|
||||
let turn_context = self.current_turn_context_snapshot();
|
||||
let collaboration_mode = if self.collaboration_modes_enabled() {
|
||||
self.active_collaboration_mask
|
||||
.as_ref()
|
||||
@@ -5096,6 +5099,7 @@ impl ChatWidget {
|
||||
text_elements,
|
||||
local_image_paths,
|
||||
remote_image_urls,
|
||||
Some(turn_context),
|
||||
));
|
||||
} else if render_in_history && !remote_image_urls.is_empty() {
|
||||
self.last_rendered_user_message_event =
|
||||
@@ -5110,6 +5114,7 @@ impl ChatWidget {
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
remote_image_urls,
|
||||
Some(turn_context),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -5395,6 +5400,7 @@ impl ChatWidget {
|
||||
num_turns: rollback.num_turns,
|
||||
});
|
||||
}
|
||||
self.emit_thread_rollback_context_diff(&rollback);
|
||||
}
|
||||
EventMsg::RawResponseItem(_)
|
||||
| EventMsg::ItemStarted(_)
|
||||
@@ -5543,6 +5549,7 @@ impl ChatWidget {
|
||||
event.text_elements,
|
||||
event.local_images,
|
||||
remote_image_urls,
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -8082,6 +8089,147 @@ impl ChatWidget {
|
||||
self.effective_reasoning_effort()
|
||||
}
|
||||
|
||||
fn permissions_label(
|
||||
&self,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
approvals_reviewer: ApprovalsReviewer,
|
||||
) -> String {
|
||||
builtin_approval_presets()
|
||||
.into_iter()
|
||||
.find(|preset| Self::preset_matches_current(approval_policy, sandbox_policy, preset))
|
||||
.map(|preset| {
|
||||
if preset.id == "auto" && approvals_reviewer == ApprovalsReviewer::GuardianSubagent
|
||||
{
|
||||
"Smart Approvals".to_string()
|
||||
} else if preset.id == "auto" {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if matches!(
|
||||
WindowsSandboxLevel::from_config(&self.config),
|
||||
WindowsSandboxLevel::RestrictedToken
|
||||
) {
|
||||
return "Default (non-admin sandbox)".to_string();
|
||||
}
|
||||
}
|
||||
"Default".to_string()
|
||||
} else {
|
||||
preset.label.to_string()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
let approval = approval_policy.to_string();
|
||||
let sandbox = match sandbox_policy {
|
||||
SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(),
|
||||
SandboxPolicy::ReadOnly { .. } => "read-only".to_string(),
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
network_access: true,
|
||||
..
|
||||
} => "workspace-write with network access".to_string(),
|
||||
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(),
|
||||
SandboxPolicy::ExternalSandbox { network_access } => {
|
||||
if network_access.is_enabled() {
|
||||
"external-sandbox (network access enabled)".to_string()
|
||||
} else {
|
||||
"external-sandbox".to_string()
|
||||
}
|
||||
}
|
||||
};
|
||||
format!("Custom ({sandbox}, {approval})")
|
||||
})
|
||||
}
|
||||
|
||||
fn model_context_label(
|
||||
&self,
|
||||
model: &str,
|
||||
effort: Option<ReasoningEffortConfig>,
|
||||
service_tier: Option<ServiceTier>,
|
||||
) -> String {
|
||||
let label = Self::status_line_reasoning_effort_label(effort);
|
||||
let fast_label = if self.should_show_fast_status(model, service_tier) {
|
||||
" fast"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!("{model} {label}{fast_label}")
|
||||
}
|
||||
|
||||
fn historical_turn_context_snapshot(
|
||||
&self,
|
||||
turn_context: &TurnContextItem,
|
||||
) -> TurnContextSnapshot {
|
||||
TurnContextSnapshot {
|
||||
permissions: Self::permissions_label(
|
||||
self,
|
||||
turn_context.approval_policy,
|
||||
&turn_context.sandbox_policy,
|
||||
turn_context.approvals_reviewer,
|
||||
),
|
||||
mode: turn_context
|
||||
.collaboration_mode
|
||||
.as_ref()
|
||||
.map(|mode| mode.mode.display_name())
|
||||
.unwrap_or(ModeKind::Default.display_name())
|
||||
.to_string(),
|
||||
model: self.model_context_label(
|
||||
&turn_context.model,
|
||||
turn_context.effort,
|
||||
turn_context.service_tier,
|
||||
),
|
||||
personality: turn_context
|
||||
.personality
|
||||
.map(Self::personality_label)
|
||||
.unwrap_or("Default")
|
||||
.to_string(),
|
||||
cwd: format_directory_display(&turn_context.cwd, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_thread_rollback_context_diff(
|
||||
&self,
|
||||
rollback: &codex_protocol::protocol::ThreadRolledBackEvent,
|
||||
) {
|
||||
let Some(turn_context) = rollback.rolled_back_to_turn_context.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let historical_snapshot = self.historical_turn_context_snapshot(turn_context);
|
||||
let current_snapshot = self.current_turn_context_snapshot();
|
||||
for message in historical_snapshot.rollback_diff_messages(¤t_snapshot) {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(message, None),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn current_turn_context_snapshot(&self) -> TurnContextSnapshot {
|
||||
let permissions = self.permissions_label(
|
||||
self.config.permissions.approval_policy.value(),
|
||||
self.config.permissions.sandbox_policy.get(),
|
||||
self.config.approvals_reviewer,
|
||||
);
|
||||
let model = self.model_context_label(
|
||||
self.model_display_name(),
|
||||
self.effective_reasoning_effort(),
|
||||
self.config.service_tier,
|
||||
);
|
||||
let personality = self
|
||||
.config
|
||||
.personality
|
||||
.filter(|_| self.config.features.enabled(Feature::Personality))
|
||||
.filter(|_| self.current_model_supports_personality())
|
||||
.map(Self::personality_label)
|
||||
.unwrap_or("Default")
|
||||
.to_string();
|
||||
|
||||
TurnContextSnapshot {
|
||||
permissions,
|
||||
mode: self.active_mode_kind().display_name().to_string(),
|
||||
model,
|
||||
personality,
|
||||
cwd: format_directory_display(&self.config.cwd, None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn active_collaboration_mode_kind(&self) -> ModeKind {
|
||||
self.active_mode_kind()
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 3146
|
||||
expression: rendered
|
||||
---
|
||||
• Permissions updated to Smart Approvals
|
||||
|
||||
• Model changed to gpt-5.4 high for Plan mode.
|
||||
|
||||
• Personality set to Pragmatic
|
||||
|
||||
• Directory changed to /tmp/current
|
||||
@@ -107,6 +107,7 @@ use codex_protocol::protocol::TokenCountEvent;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_protocol::protocol::UndoCompletedEvent;
|
||||
use codex_protocol::protocol::UndoStartedEvent;
|
||||
@@ -3053,6 +3054,7 @@ async fn replayed_thread_rollback_emits_ordered_app_event() {
|
||||
|
||||
chat.replay_initial_messages(vec![EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 2,
|
||||
rolled_back_to_turn_context: None,
|
||||
})]);
|
||||
|
||||
let mut saw = false;
|
||||
@@ -3067,6 +3069,88 @@ async fn replayed_thread_rollback_emits_ordered_app_event() {
|
||||
assert!(saw, "expected replay rollback app event");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_thread_rollback_turn_context_diff_emits_ordered_info_messages() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
chat.set_feature_enabled(Feature::Personality, true);
|
||||
chat.set_approval_policy(AskForApproval::OnRequest);
|
||||
chat.set_sandbox_policy(SandboxPolicy::new_workspace_write_policy())
|
||||
.expect("workspace-write sandbox");
|
||||
chat.set_approvals_reviewer(ApprovalsReviewer::GuardianSubagent);
|
||||
chat.set_service_tier(Some(ServiceTier::Fast));
|
||||
chat.set_personality(Personality::Pragmatic);
|
||||
chat.config.cwd = PathBuf::from("/tmp/current");
|
||||
chat.set_collaboration_mask(CollaborationModeMask {
|
||||
name: "Plan".to_string(),
|
||||
mode: Some(ModeKind::Plan),
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
reasoning_effort: Some(Some(ReasoningEffortConfig::High)),
|
||||
developer_instructions: None,
|
||||
});
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
chat.replay_initial_messages(vec![EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: Some(TurnContextItem {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
trace_id: None,
|
||||
cwd: PathBuf::from("/tmp/historical"),
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
|
||||
network: None,
|
||||
model: "gpt-5.3-codex".to_string(),
|
||||
service_tier: None,
|
||||
personality: None,
|
||||
collaboration_mode: Some(CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings: Settings {
|
||||
model: "gpt-5.3-codex".to_string(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::Medium),
|
||||
developer_instructions: None,
|
||||
},
|
||||
}),
|
||||
realtime_active: Some(false),
|
||||
effort: Some(ReasoningEffortConfig::Medium),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: None,
|
||||
}),
|
||||
})]);
|
||||
|
||||
let mut saw_rollback = false;
|
||||
let mut rendered_cells = Vec::new();
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
match event {
|
||||
AppEvent::ApplyThreadRollback { num_turns } => {
|
||||
assert!(!saw_rollback, "expected only one rollback app event");
|
||||
saw_rollback = true;
|
||||
assert_eq!(num_turns, 1);
|
||||
}
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
assert!(
|
||||
saw_rollback,
|
||||
"rollback trim should be queued before info cells"
|
||||
);
|
||||
rendered_cells.push(lines_to_single_string(&cell.display_lines(80)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(saw_rollback, "expected replay rollback app event");
|
||||
let rendered = rendered_cells.join("\n");
|
||||
assert_snapshot!(
|
||||
"replayed_thread_rollback_turn_context_diff_info_messages",
|
||||
rendered
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_implementation_popup_skips_when_messages_queued() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
@@ -5918,7 +6002,10 @@ async fn slash_copy_state_clears_on_thread_rollback() {
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "rollback-1".into(),
|
||||
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
|
||||
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: None,
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(chat.last_copyable_output, None);
|
||||
@@ -6002,7 +6089,10 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() {
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "rollback-1".into(),
|
||||
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
|
||||
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: None,
|
||||
}),
|
||||
});
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ use crate::style::user_message_style;
|
||||
use crate::text_formatting::format_and_truncate_tool_result;
|
||||
use crate::text_formatting::truncate_text;
|
||||
use crate::tooltips;
|
||||
use crate::turn_context::TurnContextSnapshot;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
use crate::update_action::UpdateAction;
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
@@ -203,6 +204,7 @@ pub(crate) struct UserHistoryCell {
|
||||
#[allow(dead_code)]
|
||||
pub local_image_paths: Vec<PathBuf>,
|
||||
pub remote_image_urls: Vec<String>,
|
||||
pub turn_context: Option<TurnContextSnapshot>,
|
||||
}
|
||||
|
||||
/// Build logical lines for a user message with styled text elements.
|
||||
@@ -1208,12 +1210,14 @@ pub(crate) fn new_user_prompt(
|
||||
text_elements: Vec<TextElement>,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
remote_image_urls: Vec<String>,
|
||||
turn_context: Option<TurnContextSnapshot>,
|
||||
) -> UserHistoryCell {
|
||||
UserHistoryCell {
|
||||
message,
|
||||
text_elements,
|
||||
local_image_paths,
|
||||
remote_image_urls,
|
||||
turn_context,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3841,6 +3845,7 @@ mod tests {
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
turn_context: None,
|
||||
};
|
||||
|
||||
// Small width to force wrapping more clearly. Effective wrap width is width-2 due to the ▌ prefix and trailing space.
|
||||
@@ -3858,6 +3863,7 @@ mod tests {
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: vec!["https://example.com/example.png".to_string()],
|
||||
turn_context: None,
|
||||
};
|
||||
|
||||
let rendered = render_lines(&cell.display_lines(80)).join("\n");
|
||||
@@ -3874,6 +3880,7 @@ mod tests {
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: vec!["data:image/png;base64,aGVsbG8=".to_string()],
|
||||
turn_context: None,
|
||||
};
|
||||
|
||||
let rendered = render_lines(&cell.display_lines(80)).join("\n");
|
||||
@@ -3892,6 +3899,7 @@ mod tests {
|
||||
"https://example.com/one.png".to_string(),
|
||||
"https://example.com/two.png".to_string(),
|
||||
],
|
||||
turn_context: None,
|
||||
};
|
||||
|
||||
let rendered = render_lines(&cell.display_lines(80)).join("\n");
|
||||
@@ -3911,6 +3919,7 @@ mod tests {
|
||||
"https://example.com/one.png".to_string(),
|
||||
"https://example.com/two.png".to_string(),
|
||||
],
|
||||
turn_context: None,
|
||||
};
|
||||
|
||||
let width = 80;
|
||||
@@ -3930,6 +3939,7 @@ mod tests {
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: vec!["https://example.com/one.png".to_string()],
|
||||
turn_context: None,
|
||||
};
|
||||
|
||||
let rendered = render_lines(&cell.display_lines(80));
|
||||
@@ -3953,6 +3963,7 @@ mod tests {
|
||||
)],
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: vec!["https://example.com/one.png".to_string()],
|
||||
turn_context: None,
|
||||
};
|
||||
|
||||
let rendered = render_lines(&cell.display_lines(80));
|
||||
@@ -3973,6 +3984,7 @@ mod tests {
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
turn_context: None,
|
||||
});
|
||||
|
||||
let width: u16 = 52;
|
||||
|
||||
@@ -123,6 +123,7 @@ mod text_formatting;
|
||||
mod theme_picker;
|
||||
mod tooltips;
|
||||
mod tui;
|
||||
mod turn_context;
|
||||
mod ui_consts;
|
||||
pub mod update_action;
|
||||
mod update_prompt;
|
||||
@@ -1427,9 +1428,11 @@ mod tests {
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: config.approvals_reviewer,
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
model,
|
||||
service_tier: config.service_tier,
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
realtime_active: Some(false),
|
||||
|
||||
@@ -24,6 +24,7 @@ use crate::history_cell::UserHistoryCell;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::InsetRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::style::user_message_style;
|
||||
@@ -429,6 +430,7 @@ pub(crate) struct TranscriptOverlay {
|
||||
/// Committed transcript cells (does not include the live tail).
|
||||
cells: Vec<Arc<dyn HistoryCell>>,
|
||||
highlight_cell: Option<usize>,
|
||||
highlight_context_lines: Option<Vec<Line<'static>>>,
|
||||
/// Cache key for the render-only live tail appended after committed cells.
|
||||
live_tail_key: Option<LiveTailKey>,
|
||||
is_done: bool,
|
||||
@@ -457,12 +459,13 @@ impl TranscriptOverlay {
|
||||
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>) -> Self {
|
||||
Self {
|
||||
view: PagerView::new(
|
||||
Self::render_cells(&transcript_cells, None),
|
||||
Self::render_cells(&transcript_cells, None, None),
|
||||
"T R A N S C R I P T".to_string(),
|
||||
usize::MAX,
|
||||
),
|
||||
cells: transcript_cells,
|
||||
highlight_cell: None,
|
||||
highlight_context_lines: None,
|
||||
live_tail_key: None,
|
||||
is_done: false,
|
||||
}
|
||||
@@ -471,6 +474,7 @@ impl TranscriptOverlay {
|
||||
fn render_cells(
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
highlight_cell: Option<usize>,
|
||||
highlight_context_lines: Option<&[Line<'static>]>,
|
||||
) -> Vec<Box<dyn Renderable>> {
|
||||
cells
|
||||
.iter()
|
||||
@@ -492,6 +496,15 @@ impl TranscriptOverlay {
|
||||
style: Style::default(),
|
||||
})) as Box<dyn Renderable>
|
||||
};
|
||||
if highlight_cell == Some(i)
|
||||
&& let Some(lines) = highlight_context_lines
|
||||
&& !lines.is_empty()
|
||||
{
|
||||
let summary = Box::new(CachedRenderable::new(
|
||||
Paragraph::new(Text::from(lines.to_vec())).wrap(Wrap { trim: false }),
|
||||
)) as Box<dyn Renderable>;
|
||||
cell_renderable = Box::new(ColumnRenderable::with([summary, cell_renderable]));
|
||||
}
|
||||
if !c.is_stream_continuation() && i > 0 {
|
||||
cell_renderable = Box::new(InsetRenderable::new(
|
||||
cell_renderable,
|
||||
@@ -519,7 +532,11 @@ impl TranscriptOverlay {
|
||||
let had_prior_cells = !self.cells.is_empty();
|
||||
let tail_renderable = self.take_live_tail_renderable();
|
||||
self.cells.push(cell);
|
||||
self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell);
|
||||
self.view.renderables = Self::render_cells(
|
||||
&self.cells,
|
||||
self.highlight_cell,
|
||||
self.highlight_context_lines.as_deref(),
|
||||
);
|
||||
if let Some(tail) = tail_renderable {
|
||||
let tail = if !had_prior_cells
|
||||
&& self
|
||||
@@ -553,6 +570,7 @@ impl TranscriptOverlay {
|
||||
.is_some_and(|idx| idx >= self.cells.len())
|
||||
{
|
||||
self.highlight_cell = None;
|
||||
self.highlight_context_lines = None;
|
||||
}
|
||||
self.rebuild_renderables();
|
||||
if follow_bottom {
|
||||
@@ -608,8 +626,13 @@ impl TranscriptOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
|
||||
pub(crate) fn set_highlight_cell(
|
||||
&mut self,
|
||||
cell: Option<usize>,
|
||||
context_lines: Option<Vec<Line<'static>>>,
|
||||
) {
|
||||
self.highlight_cell = cell;
|
||||
self.highlight_context_lines = context_lines;
|
||||
self.rebuild_renderables();
|
||||
if let Some(idx) = self.highlight_cell {
|
||||
self.view.scroll_chunk_into_view(idx);
|
||||
@@ -626,7 +649,11 @@ impl TranscriptOverlay {
|
||||
|
||||
fn rebuild_renderables(&mut self) {
|
||||
let tail_renderable = self.take_live_tail_renderable();
|
||||
self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell);
|
||||
self.view.renderables = Self::render_cells(
|
||||
&self.cells,
|
||||
self.highlight_cell,
|
||||
self.highlight_context_lines.as_deref(),
|
||||
);
|
||||
if let Some(tail) = tail_renderable {
|
||||
self.view.renderables.push(tail);
|
||||
}
|
||||
@@ -809,7 +836,9 @@ mod tests {
|
||||
use crate::exec_cell::CommandOutput;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::history_cell::new_patch_event;
|
||||
use crate::turn_context::TurnContextSnapshot;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::protocol::FileChange;
|
||||
use ratatui::Terminal;
|
||||
@@ -863,7 +892,7 @@ mod tests {
|
||||
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
|
||||
lines: vec![Line::from("hello")],
|
||||
})]);
|
||||
overlay.set_highlight_cell(Some(0));
|
||||
overlay.set_highlight_cell(Some(0), None);
|
||||
|
||||
// Render into a wide buffer so the footer hints aren't truncated.
|
||||
let area = Rect::new(0, 0, 120, 10);
|
||||
@@ -897,6 +926,37 @@ mod tests {
|
||||
assert_snapshot!(term.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_highlighted_turn_context_snapshot() {
|
||||
let selected = TurnContextSnapshot {
|
||||
permissions: "Default".to_string(),
|
||||
mode: "Default".to_string(),
|
||||
model: "gpt-5.4 high".to_string(),
|
||||
personality: "Default".to_string(),
|
||||
cwd: "~/code/old".to_string(),
|
||||
};
|
||||
let current = TurnContextSnapshot {
|
||||
permissions: "Smart Approvals".to_string(),
|
||||
mode: "Plan".to_string(),
|
||||
model: "gpt-5.4 xhigh fast".to_string(),
|
||||
personality: "Pragmatic".to_string(),
|
||||
cwd: "~/code/new".to_string(),
|
||||
};
|
||||
let mut overlay = TranscriptOverlay::new(vec![Arc::new(UserHistoryCell {
|
||||
message: "Implement the highlighted task".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
turn_context: Some(selected.clone()),
|
||||
})]);
|
||||
overlay.set_highlight_cell(Some(0), selected.diff_lines(¤t));
|
||||
|
||||
let mut term = Terminal::new(TestBackend::new(72, 16)).expect("term");
|
||||
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
assert_snapshot!(term.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_renders_live_tail() {
|
||||
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source: tui/src/pager_overlay.rs
|
||||
expression: term.backend()
|
||||
---
|
||||
"/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / "
|
||||
" Context changed since this turn: "
|
||||
" permissions: Default -> Smart Approvals "
|
||||
" mode: Default -> Plan "
|
||||
" model: gpt-5.4 high -> gpt-5.4 xhigh fast "
|
||||
" personality: Default -> Pragmatic "
|
||||
" directory: ~/code/old -> ~/code/new "
|
||||
" "
|
||||
" "
|
||||
"› Implement the highlighted task "
|
||||
" "
|
||||
"~ "
|
||||
"───────────────────────────────────────────────────────────────── 100% ─"
|
||||
" ↑/↓ to scroll pgup/pgdn to page home/end to jump "
|
||||
" q to quit esc/← to edit prev → to edit next enter to edit message"
|
||||
" "
|
||||
@@ -0,0 +1,171 @@
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct TurnContextSnapshot {
|
||||
pub permissions: String,
|
||||
pub mode: String,
|
||||
pub model: String,
|
||||
pub personality: String,
|
||||
pub cwd: String,
|
||||
}
|
||||
|
||||
impl TurnContextSnapshot {
|
||||
pub(crate) fn diff_lines(&self, current: &Self) -> Option<Vec<Line<'static>>> {
|
||||
let mut diffs: Vec<(&'static str, &str, &str)> = Vec::new();
|
||||
|
||||
if self.permissions != current.permissions {
|
||||
diffs.push(("permissions", &self.permissions, ¤t.permissions));
|
||||
}
|
||||
if self.mode != current.mode {
|
||||
diffs.push(("mode", &self.mode, ¤t.mode));
|
||||
}
|
||||
if self.model != current.model {
|
||||
diffs.push(("model", &self.model, ¤t.model));
|
||||
}
|
||||
if self.personality != current.personality {
|
||||
diffs.push(("personality", &self.personality, ¤t.personality));
|
||||
}
|
||||
if self.cwd != current.cwd {
|
||||
diffs.push(("directory", &self.cwd, ¤t.cwd));
|
||||
}
|
||||
|
||||
if diffs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut lines = vec![vec![" ".into(), "Context changed since this turn:".bold()].into()];
|
||||
|
||||
for (label, then_value, now_value) in diffs {
|
||||
lines.push(
|
||||
vec![
|
||||
" ".into(),
|
||||
format!("{label}: ").dim(),
|
||||
then_value.to_string().dim(),
|
||||
" -> ".dim(),
|
||||
now_value.to_string().cyan(),
|
||||
]
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
lines.push("".into());
|
||||
|
||||
Some(lines)
|
||||
}
|
||||
|
||||
pub(crate) fn rollback_diff_messages(&self, current: &Self) -> Vec<String> {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
if self.permissions != current.permissions {
|
||||
messages.push(format!("Permissions updated to {}", current.permissions));
|
||||
}
|
||||
|
||||
let mode_changed = self.mode != current.mode;
|
||||
let model_changed = self.model != current.model;
|
||||
if model_changed {
|
||||
let mut message = format!("Model changed to {}", current.model);
|
||||
if mode_changed {
|
||||
message.push_str(" for ");
|
||||
message.push_str(¤t.mode);
|
||||
message.push_str(" mode.");
|
||||
}
|
||||
messages.push(message);
|
||||
} else if mode_changed {
|
||||
messages.push(format!("Switched to {} mode.", current.mode));
|
||||
}
|
||||
|
||||
if self.personality != current.personality {
|
||||
messages.push(format!("Personality set to {}", current.personality));
|
||||
}
|
||||
|
||||
if self.cwd != current.cwd {
|
||||
messages.push(format!("Directory changed to {}", current.cwd));
|
||||
}
|
||||
|
||||
messages
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TurnContextSnapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn render_lines(lines: &[ratatui::text::Line<'static>]) -> Vec<String> {
|
||||
lines.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_lines_returns_none_when_context_matches() {
|
||||
let snapshot = TurnContextSnapshot {
|
||||
permissions: "Default".to_string(),
|
||||
mode: "Default".to_string(),
|
||||
model: "gpt-5.4 xhigh fast".to_string(),
|
||||
personality: "Default".to_string(),
|
||||
cwd: "~/code/codex".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(snapshot.diff_lines(&snapshot), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_lines_lists_changed_fields_in_stable_order() {
|
||||
let then = TurnContextSnapshot {
|
||||
permissions: "Default".to_string(),
|
||||
mode: "Default".to_string(),
|
||||
model: "gpt-5.4 high".to_string(),
|
||||
personality: "Default".to_string(),
|
||||
cwd: "~/code/old".to_string(),
|
||||
};
|
||||
let now = TurnContextSnapshot {
|
||||
permissions: "Smart Approvals".to_string(),
|
||||
mode: "Plan".to_string(),
|
||||
model: "gpt-5.4 xhigh fast".to_string(),
|
||||
personality: "Pragmatic".to_string(),
|
||||
cwd: "~/code/new".to_string(),
|
||||
};
|
||||
|
||||
let lines = then.diff_lines(&now).expect("expected diff lines");
|
||||
|
||||
assert_eq!(
|
||||
render_lines(&lines),
|
||||
vec![
|
||||
" Context changed since this turn:".to_string(),
|
||||
" permissions: Default -> Smart Approvals".to_string(),
|
||||
" mode: Default -> Plan".to_string(),
|
||||
" model: gpt-5.4 high -> gpt-5.4 xhigh fast".to_string(),
|
||||
" personality: Default -> Pragmatic".to_string(),
|
||||
" directory: ~/code/old -> ~/code/new".to_string(),
|
||||
String::new(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_diff_messages_use_existing_tui_phrasing() {
|
||||
let then = TurnContextSnapshot {
|
||||
permissions: "Default".to_string(),
|
||||
mode: "Default".to_string(),
|
||||
model: "gpt-5.4 high".to_string(),
|
||||
personality: "Default".to_string(),
|
||||
cwd: "~/code/old".to_string(),
|
||||
};
|
||||
let now = TurnContextSnapshot {
|
||||
permissions: "Smart Approvals".to_string(),
|
||||
mode: "Plan".to_string(),
|
||||
model: "gpt-5.4 xhigh fast".to_string(),
|
||||
personality: "Pragmatic".to_string(),
|
||||
cwd: "~/code/new".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
then.rollback_diff_messages(&now),
|
||||
vec![
|
||||
"Permissions updated to Smart Approvals".to_string(),
|
||||
"Model changed to gpt-5.4 xhigh fast for Plan mode.".to_string(),
|
||||
"Personality set to Pragmatic".to_string(),
|
||||
"Directory changed to ~/code/new".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7490,7 +7490,10 @@ guardian_approval = true
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
}),
|
||||
EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
|
||||
EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: None,
|
||||
}),
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "third prompt".to_string(),
|
||||
images: None,
|
||||
@@ -7580,7 +7583,10 @@ guardian_approval = true
|
||||
// Simulate a live rollback arriving before queued replay inserts are drained.
|
||||
app.handle_codex_event_now(Event {
|
||||
id: "live-rollback".to_string(),
|
||||
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
|
||||
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: None,
|
||||
}),
|
||||
});
|
||||
|
||||
let mut saw_rollback = false;
|
||||
|
||||
@@ -3030,6 +3030,7 @@ async fn replayed_thread_rollback_emits_ordered_app_event() {
|
||||
|
||||
chat.replay_initial_messages(vec![EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 2,
|
||||
rolled_back_to_turn_context: None,
|
||||
})]);
|
||||
|
||||
let mut saw = false;
|
||||
@@ -5865,7 +5866,10 @@ async fn slash_copy_state_clears_on_thread_rollback() {
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "rollback-1".into(),
|
||||
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
|
||||
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: None,
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(chat.last_copyable_output, None);
|
||||
@@ -5949,7 +5953,10 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() {
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "rollback-1".into(),
|
||||
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
|
||||
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
rolled_back_to_turn_context: None,
|
||||
}),
|
||||
});
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
|
||||
@@ -1787,9 +1787,11 @@ mod tests {
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: config.approvals_reviewer,
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
model,
|
||||
service_tier: config.service_tier,
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
realtime_active: Some(false),
|
||||
|
||||
@@ -541,6 +541,7 @@ fn status_summary_spans(status: &AgentStatus) -> Vec<Span<'static>> {
|
||||
match status {
|
||||
AgentStatus::PendingInit => vec![Span::from("Pending init").cyan()],
|
||||
AgentStatus::Running => vec![Span::from("Running").cyan().bold()],
|
||||
AgentStatus::Interrupted => vec![Span::from("Interrupted").magenta()],
|
||||
AgentStatus::Completed(message) => {
|
||||
let mut spans = vec![Span::from("Completed").green()];
|
||||
if let Some(message) = message.as_ref() {
|
||||
|
||||
Reference in New Issue
Block a user