use agent_client_protocol_schema::v1::{ContentBlock, ContentChunk, MessageId};
use aion_core::{ActivityEventKind, MessageRole, ProgressDetail};
use crate::translate::{EventKind, update_raw};
const MESSAGE_JOIN: &str = "\n";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ChunkStream {
AgentMessage,
AgentThought,
User,
}
impl ChunkStream {
const fn variant(self) -> &'static str {
match self {
Self::AgentMessage => "agent_message_chunk",
Self::AgentThought => "agent_thought_chunk",
Self::User => "user_message_chunk",
}
}
const fn role(self) -> MessageRole {
match self {
Self::AgentMessage | Self::AgentThought => MessageRole::Assistant,
Self::User => MessageRole::User,
}
}
const fn feeds_the_result(self) -> bool {
matches!(self, Self::AgentMessage)
}
}
#[derive(Debug)]
struct OpenMessage {
id: Option<MessageId>,
text: String,
}
#[derive(Debug, Default)]
pub struct MessageAccumulator {
agent_message: Option<OpenMessage>,
agent_thought: Option<OpenMessage>,
user: Option<OpenMessage>,
completed: String,
final_message: String,
}
impl MessageAccumulator {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn completed_text(&self) -> &str {
&self.completed
}
#[must_use]
pub fn final_message(&self) -> &str {
&self.final_message
}
pub fn push_agent_chunk(&mut self, chunk: &ContentChunk) -> Vec<EventKind> {
let (mut events, text) = self.absorb(ChunkStream::AgentMessage, chunk);
if let Some(text) = text {
events.push((
ActivityEventKind::Delta {
message_id: message_id_label(chunk.message_id.as_ref()),
text_fragment: text.to_owned(),
},
true,
));
}
events
}
pub fn push_thought_chunk(&mut self, chunk: &ContentChunk) -> Vec<EventKind> {
let (mut events, text) = self.absorb(ChunkStream::AgentThought, chunk);
if let Some(text) = text {
events.push((
ActivityEventKind::Progress {
detail: ProgressDetail::Thinking {
message_id: message_id_label(chunk.message_id.as_ref()),
text: text.to_owned(),
},
},
true,
));
}
events
}
pub fn push_user_chunk(&mut self, chunk: &ContentChunk) -> Vec<EventKind> {
let (events, _text) = self.absorb(ChunkStream::User, chunk);
events
}
pub fn finish(&mut self) -> Vec<EventKind> {
let mut events = Vec::new();
for stream in [
ChunkStream::User,
ChunkStream::AgentThought,
ChunkStream::AgentMessage,
] {
events.extend(self.close(stream));
}
events
}
fn absorb<'chunk>(
&mut self,
stream: ChunkStream,
chunk: &'chunk ContentChunk,
) -> (Vec<EventKind>, Option<&'chunk str>) {
let Some(text) = block_text(&chunk.content) else {
let source = format!("{}/content", stream.variant());
return (vec![update_raw(&source, &chunk.content)], None);
};
let mut events = Vec::new();
if self.starts_a_new_message(stream, chunk.message_id.as_ref()) {
events.extend(self.close(stream));
}
let open = self.slot_mut(stream).get_or_insert_with(|| OpenMessage {
id: chunk.message_id.clone(),
text: String::new(),
});
open.text.push_str(text);
(events, Some(text))
}
fn starts_a_new_message(&self, stream: ChunkStream, incoming: Option<&MessageId>) -> bool {
let open = self.slot(stream).and_then(|open| open.id.as_ref());
match (open, incoming) {
(Some(open), Some(incoming)) => open != incoming,
_ => false,
}
}
fn close(&mut self, stream: ChunkStream) -> Vec<EventKind> {
let Some(open) = self.slot_mut(stream).take() else {
return Vec::new();
};
if open.text.is_empty() {
return Vec::new();
}
if stream.feeds_the_result() {
if !self.completed.is_empty() {
self.completed.push_str(MESSAGE_JOIN);
}
self.completed.push_str(&open.text);
self.final_message.clone_from(&open.text);
}
vec![(
ActivityEventKind::Message {
role: stream.role(),
text: open.text,
},
false,
)]
}
const fn slot(&self, stream: ChunkStream) -> Option<&OpenMessage> {
match stream {
ChunkStream::AgentMessage => self.agent_message.as_ref(),
ChunkStream::AgentThought => self.agent_thought.as_ref(),
ChunkStream::User => self.user.as_ref(),
}
}
const fn slot_mut(&mut self, stream: ChunkStream) -> &mut Option<OpenMessage> {
match stream {
ChunkStream::AgentMessage => &mut self.agent_message,
ChunkStream::AgentThought => &mut self.agent_thought,
ChunkStream::User => &mut self.user,
}
}
}
fn block_text(block: &ContentBlock) -> Option<&str> {
match block {
ContentBlock::Text(text) => Some(&text.text),
_ => None,
}
}
fn message_id_label(id: Option<&MessageId>) -> String {
id.map(ToString::to_string).unwrap_or_default()
}
#[cfg(test)]
mod tests;