From 6fbd7558462da2146ef20a22b3eb2f1d1303cc44 Mon Sep 17 00:00:00 2001 From: Huskies Agent Date: Tue, 21 Jul 2026 11:35:25 +0000 Subject: [PATCH] huskies: merge 1239 story Matrix bot posts \"Working...\" immediately on message receipt --- .../matrix/bot/messages/handle_message.rs | 5 +- .../matrix/bot/messages/on_room_message.rs | 80 ++++++++++++++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/server/src/chat/transport/matrix/bot/messages/handle_message.rs b/server/src/chat/transport/matrix/bot/messages/handle_message.rs index 11802a37..546095c8 100644 --- a/server/src/chat/transport/matrix/bot/messages/handle_message.rs +++ b/server/src/chat/transport/matrix/bot/messages/handle_message.rs @@ -18,8 +18,9 @@ use super::super::history::{ConversationEntry, ConversationRole, save_history}; use super::format_user_prompt; /// Text posted to the room by [`spawn_digging_in_watcher`] when a turn runs -/// long without emitting any user-facing text. -const DIGGING_IN_MESSAGE: &str = "Working..."; +/// long without emitting any user-facing text, and by `on_room_message` as +/// an immediate acknowledgement when a message is first received (story 1239). +pub(super) const DIGGING_IN_MESSAGE: &str = "Working..."; /// Spawns a background watcher that posts a single "digging in" notice to /// `room_id` if `threshold` elapses before `sent_any_text` becomes `true`. diff --git a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs index 8a27fe22..8c18e773 100644 --- a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs +++ b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs @@ -390,6 +390,26 @@ fn parse_question_reply( } } +/// Post an immediate "Working..." acknowledgement for a newly received room +/// message, before it is handed to the dispatcher (which may coalesce it +/// with other messages and/or queue it behind an in-flight run). +/// +/// Called once per incoming room message that reaches the LLM dispatch path +/// — never once per resulting agent turn — so a burst of messages the +/// dispatcher later coalesces into a single turn still yields one notice per +/// message the user actually sent (story 1239). +async fn post_working_notice(ctx: &BotContext, room_id_str: &str) { + let html = markdown_to_html(handle_message::DIGGING_IN_MESSAGE); + if let Ok(msg_id) = ctx + .transport + .send_message(room_id_str, handle_message::DIGGING_IN_MESSAGE, &html) + .await + && let Ok(event_id) = msg_id.parse() + { + ctx.bot_sent_event_ids.lock().await.insert(event_id); + } +} + pub(in crate::chat::transport::matrix::bot) async fn on_room_message( ev: OriginalSyncRoomMessageEvent, room: Room, @@ -1565,6 +1585,10 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( return; } + // Acknowledge receipt immediately, before the message is handed to the + // dispatcher below (story 1239). + post_working_notice(&ctx, &room_id_str).await; + // Hand the message to the protocol-agnostic dispatcher instead of spawning // directly. The dispatcher applies a coalesce window and a per-session // serial lock, preventing duplicate concurrent Timmy spawns. @@ -1604,7 +1628,8 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( mod tests { use super::{ eval_gateway_overview_command, eval_gateway_status_command, eval_switch_command, - parse_question_reply, try_handle_compact_command, try_handle_stop_command, + parse_question_reply, post_working_notice, try_handle_compact_command, + try_handle_stop_command, }; use crate::chat::{ChatTransport, MessageId}; use crate::http::context::QuestionAnswer; @@ -2279,4 +2304,57 @@ mod tests { "no reply should be sent for a message that isn't a bare stop" ); } + + // ── post_working_notice (story 1239) ───────────────────────────────── + + /// AC1/AC2: receiving a message posts "Working..." to the room + /// immediately — before any agent/dispatcher work happens — via a + /// single, synchronously-awaited call. + #[tokio::test] + async fn post_working_notice_sends_immediately_to_the_room() { + use std::sync::Arc; + + let project_root_dir = tempfile::tempdir().unwrap(); + let services = crate::services::Services::new_test( + project_root_dir.path().to_path_buf(), + "Huskies".to_string(), + ); + let transport = Arc::new(CapturingTransport::new()); + let ctx = make_test_ctx(services, transport.clone()); + + post_working_notice(&ctx, "!room:example.com").await; + + let sent = transport.sent.lock().unwrap().clone(); + assert_eq!(sent.len(), 1, "exactly one notice must be sent"); + assert_eq!(sent[0].0, "!room:example.com"); + assert_eq!(sent[0].1, "Working..."); + } + + /// AC3: the notice is emitted once per call — i.e. once per incoming + /// user message — not coalesced into a single notice the way the + /// dispatcher coalesces messages into one agent turn. Two messages + /// (two calls) must yield two notices. + #[tokio::test] + async fn post_working_notice_fires_once_per_message_not_once_per_turn() { + use std::sync::Arc; + + let project_root_dir = tempfile::tempdir().unwrap(); + let services = crate::services::Services::new_test( + project_root_dir.path().to_path_buf(), + "Huskies".to_string(), + ); + let transport = Arc::new(CapturingTransport::new()); + let ctx = make_test_ctx(services, transport.clone()); + + post_working_notice(&ctx, "!room:example.com").await; + post_working_notice(&ctx, "!room:example.com").await; + + let sent = transport.sent.lock().unwrap().clone(); + assert_eq!( + sent.len(), + 2, + "two incoming messages must produce two notices, even though the \ + dispatcher would coalesce them into a single agent turn" + ); + } }