Skip to main content

soothe_client/appkit/
turn_boundary.rs

1//! DaemonSession turn-end contract for the pool TurnRunner path.
2
3use serde_json::Value;
4
5use crate::stream_terminal::{is_turn_end_custom_data, is_turn_progress_chunk, STREAM_END};
6
7/// Completion event for turn-scoped `soothe.stream.end`.
8pub const TURN_END_STREAM_END: &str = STREAM_END;
9/// Completion event for gated `status=idle`.
10pub const TURN_END_IDLE: &str = "status.idle";
11/// Completion event for `status=stopped` after running.
12pub const TURN_END_STOPPED: &str = "status.stopped";
13
14/// Per-turn progress flags (DaemonSession parity; not shared across chats).
15#[derive(Debug, Default, Clone)]
16pub struct TurnLifecycleGate {
17    /// Saw `status=running` for this turn.
18    pub saw_running: bool,
19    /// Saw any event payload after filtering.
20    pub saw_stream_payload: bool,
21    /// Saw non-intake turn progress (`messages` / step customs).
22    pub saw_turn_progress: bool,
23}
24
25impl TurnLifecycleGate {
26    /// Observe a status frame.
27    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    /// Observe an event frame.
34    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    /// Whether turn-scoped `stream.end` may end the turn.
42    pub fn allow_stream_end(&self) -> bool {
43        self.saw_running && self.saw_turn_progress
44    }
45
46    /// Whether `status=idle` may soft-complete the turn.
47    pub fn allow_idle_complete(&self) -> bool {
48        self.saw_running && self.saw_stream_payload
49    }
50}
51
52/// Applies DaemonSession end rules to pool frames.
53#[derive(Debug, Default)]
54pub struct TurnBoundary {
55    /// Progress gate for this turn.
56    pub gate: TurnLifecycleGate,
57    /// True after a terminal boundary was observed.
58    pub ended: bool,
59    /// Wire completion reason when `ended`.
60    pub reason: String,
61}
62
63impl TurnBoundary {
64    /// Feed a status frame. Returns Some(reason) when the turn ends.
65    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    /// Feed an event frame. Returns Some(reason) when the turn ends.
80    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
107/// True for TurnBoundary completion_event values (not phase deliverables).
108pub 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}