use oino_ai::{AgentMessage, ImageContent, UserContent, UserContentField, UserMessage};
use serde::{Deserialize, Serialize};
use super::prompt::{InputSource, PromptOptions};
use super::{AgentSession, SessionOpError, lock};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum QueueKind {
Steering,
FollowUp,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "camelCase")]
pub enum QueueEdit {
Remove {
queue: QueueKind,
index: usize,
},
Edit {
queue: QueueKind,
index: usize,
text: String,
},
Move {
queue: QueueKind,
from: usize,
to: usize,
},
SendNow {
queue: QueueKind,
index: usize,
},
}
fn user_text(message: &AgentMessage) -> Option<String> {
match message {
AgentMessage::User(user) => Some(user.content.text("")),
_ => None,
}
}
fn user_images(message: &AgentMessage) -> Vec<ImageContent> {
match message {
AgentMessage::User(UserMessage {
content: UserContentField::Blocks(blocks),
..
}) => blocks
.iter()
.filter_map(|block| match block {
UserContent::Image(image) => Some(image.clone()),
UserContent::Text(_) => None,
})
.collect(),
_ => Vec::new(),
}
}
fn match_texts(texts: &[String], messages: &[AgentMessage]) -> Vec<Option<usize>> {
let mut claimed = vec![false; messages.len()];
texts
.iter()
.map(|text| {
let found = messages.iter().enumerate().position(|(i, message)| {
!claimed[i] && user_text(message).as_deref() == Some(text.as_str())
});
if let Some(i) = found {
claimed[i] = true;
}
found
})
.collect()
}
fn out_of_range(index: usize, len: usize) -> SessionOpError {
SessionOpError::Failed(format!("Queue index {index} out of range ({len} queued)"))
}
impl AgentSession {
pub async fn edit_queue(&self, edit: QueueEdit) -> Result<(), SessionOpError> {
if let QueueEdit::SendNow { queue, index } = edit {
return self.send_queued_now(queue, index).await;
}
let edit = match edit {
QueueEdit::Edit { queue, index, text } => QueueEdit::Edit {
queue,
index,
text: self.expand_queued_text(&text),
},
other => other,
};
self.apply_queue_edit(&edit)?;
self.emit_queue_update();
Ok(())
}
fn expand_queued_text(&self, text: &str) -> String {
let resources = self
.inner
.resources
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
oino_resources::templates::expand_prompt_template(text, resources.prompts())
.unwrap_or_else(|| text.to_owned())
}
fn apply_queue_edit(&self, edit: &QueueEdit) -> Result<(), SessionOpError> {
let queue = match edit {
QueueEdit::Remove { queue, .. }
| QueueEdit::Edit { queue, .. }
| QueueEdit::Move { queue, .. }
| QueueEdit::SendNow { queue, .. } => *queue,
};
let steering = queue == QueueKind::Steering;
let mut book = lock(&self.inner.book);
let texts = if steering {
&mut book.steering
} else {
&mut book.follow_up
};
self.inner.agent.update_queue(steering, |messages| {
let matches = match_texts(texts, messages);
match edit {
QueueEdit::Remove { index, .. } | QueueEdit::SendNow { index, .. } => {
if *index >= texts.len() {
return Err(out_of_range(*index, texts.len()));
}
texts.remove(*index);
if let Some(at) = matches[*index] {
messages.remove(at);
}
}
QueueEdit::Edit { index, text, .. } => {
if *index >= texts.len() {
return Err(out_of_range(*index, texts.len()));
}
texts[*index].clone_from(text);
if let Some(at) = matches[*index] {
let images = user_images(&messages[at]);
let mut content = vec![UserContent::text(text.clone())];
content.extend(images.into_iter().map(UserContent::Image));
if let AgentMessage::User(user) = &mut messages[at] {
user.content = UserContentField::Blocks(content);
}
}
}
QueueEdit::Move { from, to, .. } => {
let len = texts.len();
if *from >= len || *to >= len {
return Err(out_of_range((*from).max(*to), len));
}
let item = texts.remove(*from);
texts.insert(*to, item);
let mut slots: Vec<usize> = matches.iter().flatten().copied().collect();
if slots.len() == len {
let mut ordered: Vec<AgentMessage> = matches
.iter()
.flatten()
.map(|&i| messages[i].clone())
.collect();
let moved = ordered.remove(*from);
ordered.insert(*to, moved);
slots.sort_unstable();
for (slot, message) in slots.into_iter().zip(ordered) {
messages[slot] = message;
}
}
}
}
Ok(())
})
}
async fn send_queued_now(&self, queue: QueueKind, index: usize) -> Result<(), SessionOpError> {
let steering = queue == QueueKind::Steering;
let taken = {
let book = lock(&self.inner.book);
let texts = if steering {
&book.steering
} else {
&book.follow_up
};
let text = texts
.get(index)
.cloned()
.ok_or_else(|| out_of_range(index, texts.len()))?;
let message = self.inner.agent.update_queue(steering, |messages| {
match_texts(texts, messages)[index].map(|at| messages[at].clone())
});
(text, message)
};
let (text, message) = taken;
self.apply_queue_edit(&QueueEdit::Remove { queue, index })?;
if self.is_streaming() {
let message = message.unwrap_or_else(|| {
AgentMessage::User(UserMessage {
content: UserContentField::Blocks(vec![UserContent::text(text.clone())]),
timestamp: oino_agent::now_ms(),
})
});
lock(&self.inner.book).steering.insert(0, text);
self.inner
.agent
.update_queue(true, |messages| messages.insert(0, message));
self.emit_queue_update();
return Ok(());
}
self.emit_queue_update();
let images = message.as_ref().map(user_images).unwrap_or_default();
self.prompt(
text,
PromptOptions {
images,
source: InputSource::Rpc,
..PromptOptions::default()
},
)
.await
.map(drop)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn user(text: &str) -> AgentMessage {
AgentMessage::User(UserMessage {
content: UserContentField::Blocks(vec![UserContent::text(text.to_owned())]),
timestamp: 0,
})
}
#[test]
fn texts_match_user_messages_in_order() {
let messages = vec![user("a"), user("b"), user("a")];
let texts = vec!["a".to_owned(), "a".to_owned(), "c".to_owned()];
assert_eq!(match_texts(&texts, &messages), vec![Some(0), Some(2), None]);
}
#[test]
fn edits_round_trip_as_json() {
let edit: QueueEdit = serde_json::from_value(
serde_json::json!({"op": "move", "queue": "followUp", "from": 1, "to": 0}),
)
.unwrap();
assert_eq!(
edit,
QueueEdit::Move {
queue: QueueKind::FollowUp,
from: 1,
to: 0
}
);
}
}