Skip to content

Commit 868adcb

Browse files
howieclaude
andcommitted
fix(reactions): block streaming sentinel flash, guard clear() on finished, fix mood face
Five fixes found by group review R1: 1. Streaming sentinel flash (Critical): the edit loop fires every 1500ms and would briefly show "<silent />" text in the placeholder before delete_message ran. Now skip buf_tx.send() when text_buf.trim() == "<silent />". 2. clear() missing finished guard (Important): after suppress() sets finished=true, a subsequent clear() call (e.g. from dispatch's remove_after_reply path) would fire an extra remove_reaction API call on an already-cleared controller. Added if inner.finished { return; } guard. 3. suppress() remove_reaction error logging (Important): silent discard of the remove_reaction failure meant a stuck emoji would leave no trace in telemetry. Now emits tracing::warn! on failure, consistent with delete_message logging. 4. set_done() mood face fires after suppress() (Critical test finding): the mood face addition code ran unconditionally after finish(), which is a no-op when already finished. Added guard: only add face if inner.current == emoji (i.e. finish() actually ran, not already suppressed). 5. reactions tests (Critical gap): added #[cfg(test)] module with MockAdapter recording add_reaction/remove_reaction calls. Two tests verify: - suppress() removes current reaction and blocks subsequent set_done() - clear() is a no-op after suppress() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3ae80ab commit 868adcb

2 files changed

Lines changed: 131 additions & 15 deletions

File tree

src/adapter.rs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -590,16 +590,22 @@ impl AdapterRouter {
590590
match event {
591591
AcpEvent::Text(t) => {
592592
text_buf.push_str(&t);
593+
// Don't stream potential-sentinel content to the edit
594+
// loop — if the agent is outputting <silent /> the
595+
// placeholder should stay as "…" until delete fires,
596+
// not flash the literal sentinel text to users.
593597
if let Some(tx) = &buf_tx {
594-
let _ = tx.send(format!(
595-
"{reset_prelude}{}",
596-
compose_display(
597-
&tool_lines,
598-
&text_buf,
599-
true,
600-
tool_display,
601-
)
602-
));
598+
if text_buf.trim() != "<silent />" {
599+
let _ = tx.send(format!(
600+
"{reset_prelude}{}",
601+
compose_display(
602+
&tool_lines,
603+
&text_buf,
604+
true,
605+
tool_display,
606+
)
607+
));
608+
}
603609
}
604610
}
605611
AcpEvent::Thinking => {

src/reactions.rs

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,15 @@ impl StatusReactionController {
9898
}
9999
let emoji = { self.inner.lock().await.emojis.done.clone() };
100100
self.finish(&emoji).await;
101-
// Add a random mood face
101+
// Add a random mood face — only if finish() actually ran (inner.current == emoji).
102+
// If suppress() was called first, finished=true and current was cleared, so
103+
// current != emoji and we skip the face to avoid reacting on a suppressed reply.
102104
let faces = ["😊", "😎", "🫡", "🤓", "😏", "✌️", "💪", "🦾"];
103105
let face = faces[rand::random::<usize>() % faces.len()];
104106
let inner = self.inner.lock().await;
105-
let _ = inner.adapter.add_reaction(&inner.message, face).await;
107+
if inner.current == emoji {
108+
let _ = inner.adapter.add_reaction(&inner.message, face).await;
109+
}
106110
}
107111

108112
pub async fn set_error(&self) {
@@ -118,6 +122,9 @@ impl StatusReactionController {
118122
return;
119123
}
120124
let mut inner = self.inner.lock().await;
125+
if inner.finished {
126+
return;
127+
}
121128
cancel_timers(&mut inner);
122129
let current = inner.current.clone();
123130
if !current.is_empty() {
@@ -142,10 +149,9 @@ impl StatusReactionController {
142149
cancel_timers(&mut inner);
143150
let current = inner.current.clone();
144151
if !current.is_empty() {
145-
let _ = inner
146-
.adapter
147-
.remove_reaction(&inner.message, &current)
148-
.await;
152+
if let Err(e) = inner.adapter.remove_reaction(&inner.message, &current).await {
153+
tracing::warn!(error = ?e, "suppress: failed to remove reaction");
154+
}
149155
inner.current.clear();
150156
}
151157
}
@@ -295,3 +301,107 @@ fn cancel_timers(inner: &mut Inner) {
295301
h.abort();
296302
}
297303
}
304+
305+
#[cfg(test)]
306+
mod tests {
307+
use super::*;
308+
use crate::adapter::{ChannelRef, MessageRef};
309+
use async_trait::async_trait;
310+
use std::sync::Arc;
311+
use tokio::sync::Mutex;
312+
313+
/// Minimal mock adapter that records add_reaction / remove_reaction calls.
314+
struct MockAdapter {
315+
calls: Arc<Mutex<Vec<String>>>,
316+
}
317+
318+
impl MockAdapter {
319+
fn new() -> (Arc<Self>, Arc<Mutex<Vec<String>>>) {
320+
let calls = Arc::new(Mutex::new(Vec::new()));
321+
(Arc::new(Self { calls: calls.clone() }), calls)
322+
}
323+
}
324+
325+
#[async_trait]
326+
impl ChatAdapter for MockAdapter {
327+
fn platform(&self) -> &'static str { "mock" }
328+
fn message_limit(&self) -> usize { 2000 }
329+
async fn send_message(&self, _ch: &ChannelRef, _content: &str) -> anyhow::Result<MessageRef> {
330+
unimplemented!()
331+
}
332+
async fn create_thread(&self, _ch: &ChannelRef, _trigger: &MessageRef, _title: &str) -> anyhow::Result<ChannelRef> {
333+
unimplemented!()
334+
}
335+
async fn add_reaction(&self, _msg: &MessageRef, emoji: &str) -> anyhow::Result<()> {
336+
self.calls.lock().await.push(format!("add:{emoji}"));
337+
Ok(())
338+
}
339+
async fn remove_reaction(&self, _msg: &MessageRef, emoji: &str) -> anyhow::Result<()> {
340+
self.calls.lock().await.push(format!("remove:{emoji}"));
341+
Ok(())
342+
}
343+
fn use_streaming(&self, _other_bot_present: bool) -> bool { false }
344+
}
345+
346+
fn make_message_ref() -> MessageRef {
347+
MessageRef {
348+
channel: ChannelRef {
349+
platform: "mock".into(),
350+
channel_id: "C1".into(),
351+
thread_id: None,
352+
parent_id: None,
353+
origin_event_id: None,
354+
},
355+
message_id: "M1".into(),
356+
}
357+
}
358+
359+
fn make_controller(adapter: Arc<dyn ChatAdapter>) -> StatusReactionController {
360+
StatusReactionController::new(
361+
true,
362+
adapter,
363+
make_message_ref(),
364+
ReactionEmojis::default(),
365+
ReactionTiming::default(),
366+
)
367+
}
368+
369+
#[tokio::test]
370+
async fn suppress_removes_current_reaction_and_blocks_set_done() {
371+
let (adapter, calls) = MockAdapter::new();
372+
let ctrl = make_controller(adapter);
373+
374+
// set_queued uses apply_immediate (no debounce) so the reaction is
375+
// present synchronously before suppress() is called.
376+
ctrl.set_queued().await;
377+
378+
ctrl.suppress().await;
379+
380+
// suppress() must remove the current reaction
381+
let snapshot = calls.lock().await.clone();
382+
let removed = snapshot.iter().any(|c| c.starts_with("remove:"));
383+
assert!(removed, "suppress() should remove the current reaction; calls: {snapshot:?}");
384+
385+
// set_done() must be a no-op after suppress()
386+
ctrl.set_done().await;
387+
let after_done = calls.lock().await.clone();
388+
let new_adds: Vec<_> = after_done[snapshot.len()..].iter().filter(|c| c.starts_with("add:")).collect();
389+
assert!(new_adds.is_empty(), "set_done() must be no-op after suppress(); new add calls: {new_adds:?}");
390+
}
391+
392+
#[tokio::test]
393+
async fn clear_is_no_op_after_suppress() {
394+
let (adapter, calls) = MockAdapter::new();
395+
let ctrl = make_controller(adapter);
396+
397+
ctrl.set_queued().await;
398+
399+
ctrl.suppress().await;
400+
let after_suppress = calls.lock().await.len();
401+
402+
// clear() must not fire additional API calls after suppress()
403+
ctrl.clear().await;
404+
let after_clear = calls.lock().await.len();
405+
assert_eq!(after_suppress, after_clear, "clear() must be no-op after suppress()");
406+
}
407+
}

0 commit comments

Comments
 (0)