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_status(&mut self, state: &str) {
28 if state.eq_ignore_ascii_case("running") {
29 self.saw_running = true;
30 }
31 }
32
33 pub fn observe_event(&mut self, mode: &str, data: &Value) {
35 self.saw_stream_payload = true;
36 if is_turn_progress_chunk(mode, data) {
37 self.saw_turn_progress = true;
38 }
39 }
40
41 pub fn allow_stream_end(&self) -> bool {
43 self.saw_running && self.saw_turn_progress
44 }
45
46 pub fn allow_idle_complete(&self) -> bool {
48 self.saw_running && self.saw_stream_payload
49 }
50}
51
52#[derive(Debug, Default)]
54pub struct TurnBoundary {
55 pub gate: TurnLifecycleGate,
57 pub ended: bool,
59 pub reason: String,
61}
62
63impl TurnBoundary {
64 pub fn feed_status(&mut self, state: &str) -> Option<&'static str> {
66 if self.ended {
67 return static_reason(&self.reason);
68 }
69 self.gate.observe_status(state);
70 if state.eq_ignore_ascii_case("stopped") && self.gate.saw_running {
71 return Some(self.mark(TURN_END_STOPPED));
72 }
73 if state.eq_ignore_ascii_case("idle") && self.gate.allow_idle_complete() {
74 return Some(self.mark(TURN_END_IDLE));
75 }
76 None
77 }
78
79 pub fn feed_event(&mut self, mode: &str, data: &Value) -> Option<&'static str> {
81 if self.ended {
82 return static_reason(&self.reason);
83 }
84 self.gate.observe_event(mode, data);
85 if mode == "custom" && is_turn_end_custom_data(data) && self.gate.allow_stream_end() {
86 return Some(self.mark(TURN_END_STREAM_END));
87 }
88 None
89 }
90
91 fn mark(&mut self, reason: &'static str) -> &'static str {
92 self.ended = true;
93 self.reason = reason.to_string();
94 reason
95 }
96}
97
98fn static_reason(reason: &str) -> Option<&'static str> {
99 match reason {
100 TURN_END_STREAM_END => Some(TURN_END_STREAM_END),
101 TURN_END_IDLE => Some(TURN_END_IDLE),
102 TURN_END_STOPPED => Some(TURN_END_STOPPED),
103 _ => None,
104 }
105}
106
107pub fn is_daemon_turn_end_event(completion_event: &str) -> bool {
109 matches!(
110 completion_event.trim(),
111 TURN_END_STREAM_END | TURN_END_IDLE | TURN_END_STOPPED
112 )
113}