diff --git a/src/agent/channel_history.rs b/src/agent/channel_history.rs index 24d330867..d2d68b198 100644 --- a/src/agent/channel_history.rs +++ b/src/agent/channel_history.rs @@ -172,7 +172,25 @@ 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 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 6421ad567..cb611759f 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,18 @@ fn forward_sse_event( }) .ok(); } + spacebot::OutboundResponse::RichMessage { text, cards, .. } => { + // 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(), + 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..87033658e 100644 --- a/src/messaging/webchat.rs +++ b/src/messaging/webchat.rs @@ -76,7 +76,9 @@ 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, .. } => { + OutboundResponse::text_with_cards(text, cards) + } _ => return Ok(()), }; diff --git a/src/tools/reply.rs b/src/tools/reply.rs index c38159cfd..5ce0e9c40 100644 --- a/src/tools/reply.rs +++ b/src/tools/reply.rs @@ -460,17 +460,27 @@ 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, flatten card content so consumers of + // stored history (including webchat) see the full response. + let logged_content = if let OutboundResponse::RichMessage { cards, .. } = &response { + OutboundResponse::text_with_cards(&converted_content, cards) + } else { + converted_content.clone() + }; + self.response_tx .send(response) .await @@ -478,7 +488,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), );