Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions src/openhuman/inference/provider/claude_code/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,28 @@ fn parse_error_log_line(ev: &ClaudeCodeEvent) -> Option<String> {
))
}

/// How much of the child's stderr we keep for diagnostics.
const STDERR_DIAGNOSTIC_CAP: usize = 16_384;

/// Append `chunk` to `acc`, keeping at most `max_bytes` and never splitting a
/// character.
///
/// `String::truncate` takes a *byte* index and panics when it is not a character
/// boundary, so bounding this accumulator with `acc.truncate(16_384)` aborted the
/// task the moment a multi-byte character straddled the cap. The panic happened
/// inside `tokio::spawn`, and the join is `unwrap_or_default()`, so it surfaced as
/// an empty stderr string: the operator lost the whole error output for that turn
/// and saw `exit Some(1) stderr=`.
fn push_bounded(acc: &mut String, chunk: &str, max_bytes: usize) {
acc.push_str(chunk);
if acc.len() > max_bytes {
let keep = utf8_safe_prefix_at_byte_boundary(acc, max_bytes).len();
acc.truncate(keep);
}
}

use crate::openhuman::util::text::utf8_safe_prefix_at_byte_boundary;

use super::event_mapper::EventMapper;
use super::input_builder::build_stdin;
use super::session_store::{generate_uuid_v4, is_uuid_v4, SessionStore};
Expand Down Expand Up @@ -475,10 +497,11 @@ pub async fn run_turn(ctx: TurnContext<'_>) -> anyhow::Result<ChatResponse> {
if n == 0 {
break;
}
acc.push_str(&String::from_utf8_lossy(&tmp[..n]));
if acc.len() > 16_384 {
acc.truncate(16_384);
}
push_bounded(
&mut acc,
&String::from_utf8_lossy(&tmp[..n]),
STDERR_DIAGNOSTIC_CAP,
);
}
acc
});
Expand Down
46 changes: 44 additions & 2 deletions src/openhuman/inference/provider/claude_code/driver_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,50 @@ fn full_access_reads_persisted_toggle_when_env_unset() {
let _ = std::fs::remove_dir_all(&ws);
}

/// `String::truncate` takes a byte index and panics when it is not a character
/// boundary, so bounding the stderr accumulator with `acc.truncate(16_384)`
/// aborted the drain task whenever a multi-byte character straddled the cap.
/// The join is `unwrap_or_default()`, so the operator saw `stderr=` and lost
/// the whole error output for that turn.
#[test]
fn push_bounded_never_splits_a_character() {
// "aéb" is 4 bytes: a=0, é=1..2, b=3. A cap of 2 lands *inside* 'é', so the
// helper must back up to byte 1 -- `String::truncate(2)` would panic here.
let mut acc = String::new();
super::push_bounded(&mut acc, "aéb", 2);
assert_eq!(acc, "a", "must back up to the boundary, not split it");

// A cap of 3 lands exactly on a boundary, so nothing is given up needlessly.
let mut acc = String::new();
super::push_bounded(&mut acc, "aéb", 3);
assert_eq!(acc, "aé");
assert!(acc.len() <= 3);

// The same, driven the way the reader does it: many small chunks over the cap.
let mut acc = String::new();
for _ in 0..600 {
super::push_bounded(&mut acc, "日本語テキスト", 1024);
}
assert!(acc.len() <= 1024);
// The real assertion: it is still valid UTF-8 and did not panic getting here.
assert!(std::str::from_utf8(acc.as_bytes()).is_ok());
}

#[test]
fn push_bounded_keeps_everything_below_the_cap() {
let mut acc = String::new();
super::push_bounded(&mut acc, "hello ", 64);
super::push_bounded(&mut acc, "world", 64);
assert_eq!(acc, "hello world");
}

#[test]
fn push_bounded_handles_an_ascii_cap_exactly() {
let mut acc = String::new();
super::push_bounded(&mut acc, "abcdef", 3);
assert_eq!(acc, "abc");
}

#[test]
fn parse_error_events_produce_a_log_line() {
let ev = ClaudeCodeEvent::ParseError {
Expand Down Expand Up @@ -236,5 +280,3 @@ fn the_shape_of_the_line_is_reported() {
assert!(shape("panic: claude-code crashed").contains("non-json"));
assert!(shape(" ").contains("blank"));
}

use super::*;
Loading