mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* fix(llm): prevent UTF-8 panic in line_bounds() (fixes #1669) `line_bounds()` used `text[..pos]` slicing which panics when `pos` lands inside a multi-byte UTF-8 character. This happens when `end.saturating_sub(1)` in `is_recoverable_tool_call_segment()` steps back into a multi-byte char like emoji. Fix: clamp `pos` to `text.len()` and walk backward to the nearest char boundary before slicing. Add 5 regression tests covering mid-char positions, emoji boundaries, and out-of-bounds pos. Also fix pre-existing clippy `unnecessary_sort_by` warnings in web gateway handlers. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <[email protected]> Co-Authored-By: Happy <[email protected]> * test: assert expected values in line_bounds UTF-8 tests Address Gemini review: strengthen regression tests to verify correct return values (not just absence of panic) when pos lands mid-char. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <[email protected]> Co-Authored-By: Happy <[email protected]> --------- Co-authored-by: willamhou <[email protected]> Co-authored-by: Claude <[email protected]> Co-authored-by: Happy <[email protected]>
This commit is contained in:
co-authored by
willamhou
Claude
Happy
parent
9c5ba43ccd
commit
7234700c78
@@ -533,7 +533,7 @@ pub async fn chat_threads_handler(
|
||||
// Fallback: in-memory only (no assistant thread without DB)
|
||||
let sess = session.lock().await;
|
||||
let mut sorted_threads: Vec<_> = sess.threads.values().collect();
|
||||
sorted_threads.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||
sorted_threads.sort_by_key(|t| std::cmp::Reverse(t.updated_at));
|
||||
let threads: Vec<ThreadInfo> = sorted_threads
|
||||
.into_iter()
|
||||
.map(|t| ThreadInfo {
|
||||
|
||||
@@ -1890,7 +1890,7 @@ async fn chat_threads_handler(
|
||||
|
||||
// Fallback: in-memory only (no assistant thread without DB)
|
||||
let mut sorted_threads: Vec<_> = sess.threads.values().collect();
|
||||
sorted_threads.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||
sorted_threads.sort_by_key(|t| std::cmp::Reverse(t.updated_at));
|
||||
let threads: Vec<ThreadInfo> = sorted_threads
|
||||
.into_iter()
|
||||
.map(|t| ThreadInfo {
|
||||
|
||||
+56
-2
@@ -1376,9 +1376,18 @@ fn overlaps_code_region(start: usize, end: usize, regions: &[CodeRegion]) -> boo
|
||||
}
|
||||
|
||||
/// Return the byte bounds of the line containing `pos`, excluding the trailing newline.
|
||||
///
|
||||
/// `pos` is clamped to `text.len()` and adjusted to the nearest char boundary,
|
||||
/// so callers need not guarantee that `pos` falls on a boundary.
|
||||
fn line_bounds(text: &str, pos: usize) -> (usize, usize) {
|
||||
let start = text[..pos].rfind('\n').map_or(0, |idx| idx + 1);
|
||||
let end = text[pos..].find('\n').map_or(text.len(), |idx| pos + idx);
|
||||
let pos = pos.min(text.len());
|
||||
// Walk backward to find a valid char boundary (at most 3 bytes for UTF-8).
|
||||
let mut safe = pos;
|
||||
while safe > 0 && !text.is_char_boundary(safe) {
|
||||
safe -= 1;
|
||||
}
|
||||
let start = text[..safe].rfind('\n').map_or(0, |idx| idx + 1);
|
||||
let end = text[safe..].find('\n').map_or(text.len(), |idx| safe + idx);
|
||||
(start, end)
|
||||
}
|
||||
|
||||
@@ -2302,6 +2311,51 @@ That's my plan."#;
|
||||
assert_eq!(regions[0].end, text.len());
|
||||
}
|
||||
|
||||
// ---- line_bounds UTF-8 safety (issue #1669) ----
|
||||
|
||||
#[test]
|
||||
fn test_line_bounds_ascii() {
|
||||
let text = "hello\nworld\n";
|
||||
assert_eq!(line_bounds(text, 0), (0, 5));
|
||||
assert_eq!(line_bounds(text, 6), (6, 11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_bounds_at_text_len() {
|
||||
let text = "abc";
|
||||
assert_eq!(line_bounds(text, 3), (0, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_bounds_mid_multibyte_char() {
|
||||
// '🔥' is 4 bytes (F0 9F 94 A5). Passing pos=1 lands inside the char.
|
||||
// line_bounds must not panic — it should snap to a valid boundary.
|
||||
let text = "🔥\n<tool_call>";
|
||||
// All mid-char positions should snap back to byte 0 (start of '🔥'),
|
||||
// so line bounds cover the first line: "🔥" = bytes 0..4.
|
||||
assert_eq!(line_bounds(text, 1), (0, 4)); // would panic before fix
|
||||
assert_eq!(line_bounds(text, 2), (0, 4));
|
||||
assert_eq!(line_bounds(text, 3), (0, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_bounds_emoji_before_newline() {
|
||||
// 'Result: 🔥\n<tool_call>' — end.saturating_sub(1) from the \n position
|
||||
// should not panic even with multi-byte chars on the same line.
|
||||
let text = "Result: 🔥\n<tool_call>";
|
||||
let newline_pos = text.find('\n').unwrap();
|
||||
// saturating_sub(1) lands inside '🔥' (byte 11 → 10, but char ends at 12).
|
||||
// Snaps back to byte 8 (start of '🔥'), line covers "Result: 🔥" = bytes 0..12.
|
||||
assert_eq!(line_bounds(text, newline_pos.saturating_sub(1)), (0, 12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_bounds_pos_beyond_len() {
|
||||
let text = "abc";
|
||||
// pos > text.len() should be clamped, not panic
|
||||
assert_eq!(line_bounds(text, 100), (0, 3));
|
||||
}
|
||||
|
||||
// ---- recover_tool_calls_from_content tests ----
|
||||
|
||||
fn make_tools(names: &[&str]) -> Vec<ToolDefinition> {
|
||||
|
||||
Reference in New Issue
Block a user