use serde_json::Value;
use crate::stream_terminal::{is_turn_end_custom_data, is_turn_progress_chunk, STREAM_END};
pub const TURN_END_STREAM_END: &str = STREAM_END;
pub const TURN_END_IDLE: &str = "status.idle";
pub const TURN_END_STOPPED: &str = "status.stopped";
#[derive(Debug, Default, Clone)]
pub struct TurnLifecycleGate {
pub saw_running: bool,
pub saw_stream_payload: bool,
pub saw_turn_progress: bool,
}
impl TurnLifecycleGate {
pub fn observe_status(&mut self, state: &str) {
if state.eq_ignore_ascii_case("running") {
self.saw_running = true;
}
}
pub fn observe_event(&mut self, mode: &str, data: &Value) {
self.saw_stream_payload = true;
if is_turn_progress_chunk(mode, data) {
self.saw_turn_progress = true;
}
}
pub fn allow_stream_end(&self) -> bool {
self.saw_running && self.saw_turn_progress
}
pub fn allow_idle_complete(&self) -> bool {
self.saw_running && self.saw_stream_payload
}
}
#[derive(Debug, Default)]
pub struct TurnBoundary {
pub gate: TurnLifecycleGate,
pub ended: bool,
pub reason: String,
}
impl TurnBoundary {
pub fn feed_status(&mut self, state: &str) -> Option<&'static str> {
if self.ended {
return static_reason(&self.reason);
}
self.gate.observe_status(state);
if state.eq_ignore_ascii_case("stopped") && self.gate.saw_running {
return Some(self.mark(TURN_END_STOPPED));
}
if state.eq_ignore_ascii_case("idle") && self.gate.allow_idle_complete() {
return Some(self.mark(TURN_END_IDLE));
}
None
}
pub fn feed_event(&mut self, mode: &str, data: &Value) -> Option<&'static str> {
if self.ended {
return static_reason(&self.reason);
}
self.gate.observe_event(mode, data);
if mode == "custom" && is_turn_end_custom_data(data) && self.gate.allow_stream_end() {
return Some(self.mark(TURN_END_STREAM_END));
}
None
}
fn mark(&mut self, reason: &'static str) -> &'static str {
self.ended = true;
self.reason = reason.to_string();
reason
}
}
fn static_reason(reason: &str) -> Option<&'static str> {
match reason {
TURN_END_STREAM_END => Some(TURN_END_STREAM_END),
TURN_END_IDLE => Some(TURN_END_IDLE),
TURN_END_STOPPED => Some(TURN_END_STOPPED),
_ => None,
}
}
pub fn is_daemon_turn_end_event(completion_event: &str) -> bool {
matches!(
completion_event.trim(),
TURN_END_STREAM_END | TURN_END_IDLE | TURN_END_STOPPED
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn ignores_pre_running_idle() {
let mut b = TurnBoundary::default();
assert!(b.feed_status("idle").is_none());
b.feed_status("running");
b.feed_event(
"messages",
&json!([{"type":"AIMessageChunk","content":"x"}]),
);
assert_eq!(b.feed_status("idle"), Some(TURN_END_IDLE));
}
#[test]
fn stream_end_requires_running_and_progress() {
let mut b = TurnBoundary::default();
let end = json!({"type": STREAM_END, "scope": "turn"});
assert!(b.feed_event("custom", &end).is_none());
b.feed_status("running");
assert!(b.feed_event("custom", &end).is_none());
b.feed_event("messages", &json!({}));
assert_eq!(b.feed_event("custom", &end), Some(TURN_END_STREAM_END));
}
#[test]
fn stopped_after_running() {
let mut b = TurnBoundary::default();
assert!(b.feed_status("stopped").is_none());
b.feed_status("running");
assert_eq!(b.feed_status("stopped"), Some(TURN_END_STOPPED));
}
#[test]
fn daemon_turn_end_event_helper() {
assert!(is_daemon_turn_end_event(TURN_END_STREAM_END));
assert!(!is_daemon_turn_end_event(
"soothe.protocol.message.goal_completion"
));
}
}