fix(tool_notifier): use char-safe truncation to prevent UTF-8 boundary panic#29
fix(tool_notifier): use char-safe truncation to prevent UTF-8 boundary panic#29
Conversation
… tool call UI Covers three features approved for implementation: 1. Framework auto-inject of semantically relevant past messages via <retrieved_context> 2. Nightly cron summarization storing [SUMMARY] system messages in SQLite 3. Live-edited Telegram status message showing active tool calls (/verbose toggle) https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
…d tool call UI 7-task TDD plan covering: - DB migration (is_summarized column, conversation-scoped search) - memory/rag.rs (auto_retrieve_context with <retrieved_context> injection) - memory/summarizer.rs (nightly LLM-based summarization via cron) - platform/tool_notifier.rs (live-editing Telegram progress message) - /verbose bot command (per-user toggle stored in knowledge table) - Enhanced system prompt for small model compatibility https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
Two features now in scope: 1. Query rewriting (memory/query_rewriter.rs) — rewrites ambiguous follow-ups into standalone queries before RAG vector search; falls back gracefully 2. LLM response streaming — final text response streamed token-by-token to Telegram via reqwest SSE + edit_message_text; tool iterations stay blocking https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
…s 8-13) Extends the original 7-task plan with: - Task 8: memory/query_rewriter.rs with rewrite_for_rag() + 5 unit tests - Task 9: wire rewriter into auto_retrieve_context() (new llm + recent_messages params) - Task 10: llm.rs chat_stream() with SSE parsing, StreamRequest, parse_sse_content() - Task 11: process_message() gains stream_token_tx param; final response chunked to channel - Task 12: Telegram spawns streaming receiver task with 500ms edit batching + 3800-char split - Task 13: final verification (test/clippy/release build/push) https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
…, raw message limit in load_messages https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
…m prompt each turn - Add src/memory/rag.rs with auto_retrieve_context(): skips short queries (<5 chars) and /commands, returns <retrieved_context> block with snippets capped at 300 chars - Register pub mod rag in src/memory/mod.rs - Add rag_limit, max_raw_messages, summarize_threshold, summarize_cron to MemoryConfig with defaults (5, 50, 20, "0 0 2 * * *") - Wire RAG into agent.rs: inject context block into system prompt after refresh but before user message; use load_messages_with_limit with config.memory.max_raw_messages - Suppress dead_code warnings for load_messages() and future-use config fields https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
…on job Adds summarize_conversation() and summarize_all_active() in memory/summarizer.rs, registers a configurable nightly cron job in scheduler/tasks.rs, and wires the LlmClient + MemoryConfig fields through register_builtin_tasks in main.rs. https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
- Add get_unsummarized_messages(), mark_messages_summarized(), get_active_conversations() to MemoryStore - Add #[derive(Clone)] to LlmClient (required for nightly summarization cron closure) https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
…l infrastructure - New src/platform/tool_notifier.rs: ToolEvent enum, format_args_preview(), ToolCallNotifier struct with start/handle_event/finish methods and rate-limited Telegram edits - Register pub mod tool_notifier in platform/mod.rs - Add tool_event_tx: Option<mpsc::Sender<ToolEvent>> param to agent process_message() - Emit ToolEvent::Started/Completed around each tool execution in agent loop - Update all callers (telegram.rs, main.rs) to pass None (Task 5 will wire the real channel) https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
…otifier into agentic loop
- Add /verbose toggle command: reads/writes tool_ui_enabled_{user_id} in knowledge store
- Add is_verbose_enabled() helper + test
- When verbose on: create mpsc channel, spawn ToolCallNotifier task, pass sender to process_message
- process_message drops sender on return, notifier task exits cleanly via channel close
- Update /start message to list /verbose command
https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
…otifier into agentic loop Adds is_verbose_enabled() helper with unit test, /verbose toggle command that persists per-user setting in memory, and wires ToolCallNotifier into process_message via mpsc channel when verbose mode is enabled. Also fixes formatting across several files and a redundant_closure clippy warning in memory/conversations.rs. https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
…follow-ups before vector search - Update auto_retrieve_context() signature: add llm: Option<&LlmClient> and recent_messages params - When llm provided, call rewrite_for_rag() to resolve pronouns before FTS/vector search - Pass llm and last 6 user/assistant messages from agent.rs call site - Update all existing rag.rs tests to pass None/&[] for new params https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
…aming via mpsc channel - Add reqwest stream feature to Cargo.toml - Add StreamRequest struct with stream=true field - Add parse_sse_content() to extract tokens from OpenRouter SSE data lines - Add chat_stream() method to LlmClient: streams response tokens via mpsc::Sender<String> - 6 new unit tests for SSE parsing and StreamRequest serialization https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
…esponse as word-chunks via mpsc - Add stream_token_tx: Option<mpsc::Sender<String>> param to process_message() - Stream final text response as 4-word chunks with 30ms delay for typing effect - Add test_assemble_tokens_joins_correctly to document assembly contract - Update all callers (main.rs, telegram.rs) to pass None (Task 12 will wire real channel) https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
…ing split threshold
…egram message as LLM tokens arrive - Spawn stream receiver task with zero-width space placeholder message - Receive word-chunk tokens from process_message via mpsc channel - Edit Telegram message at most every 500ms (rate limit) - Split into new message when buffer exceeds 3800 chars - stream_handle.await after process_message for final edit to complete - Error path: abort stream_handle, send error as new message - Fix formatting across llm.rs, rag.rs, query_rewriter.rs https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
…eam_handle Telegram rejects messages containing only zero-width space (U+200B), causing stream_handle to return early and drop the receiver. This made every tx.send() in agent.rs fail, breaking the streaming loop so process_message returned Ok while nothing was ever delivered to the user. Remove the placeholder send. Instead, accumulate tokens and: - Send the first real message after 500ms of content (or at channel close). - Edit that message for subsequent updates. - Fall back to a direct send_message at the end if no intermediate message was sent (covers short responses < 500ms token delivery). Errors on send are now logged via tracing::error/warn instead of being silently swallowed. https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
…essage
Root cause: ToolCallNotifier::finish() unconditionally called delete_message(),
erasing all evidence of tool calls from the Telegram chat. After the response
arrived the user saw no record of which tools were used — no tool UI.
Fix: when tools were actually called, edit the status message to a final
persistent summary ("🔧 Tools used:\n✅ tool(args)") instead of deleting it.
When no tools ran, delete the "⏳ Working..." placeholder as before (not useful).
Adds test_format_final_includes_all_tools to verify the new format.
https://claude.ai/code/session_0144HmaaG5wwqmBkBYhtGBgh
…ag-telegram-T4Jmo
Two tests reproduce the production panic in format_args_preview when tool arguments contain Chinese/multibyte characters and byte-indexed slicing lands mid-character. These tests are intentionally failing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tion tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…y panic Byte-indexed slicing (&s[..60]) panics when the byte boundary falls inside a multi-byte character (e.g. Chinese 香 = 3 bytes at 58..61). Replace with truncate_chars() which walks .chars() to find the safe byte boundary, used in both the single-arg fast-path and the raw-JSON fallback inside format_args_preview. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Claude finished @chinkan's task in 1m 51s —— View job Code Review: PR #29 — fix(tool_notifier): use char-safe truncation to prevent UTF-8 boundary panicTasks:
OverviewThe stated fix (char-safe truncation in ✅ The Core Fix (tool_notifier.rs)The fn truncate_chars(s: &str, max_chars: usize) -> String {
let mut byte_end = 0usize;
for (char_count, ch) in s.chars().enumerate() {
if char_count == max_chars {
return format!("{}...", &s[..byte_end]);
}
byte_end += ch.len_utf8();
}
s.to_string()
}
🔴 Critical: Same Bug Exists in Two New FilesThe identical byte-indexing pattern that caused the original crash has been introduced in two new files:
let snippet = if content.len() > 300 {
format!("{}...", &content[..300]) // 🔴 panics if byte 300 is mid-char
let snippet = if c.len() > 200 {
format!("{}...", &c[..200]) // 🔴 panics if byte 200 is mid-charBoth paths will be hit with Chinese/Japanese/emoji content (the exact scenario that caused the original crash). The 🟡 Major: PR Scope vs. Title MismatchThe PR title reads
This makes the PR very hard to review: the tiny UTF-8 fix is lost inside a large feature branch. Consider splitting into focused PRs, or at minimum renaming the title/description to reflect the actual scope. 🟡 Medium:
|
| Severity | Issue |
|---|---|
| 🔴 Critical | Same byte-unsafe truncation in rag.rs:45 and query_rewriter.rs:97 |
| 🟡 Major | PR title/description doesn't reflect actual scope |
| 🟡 Medium | mark_messages_summarized: N+1 writes without transaction |
| 🟡 Medium | Duplicated unsafe sqlite-vec registration block |
| 🟡 Medium | Weak byte-length assertion in truncation test |
| 🟢 Minor | split_message only exists in test context |
| 🟢 Minor | Widespread #[allow(dead_code)] on new public code |
| 🟢 Minor | Endianness assumption undocumented in f32_slice_to_bytes |
The critical items (rag.rs / query_rewriter.rs truncation) should be fixed before merging, as they reintroduce the exact class of panic this PR was written to eliminate.
… and verbose tool UI features
|
Claude finished @chinkan's task in 1m 31s —— View job Code Review: PR #29 — fix(tool_notifier): use char-safe truncation to prevent UTF-8 boundary panicTasks:
Status Since Last ReviewThis review was triggered by a new push to the branch. The previous review identified two critical byte-unsafe truncation bugs introduced alongside the UTF-8 fix. Both remain unaddressed. ✅ The Core Fix (tool_notifier.rs)The fn truncate_chars(s: &str, max_chars: usize) -> String {
let mut byte_end = 0usize;
for (char_count, ch) in s.chars().enumerate() {
if char_count == max_chars {
return format!("{}...", &s[..byte_end]);
}
byte_end += ch.len_utf8();
}
s.to_string()
}Both call sites in 🔴 Critical: Same Bug Still Present in Two New FilesThe exact byte-indexing panic that this PR was written to fix has been reintroduced in two files and has not been fixed after the previous review pointed them out.
// src/memory/rag.rs:45-46
let snippet = if content.len() > 300 {
format!("{}...", &content[..300]) // 🔴 panics if byte 300 is mid-char
// src/memory/query_rewriter.rs:97-98
let snippet = if c.len() > 200 {
format!("{}...", &c[..200]) // 🔴 panics if byte 200 is mid-charBoth paths are exercised with Chinese/Japanese input (the exact scenario that caused the original crash). The The fix: extract // rag.rs
let snippet = crate::utils::str::truncate_chars(content, 300);
// query_rewriter.rs
let snippet = crate::utils::str::truncate_chars(c, 200);🟡 Medium:
|
| Severity | Issue | Status |
|---|---|---|
| 🔴 Critical | Byte-unsafe truncation in rag.rs:45 and query_rewriter.rs:97 |
Not fixed |
| 🟡 Medium | mark_messages_summarized: N+1 writes without transaction |
Not fixed |
| 🟡 Medium | Duplicated unsafe sqlite-vec registration block |
Not fixed |
| 🟡 Medium | Weak byte-length assertion in truncation test | Not fixed |
| 🟢 Minor | Endianness assumption undocumented in f32_slice_to_bytes |
Not fixed |
| 🟢 Minor | Widespread #[allow(dead_code)] on new public code |
Not fixed |
The two critical items in rag.rs and query_rewriter.rs must be fixed before merging — they reintroduce the exact class of UTF-8 boundary panic this PR was written to eliminate.
Move the UTF-8-safe string truncation helper from tool_notifier.rs into a new src/utils/str module so rag.rs and query_rewriter.rs can reuse it without duplicating the implementation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
&content[..300] panics when byte 300 falls inside a multi-byte char. Chinese/Japanese input in retrieved context triggered this path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t UTF-8 panic &c[..200] panics when byte 200 falls inside a multi-byte character. Also adds a multibyte regression test — the existing test only used ASCII. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Claude finished @chinkan's task in 1m 32s —— View job Code Review: PR #29 — fix(tool_notifier): use char-safe truncation to prevent UTF-8 boundary panicTasks:
Status Since Last ReviewGood progress: both critical UTF-8 fixes from the previous two review rounds have been applied. The remaining items are medium/minor. ✅ Critical Issues — Resolved
let snippet = crate::utils::str::truncate_chars(content, 300);
let snippet = crate::utils::str::truncate_chars(c, 200);Both sites now use the shared 🟡 Medium:
|
| Severity | Issue | Status |
|---|---|---|
| 🔴 Critical | Byte-unsafe truncation in rag.rs and query_rewriter.rs |
Fixed ✅ |
| 🟡 Medium | mark_messages_summarized: N+1 writes without transaction |
Not fixed |
| 🟡 Medium | Duplicated unsafe sqlite-vec registration block |
Not fixed |
| 🟡 Medium | Weak byte-length assertion in truncation test | Not fixed |
| 🟢 Minor | split_message test-only — now intentional with streaming |
Resolved ✅ |
| 🟢 Minor | Endianness assumption undocumented in f32_slice_to_bytes |
Not fixed |
| 🟢 Minor | #[allow(dead_code)] on new public code |
Not fixed (acceptable for WIP) |
The critical blocking items are resolved. The remaining medium items (N+1 writes, duplicated unsafe block, weak assertion) are worth addressing before merge but are not safety/correctness blockers in the same class as the UTF-8 panic. The PR is significantly cleaner than when it started.
Summary
format_args_preview(src/platform/tool_notifier.rs) caused by byte-indexed string slicing (&s[..60]) panicking when byte 60 falls inside a multi-byte UTF-8 character (e.g. Chinese香= bytes 58–61)truncate_chars()helper that walks.chars()to find the safe byte boundary before slicingRoot Cause
String::len()returns byte length, not char count. Theschedule_taskargs JSON contained Chinese characters; the multi-arg fallback path hit&args_json[..60]with byte 60 in the middle of香, causing a panic → tokio worker death →TX is deadcascade crash.Test Plan
test_format_args_preview_multi_arg_chinese_panics_before_fix— reproduces the exact production crash input, now passes after fixtest_format_args_preview_single_arg_with_chinese— exercises single-arg path with Chinese stringcargo test)cargo clippy -- -D warningscleancargo fmt --checkclean🤖 Generated with Claude Code