heartbit_core/agent/flow/
event.rs1use std::sync::Arc;
4
5use serde::{Deserialize, Serialize};
6
7use crate::llm::types::TokenUsage;
8
9use super::ctx::WorkflowCtx;
10
11#[non_exhaustive]
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(tag = "type", rename_all = "snake_case")]
22pub enum WorkflowEvent {
23 PhaseStarted {
25 title: String,
27 },
28 AgentStarted {
30 label: String,
32 phase: Option<String>,
34 },
35 AgentFinished {
37 label: String,
39 usage: TokenUsage,
41 },
42 AgentReplayed {
45 label: String,
47 usage: TokenUsage,
49 },
50 AgentSkipped {
53 label: String,
55 },
56 AgentFailed {
59 label: String,
61 error: String,
63 },
64 LogLine {
66 msg: String,
68 },
69}
70
71pub type OnWorkflowEvent = dyn Fn(WorkflowEvent) + Send + Sync;
74
75#[must_use = "the phase ends when the returned guard is dropped"]
83pub fn phase(ctx: &WorkflowCtx, title: impl Into<String>) -> PhaseGuard {
84 let title = title.into();
85 ctx.emit(WorkflowEvent::PhaseStarted {
86 title: title.clone(),
87 });
88 let prior = ctx.swap_default_phase(Some(Arc::from(title.as_str())));
89 PhaseGuard {
90 ctx: ctx.clone(),
91 prior,
92 }
93}
94
95pub fn log(ctx: &WorkflowCtx, msg: impl Into<String>) {
97 ctx.emit(WorkflowEvent::LogLine { msg: msg.into() });
98}
99
100pub struct PhaseGuard {
102 ctx: WorkflowCtx,
103 prior: Option<Arc<str>>,
104}
105
106impl Drop for PhaseGuard {
107 fn drop(&mut self) {
108 self.ctx.swap_default_phase(self.prior.take());
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::WorkflowEvent;
115
116 #[test]
117 fn workflow_event_serializes_with_snake_case_type_tag() {
118 let event = WorkflowEvent::PhaseStarted {
119 title: "review".into(),
120 };
121 let json = serde_json::to_string(&event).expect("serialize");
122 assert!(json.contains(r#""type":"phase_started""#), "json: {json}");
123 assert!(json.contains(r#""title":"review""#), "json: {json}");
124 }
125
126 #[test]
127 fn log_line_event_roundtrips_through_json() {
128 let event = WorkflowEvent::LogLine {
129 msg: "3/10 found".into(),
130 };
131 let json = serde_json::to_string(&event).expect("serialize");
132 let back: WorkflowEvent = serde_json::from_str(&json).expect("deserialize");
133 match back {
134 WorkflowEvent::LogLine { msg } => assert_eq!(msg, "3/10 found"),
135 other => panic!("expected LogLine, got {other:?}"),
136 }
137 }
138
139 use std::sync::{Arc, Mutex};
144
145 use super::super::ctx::WorkflowCtx;
146 use super::{OnWorkflowEvent, log, phase};
147 use crate::agent::test_helpers::MockProvider;
148 use crate::llm::BoxedProvider;
149
150 fn ctx_capturing() -> (WorkflowCtx, Arc<Mutex<Vec<WorkflowEvent>>>) {
152 let sink: Arc<Mutex<Vec<WorkflowEvent>>> = Arc::new(Mutex::new(Vec::new()));
153 let sink2 = Arc::clone(&sink);
154 let cb: Arc<OnWorkflowEvent> = Arc::new(move |ev: WorkflowEvent| {
155 sink2.lock().expect("sink lock").push(ev);
156 });
157 let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::new(MockProvider::new(vec![]))))
158 .on_event(cb)
159 .build()
160 .expect("build ctx");
161 (ctx, sink)
162 }
163
164 #[test]
165 fn phase_emits_started_and_sets_default_phase() {
166 let (ctx, sink) = ctx_capturing();
167 assert!(ctx.current_phase().is_none());
168
169 let _guard = phase(&ctx, "review");
170 assert_eq!(ctx.current_phase().as_deref(), Some("review"));
171
172 let events = sink.lock().expect("lock");
173 assert!(
174 matches!(events.first(), Some(WorkflowEvent::PhaseStarted { title }) if title == "review"),
175 "events: {events:?}"
176 );
177 }
178
179 #[test]
180 fn phase_guard_restores_prior_default_on_drop() {
181 let (ctx, _sink) = ctx_capturing();
182 {
183 let _outer = phase(&ctx, "outer");
184 assert_eq!(ctx.current_phase().as_deref(), Some("outer"));
185 {
186 let _inner = phase(&ctx, "inner");
187 assert_eq!(ctx.current_phase().as_deref(), Some("inner"));
188 }
189 assert_eq!(ctx.current_phase().as_deref(), Some("outer"));
191 }
192 assert!(ctx.current_phase().is_none());
194 }
195
196 #[test]
197 fn log_emits_log_line() {
198 let (ctx, sink) = ctx_capturing();
199 log(&ctx, "halfway there");
200 let events = sink.lock().expect("lock");
201 assert!(
202 matches!(events.last(), Some(WorkflowEvent::LogLine { msg }) if msg == "halfway there"),
203 "events: {events:?}"
204 );
205 }
206}