From 1f3333efb79d6ecd97ba88d00dbfd48a01a60d4e Mon Sep 17 00:00:00 2001 From: IRISX Date: Mon, 13 Apr 2026 03:24:06 +0800 Subject: [PATCH 01/14] feat: track context window usage from usage_update notifications Capture `used` and `size` from ACP `usage_update` session notifications into atomic counters on AcpConnection. This enables downstream features like context display in /doctor and auto-compact triggers. - Add `context_used` / `context_size` AtomicU64 fields to AcpConnection - Capture values from `usage_update` in the reader loop - Add `UsageUpdate { used, size }` variant to AcpEvent enum - Add `usage_update` branch to classify_notification Verified with Claude Code (1M ctx), Codex CLI (258K ctx), and Gemini CLI (schema-defined). Zero-cost when no usage_update is emitted (Copilot). --- src/acp/connection.rs | 25 +++++++++++++++++++++++++ src/acp/protocol.rs | 6 ++++++ 2 files changed, 31 insertions(+) diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 53770509..aaf63e73 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -52,6 +52,9 @@ pub struct AcpConnection { pub acp_session_id: Option, pub last_active: Instant, pub session_reset: bool, + /// Context window usage from the latest `usage_update` ACP notification. + pub context_used: Arc, + pub context_size: Arc, _reader_handle: JoinHandle<()>, } @@ -86,11 +89,15 @@ impl AcpConnection { Arc::new(Mutex::new(HashMap::new())); let notify_tx: Arc>>> = Arc::new(Mutex::new(None)); + let context_used = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let context_size = Arc::new(std::sync::atomic::AtomicU64::new(0)); let reader_handle = { let pending = pending.clone(); let notify_tx = notify_tx.clone(); let stdin_clone = stdin.clone(); + let ctx_used = context_used.clone(); + let ctx_size = context_size.clone(); tokio::spawn(async move { let mut reader = BufReader::new(stdout); let mut line = String::new(); @@ -129,6 +136,22 @@ impl AcpConnection { continue; } + // Capture usage_update for context window tracking + if msg.method.as_deref() == Some("session/update") { + if let Some(upd) = msg.params.as_ref() + .and_then(|p| p.get("update")) + { + if upd.get("sessionUpdate").and_then(|v| v.as_str()) == Some("usage_update") { + if let Some(used) = upd.get("used").and_then(|v| v.as_u64()) { + ctx_used.store(used, Ordering::Relaxed); + } + if let Some(size) = upd.get("size").and_then(|v| v.as_u64()) { + ctx_size.store(size, Ordering::Relaxed); + } + } + } + } + // Response (has id) → resolve pending AND forward to subscriber if let Some(id) = msg.id { let mut map = pending.lock().await; @@ -186,6 +209,8 @@ impl AcpConnection { acp_session_id: None, last_active: Instant::now(), session_reset: false, + context_used, + context_size, _reader_handle: reader_handle, }) } diff --git a/src/acp/protocol.rs b/src/acp/protocol.rs index 82f00eb8..2800a113 100644 --- a/src/acp/protocol.rs +++ b/src/acp/protocol.rs @@ -63,6 +63,7 @@ pub enum AcpEvent { ToolStart { id: String, title: String }, ToolDone { id: String, title: String, status: String }, Status, + UsageUpdate { used: u64, size: u64 }, } pub fn classify_notification(msg: &JsonRpcMessage) -> Option { @@ -105,6 +106,11 @@ pub fn classify_notification(msg: &JsonRpcMessage) -> Option { } } "plan" => Some(AcpEvent::Status), + "usage_update" => { + let used = update.get("used").and_then(|v| v.as_u64()).unwrap_or(0); + let size = update.get("size").and_then(|v| v.as_u64()).unwrap_or(0); + Some(AcpEvent::UsageUpdate { used, size }) + } _ => None, } } From dacb7fb3f8a9ef37e1d2b6df123588db83b63b7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AC=9D=E9=A0=A1=E8=AD=AF?= Date: Mon, 13 Apr 2026 03:35:28 +0800 Subject: [PATCH 02/14] Update src/acp/connection.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/acp/connection.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/acp/connection.rs b/src/acp/connection.rs index aaf63e73..e0bccfb4 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -142,12 +142,15 @@ impl AcpConnection { .and_then(|p| p.get("update")) { if upd.get("sessionUpdate").and_then(|v| v.as_str()) == Some("usage_update") { - if let Some(used) = upd.get("used").and_then(|v| v.as_u64()) { - ctx_used.store(used, Ordering::Relaxed); - } - if let Some(size) = upd.get("size").and_then(|v| v.as_u64()) { - ctx_size.store(size, Ordering::Relaxed); - } + let used = upd.get("used") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + ctx_used.store(used, Ordering::Relaxed); + + let size = upd.get("size") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + ctx_size.store(size, Ordering::Relaxed); } } } From 497b170fd89d14bbfce3dd4d1486dbaab5ba7568 Mon Sep 17 00:00:00 2001 From: IRISX Date: Mon, 13 Apr 2026 03:39:47 +0800 Subject: [PATCH 03/14] refactor: reuse classify_notification for usage_update capture Replace inline usage_update parsing in the reader loop with a call to the shared classify_notification() helper (protocol.rs), keeping the parsing logic in one place and avoiding divergence as ACP evolves. --- src/acp/connection.rs | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/src/acp/connection.rs b/src/acp/connection.rs index e0bccfb4..9a36323c 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -136,23 +136,14 @@ impl AcpConnection { continue; } - // Capture usage_update for context window tracking - if msg.method.as_deref() == Some("session/update") { - if let Some(upd) = msg.params.as_ref() - .and_then(|p| p.get("update")) - { - if upd.get("sessionUpdate").and_then(|v| v.as_str()) == Some("usage_update") { - let used = upd.get("used") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - ctx_used.store(used, Ordering::Relaxed); - - let size = upd.get("size") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - ctx_size.store(size, Ordering::Relaxed); - } - } + // Capture usage_update for context window tracking. + // Reuses the shared classify_notification parser so the + // logic stays in one place (protocol.rs). + if let Some(crate::acp::protocol::AcpEvent::UsageUpdate { used, size }) = + crate::acp::protocol::classify_notification(&msg) + { + ctx_used.store(used, Ordering::Relaxed); + ctx_size.store(size, Ordering::Relaxed); } // Response (has id) → resolve pending AND forward to subscriber From 61b83bad34b45aa514b3b8e532b435260b22ae60 Mon Sep 17 00:00:00 2001 From: IRISX Date: Mon, 13 Apr 2026 03:46:06 +0800 Subject: [PATCH 04/14] chore: trigger re-review From cb6f6b19ea701ca5044a7bd68e449a0f6af597b3 Mon Sep 17 00:00:00 2001 From: IRISX Date: Mon, 13 Apr 2026 04:02:14 +0800 Subject: [PATCH 05/14] fix: require both used/size fields and document 0-sentinel Address review feedback: - protocol.rs: use `?` instead of unwrap_or(0) so a usage_update missing either field returns None (preserves previous counters). - connection.rs: document that 0 means "no usage_update received yet". The two atomic fields are intentionally independent (no lock) because the only current reader is /doctor display, which tolerates a brief mixed snapshot. A snapshot helper can be added when paired reads become critical. --- src/acp/connection.rs | 1 + src/acp/protocol.rs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 9a36323c..98b4703a 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -53,6 +53,7 @@ pub struct AcpConnection { pub last_active: Instant, pub session_reset: bool, /// Context window usage from the latest `usage_update` ACP notification. + /// A value of 0 means no `usage_update` has been received yet (unknown). pub context_used: Arc, pub context_size: Arc, _reader_handle: JoinHandle<()>, diff --git a/src/acp/protocol.rs b/src/acp/protocol.rs index 2800a113..76b0c5d2 100644 --- a/src/acp/protocol.rs +++ b/src/acp/protocol.rs @@ -107,8 +107,8 @@ pub fn classify_notification(msg: &JsonRpcMessage) -> Option { } "plan" => Some(AcpEvent::Status), "usage_update" => { - let used = update.get("used").and_then(|v| v.as_u64()).unwrap_or(0); - let size = update.get("size").and_then(|v| v.as_u64()).unwrap_or(0); + let used = update.get("used").and_then(|v| v.as_u64())?; + let size = update.get("size").and_then(|v| v.as_u64())?; Some(AcpEvent::UsageUpdate { used, size }) } _ => None, From 2d5370926b54526a9100baedfea1ab055df87db0 Mon Sep 17 00:00:00 2001 From: IRISX Date: Mon, 13 Apr 2026 04:26:58 +0800 Subject: [PATCH 06/14] chore: add Copilot code review instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `.github/copilot-instructions.md` to guide GitHub Copilot's automated PR reviews with project-specific context: - ACP protocol correctness (JSON-RPC routing, notification handling) - Concurrency safety (atomic fields, Mutex across await, pool locks) - Discord API constraints (2000 char limit, 3s autocomplete deadline) - Skip CI-covered checks (rustfmt, clippy, tests) - >80% confidence threshold to reduce noise Modeled after block/goose's production-grade instructions. 3440 chars — within the 4000 char limit. --- .github/copilot-instructions.md | 67 +++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..d4ab1a57 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,67 @@ +# GitHub Copilot Code Review Instructions + +## Review Philosophy +- Only comment when you have HIGH CONFIDENCE (>80%) that an issue exists +- Be concise: one sentence per comment when possible +- Focus on actionable feedback, not observations +- Silence is preferred over noisy false positives + +## Project Context +- **OpenAB**: A lightweight ACP (Agent Client Protocol) harness bridging Discord ↔ any ACP-compatible coding CLI over stdio JSON-RPC +- **Language**: Rust 2021 edition, single binary +- **Async runtime**: tokio (full features) +- **Discord**: serenity 0.12 (gateway + cache) +- **Error handling**: `anyhow::Result` everywhere, no `unwrap()` in production paths +- **Serialization**: serde + serde_json for ACP JSON-RPC, toml for config +- **Key modules**: `acp/connection.rs` (ACP stdio bridge), `acp/pool.rs` (session pool), `discord.rs` (Discord event handler), `config.rs` (TOML config), `usage.rs` (pluggable quota runners), `reactions.rs` (emoji reactions), `stt.rs` (speech-to-text) + +## Priority Areas (Review These) + +### Correctness +- Logic errors that could cause panics or incorrect behavior +- ACP JSON-RPC protocol violations (wrong method names, missing fields, incorrect response routing) +- Race conditions in async code (especially in the reader loop and session pool) +- Resource leaks (child processes not killed, channels not closed) +- Off-by-one in timeout calculations +- Incorrect error propagation — `unwrap()` in non-test code is always a bug + +### Concurrency & Safety +- Multiple atomic fields updated independently — document if readers may see mixed snapshots +- `Mutex` held across `.await` points (potential deadlock) +- Session pool lock scope — `RwLock` held during I/O can stall all sessions +- Child process lifecycle — `kill_on_drop` must be set, zombie processes must not accumulate + +### ACP Protocol +- `session/request_permission` must always get a response (auto-allow or forwarded) +- `session/update` notifications must not be consumed — forward to subscriber after capture +- `usage_update`, `available_commands_update`, `tool_call`, `agent_message_chunk` must be classified correctly +- Timeout values: initialize=90s, session/new=120s, others=30s (Gemini cold-start is slow) + +### Discord API +- Messages >2000 chars will be rejected — truncate or split +- Slash command registration is per-guild, max 100 per bot +- Autocomplete responses must return within 3s (no heavy I/O) +- Ephemeral messages for errors, regular messages for results + +### Config & Deployment +- `config.toml` fields must have sensible defaults — missing `[usage]` section should not crash +- Environment variable expansion via `${VAR}` must handle missing vars gracefully +- Agent `env` map is passed to child processes — sensitive values should not be logged + +## CI Pipeline (Do Not Flag These) +- `cargo fmt --check` — formatting is enforced by CI +- `cargo clippy --all-targets -- -D warnings` — lint warnings are enforced by CI +- `cargo test` — test failures are caught by CI + +## Skip These (Low Value) +- Style/formatting — CI handles via rustfmt +- Clippy warnings — CI handles +- Minor naming suggestions unless truly confusing +- Suggestions to add comments for self-documenting code +- Logging level suggestions unless security-relevant +- Import ordering + +## Response Format +1. State the problem (1 sentence) +2. Why it matters (1 sentence, only if not obvious) +3. Suggested fix (code snippet or specific action) From 325d366033bb15ab6396df1ae42df33a29a608e8 Mon Sep 17 00:00:00 2001 From: IRISX Date: Mon, 13 Apr 2026 06:56:22 +0800 Subject: [PATCH 07/14] fix: clarify 0-sentinel and correct timeout docs - connection.rs: document that context_size==0 means unknown (not context_used==0, which can be legitimate for empty contexts) - copilot-instructions.md: fix timeout values to match upstream (session/new=120s, all others=30s; remove incorrect initialize=90s) --- .github/copilot-instructions.md | 2 +- src/acp/connection.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d4ab1a57..caea1d78 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -35,7 +35,7 @@ - `session/request_permission` must always get a response (auto-allow or forwarded) - `session/update` notifications must not be consumed — forward to subscriber after capture - `usage_update`, `available_commands_update`, `tool_call`, `agent_message_chunk` must be classified correctly -- Timeout values: initialize=90s, session/new=120s, others=30s (Gemini cold-start is slow) +- Timeout values: session/new=120s, all other methods (including initialize)=30s ### Discord API - Messages >2000 chars will be rejected — truncate or split diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 98b4703a..f95c5001 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -53,7 +53,8 @@ pub struct AcpConnection { pub last_active: Instant, pub session_reset: bool, /// Context window usage from the latest `usage_update` ACP notification. - /// A value of 0 means no `usage_update` has been received yet (unknown). + /// `context_used` may legitimately be 0 (e.g., new or empty context). + /// Treat `context_size == 0` as meaning no `usage_update` has been received yet. pub context_used: Arc, pub context_size: Arc, _reader_handle: JoinHandle<()>, From 38571fe4cbbd887eb1f8cd1a64e436d827c7ac26 Mon Sep 17 00:00:00 2001 From: IRISX Date: Mon, 13 Apr 2026 10:01:30 +0800 Subject: [PATCH 08/14] feat(discord): display context window usage after each prompt response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Append a small footer to each agent response showing the current context window utilization, e.g.: 📊 Context: 31434/1000000 tokens (3%) Only shown when context_size > 0 (i.e., at least one usage_update has been received). Uses Discord's -# small text syntax. No impact on backends that don't emit usage_update. --- src/discord.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/discord.rs b/src/discord.rs index e267064e..8ff0bcd3 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -589,6 +589,16 @@ async fn stream_prompt( final_content }; + // Append context window usage footer if available. + let ctx_size = conn.context_size.load(std::sync::atomic::Ordering::Relaxed); + let final_content = if ctx_size > 0 { + let ctx_used = conn.context_used.load(std::sync::atomic::Ordering::Relaxed); + let pct = (ctx_used as f64 / ctx_size as f64 * 100.0).round() as u64; + format!("{final_content}\n-# 📊 Context: {ctx_used}/{ctx_size} tokens ({pct}%)") + } else { + final_content + }; + let chunks = format::split_message(&final_content, 2000); for (i, chunk) in chunks.iter().enumerate() { if i == 0 { From 43d3a8ce83fa996807f98207e32032269f7b18f0 Mon Sep 17 00:00:00 2001 From: IRISX Date: Mon, 13 Apr 2026 10:12:34 +0800 Subject: [PATCH 09/14] fix(discord): skip context footer on long messages, shorten format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip footer when message is >1900 chars to prevent it splitting into a separate Discord message - Shorten "📊 Context: X/Y tokens (N%)" to "📊 X/Y tokens (N%)" --- src/discord.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/discord.rs b/src/discord.rs index 8ff0bcd3..be7b6019 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -590,11 +590,13 @@ async fn stream_prompt( }; // Append context window usage footer if available. + // Skip when the message is already near the 2000-char Discord limit + // to avoid the footer splitting into a separate message. let ctx_size = conn.context_size.load(std::sync::atomic::Ordering::Relaxed); - let final_content = if ctx_size > 0 { + let final_content = if ctx_size > 0 && final_content.len() < 1900 { let ctx_used = conn.context_used.load(std::sync::atomic::Ordering::Relaxed); let pct = (ctx_used as f64 / ctx_size as f64 * 100.0).round() as u64; - format!("{final_content}\n-# 📊 Context: {ctx_used}/{ctx_size} tokens ({pct}%)") + format!("{final_content}\n-# 📊 {ctx_used}/{ctx_size} tokens ({pct}%)") } else { final_content }; From a6e784119b8e1d69a68f6802b470fe40e7010e1f Mon Sep 17 00:00:00 2001 From: IRISX Date: Mon, 13 Apr 2026 10:20:50 +0800 Subject: [PATCH 10/14] fix(discord): append context footer to last chunk instead of skipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of hiding the context footer on long messages, append it to the last chunk if it fits within the 2000-char limit. This ensures users always see context usage — especially important for long responses that consume more context window. --- src/discord.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/discord.rs b/src/discord.rs index be7b6019..85d12136 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -589,24 +589,31 @@ async fn stream_prompt( final_content }; - // Append context window usage footer if available. - // Skip when the message is already near the 2000-char Discord limit - // to avoid the footer splitting into a separate message. + // Build context usage footer (empty string if no data). let ctx_size = conn.context_size.load(std::sync::atomic::Ordering::Relaxed); - let final_content = if ctx_size > 0 && final_content.len() < 1900 { + let ctx_footer = if ctx_size > 0 { let ctx_used = conn.context_used.load(std::sync::atomic::Ordering::Relaxed); let pct = (ctx_used as f64 / ctx_size as f64 * 100.0).round() as u64; - format!("{final_content}\n-# 📊 {ctx_used}/{ctx_size} tokens ({pct}%)") + format!("\n-# 📊 {ctx_used}/{ctx_size} tokens ({pct}%)") } else { - final_content + String::new() }; let chunks = format::split_message(&final_content, 2000); + let last_idx = chunks.len().saturating_sub(1); for (i, chunk) in chunks.iter().enumerate() { + // Append context footer to the last chunk if it fits. + let content = if i == last_idx && !ctx_footer.is_empty() + && chunk.len() + ctx_footer.len() < 2000 + { + format!("{chunk}{ctx_footer}") + } else { + chunk.to_string() + }; if i == 0 { - let _ = edit(&ctx, channel, current_msg_id, chunk).await; + let _ = edit(&ctx, channel, current_msg_id, &content).await; } else { - let _ = channel.say(&ctx.http, chunk).await; + let _ = channel.say(&ctx.http, &content).await; } } From 31f766791fc0ca79d5bfc445f112f9d7c064a069 Mon Sep 17 00:00:00 2001 From: IRISX Date: Mon, 13 Apr 2026 11:23:03 +0800 Subject: [PATCH 11/14] fix: address Copilot review feedback on context tracking - Extract format_context_footer() helper with unit tests - Clamp percentage to 100 via u128 integer math (handles used > size) - Use chars().count() instead of byte .len() for Discord 2000-char limit - Send footer as separate message when it doesn't fit in last chunk - Document atomic consistency semantics on context_used/context_size Co-Authored-By: Claude Opus 4.6 (1M context) --- src/acp/connection.rs | 5 +++ src/discord.rs | 71 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/src/acp/connection.rs b/src/acp/connection.rs index f95c5001..b83b8a96 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -55,6 +55,11 @@ pub struct AcpConnection { /// Context window usage from the latest `usage_update` ACP notification. /// `context_used` may legitimately be 0 (e.g., new or empty context). /// Treat `context_size == 0` as meaning no `usage_update` has been received yet. + /// + /// **Consistency note:** These two fields are updated via separate relaxed + /// atomic stores, so readers may observe a mixed snapshot (e.g., new `used` + /// with old `size`). This is acceptable for best-effort display purposes; + /// any consumer requiring a consistent pair should use the snapshot helper. pub context_used: Arc, pub context_size: Arc, _reader_handle: JoinHandle<()>, diff --git a/src/discord.rs b/src/discord.rs index 85d12136..59955c6d 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -589,24 +589,25 @@ async fn stream_prompt( final_content }; - // Build context usage footer (empty string if no data). + // Build context usage footer. let ctx_size = conn.context_size.load(std::sync::atomic::Ordering::Relaxed); - let ctx_footer = if ctx_size > 0 { - let ctx_used = conn.context_used.load(std::sync::atomic::Ordering::Relaxed); - let pct = (ctx_used as f64 / ctx_size as f64 * 100.0).round() as u64; - format!("\n-# 📊 {ctx_used}/{ctx_size} tokens ({pct}%)") - } else { - String::new() - }; + let ctx_used = conn.context_used.load(std::sync::atomic::Ordering::Relaxed); + let ctx_footer = format_context_footer(ctx_used, ctx_size); let chunks = format::split_message(&final_content, 2000); let last_idx = chunks.len().saturating_sub(1); for (i, chunk) in chunks.iter().enumerate() { - // Append context footer to the last chunk if it fits. - let content = if i == last_idx && !ctx_footer.is_empty() - && chunk.len() + ctx_footer.len() < 2000 - { - format!("{chunk}{ctx_footer}") + // Append context footer to the last chunk if it fits (char count, not bytes). + let content = if i == last_idx { + if let Some(ref footer) = ctx_footer { + if chunk.chars().count() + footer.chars().count() < 2000 { + format!("{chunk}{footer}") + } else { + chunk.to_string() + } + } else { + chunk.to_string() + } } else { chunk.to_string() }; @@ -616,6 +617,13 @@ async fn stream_prompt( let _ = channel.say(&ctx.http, &content).await; } } + // If footer didn't fit in the last chunk, send as a separate message. + if let Some(ref footer) = ctx_footer { + let last_chunk = chunks.last().map(|c| c.as_str()).unwrap_or(""); + if last_chunk.chars().count() + footer.chars().count() >= 2000 { + let _ = channel.say(&ctx.http, footer).await; + } + } Ok(()) }) @@ -623,6 +631,17 @@ async fn stream_prompt( .await } +/// Format a context usage footer for Discord messages. +/// Returns `None` when `ctx_size == 0` (no `usage_update` received yet). +/// The percentage is clamped to 100 to handle `used > size` edge cases. +fn format_context_footer(ctx_used: u64, ctx_size: u64) -> Option { + if ctx_size == 0 { + return None; + } + let pct = ((ctx_used as u128 * 100 + ctx_size as u128 / 2) / ctx_size as u128).min(100) as u64; + Some(format!("\n-# 📊 {ctx_used}/{ctx_size} tokens ({pct}%)")) +} + /// Flatten a tool-call title into a single line that's safe to render /// inside Discord inline-code spans. Discord renders single-backtick /// code on a single line only, so multi-line shell commands (heredocs, @@ -798,4 +817,30 @@ mod tests { let garbage = vec![0x00, 0x01, 0x02, 0x03]; assert!(resize_and_compress(&garbage).is_err()); } + + #[test] + fn context_footer_none_when_size_zero() { + assert!(format_context_footer(0, 0).is_none()); + assert!(format_context_footer(500, 0).is_none()); + } + + #[test] + fn context_footer_normal_percentage() { + let footer = format_context_footer(31434, 1_000_000).unwrap(); + assert!(footer.contains("31434/1000000")); + assert!(footer.contains("3%")); + } + + #[test] + fn context_footer_clamps_over_100() { + // used > size should clamp to 100% + let footer = format_context_footer(1_200_000, 1_000_000).unwrap(); + assert!(footer.contains("100%")); + } + + #[test] + fn context_footer_100_percent() { + let footer = format_context_footer(1_000_000, 1_000_000).unwrap(); + assert!(footer.contains("100%")); + } } From 1e7bb3d66e8c11119b36bd70f18d910e249847e0 Mon Sep 17 00:00:00 2001 From: IRISX Date: Mon, 13 Apr 2026 11:35:29 +0800 Subject: [PATCH 12/14] fix: off-by-one, standalone footer newline, doc comment accuracy - Use <= 2000 for footer append check (fixes off-by-one at boundary) - Use > 2000 for separate-message condition (consistent with above) - trim_start_matches('\n') on standalone footer (no empty first line) - Remove reference to non-existent snapshot helper in doc comment Co-Authored-By: Claude Opus 4.6 (1M context) --- src/acp/connection.rs | 7 ++++--- src/discord.rs | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/acp/connection.rs b/src/acp/connection.rs index b83b8a96..16e18015 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -57,9 +57,10 @@ pub struct AcpConnection { /// Treat `context_size == 0` as meaning no `usage_update` has been received yet. /// /// **Consistency note:** These two fields are updated via separate relaxed - /// atomic stores, so readers may observe a mixed snapshot (e.g., new `used` - /// with old `size`). This is acceptable for best-effort display purposes; - /// any consumer requiring a consistent pair should use the snapshot helper. + /// atomic stores, so readers may observe a mixed pair (e.g., new `used` + /// with old `size`). This is acceptable for best-effort display purposes. + /// Code that requires a coordinated view of both values must use additional + /// synchronization instead of reading these atomics independently. pub context_used: Arc, pub context_size: Arc, _reader_handle: JoinHandle<()>, diff --git a/src/discord.rs b/src/discord.rs index 59955c6d..d2cacd11 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -600,7 +600,7 @@ async fn stream_prompt( // Append context footer to the last chunk if it fits (char count, not bytes). let content = if i == last_idx { if let Some(ref footer) = ctx_footer { - if chunk.chars().count() + footer.chars().count() < 2000 { + if chunk.chars().count() + footer.chars().count() <= 2000 { format!("{chunk}{footer}") } else { chunk.to_string() @@ -620,8 +620,9 @@ async fn stream_prompt( // If footer didn't fit in the last chunk, send as a separate message. if let Some(ref footer) = ctx_footer { let last_chunk = chunks.last().map(|c| c.as_str()).unwrap_or(""); - if last_chunk.chars().count() + footer.chars().count() >= 2000 { - let _ = channel.say(&ctx.http, footer).await; + if last_chunk.chars().count() + footer.chars().count() > 2000 { + let standalone = footer.trim_start_matches('\n'); + let _ = channel.say(&ctx.http, standalone).await; } } From 1bb9d26ed29bfa6bf68a7bbd98626ed44bca3a44 Mon Sep 17 00:00:00 2001 From: IRISX Date: Mon, 13 Apr 2026 11:44:00 +0800 Subject: [PATCH 13/14] refactor: move newline out of format_context_footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Footer string no longer carries a leading '\n' — callers add the separator explicitly. This eliminates the trim_start_matches hack when sending the footer as a standalone message. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/discord.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/discord.rs b/src/discord.rs index d2cacd11..432b4c2b 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -598,10 +598,11 @@ async fn stream_prompt( let last_idx = chunks.len().saturating_sub(1); for (i, chunk) in chunks.iter().enumerate() { // Append context footer to the last chunk if it fits (char count, not bytes). + // +1 accounts for the '\n' separator between chunk and footer. let content = if i == last_idx { if let Some(ref footer) = ctx_footer { - if chunk.chars().count() + footer.chars().count() <= 2000 { - format!("{chunk}{footer}") + if chunk.chars().count() + 1 + footer.chars().count() <= 2000 { + format!("{chunk}\n{footer}") } else { chunk.to_string() } @@ -620,9 +621,8 @@ async fn stream_prompt( // If footer didn't fit in the last chunk, send as a separate message. if let Some(ref footer) = ctx_footer { let last_chunk = chunks.last().map(|c| c.as_str()).unwrap_or(""); - if last_chunk.chars().count() + footer.chars().count() > 2000 { - let standalone = footer.trim_start_matches('\n'); - let _ = channel.say(&ctx.http, standalone).await; + if last_chunk.chars().count() + 1 + footer.chars().count() > 2000 { + let _ = channel.say(&ctx.http, footer).await; } } @@ -635,12 +635,13 @@ async fn stream_prompt( /// Format a context usage footer for Discord messages. /// Returns `None` when `ctx_size == 0` (no `usage_update` received yet). /// The percentage is clamped to 100 to handle `used > size` edge cases. +/// The returned string does NOT include a leading newline — callers add it as needed. fn format_context_footer(ctx_used: u64, ctx_size: u64) -> Option { if ctx_size == 0 { return None; } let pct = ((ctx_used as u128 * 100 + ctx_size as u128 / 2) / ctx_size as u128).min(100) as u64; - Some(format!("\n-# 📊 {ctx_used}/{ctx_size} tokens ({pct}%)")) + Some(format!("-# 📊 {ctx_used}/{ctx_size} tokens ({pct}%)")) } /// Flatten a tool-call title into a single line that's safe to render @@ -830,6 +831,8 @@ mod tests { let footer = format_context_footer(31434, 1_000_000).unwrap(); assert!(footer.contains("31434/1000000")); assert!(footer.contains("3%")); + // No leading newline — callers add it + assert!(!footer.starts_with('\n')); } #[test] From d751c4bc87af57df40303799a945b055d9689ada Mon Sep 17 00:00:00 2001 From: IRISX Date: Mon, 13 Apr 2026 12:00:11 +0800 Subject: [PATCH 14/14] fix: store size before used, add Context: label to footer - Store ctx_size before ctx_used to reduce race where reader sees size=0 (sentinel for "no data") while used is already updated - Add "Context:" label to footer to match PR description format Co-Authored-By: Claude Opus 4.6 (1M context) --- src/acp/connection.rs | 4 +++- src/discord.rs | 5 ++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 16e18015..e36cb188 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -150,8 +150,10 @@ impl AcpConnection { if let Some(crate::acp::protocol::AcpEvent::UsageUpdate { used, size }) = crate::acp::protocol::classify_notification(&msg) { - ctx_used.store(used, Ordering::Relaxed); + // Store size before used so readers that check + // `context_size == 0` as "no data yet" see size first. ctx_size.store(size, Ordering::Relaxed); + ctx_used.store(used, Ordering::Relaxed); } // Response (has id) → resolve pending AND forward to subscriber diff --git a/src/discord.rs b/src/discord.rs index 432b4c2b..bba7ac32 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -641,7 +641,7 @@ fn format_context_footer(ctx_used: u64, ctx_size: u64) -> Option { return None; } let pct = ((ctx_used as u128 * 100 + ctx_size as u128 / 2) / ctx_size as u128).min(100) as u64; - Some(format!("-# 📊 {ctx_used}/{ctx_size} tokens ({pct}%)")) + Some(format!("-# 📊 Context: {ctx_used}/{ctx_size} tokens ({pct}%)")) } /// Flatten a tool-call title into a single line that's safe to render @@ -829,9 +829,8 @@ mod tests { #[test] fn context_footer_normal_percentage() { let footer = format_context_footer(31434, 1_000_000).unwrap(); - assert!(footer.contains("31434/1000000")); + assert!(footer.contains("Context: 31434/1000000")); assert!(footer.contains("3%")); - // No leading newline — callers add it assert!(!footer.starts_with('\n')); }