#[cfg(test)]
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use promptforge_core::observe::{Observation, Observer};
use rmcp::RoleServer;
use rmcp::model::ProgressToken;
use rmcp::service::Peer;
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::mpsc::{self, Receiver, Sender};
mod pump;
#[cfg(test)]
mod tests;
pub(crate) use pump::ProgressPump;
const CAPACITY: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Frame {
progress: u32,
message: String,
}
#[derive(Debug)]
pub(crate) struct McpObserver {
frames: Option<Sender<Frame>>,
completed: AtomicU32,
turns: AtomicU32,
dropped: AtomicU32,
disconnected: AtomicU32,
#[cfg(test)]
records: Mutex<Vec<(String, String, String)>>,
}
impl McpObserver {
#[must_use]
pub(crate) fn silent() -> McpObserver {
McpObserver::over(None)
}
pub(crate) fn reporting(
peer: Peer<RoleServer>,
token: ProgressToken,
) -> (McpObserver, ProgressPump) {
let (observer, frames) = McpObserver::queued();
let pump = ProgressPump::spawn(frames, peer, token);
(observer, pump)
}
#[must_use]
pub(crate) fn turns(&self) -> u32 {
self.turns.load(Ordering::Relaxed)
}
#[cfg(test)]
#[must_use]
pub(crate) fn dropped(&self) -> u32 {
self.dropped.load(Ordering::Relaxed)
}
#[cfg(test)]
#[must_use]
pub(crate) fn disconnected(&self) -> u32 {
self.disconnected.load(Ordering::Relaxed)
}
#[cfg(test)]
pub(crate) fn records(&self) -> Vec<(String, String, String)> {
self.records
.lock()
.expect("the MCP test recorder mutex must not be poisoned")
.clone()
}
fn over(frames: Option<Sender<Frame>>) -> McpObserver {
McpObserver {
frames,
completed: AtomicU32::new(0),
turns: AtomicU32::new(0),
dropped: AtomicU32::new(0),
disconnected: AtomicU32::new(0),
#[cfg(test)]
records: Mutex::new(Vec::new()),
}
}
fn queued() -> (McpObserver, Receiver<Frame>) {
let (sender, receiver) = mpsc::channel(CAPACITY);
(McpObserver::over(Some(sender)), receiver)
}
fn advance(&self) -> u32 {
self.completed.fetch_add(1, Ordering::Relaxed) + 1
}
fn queue(&self, progress: u32, message: &str) {
let Some(frames) = &self.frames else {
return;
};
let frame = Frame {
progress,
message: message.to_owned(),
};
match frames.try_send(frame) {
Ok(()) => {}
Err(TrySendError::Full(_)) => {
let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1;
tracing::debug!(
dropped,
progress,
"dropped a progress frame: the queue is full"
);
}
Err(TrySendError::Closed(_)) => {
let disconnected = self.disconnected.fetch_add(1, Ordering::Relaxed) + 1;
tracing::debug!(
disconnected,
progress,
"dropped a progress frame: the pump is gone"
);
}
}
}
}
impl Observer for McpObserver {
fn observe(&self, execution: &str, section: &str, event: Observation) {
#[cfg(test)]
self.records
.lock()
.expect("the MCP test recorder mutex must not be poisoned")
.push((execution.to_owned(), section.to_owned(), event.to_string()));
let _ = execution;
match event {
Observation::RunStarted => {
tracing::info!("run started");
self.queue(0, section);
}
Observation::SectionStarted => {
self.queue(self.advance(), section);
}
Observation::SectionFinished => {
tracing::debug!("section finished");
}
Observation::ModelTurnCompleted => {
let turn = self.turns.fetch_add(1, Ordering::Relaxed) + 1;
tracing::debug!(turn, "model turn completed");
}
Observation::ModelTurnFailed => {
tracing::warn!("model turn failed");
}
Observation::ToolCallSucceeded => {
tracing::debug!("tool call succeeded");
}
Observation::ToolCallFailed => {
tracing::warn!("tool call failed");
}
Observation::RunSucceeded => {
tracing::debug!("run success observed");
}
Observation::RunFailed => {
tracing::debug!("run failure observed");
}
_other => {
tracing::debug!("unrecognized observation");
}
}
}
}