fix(ai): treat rewind at end of history as no-op success

The mobile client's regenerate-after-failure flow sends a discard index
equal to the server's rendered count (its optimistic user bubble for the
failed turn was never persisted). find_raw_cut treated this as out of
range, surfacing as "Chat rewind failed: discard_from_rendered_index out
of range" and blocking the retry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron
2026-04-24 19:12:17 -04:00
parent 0ebc2e9003
commit 0e55a6b125

View File

@@ -1071,8 +1071,13 @@ fn is_rendered(m: &ChatMessage) -> bool {
/// Given a rendered index to start discarding from, find the raw index at
/// which to truncate. The cut position is the raw length after all prior
/// rendered messages — which also strips any tool-call scaffolding that
/// immediately precedes the discarded rendered message. Returns `None` if
/// `discard_from_rendered_index` is past the end of the rendered view.
/// immediately precedes the discarded rendered message.
///
/// Discarding *at* the end (`discard == rendered_count`) is a no-op success:
/// returns `Some(messages.len())`. The mobile client hits this when
/// regenerating after a failed turn — its optimistic user bubble lives at
/// the index just past the server's persisted history. Strictly past the end
/// (`discard > rendered_count`) returns `None`.
pub(crate) fn find_raw_cut(
messages: &[ChatMessage],
discard_from_rendered_index: usize,
@@ -1089,10 +1094,8 @@ pub(crate) fn find_raw_cut(
rendered_count += 1;
last_kept_raw_end = i + 1;
}
if rendered_count == discard_from_rendered_index {
// Discarding past the last rendered message is a no-op, but we
// surface it as "nothing to cut" rather than silent success.
return None;
if discard_from_rendered_index == rendered_count {
return Some(messages.len());
}
None
}
@@ -1361,4 +1364,18 @@ mod tests {
let msgs = vec![ChatMessage::user("q1"), assistant_text("a1")];
assert!(find_raw_cut(&msgs, 5).is_none());
}
#[test]
fn rewind_at_end_is_noop_success() {
// Mobile client retries after a failed turn that never persisted —
// its optimistic user bubble's index equals the server's rendered
// count. Should resolve to "no cut" rather than an out-of-range error.
let msgs = vec![
ChatMessage::system("s"),
ChatMessage::user("q1"),
assistant_text("a1"),
];
let cut = find_raw_cut(&msgs, 2).expect("boundary cut should succeed");
assert_eq!(cut, msgs.len());
}
}