use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
use crate::domain::{Msg, Question, QuestionResolution, ToolCallId, TurnId};
#[derive(Clone)]
pub struct QuestionBroker {
pending: Arc<Mutex<HashMap<ToolCallId, oneshot::Sender<QuestionResolution>>>>,
msg_tx: mpsc::Sender<Msg>,
}
impl QuestionBroker {
pub fn new(msg_tx: mpsc::Sender<Msg>) -> Self {
Self {
pending: Arc::new(Mutex::new(HashMap::new())),
msg_tx,
}
}
pub async fn request(
&self,
token: &CancellationToken,
turn: TurnId,
call_id: ToolCallId,
questions: Vec<Question>,
) -> QuestionResolution {
let (tx, rx) = oneshot::channel();
self.pending
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(call_id, tx);
let sent = self
.msg_tx
.send(Msg::QuestionAsked {
turn,
call_id,
questions,
})
.await;
if sent.is_err() {
self.pending
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.remove(&call_id);
return QuestionResolution::Dismissed;
}
tokio::select! {
biased;
_ = token.cancelled() => {
self.pending.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&call_id);
QuestionResolution::Dismissed
}
resolution = rx => resolution.unwrap_or(QuestionResolution::Dismissed),
}
}
pub fn resolve(&self, call_id: ToolCallId, resolution: QuestionResolution) {
let entry = self
.pending
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.remove(&call_id);
if let Some(tx) = entry {
let _ = tx.send(resolution);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{QuestionAnswer, QuestionOption};
fn sample_questions() -> Vec<Question> {
vec![Question {
header: "Database".to_string(),
question: "Which database?".to_string(),
kind: crate::domain::QuestionKind::Select,
options: vec![QuestionOption {
label: "PostgreSQL".to_string(),
description: None,
recommended: true,
preview: None,
}],
memory_key: None,
}]
}
#[tokio::test]
async fn resolve_delivers_answers() {
let (tx, _rx) = mpsc::channel::<Msg>(8);
let broker = QuestionBroker::new(tx);
let b2 = broker.clone();
let handle = tokio::spawn(async move {
b2.request(
&CancellationToken::new(),
TurnId(1),
ToolCallId(1),
sample_questions(),
)
.await
});
let answers = vec![QuestionAnswer {
header: "Database".to_string(),
question: "Which database?".to_string(),
selected: vec!["PostgreSQL".to_string()],
note: None,
}];
for _ in 0..100 {
broker.resolve(
ToolCallId(1),
QuestionResolution::Answered {
answers: answers.clone(),
remember: false,
},
);
tokio::task::yield_now().await;
if broker
.pending
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.is_empty()
{
break;
}
}
let resolution = handle.await.unwrap();
assert_eq!(
resolution,
QuestionResolution::Answered {
answers,
remember: false
}
);
}
#[tokio::test]
async fn cancel_token_dismisses() {
let (tx, _rx) = mpsc::channel::<Msg>(8);
let broker = QuestionBroker::new(tx);
let token = CancellationToken::new();
let token2 = token.clone();
let handle = tokio::spawn(async move {
broker
.request(&token2, TurnId(1), ToolCallId(2), sample_questions())
.await
});
tokio::task::yield_now().await;
token.cancel();
assert_eq!(handle.await.unwrap(), QuestionResolution::Dismissed);
}
}