diff --git a/crates/loopal-protocol/src/projection.rs b/crates/loopal-protocol/src/projection.rs index fffdfee..cdf723a 100644 --- a/crates/loopal-protocol/src/projection.rs +++ b/crates/loopal-protocol/src/projection.rs @@ -111,7 +111,11 @@ fn summarize_input(input: &serde_json::Value) -> String { if s.len() <= 60 { s } else { - format!("{}...", &s[..57]) + let mut end = 57; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}...", &s[..end]) } } diff --git a/crates/loopal-protocol/tests/suite/projection_edge_test.rs b/crates/loopal-protocol/tests/suite/projection_edge_test.rs index fe51076..aa34143 100644 --- a/crates/loopal-protocol/tests/suite/projection_edge_test.rs +++ b/crates/loopal-protocol/tests/suite/projection_edge_test.rs @@ -1,6 +1,58 @@ use loopal_message::{ContentBlock, Message, MessageRole}; use loopal_protocol::projection::project_messages; +#[test] +fn summarize_input_respects_utf8_boundary() { + // Exact reproduction of the crash: byte 57 falls inside '建' (bytes 55..58). + let long_chinese = serde_json::json!({ + "subject": "创建 content-pipeline 目录结构", + "description": "创建 channels/content-pipeline/drafts/ 目录和 .gitkeep", + "activeForm": "Creating directories" + }); + let raw = long_chinese.to_string(); + assert!( + raw.len() > 60, + "input must exceed 60 bytes to trigger truncation" + ); + assert!( + !raw.is_char_boundary(57), + "byte 57 must be mid-character to test the fix" + ); + + let msg = Message { + id: None, + role: MessageRole::Assistant, + content: vec![ContentBlock::ToolUse { + id: "tu-utf8".into(), + name: "TaskCreate".into(), + input: long_chinese, + }], + }; + let display = project_messages(&[msg]); + let summary = &display[0].tool_calls[0].summary; + assert!(summary.starts_with("TaskCreate(")); + assert!(summary.ends_with("...)")); + // Verify truncated content is valid UTF-8 (iterating chars would panic otherwise) + assert!(summary.chars().count() > 0); +} + +#[test] +fn summarize_input_short_input_not_truncated() { + let short = serde_json::json!({"path": "/tmp/foo"}); + let msg = Message { + id: None, + role: MessageRole::Assistant, + content: vec![ContentBlock::ToolUse { + id: "tu-short".into(), + name: "Read".into(), + input: short.clone(), + }], + }; + let display = project_messages(&[msg]); + let summary = &display[0].tool_calls[0].summary; + assert_eq!(summary, &format!("Read({})", short)); +} + #[test] fn project_multiple_images_count() { let msg = Message { diff --git a/crates/tools/process/bash/src/bg_monitor.rs b/crates/tools/process/bash/src/bg_monitor.rs index 69cad40..ae24b33 100644 --- a/crates/tools/process/bash/src/bg_monitor.rs +++ b/crates/tools/process/bash/src/bg_monitor.rs @@ -96,7 +96,11 @@ pub fn truncate_cmd(cmd: &str, max: usize) -> String { if single_line.len() <= max { single_line } else { - format!("{}…", &single_line[..max - 1]) + let mut end = max.saturating_sub(1); + while end > 0 && !single_line.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &single_line[..end]) } } @@ -112,3 +116,49 @@ pub async fn read_pipe(buf: &Mutex, rea } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_cmd_ascii_within_limit() { + assert_eq!(truncate_cmd("ls -la", 10), "ls -la"); + } + + #[test] + fn truncate_cmd_ascii_exceeds_limit() { + let result = truncate_cmd("echo hello world foo bar", 10); + assert!(result.ends_with('…')); + // 9 ASCII chars + 3-byte '…' = 12 bytes max + assert!(result.len() <= 12); + } + + #[test] + fn truncate_cmd_multibyte_boundary() { + // '创' = 3 bytes, so "echo 创建目录" has byte offsets where max could land mid-char + let result = truncate_cmd("echo 创建目录结构并初始化配置文件", 12); + assert!(result.ends_with('…')); + // Strip the trailing '…' (3 bytes) and verify the prefix is valid UTF-8 + let prefix = &result[..result.len() - '…'.len_utf8()]; + assert!(prefix.is_char_boundary(prefix.len())); + } + + #[test] + fn truncate_cmd_max_exactly_inside_char() { + // "创" is bytes 0..3; max=2 lands inside it, must back up to 0 + let result = truncate_cmd("创建", 2); + assert_eq!(result, "…"); // backed up to 0, only ellipsis remains + } + + #[test] + fn truncate_cmd_collapses_whitespace() { + assert_eq!(truncate_cmd("ls -la /tmp", 20), "ls -la /tmp"); + } + + #[test] + fn truncate_cmd_zero_max() { + let result = truncate_cmd("hello", 0); + assert_eq!(result, "…"); + } +} diff --git a/src/bootstrap/multiprocess.rs b/src/bootstrap/multiprocess.rs index 21b4612..441be2d 100644 --- a/src/bootstrap/multiprocess.rs +++ b/src/bootstrap/multiprocess.rs @@ -58,13 +58,20 @@ pub async fn run( // 9. Load display history or show welcome let session_manager = loopal_runtime::SessionManager::new()?; if let Some(sid) = resume { - if let Ok((session, messages)) = session_manager.resume_session(sid) { - session_ctrl.load_display_history(project_messages(&messages)); - super::sub_agent_resume::load_sub_agent_histories( - &session_ctrl, - &session, - &session_manager, - ); + match session_manager.resume_session(sid) { + Ok((session, messages)) => { + session_ctrl.load_display_history(project_messages(&messages)); + super::sub_agent_resume::load_sub_agent_histories( + &session_ctrl, + &session, + &session_manager, + ); + } + Err(e) => { + tracing::warn!(session_id = sid, error = %e, "failed to resume session"); + let short = &sid[..8.min(sid.len())]; + session_ctrl.push_system_message(format!("Failed to resume session {short}: {e}")); + } } } else { let display_path = super::abbreviate_home(cwd);