From dc3cf71f51f7813d5b1730cb622d9c84628ea083 Mon Sep 17 00:00:00 2001 From: DarkSkyXD Date: Sun, 29 Mar 2026 19:10:04 -0500 Subject: [PATCH 1/2] fix: flatten rich message cards into text for webchat frontend The webchat frontend doesn't support rich embeds (cards), so card content was being lost when messages used the RichMessage variant. This flattens card titles, descriptions, and fields into plain text across SSE forwarding, broadcast, conversation logging, and cancelled-history extraction. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/agent/channel_history.rs | 19 ++++++++++++++++++- src/main.rs | 20 +++++++++++++++++++- src/messaging/webchat.rs | 13 ++++++++++++- src/tools/reply.rs | 24 +++++++++++++++++++++--- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/agent/channel_history.rs b/src/agent/channel_history.rs index 24d330867..ff51621c6 100644 --- a/src/agent/channel_history.rs +++ b/src/agent/channel_history.rs @@ -172,7 +172,24 @@ fn extract_reply_content_from_cancelled_history( if let Some(content_value) = tool_call.function.arguments.get("content") && let Some(text) = content_value.as_str() { - return Some(text.to_string()); + // Also extract card descriptions so the full response + // (not just the short content text) is preserved in + // conversation history for the webchat frontend. + let card_text = tool_call + .function + .arguments + .get("cards") + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + .map(|cards| crate::OutboundResponse::text_from_cards(&cards)) + .unwrap_or_default(); + + if card_text.is_empty() { + return Some(text.to_string()); + } else if text.trim().is_empty() { + return Some(card_text); + } else { + return Some(format!("{}\n\n{}", text, card_text)); + } } } } diff --git a/src/main.rs b/src/main.rs index 6421ad567..462c2bcb1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -275,7 +275,6 @@ fn forward_sse_event( ) { match response { spacebot::OutboundResponse::Text(text) - | spacebot::OutboundResponse::RichMessage { text, .. } | spacebot::OutboundResponse::ThreadReply { text, .. } => { api_event_tx .send(spacebot::api::ApiEvent::OutboundMessage { @@ -285,6 +284,25 @@ fn forward_sse_event( }) .ok(); } + spacebot::OutboundResponse::RichMessage { text, cards, .. } => { + // Flatten card content into the text so the webchat frontend + // (which doesn't support rich embeds) shows the full response. + let card_text = spacebot::OutboundResponse::text_from_cards(cards); + let full_text = if card_text.is_empty() { + text.clone() + } else if text.trim().is_empty() { + card_text + } else { + format!("{}\n\n{}", text, card_text) + }; + api_event_tx + .send(spacebot::api::ApiEvent::OutboundMessage { + agent_id: agent_id.to_string(), + channel_id: channel_id.to_string(), + text: full_text, + }) + .ok(); + } spacebot::OutboundResponse::Status(spacebot::StatusUpdate::Thinking) => { api_event_tx .send(spacebot::api::ApiEvent::TypingState { diff --git a/src/messaging/webchat.rs b/src/messaging/webchat.rs index 4e2e32715..cc791c5e0 100644 --- a/src/messaging/webchat.rs +++ b/src/messaging/webchat.rs @@ -76,7 +76,18 @@ impl Messaging for WebChatAdapter { async fn broadcast(&self, target: &str, response: OutboundResponse) -> crate::Result<()> { let text = match &response { OutboundResponse::Text(text) => text.clone(), - OutboundResponse::RichMessage { text, .. } => text.clone(), + OutboundResponse::RichMessage { text, cards, .. } => { + // Flatten card content into the text so the webchat frontend + // (which doesn't support rich embeds) shows the full response. + let card_text = OutboundResponse::text_from_cards(cards); + if card_text.is_empty() { + text.clone() + } else if text.trim().is_empty() { + card_text + } else { + format!("{}\n\n{}", text, card_text) + } + } _ => return Ok(()), }; diff --git a/src/tools/reply.rs b/src/tools/reply.rs index c38159cfd..8ae521f77 100644 --- a/src/tools/reply.rs +++ b/src/tools/reply.rs @@ -460,17 +460,35 @@ impl Tool for ReplyTool { text: converted_content.clone(), } } else if args.cards.is_some() || args.interactive_elements.is_some() || poll.is_some() { + let cards = args.cards.unwrap_or_default(); + let interactive_elements = args.interactive_elements.unwrap_or_default(); OutboundResponse::RichMessage { text: converted_content.clone(), blocks: vec![], - cards: args.cards.unwrap_or_default(), - interactive_elements: args.interactive_elements.unwrap_or_default(), + cards, + interactive_elements, poll, } } else { OutboundResponse::Text(converted_content.clone()) }; + // For the conversation log, include flattened card content so that + // the webchat history (which doesn't support rich embeds) shows the + // full response when messages are loaded from the database. + let logged_content = if let OutboundResponse::RichMessage { cards, .. } = &response { + let card_text = OutboundResponse::text_from_cards(cards); + if card_text.is_empty() { + converted_content.clone() + } else if converted_content.trim().is_empty() { + card_text + } else { + format!("{}\n\n{}", converted_content, card_text) + } + } else { + converted_content.clone() + }; + self.response_tx .send(response) .await @@ -478,7 +496,7 @@ impl Tool for ReplyTool { self.conversation_logger.log_bot_message_with_name( &self.channel_id, - &converted_content, + &logged_content, Some(&self.agent_display_name), ); From 3c9b6b66d7dbe5a0c1a20a10602323de938741aa Mon Sep 17 00:00:00 2001 From: DarkSkyXD Date: Sun, 29 Mar 2026 19:16:22 -0500 Subject: [PATCH 2/2] refactor: extract text_with_cards helper and fix review issues - Extract duplicated card-flattening logic into OutboundResponse::text_with_cards() in src/lib.rs - Add warn logging for card deserialization failures in channel_history.rs (was silently swallowed by .ok()) - Fix inaccurate comments: main.rs referenced "webchat frontend" but serves all SSE consumers; channel_history.rs referenced "webchat frontend" but feeds in-memory LLM history Co-Authored-By: Claude Opus 4.6 (1M context) --- src/agent/channel_history.rs | 33 +++++++++++++++++---------------- src/lib.rs | 16 ++++++++++++++++ src/main.rs | 13 +++---------- src/messaging/webchat.rs | 11 +---------- src/tools/reply.rs | 14 +++----------- 5 files changed, 40 insertions(+), 47 deletions(-) diff --git a/src/agent/channel_history.rs b/src/agent/channel_history.rs index ff51621c6..d2d68b198 100644 --- a/src/agent/channel_history.rs +++ b/src/agent/channel_history.rs @@ -174,22 +174,23 @@ fn extract_reply_content_from_cancelled_history( { // Also extract card descriptions so the full response // (not just the short content text) is preserved in - // conversation history for the webchat frontend. - let card_text = tool_call - .function - .arguments - .get("cards") - .and_then(|v| serde_json::from_value::>(v.clone()).ok()) - .map(|cards| crate::OutboundResponse::text_from_cards(&cards)) - .unwrap_or_default(); - - if card_text.is_empty() { - return Some(text.to_string()); - } else if text.trim().is_empty() { - return Some(card_text); - } else { - return Some(format!("{}\n\n{}", text, card_text)); - } + // conversation history for subsequent LLM turns. + let cards = match tool_call.function.arguments.get("cards") { + Some(v) => { + serde_json::from_value::>(v.clone()) + .unwrap_or_else(|e| { + tracing::warn!( + error = %e, + "failed to deserialize cards from cancelled reply tool call; \ + card content will be omitted from history" + ); + Vec::new() + }) + } + None => Vec::new(), + }; + + return Some(crate::OutboundResponse::text_with_cards(text, &cards)); } } } diff --git a/src/lib.rs b/src/lib.rs index d334c1842..8a150e48a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -765,6 +765,22 @@ impl OutboundResponse { } sections.join("\n\n") } + + /// Merge `text` with a plaintext representation of `cards`. + /// + /// Returns `text` unchanged when cards produce no text content, + /// the card text alone when `text` is whitespace-only, or both + /// joined with a blank line. + pub fn text_with_cards(text: &str, cards: &[Card]) -> String { + let card_text = Self::text_from_cards(cards); + if card_text.is_empty() { + text.to_string() + } else if text.trim().is_empty() { + card_text + } else { + format!("{}\n\n{}", text, card_text) + } + } } /// A generic rich-formatted card (maps to Embeds in Discord). diff --git a/src/main.rs b/src/main.rs index 462c2bcb1..cb611759f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -285,16 +285,9 @@ fn forward_sse_event( .ok(); } spacebot::OutboundResponse::RichMessage { text, cards, .. } => { - // Flatten card content into the text so the webchat frontend - // (which doesn't support rich embeds) shows the full response. - let card_text = spacebot::OutboundResponse::text_from_cards(cards); - let full_text = if card_text.is_empty() { - text.clone() - } else if text.trim().is_empty() { - card_text - } else { - format!("{}\n\n{}", text, card_text) - }; + // Flatten card content so SSE consumers (dashboard and webchat), + // which don't render rich embeds, see the full response. + let full_text = spacebot::OutboundResponse::text_with_cards(text, cards); api_event_tx .send(spacebot::api::ApiEvent::OutboundMessage { agent_id: agent_id.to_string(), diff --git a/src/messaging/webchat.rs b/src/messaging/webchat.rs index cc791c5e0..87033658e 100644 --- a/src/messaging/webchat.rs +++ b/src/messaging/webchat.rs @@ -77,16 +77,7 @@ impl Messaging for WebChatAdapter { let text = match &response { OutboundResponse::Text(text) => text.clone(), OutboundResponse::RichMessage { text, cards, .. } => { - // Flatten card content into the text so the webchat frontend - // (which doesn't support rich embeds) shows the full response. - let card_text = OutboundResponse::text_from_cards(cards); - if card_text.is_empty() { - text.clone() - } else if text.trim().is_empty() { - card_text - } else { - format!("{}\n\n{}", text, card_text) - } + OutboundResponse::text_with_cards(text, cards) } _ => return Ok(()), }; diff --git a/src/tools/reply.rs b/src/tools/reply.rs index 8ae521f77..5ce0e9c40 100644 --- a/src/tools/reply.rs +++ b/src/tools/reply.rs @@ -473,18 +473,10 @@ impl Tool for ReplyTool { OutboundResponse::Text(converted_content.clone()) }; - // For the conversation log, include flattened card content so that - // the webchat history (which doesn't support rich embeds) shows the - // full response when messages are loaded from the database. + // For the conversation log, flatten card content so consumers of + // stored history (including webchat) see the full response. let logged_content = if let OutboundResponse::RichMessage { cards, .. } = &response { - let card_text = OutboundResponse::text_from_cards(cards); - if card_text.is_empty() { - converted_content.clone() - } else if converted_content.trim().is_empty() { - card_text - } else { - format!("{}\n\n{}", converted_content, card_text) - } + OutboundResponse::text_with_cards(&converted_content, cards) } else { converted_content.clone() };