soothe_client/appkit/
turn_boundary.rs1use serde_json::Value;
4
5use crate::stream_terminal::{is_turn_end_custom_data, is_turn_progress_chunk, STREAM_END};
6
7pub const TURN_END_STREAM_END: &str = STREAM_END;
9pub const TURN_END_IDLE: &str = "status.idle";
11pub const TURN_END_STOPPED: &str = "status.stopped";
13
14#[derive(Debug, Default, Clone)]
16pub struct TurnLifecycleGate {
17 pub saw_running: bool,
19 pub saw_stream_payload: bool,
21 pub saw_turn_progress: bool,
23}
24
25impl TurnLifecycleGate {
26 pub fn observe(&mut self, msg: &Value) {
28 if msg.get("type").and_then(|v| v.as_str()) == Some("status") {
29 if let Some(state) = msg.get("state").and_then(|v| v.as_str()) {
30 self.observe_status(state);
31 }
32 return;
33 }
34 if msg.get("type").and_then(|v| v.as_str()) == Some("event") || msg.get("mode").is_some() {
35 let mode = msg.get("mode").and_then(|v| v.as_str()).unwrap_or("");
36 let data = msg.get("data").cloned().unwrap_or(Value::Null);
37 self.observe_event(mode, &data);
38 }
39 }
40
41 pub fn observe_status(&mut self, state: &str) {
43 if state.eq_ignore_ascii_case("running") {
44 self.saw_running = true;
45 }
46 }
47
48 pub fn observe_event(&mut self, mode: &str, data: &Value) {
50 self.saw_stream_payload = true;
51 if is_turn_progress_chunk(mode, data) {
52 self.saw_turn_progress = true;
53 }
54 }
55
56 pub fn allow_stream_end(&self) -> bool {
58 self.saw_running && self.saw_turn_progress
59 }
60
61 pub fn allow_idle_complete(&self) -> bool {
63 self.saw_running && self.saw_stream_payload
64 }
65}
66
67#[derive(Debug, Default)]
69pub struct TurnBoundary {
70 pub gate: TurnLifecycleGate,
72 pub ended: bool,
74 pub reason: String,
76}
77
78impl TurnBoundary {
79 pub fn feed_status(&mut self, state: &str) -> Option<&'static str> {
81 if self.ended {
82 return static_reason(&self.reason);
83 }
84 self.gate.observe_status(state);
85 if state.eq_ignore_ascii_case("stopped") && self.gate.saw_running {
86 return Some(self.mark(TURN_END_STOPPED));
87 }
88 if state.eq_ignore_ascii_case("idle") && self.gate.allow_idle_complete() {
89 return Some(self.mark(TURN_END_IDLE));
90 }
91 None
92 }
93
94 pub fn feed_event(&mut self, mode: &str, data: &Value) -> Option<&'static str> {
96 if self.ended {
97 return static_reason(&self.reason);
98 }
99 self.gate.observe_event(mode, data);
100 if mode == "custom" && is_turn_end_custom_data(data) && self.gate.allow_stream_end() {
101 return Some(self.mark(TURN_END_STREAM_END));
102 }
103 None
104 }
105
106 fn mark(&mut self, reason: &'static str) -> &'static str {
107 self.ended = true;
108 self.reason = reason.to_string();
109 reason
110 }
111}
112
113fn static_reason(reason: &str) -> Option<&'static str> {
114 match reason {
115 TURN_END_STREAM_END => Some(TURN_END_STREAM_END),
116 TURN_END_IDLE => Some(TURN_END_IDLE),
117 TURN_END_STOPPED => Some(TURN_END_STOPPED),
118 _ => None,
119 }
120}
121
122pub fn is_daemon_turn_end_event(completion_event: &str) -> bool {
124 matches!(
125 completion_event.trim(),
126 TURN_END_STREAM_END | TURN_END_IDLE | TURN_END_STOPPED
127 )
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use serde_json::json;
134
135 #[test]
136 fn ignores_pre_running_idle() {
137 let mut b = TurnBoundary::default();
138 assert!(b.feed_status("idle").is_none());
139 b.feed_status("running");
140 b.feed_event(
141 "messages",
142 &json!([{"type":"AIMessageChunk","content":"x"}]),
143 );
144 assert_eq!(b.feed_status("idle"), Some(TURN_END_IDLE));
145 }
146
147 #[test]
148 fn stream_end_requires_running_and_progress() {
149 let mut b = TurnBoundary::default();
150 let end = json!({"type": STREAM_END, "scope": "turn"});
151 assert!(b.feed_event("custom", &end).is_none());
152 b.feed_status("running");
153 assert!(b.feed_event("custom", &end).is_none());
154 b.feed_event("messages", &json!({}));
155 assert_eq!(b.feed_event("custom", &end), Some(TURN_END_STREAM_END));
156 }
157
158 #[test]
159 fn stopped_after_running() {
160 let mut b = TurnBoundary::default();
161 assert!(b.feed_status("stopped").is_none());
162 b.feed_status("running");
163 assert_eq!(b.feed_status("stopped"), Some(TURN_END_STOPPED));
164 }
165
166 #[test]
167 fn daemon_turn_end_event_helper() {
168 assert!(is_daemon_turn_end_event(TURN_END_STREAM_END));
169 assert!(!is_daemon_turn_end_event(
170 "soothe.protocol.message.goal_completion"
171 ));
172 }
173}