Preserve thread context when retrying or editing turns (#33211)

## What changed

- Add experimental `beforeTurnId` support to `thread/fork` for copying history strictly before a turn, including an in-progress turn.
- Use before-turn forks for TUI backtracking and safety retries so the replacement prompt retains thread context instead of recreating the branch from the preceding turn or a new thread.
- Add experimental `deferGoalContinuation` support so safety-retry forks can inherit an active goal while waiting for the replacement turn before automatic continuation resumes.
- Hide duplicated nested-review prompts when replaying history or selecting a prompt to edit.

## Testing

- Cover before-turn truncation, fork validation, active-goal inheritance, TUI safety retries, backtracking, and nested-review replay.

GitOrigin-RevId: 4915d85ae1929a0b8c40f3ed7dca29c7a5bdcdf3
This commit is contained in:
Felipe Coury authored and copyberry committed 2026-07-15 02:35:49 +00:00
1 parent e4711f2a3b
commit d88db19144
25 files changed
+1343 -96

No files matched your search

+14 -1
View File
@@ -12,7 +12,12 @@ impl ChatWidget {
/// avoid triggering side effects. Event ids are passed as `None` to
/// distinguish replayed events from live ones.
pub(crate) fn replay_thread_turns(&mut self, turns: Vec<Turn>, replay_kind: ReplayKind) {
for turn in turns {
let hidden_nested_review_turns = std::iter::once(/*value*/ false)
.chain(turns.windows(/*size*/ 2).map(|turns| {
crate::app_backtrack::is_hidden_nested_review_turn(&turns[0], &turns[1])
}))
.collect::<Vec<_>>();
for (turn, hidden_nested_review_turn) in turns.into_iter().zip(hidden_nested_review_turns) {
let Turn {
id: turn_id,
items_view: _,
@@ -29,8 +34,16 @@ impl ChatWidget {
self.on_task_started();
}
for item in items {
if hidden_nested_review_turn && matches!(item, ThreadItem::UserMessage { .. }) {
continue;
}
self.replay_thread_item(item, turn_id.clone(), replay_kind);
}
let status = if hidden_nested_review_turn {
TurnStatus::Completed
} else {
status
};
if matches!(
status,
TurnStatus::Completed | TurnStatus::Interrupted | TurnStatus::Failed
@@ -70,9 +70,7 @@ impl ChatWidget {
self.input_queue.user_turn_pending_start = true;
}
pub(crate) fn commit_safety_buffered_retry_submission(&mut self, prompt: UserMessage) {
let display =
user_message_display_for_history(prompt, &UserMessageHistoryRecord::UserMessageText);
pub(crate) fn commit_safety_buffered_retry_submission(&mut self, display: UserMessageDisplay) {
self.on_user_message_display(display);
}
@@ -226,6 +226,89 @@ async fn replayed_review_prompt_does_not_seed_composer_history() {
assert_eq!(chat.bottom_pane.composer_text(), "");
}
#[tokio::test]
async fn replayed_nested_review_prompts_do_not_render_or_seed_composer_history() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
let review_hint = "current changes";
let review_prompt = "Review the current code changes (staged, unstaged, and untracked files).";
let user_message = |id: &str, text: &str| AppServerThreadItem::UserMessage {
id: id.to_string(),
client_id: None,
content: vec![AppServerUserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}],
};
let review_marker = |turn_id: &str| AppServerTurn {
items: vec![
AppServerThreadItem::EnteredReviewMode {
id: format!("{turn_id}-start"),
review: review_hint.to_string(),
},
AppServerThreadItem::ExitedReviewMode {
id: format!("{turn_id}-end"),
review: "review complete".to_string(),
},
],
..app_server_turn(
turn_id,
AppServerTurnStatus::Completed,
/*duration_ms*/ None,
/*error*/ None,
)
};
chat.replay_thread_turns(
vec![
review_marker("turn-review-before-steer"),
AppServerTurn {
items: vec![
user_message("interrupted-prompt", review_hint),
user_message("interrupted-steer", review_hint),
],
completed_at: Some(1),
..app_server_turn(
"turn-interrupted",
AppServerTurnStatus::Interrupted,
/*duration_ms*/ None,
/*error*/ None,
)
},
review_marker("turn-review"),
AppServerTurn {
items: vec![
user_message("review-prompt-1", review_prompt),
user_message("review-prompt-2", review_prompt),
AppServerThreadItem::AgentMessage {
id: "review-result".to_string(),
text: "review result is retained".to_string(),
phase: Some(MessagePhase::FinalAnswer),
memory_citation: None,
},
],
..app_server_turn(
"turn-review-child",
AppServerTurnStatus::Interrupted,
/*duration_ms*/ None,
/*error*/ None,
)
},
],
ReplayKind::ResumeInitialMessages,
);
let rendered = drain_insert_history(&mut rx)
.into_iter()
.map(|lines| lines_to_single_string(&lines))
.collect::<String>();
assert!(!rendered.contains(review_prompt));
assert_eq!(rendered.matches(review_hint).count(), 4);
insta::assert_snapshot!("replayed_nested_review_prompts", rendered);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), review_hint);
}
#[tokio::test]
async fn replayed_user_message_preserves_text_elements_and_local_images() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -0,0 +1,23 @@
---
source: tui/src/chatwidget/tests/history_replay.rs
expression: rendered
---
>> Code review started: current changes <<
<< Code review finished >>
› current changes
› current changes
■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.
>> Code review started: current changes <<
<< Code review finished >>
• review result is retained