huskies: merge 1240 story Matrix bot shows live progress by editing its placeholder message
This commit is contained in:
@@ -1,14 +1,16 @@
|
|||||||
//! Matrix handle_message — runs the LLM turn for a verified incoming message and
|
//! Matrix handle_message — runs the LLM turn for a verified incoming message and
|
||||||
//! streams the assistant reply back to the room.
|
//! streams the assistant reply back to the room.
|
||||||
|
|
||||||
use crate::chat::ChatTransport;
|
|
||||||
use crate::chat::util::drain_complete_paragraphs;
|
use crate::chat::util::drain_complete_paragraphs;
|
||||||
|
use crate::chat::{ChatTransport, MessageId};
|
||||||
use crate::llm::providers::claude_code::{CANCELLED, ClaudeCodeProvider, ClaudeCodeResult};
|
use crate::llm::providers::claude_code::{CANCELLED, ClaudeCodeProvider, ClaudeCodeResult};
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use matrix_sdk::ruma::OwnedRoomId;
|
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
|
|
||||||
use super::super::context::BotContext;
|
use super::super::context::BotContext;
|
||||||
@@ -45,6 +47,85 @@ pub(in crate::chat::transport::matrix::bot) fn spawn_digging_in_watcher(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One live-progress update to apply to the room's placeholder message
|
||||||
|
/// while a turn runs (story 1240).
|
||||||
|
enum ProgressUpdate {
|
||||||
|
/// A tool is about to run — shown to the user as e.g. "Using Read...".
|
||||||
|
Activity(String),
|
||||||
|
/// The model is thinking, with no tool call yet.
|
||||||
|
Thinking,
|
||||||
|
/// A committed chunk of assistant text — finalizes the current placeholder.
|
||||||
|
Text(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The provisional state currently displayed in the open placeholder, used
|
||||||
|
/// to debounce repeated identical [`ProgressUpdate`]s into a single edit.
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
enum DisplayState {
|
||||||
|
Thinking,
|
||||||
|
Tool(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain `updates`, applying each to `room_id` via `transport`.
|
||||||
|
///
|
||||||
|
/// `current_id` starts as the id of the "Working..." placeholder already
|
||||||
|
/// posted by `post_working_notice` (`None` if that send failed). Provisional
|
||||||
|
/// updates (`Activity`/`Thinking`) edit the open placeholder in place —
|
||||||
|
/// skipped entirely when the state is unchanged from the last one shown, so
|
||||||
|
/// a long run of identical signals (e.g. many thinking-token deltas, or the
|
||||||
|
/// same tool_use signalled twice via both the real-time and fallback paths)
|
||||||
|
/// produces at most one edit (AC2/AC5). `Text` always finalizes: it edits
|
||||||
|
/// (or, if none is open, sends) the placeholder with real content and then
|
||||||
|
/// clears `current_id`, so the *next* provisional update lazily opens a
|
||||||
|
/// fresh placeholder below it (AC3) — and if no further update ever arrives,
|
||||||
|
/// nothing extra is ever created, satisfying AC4 without needing a delete
|
||||||
|
/// capability the transport doesn't have.
|
||||||
|
async fn run_progress_updates(
|
||||||
|
transport: Arc<dyn ChatTransport>,
|
||||||
|
room_id: String,
|
||||||
|
bot_sent_event_ids: Arc<TokioMutex<HashSet<OwnedEventId>>>,
|
||||||
|
mut current_id: Option<MessageId>,
|
||||||
|
mut updates: tokio::sync::mpsc::UnboundedReceiver<ProgressUpdate>,
|
||||||
|
) {
|
||||||
|
let mut display_state: Option<DisplayState> = None;
|
||||||
|
while let Some(update) = updates.recv().await {
|
||||||
|
let (text, new_state, finalizes) = match update {
|
||||||
|
ProgressUpdate::Activity(name) => {
|
||||||
|
let state = DisplayState::Tool(name.clone());
|
||||||
|
if display_state.as_ref() == Some(&state) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
(format!("Using {name}..."), Some(state), false)
|
||||||
|
}
|
||||||
|
ProgressUpdate::Thinking => {
|
||||||
|
if display_state.as_ref() == Some(&DisplayState::Thinking) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
(
|
||||||
|
"Thinking...".to_string(),
|
||||||
|
Some(DisplayState::Thinking),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ProgressUpdate::Text(text) => (text, None, true),
|
||||||
|
};
|
||||||
|
|
||||||
|
let html = markdown_to_html(&text);
|
||||||
|
if let Some(id) = ¤t_id {
|
||||||
|
let _ = transport.edit_message(&room_id, id, &text, &html).await;
|
||||||
|
} else if let Ok(msg_id) = transport.send_message(&room_id, &text, &html).await {
|
||||||
|
if let Ok(event_id) = msg_id.parse() {
|
||||||
|
bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
|
}
|
||||||
|
current_id = Some(msg_id);
|
||||||
|
}
|
||||||
|
display_state = new_state;
|
||||||
|
if finalizes {
|
||||||
|
current_id = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
||||||
room_id_str: String,
|
room_id_str: String,
|
||||||
room_id: OwnedRoomId,
|
room_id: OwnedRoomId,
|
||||||
@@ -52,6 +133,7 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
|||||||
sender: String,
|
sender: String,
|
||||||
user_message: String,
|
user_message: String,
|
||||||
mut cancel_rx: watch::Receiver<bool>,
|
mut cancel_rx: watch::Receiver<bool>,
|
||||||
|
placeholder_id: Option<MessageId>,
|
||||||
) {
|
) {
|
||||||
// Look up the room's existing Claude Code session ID (if any) so we can
|
// Look up the room's existing Claude Code session ID (if any) so we can
|
||||||
// resume the conversation with structured API messages instead of
|
// resume the conversation with structured API messages instead of
|
||||||
@@ -103,28 +185,24 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
|||||||
|
|
||||||
let provider = ClaudeCodeProvider::new();
|
let provider = ClaudeCodeProvider::new();
|
||||||
|
|
||||||
// Channel for sending complete paragraphs to the Matrix posting task.
|
// Channel for sending live-progress updates to the Matrix posting task.
|
||||||
let (msg_tx, mut msg_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
let (progress_tx, progress_rx) = tokio::sync::mpsc::unbounded_channel::<ProgressUpdate>();
|
||||||
let msg_tx_for_callback = msg_tx.clone();
|
let progress_tx_for_callback = progress_tx.clone();
|
||||||
|
let progress_tx_for_thinking = progress_tx.clone();
|
||||||
|
let progress_tx_for_activity = progress_tx.clone();
|
||||||
|
|
||||||
// Spawn a task to post messages via the transport as they arrive so we
|
// Spawn a task to apply progress updates via the transport as they arrive
|
||||||
// don't block the LLM stream while waiting for send round-trips.
|
// so we don't block the LLM stream while waiting for send/edit round-trips.
|
||||||
let post_transport = Arc::clone(&ctx.transport);
|
let post_transport = Arc::clone(&ctx.transport);
|
||||||
let post_room_id = room_id_str.clone();
|
let post_room_id = room_id_str.clone();
|
||||||
let sent_ids = Arc::clone(&ctx.bot_sent_event_ids);
|
let sent_ids = Arc::clone(&ctx.bot_sent_event_ids);
|
||||||
let sent_ids_for_post = Arc::clone(&sent_ids);
|
let post_task = tokio::spawn(run_progress_updates(
|
||||||
let post_task = tokio::spawn(async move {
|
post_transport,
|
||||||
while let Some(chunk) = msg_rx.recv().await {
|
post_room_id,
|
||||||
let html = markdown_to_html(&chunk);
|
sent_ids,
|
||||||
if let Ok(msg_id) = post_transport
|
placeholder_id,
|
||||||
.send_message(&post_room_id, &chunk, &html)
|
progress_rx,
|
||||||
.await
|
));
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
|
||||||
{
|
|
||||||
sent_ids_for_post.lock().await.insert(event_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Shared state between the sync token callback and the async outer scope.
|
// Shared state between the sync token callback and the async outer scope.
|
||||||
let buffer = Arc::new(std::sync::Mutex::new(String::new()));
|
let buffer = Arc::new(std::sync::Mutex::new(String::new()));
|
||||||
@@ -158,11 +236,15 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
|||||||
let paragraphs = drain_complete_paragraphs(&mut buf);
|
let paragraphs = drain_complete_paragraphs(&mut buf);
|
||||||
for chunk in paragraphs {
|
for chunk in paragraphs {
|
||||||
sent_any_chunk_for_callback.store(true, Ordering::Relaxed);
|
sent_any_chunk_for_callback.store(true, Ordering::Relaxed);
|
||||||
let _ = msg_tx_for_callback.send(chunk);
|
let _ = progress_tx_for_callback.send(ProgressUpdate::Text(chunk));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|_thinking| {}, // Discard thinking tokens
|
move |_thinking| {
|
||||||
|_activity| {}, // Discard activity signals
|
let _ = progress_tx_for_thinking.send(ProgressUpdate::Thinking);
|
||||||
|
},
|
||||||
|
move |activity| {
|
||||||
|
let _ = progress_tx_for_activity.send(ProgressUpdate::Activity(activity.to_string()));
|
||||||
|
},
|
||||||
);
|
);
|
||||||
tokio::pin!(chat_fut);
|
tokio::pin!(chat_fut);
|
||||||
|
|
||||||
@@ -194,7 +276,7 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
|||||||
usage,
|
usage,
|
||||||
}) => {
|
}) => {
|
||||||
let reply = if !remaining.is_empty() {
|
let reply = if !remaining.is_empty() {
|
||||||
let _ = msg_tx.send(remaining.clone());
|
let _ = progress_tx.send(ProgressUpdate::Text(remaining.clone()));
|
||||||
remaining
|
remaining
|
||||||
} else if !did_send_any {
|
} else if !did_send_any {
|
||||||
// Nothing was streamed at all (e.g. only tool calls with no
|
// Nothing was streamed at all (e.g. only tool calls with no
|
||||||
@@ -207,7 +289,7 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
|||||||
.map(|m| m.content.clone())
|
.map(|m| m.content.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if !last_text.is_empty() {
|
if !last_text.is_empty() {
|
||||||
let _ = msg_tx.send(last_text.clone());
|
let _ = progress_tx.send(ProgressUpdate::Text(last_text.clone()));
|
||||||
}
|
}
|
||||||
last_text
|
last_text
|
||||||
} else {
|
} else {
|
||||||
@@ -232,14 +314,14 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
|||||||
} else {
|
} else {
|
||||||
format!("Error processing your request: {e}")
|
format!("Error processing your request: {e}")
|
||||||
};
|
};
|
||||||
let _ = msg_tx.send(err_msg.clone());
|
let _ = progress_tx.send(ProgressUpdate::Text(err_msg.clone()));
|
||||||
(err_msg, None, None)
|
(err_msg, None, None)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Drop the sender to signal the posting task that no more messages will
|
// Drop the sender to signal the posting task that no more updates will
|
||||||
// arrive, then wait for all pending Matrix sends to complete.
|
// arrive, then wait for all pending Matrix sends/edits to complete.
|
||||||
drop(msg_tx);
|
drop(progress_tx);
|
||||||
let _ = post_task.await;
|
let _ = post_task.await;
|
||||||
|
|
||||||
if was_cancelled {
|
if was_cancelled {
|
||||||
@@ -335,18 +417,24 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::chat::MessageId;
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
/// Captures every message sent through it, for assertion in tests.
|
/// Captures every message sent and edit applied through it, for
|
||||||
|
/// assertion in tests. Each `send_message` call returns a fresh,
|
||||||
|
/// distinguishable id (`msg-0`, `msg-1`, ...) so tests can tell which
|
||||||
|
/// placeholder a subsequent edit landed on.
|
||||||
struct CapturingTransport {
|
struct CapturingTransport {
|
||||||
sent: std::sync::Mutex<Vec<(String, String)>>,
|
sent: std::sync::Mutex<Vec<(String, String)>>,
|
||||||
|
edits: std::sync::Mutex<Vec<(String, String, String)>>,
|
||||||
|
next_id: std::sync::atomic::AtomicUsize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CapturingTransport {
|
impl CapturingTransport {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
sent: std::sync::Mutex::new(Vec::new()),
|
sent: std::sync::Mutex::new(Vec::new()),
|
||||||
|
edits: std::sync::Mutex::new(Vec::new()),
|
||||||
|
next_id: std::sync::atomic::AtomicUsize::new(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +449,20 @@ mod tests {
|
|||||||
.last()
|
.last()
|
||||||
.map(|(_, plain)| plain.clone())
|
.map(|(_, plain)| plain.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn edit_count(&self) -> usize {
|
||||||
|
self.edits.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(message_id, plain_text)` for every edit, in call order.
|
||||||
|
fn edits(&self) -> Vec<(String, String)> {
|
||||||
|
self.edits
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|(id, plain, _html)| (id.clone(), plain.clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -375,16 +477,24 @@ mod tests {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.push((room_id.to_string(), plain.to_string()));
|
.push((room_id.to_string(), plain.to_string()));
|
||||||
Ok("msg-id".to_string())
|
let n = self
|
||||||
|
.next_id
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
Ok(format!("msg-{n}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn edit_message(
|
async fn edit_message(
|
||||||
&self,
|
&self,
|
||||||
_room_id: &str,
|
_room_id: &str,
|
||||||
_original_message_id: &str,
|
original_message_id: &str,
|
||||||
_plain: &str,
|
plain: &str,
|
||||||
_html: &str,
|
html: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
self.edits.lock().unwrap().push((
|
||||||
|
original_message_id.to_string(),
|
||||||
|
plain.to_string(),
|
||||||
|
html.to_string(),
|
||||||
|
));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,4 +603,164 @@ mod tests {
|
|||||||
second_handle.await.unwrap();
|
second_handle.await.unwrap();
|
||||||
assert_eq!(transport.sent_count(), 1);
|
assert_eq!(transport.sent_count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── run_progress_updates (story 1240) ─────────────────────────────────
|
||||||
|
|
||||||
|
fn spawn_progress(
|
||||||
|
transport: Arc<CapturingTransport>,
|
||||||
|
placeholder_id: Option<MessageId>,
|
||||||
|
) -> (
|
||||||
|
tokio::sync::mpsc::UnboundedSender<ProgressUpdate>,
|
||||||
|
tokio::task::JoinHandle<()>,
|
||||||
|
) {
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<ProgressUpdate>();
|
||||||
|
let bot_sent_event_ids = Arc::new(TokioMutex::new(HashSet::new()));
|
||||||
|
let handle = tokio::spawn(run_progress_updates(
|
||||||
|
transport as Arc<dyn ChatTransport>,
|
||||||
|
"!room:example.com".to_string(),
|
||||||
|
bot_sent_event_ids,
|
||||||
|
placeholder_id,
|
||||||
|
rx,
|
||||||
|
));
|
||||||
|
(tx, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC 1: an activity signal edits the existing placeholder to name the
|
||||||
|
/// tool, and a thinking signal edits it to a "Thinking..." state.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn activity_and_thinking_edit_the_open_placeholder() {
|
||||||
|
let transport = Arc::new(CapturingTransport::new());
|
||||||
|
let (tx, handle) = spawn_progress(transport.clone(), Some("placeholder-0".to_string()));
|
||||||
|
|
||||||
|
tx.send(ProgressUpdate::Activity("Read".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
tx.send(ProgressUpdate::Thinking).unwrap();
|
||||||
|
drop(tx);
|
||||||
|
handle.await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(transport.sent_count(), 0, "no new message should be sent");
|
||||||
|
let edits = transport.edits();
|
||||||
|
assert_eq!(edits.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
edits[0],
|
||||||
|
("placeholder-0".to_string(), "Using Read...".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
edits[1],
|
||||||
|
("placeholder-0".to_string(), "Thinking...".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC 2: repeating the same tool-call signal (e.g. the real-time and
|
||||||
|
/// fallback activity paths both firing for one tool_use block) produces
|
||||||
|
/// exactly one edit, not two.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn repeated_identical_activity_produces_one_edit() {
|
||||||
|
let transport = Arc::new(CapturingTransport::new());
|
||||||
|
let (tx, handle) = spawn_progress(transport.clone(), Some("placeholder-0".to_string()));
|
||||||
|
|
||||||
|
tx.send(ProgressUpdate::Activity("Bash".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
tx.send(ProgressUpdate::Activity("Bash".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
tx.send(ProgressUpdate::Activity("Bash".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
drop(tx);
|
||||||
|
handle.await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(transport.edit_count(), 1, "duplicate signals must debounce");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC 3: an intermediate text block finalizes the current placeholder
|
||||||
|
/// (edits it with the real text) and the next provisional update opens a
|
||||||
|
/// fresh placeholder below it rather than reusing the finalized one.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn intermediate_text_finalizes_then_next_update_opens_fresh_placeholder() {
|
||||||
|
let transport = Arc::new(CapturingTransport::new());
|
||||||
|
let (tx, handle) = spawn_progress(transport.clone(), Some("placeholder-0".to_string()));
|
||||||
|
|
||||||
|
tx.send(ProgressUpdate::Text("Here's part one.".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
tx.send(ProgressUpdate::Activity("Edit".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
drop(tx);
|
||||||
|
handle.await.unwrap();
|
||||||
|
|
||||||
|
let edits = transport.edits();
|
||||||
|
assert_eq!(
|
||||||
|
edits[0],
|
||||||
|
("placeholder-0".to_string(), "Here's part one.".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
transport.sent_count(),
|
||||||
|
1,
|
||||||
|
"a fresh placeholder must be sent"
|
||||||
|
);
|
||||||
|
assert_eq!(transport.last_message().unwrap(), "Using Edit...");
|
||||||
|
// The subsequent edit (from the AC5-style test below) would need to
|
||||||
|
// land on this newly sent placeholder, not the already-finalized one
|
||||||
|
// — confirmed here since this update was sent, not edited.
|
||||||
|
assert_eq!(edits.len(), 1, "the second update was sent, not edited");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC 4: when a text update is the last thing to arrive, it finalizes the
|
||||||
|
/// open placeholder in place and nothing further is ever sent — no
|
||||||
|
/// trailing placeholder is left behind.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn final_text_finalizes_with_no_trailing_placeholder() {
|
||||||
|
let transport = Arc::new(CapturingTransport::new());
|
||||||
|
let (tx, handle) = spawn_progress(transport.clone(), Some("placeholder-0".to_string()));
|
||||||
|
|
||||||
|
tx.send(ProgressUpdate::Activity("Read".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
tx.send(ProgressUpdate::Text("All done.".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
drop(tx);
|
||||||
|
handle.await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(transport.sent_count(), 0, "no trailing placeholder");
|
||||||
|
let edits = transport.edits();
|
||||||
|
assert_eq!(edits.last().unwrap().1, "All done.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AC 5: a representative turn (tool call, duplicate tool signal,
|
||||||
|
/// thinking burst, another tool call, an intermediate text block, one
|
||||||
|
/// more tool call, final text) produces roughly five edits — not
|
||||||
|
/// hundreds — even though several of those updates arrive many times.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn representative_turn_produces_roughly_five_edits() {
|
||||||
|
let transport = Arc::new(CapturingTransport::new());
|
||||||
|
let (tx, handle) = spawn_progress(transport.clone(), Some("placeholder-0".to_string()));
|
||||||
|
|
||||||
|
tx.send(ProgressUpdate::Activity("Read".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
tx.send(ProgressUpdate::Activity("Read".to_string()))
|
||||||
|
.unwrap(); // fallback duplicate of the same tool_use — must debounce
|
||||||
|
for _ in 0..50 {
|
||||||
|
tx.send(ProgressUpdate::Thinking).unwrap(); // a burst of thinking-token deltas
|
||||||
|
}
|
||||||
|
tx.send(ProgressUpdate::Activity("Bash".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
tx.send(ProgressUpdate::Text(
|
||||||
|
"Here's what I found so far.".to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
tx.send(ProgressUpdate::Activity("Edit".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
tx.send(ProgressUpdate::Text("Done.".to_string())).unwrap();
|
||||||
|
drop(tx);
|
||||||
|
handle.await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
transport.edit_count(),
|
||||||
|
5,
|
||||||
|
"one edit per distinct state change, regardless of how many \
|
||||||
|
identical updates arrived in between"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
transport.sent_count(),
|
||||||
|
1,
|
||||||
|
"one fresh placeholder for the second half"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,16 +398,20 @@ fn parse_question_reply(
|
|||||||
/// — never once per resulting agent turn — so a burst of messages the
|
/// — never once per resulting agent turn — so a burst of messages the
|
||||||
/// dispatcher later coalesces into a single turn still yields one notice per
|
/// dispatcher later coalesces into a single turn still yields one notice per
|
||||||
/// message the user actually sent (story 1239).
|
/// message the user actually sent (story 1239).
|
||||||
async fn post_working_notice(ctx: &BotContext, room_id_str: &str) {
|
async fn post_working_notice(
|
||||||
|
ctx: &BotContext,
|
||||||
|
room_id_str: &str,
|
||||||
|
) -> Option<crate::chat::MessageId> {
|
||||||
let html = markdown_to_html(handle_message::DIGGING_IN_MESSAGE);
|
let html = markdown_to_html(handle_message::DIGGING_IN_MESSAGE);
|
||||||
if let Ok(msg_id) = ctx
|
let msg_id = ctx
|
||||||
.transport
|
.transport
|
||||||
.send_message(room_id_str, handle_message::DIGGING_IN_MESSAGE, &html)
|
.send_message(room_id_str, handle_message::DIGGING_IN_MESSAGE, &html)
|
||||||
.await
|
.await
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
.ok()?;
|
||||||
{
|
if let Ok(event_id) = msg_id.parse() {
|
||||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
}
|
}
|
||||||
|
Some(msg_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||||
@@ -1586,8 +1590,9 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Acknowledge receipt immediately, before the message is handed to the
|
// Acknowledge receipt immediately, before the message is handed to the
|
||||||
// dispatcher below (story 1239).
|
// dispatcher below (story 1239). Its message id seeds the live-progress
|
||||||
post_working_notice(&ctx, &room_id_str).await;
|
// placeholder that handle_message edits in place as the turn runs (story 1240).
|
||||||
|
let placeholder_id = post_working_notice(&ctx, &room_id_str).await;
|
||||||
|
|
||||||
// Hand the message to the protocol-agnostic dispatcher instead of spawning
|
// Hand the message to the protocol-agnostic dispatcher instead of spawning
|
||||||
// directly. The dispatcher applies a coalesce window and a per-session
|
// directly. The dispatcher applies a coalesce window and a per-session
|
||||||
@@ -1601,6 +1606,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
let incoming_room_id = incoming_room_id.clone();
|
let incoming_room_id = incoming_room_id.clone();
|
||||||
let ctx = ctx_for_factory.clone();
|
let ctx = ctx_for_factory.clone();
|
||||||
let sender = sender.clone();
|
let sender = sender.clone();
|
||||||
|
let placeholder_id = placeholder_id.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
handle_message(
|
handle_message(
|
||||||
room_id_str,
|
room_id_str,
|
||||||
@@ -1609,6 +1615,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
sender,
|
sender,
|
||||||
coalesced,
|
coalesced,
|
||||||
cancel_rx,
|
cancel_rx,
|
||||||
|
placeholder_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user