proper Matrix threading + concise tool call formatting

agent_ux: uses Relation::Thread (not Reply) for tool call details.
format_tool_call extracts key params instead of dumping raw JSON.
format_tool_result truncated to 200 chars.

matrix_utils: added make_thread_reply() for threaded responses.
sync.rs routes ThreadReply engagement to threaded messages.
This commit is contained in:
2026-03-23 01:42:08 +00:00
parent 3b62d86c45
commit 7324c10d25
2 changed files with 87 additions and 30 deletions

View File

@@ -1,5 +1,5 @@
use matrix_sdk::room::Room; use matrix_sdk::room::Room;
use ruma::events::relation::InReplyTo; use ruma::events::relation::Thread;
use ruma::events::room::message::{Relation, RoomMessageEventContent}; use ruma::events::room::message::{Relation, RoomMessageEventContent};
use ruma::OwnedEventId; use ruma::OwnedEventId;
use tracing::warn; use tracing::warn;
@@ -47,25 +47,19 @@ impl AgentProgress {
} }
} }
/// Post a step update to the thread. Creates the thread on first call. /// Post a step update to the thread on the user's message.
pub async fn post_step(&mut self, text: &str) { pub async fn post_step(&mut self, text: &str) {
let content = if let Some(ref _root) = self.thread_root_id { let latest = self
// Reply in existing thread .thread_root_id
let mut msg = RoomMessageEventContent::text_markdown(text); .as_ref()
msg.relates_to = Some(Relation::Reply { .unwrap_or(&self.user_event_id)
in_reply_to: InReplyTo::new(self.user_event_id.clone()), .clone();
});
msg
} else {
// First message — starts the thread as a reply to the user's message
let mut msg = RoomMessageEventContent::text_markdown(text);
msg.relates_to = Some(Relation::Reply {
in_reply_to: InReplyTo::new(self.user_event_id.clone()),
});
msg
};
match self.room.send(content).await { let mut msg = RoomMessageEventContent::text_markdown(text);
let thread = Thread::plain(self.user_event_id.clone(), latest);
msg.relates_to = Some(Relation::Thread(thread));
match self.room.send(msg).await {
Ok(response) => { Ok(response) => {
if self.thread_root_id.is_none() { if self.thread_root_id.is_none() {
self.thread_root_id = Some(response.event_id); self.thread_root_id = Some(response.event_id);
@@ -96,19 +90,51 @@ impl AgentProgress {
.await; .await;
} }
/// Format a tool call for the thread. /// Format a tool call for the thread — concise, not raw args.
pub fn format_tool_call(name: &str, args: &str) -> String { pub fn format_tool_call(name: &str, args: &str) -> String {
format!("`{name}` → ```json\n{args}\n```") // Extract just the key params, not the full JSON blob
let summary = match serde_json::from_str::<serde_json::Value>(args) {
Ok(v) => {
let params: Vec<String> = v
.as_object()
.map(|obj| {
obj.iter()
.filter(|(_, v)| !v.is_null() && v.as_str() != Some(""))
.map(|(k, v)| {
let val = match v {
serde_json::Value::String(s) => {
if s.len() > 40 {
format!("{}", &s[..40])
} else {
s.clone()
}
}
other => other.to_string(),
};
format!("{k}={val}")
})
.collect()
})
.unwrap_or_default();
if params.is_empty() {
String::new()
} else {
format!(" ({})", params.join(", "))
}
}
Err(_) => String::new(),
};
format!("🔧 `{name}`{summary}")
} }
/// Format a tool result for the thread. /// Format a tool result for the thread — short summary only.
pub fn format_tool_result(name: &str, result: &str) -> String { pub fn format_tool_result(name: &str, result: &str) -> String {
let truncated = if result.len() > 500 { let truncated = if result.len() > 200 {
format!("{}", &result[..500]) format!("{}", &result[..200])
} else { } else {
result.to_string() result.to_string()
}; };
format!("`{name}` {truncated}") format!("`{name}`: {truncated}")
} }
} }
@@ -117,23 +143,41 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_format_tool_call() { fn test_format_tool_call_with_params() {
let formatted = AgentProgress::format_tool_call("search_archive", r#"{"query":"test"}"#); let formatted = AgentProgress::format_tool_call("search_archive", r#"{"query":"test","room":"general"}"#);
assert!(formatted.contains("search_archive")); assert!(formatted.contains("search_archive"));
assert!(formatted.contains("test")); assert!(formatted.contains("query=test"));
assert!(formatted.contains("room=general"));
assert!(formatted.starts_with("🔧"));
}
#[test]
fn test_format_tool_call_no_params() {
let formatted = AgentProgress::format_tool_call("list_rooms", "{}");
assert_eq!(formatted, "🔧 `list_rooms`");
}
#[test]
fn test_format_tool_call_truncates_long_values() {
let long_code = "x".repeat(100);
let args = format!(r#"{{"code":"{}"}}"#, long_code);
let formatted = AgentProgress::format_tool_call("run_script", &args);
assert!(formatted.contains("code="));
assert!(formatted.contains(""));
assert!(formatted.len() < 200);
} }
#[test] #[test]
fn test_format_tool_result_truncation() { fn test_format_tool_result_truncation() {
let long = "x".repeat(1000); let long = "x".repeat(500);
let formatted = AgentProgress::format_tool_result("search", &long); let formatted = AgentProgress::format_tool_result("search", &long);
assert!(formatted.len() < 600); assert!(formatted.len() < 300);
assert!(formatted.ends_with('…')); assert!(formatted.ends_with('…'));
} }
#[test] #[test]
fn test_format_tool_result_short() { fn test_format_tool_result_short() {
let formatted = AgentProgress::format_tool_result("search", "3 results found"); let formatted = AgentProgress::format_tool_result("search", "3 results found");
assert_eq!(formatted, "`search` 3 results found"); assert_eq!(formatted, "`search`: 3 results found");
} }
} }

View File

@@ -56,6 +56,19 @@ pub fn make_reply_content(body: &str, reply_to_event_id: OwnedEventId) -> RoomMe
content content
} }
/// Build a threaded reply — shows up in Matrix threads UI.
/// The thread root is the event being replied to (creates the thread on first use).
pub fn make_thread_reply(
body: &str,
thread_root_id: OwnedEventId,
) -> RoomMessageEventContent {
use ruma::events::relation::Thread;
let mut content = RoomMessageEventContent::text_markdown(body);
let thread = Thread::plain(thread_root_id.clone(), thread_root_id);
content.relates_to = Some(Relation::Thread(thread));
content
}
/// Send an emoji reaction to a message. /// Send an emoji reaction to a message.
pub async fn send_reaction( pub async fn send_reaction(
room: &Room, room: &Room,