huskies: merge 1239 story Matrix bot posts \"Working...\" immediately on message receipt

This commit is contained in:
Huskies Agent
2026-07-21 11:42:44 +00:00
parent df1e339b47
commit 6fbd755846
2 changed files with 82 additions and 3 deletions
@@ -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`.
@@ -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"
);
}
}