use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::llm::types::TokenUsage;
use super::ctx::WorkflowCtx;
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WorkflowEvent {
PhaseStarted {
title: String,
},
AgentStarted {
label: String,
phase: Option<String>,
},
AgentFinished {
label: String,
usage: TokenUsage,
},
AgentReplayed {
label: String,
usage: TokenUsage,
},
AgentSkipped {
label: String,
},
AgentFailed {
label: String,
error: String,
},
LogLine {
msg: String,
},
}
pub type OnWorkflowEvent = dyn Fn(WorkflowEvent) + Send + Sync;
#[must_use = "the phase ends when the returned guard is dropped"]
pub fn phase(ctx: &WorkflowCtx, title: impl Into<String>) -> PhaseGuard {
let title = title.into();
ctx.emit(WorkflowEvent::PhaseStarted {
title: title.clone(),
});
let prior = ctx.swap_default_phase(Some(Arc::from(title.as_str())));
PhaseGuard {
ctx: ctx.clone(),
prior,
}
}
pub fn log(ctx: &WorkflowCtx, msg: impl Into<String>) {
ctx.emit(WorkflowEvent::LogLine { msg: msg.into() });
}
pub struct PhaseGuard {
ctx: WorkflowCtx,
prior: Option<Arc<str>>,
}
impl Drop for PhaseGuard {
fn drop(&mut self) {
self.ctx.swap_default_phase(self.prior.take());
}
}
#[cfg(test)]
mod tests {
use super::WorkflowEvent;
#[test]
fn workflow_event_serializes_with_snake_case_type_tag() {
let event = WorkflowEvent::PhaseStarted {
title: "review".into(),
};
let json = serde_json::to_string(&event).expect("serialize");
assert!(json.contains(r#""type":"phase_started""#), "json: {json}");
assert!(json.contains(r#""title":"review""#), "json: {json}");
}
#[test]
fn log_line_event_roundtrips_through_json() {
let event = WorkflowEvent::LogLine {
msg: "3/10 found".into(),
};
let json = serde_json::to_string(&event).expect("serialize");
let back: WorkflowEvent = serde_json::from_str(&json).expect("deserialize");
match back {
WorkflowEvent::LogLine { msg } => assert_eq!(msg, "3/10 found"),
other => panic!("expected LogLine, got {other:?}"),
}
}
use std::sync::{Arc, Mutex};
use super::super::ctx::WorkflowCtx;
use super::{OnWorkflowEvent, log, phase};
use crate::agent::test_helpers::MockProvider;
use crate::llm::BoxedProvider;
fn ctx_capturing() -> (WorkflowCtx, Arc<Mutex<Vec<WorkflowEvent>>>) {
let sink: Arc<Mutex<Vec<WorkflowEvent>>> = Arc::new(Mutex::new(Vec::new()));
let sink2 = Arc::clone(&sink);
let cb: Arc<OnWorkflowEvent> = Arc::new(move |ev: WorkflowEvent| {
sink2.lock().expect("sink lock").push(ev);
});
let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::new(MockProvider::new(vec![]))))
.on_event(cb)
.build()
.expect("build ctx");
(ctx, sink)
}
#[test]
fn phase_emits_started_and_sets_default_phase() {
let (ctx, sink) = ctx_capturing();
assert!(ctx.current_phase().is_none());
let _guard = phase(&ctx, "review");
assert_eq!(ctx.current_phase().as_deref(), Some("review"));
let events = sink.lock().expect("lock");
assert!(
matches!(events.first(), Some(WorkflowEvent::PhaseStarted { title }) if title == "review"),
"events: {events:?}"
);
}
#[test]
fn phase_guard_restores_prior_default_on_drop() {
let (ctx, _sink) = ctx_capturing();
{
let _outer = phase(&ctx, "outer");
assert_eq!(ctx.current_phase().as_deref(), Some("outer"));
{
let _inner = phase(&ctx, "inner");
assert_eq!(ctx.current_phase().as_deref(), Some("inner"));
}
assert_eq!(ctx.current_phase().as_deref(), Some("outer"));
}
assert!(ctx.current_phase().is_none());
}
#[test]
fn log_emits_log_line() {
let (ctx, sink) = ctx_capturing();
log(&ctx, "halfway there");
let events = sink.lock().expect("lock");
assert!(
matches!(events.last(), Some(WorkflowEvent::LogLine { msg }) if msg == "halfway there"),
"events: {events:?}"
);
}
}