Skip to main content

soothe_client/appkit/
classifier.rs

1//! Event → deliverable / streaming / terminal classification.
2
3use std::collections::HashSet;
4use std::fmt;
5
6use serde_json::Value;
7
8use crate::events::{
9    EVENT_FINAL_REPORT, EVENT_STREAM_TOOL_CALL_UPDATE, EVENT_TOOL_CALL_UPDATES_BATCH,
10};
11use crate::intent_hints::DEFAULT_DELIVERABLE_PHASES;
12use crate::protocol::{unwrap_next, EventMessage};
13use crate::stream_terminal::{is_turn_end_custom_data, STREAM_END};
14
15use super::thinking_step;
16use super::turn_boundary::TurnLifecycleGate;
17
18/// Daemon's replay completion signal (internal; not exported by the wire catalog).
19const EVENT_LOOP_HISTORY_REPLAYED: &str = "soothe.lifecycle.loop.history.replayed";
20
21/// How a processed event should end the query loop.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum ChatEventTerminal {
24    /// Accumulate content; the query is still running.
25    #[default]
26    Continue,
27    /// User-visible final reply; persist it.
28    DeliverableComplete,
29    /// The query failed; persist an error.
30    FailedComplete,
31}
32
33/// Structured outcome of classifying one daemon event.
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub struct ChatEventResult {
36    /// Assistant text chunk or final reply content.
37    pub content: String,
38    /// User-visible progress line (not a final reply).
39    pub thinking_step: String,
40    /// Terminal classification for this event.
41    pub terminal: ChatEventTerminal,
42    /// Wire event type when `terminal == DeliverableComplete`.
43    pub completion_event: String,
44    /// Error message when `terminal == FailedComplete`.
45    pub error: Option<String>,
46}
47
48/// Product-specific classifier knobs.
49#[derive(Debug, Clone)]
50pub struct ClassifierConfig {
51    /// Loop-tagged message phases that may end a query with user-facing text.
52    pub deliverable_phases: HashSet<String>,
53    /// Minimum trimmed rune count for a reply to persist as final (default 8).
54    pub min_deliverable_runes: usize,
55    /// Optional override for thinking-step event allowlist.
56    pub thinking_step_events: Option<HashSet<String>>,
57    /// Treat `status=idle` plus substantive accumulated text as deliverable.
58    pub treat_status_idle_as_complete: bool,
59    /// Soft-complete on turn-scoped `soothe.stream.end` in standalone classify paths.
60    pub treat_stream_end_as_complete: bool,
61    /// Gate idle completion on [`TurnLifecycleGate::allow_idle_complete`].
62    pub gate_turn_end_signals: bool,
63}
64
65impl Default for ClassifierConfig {
66    fn default() -> Self {
67        Self {
68            deliverable_phases: HashSet::new(),
69            min_deliverable_runes: 8,
70            thinking_step_events: None,
71            treat_status_idle_as_complete: false,
72            treat_stream_end_as_complete: false,
73            gate_turn_end_signals: false,
74        }
75    }
76}
77
78/// Error constructing an [`EventClassifier`].
79#[derive(Debug, Clone, Copy, thiserror::Error)]
80#[error("ClassifierConfig.deliverable_phases must not be empty")]
81pub struct ClassifierConfigError;
82
83/// Maps decoded daemon events into deliverable, streaming, or failed outcomes.
84#[derive(Debug, Clone)]
85pub struct EventClassifier {
86    cfg: ClassifierConfig,
87}
88
89impl EventClassifier {
90    /// Construct a classifier from config.
91    ///
92    /// Returns an error when `deliverable_phases` is empty (required product decision).
93    pub fn new(mut cfg: ClassifierConfig) -> Result<Self, ClassifierConfigError> {
94        if cfg.deliverable_phases.is_empty() {
95            return Err(ClassifierConfigError);
96        }
97        if cfg.min_deliverable_runes == 0 {
98            cfg.min_deliverable_runes = 8;
99        }
100        Ok(Self { cfg })
101    }
102
103    /// Classifier with default deliverable phases from [`DEFAULT_DELIVERABLE_PHASES`].
104    pub fn with_defaults() -> Self {
105        Self::new(ClassifierConfig {
106            deliverable_phases: DEFAULT_DELIVERABLE_PHASES
107                .iter()
108                .map(|p| (*p).to_string())
109                .collect(),
110            min_deliverable_runes: 8,
111            ..ClassifierConfig::default()
112        })
113        .expect("default deliverable phases are non-empty")
114    }
115
116    /// Classify one decoded event. Prefer [`Self::classify_turn`] when stream-end or
117    /// gated idle completion needs a per-turn [`TurnLifecycleGate`].
118    pub fn classify(&self, msg: &Value, accumulated: &str) -> ChatEventResult {
119        self.classify_turn(msg, accumulated, None)
120    }
121
122    /// Classify one event and optionally observe a per-turn lifecycle gate first.
123    pub fn classify_turn(
124        &self,
125        msg: &Value,
126        accumulated: &str,
127        mut gate: Option<&mut TurnLifecycleGate>,
128    ) -> ChatEventResult {
129        if let Some(g) = gate.as_deref_mut() {
130            g.observe(msg);
131        }
132        let gate_ref = gate.as_deref();
133        self.process_chat_event(msg, accumulated, gate_ref)
134    }
135
136    /// Report whether a persisted `completion_event` is user-facing.
137    pub fn is_deliverable_completion_event(&self, event_type: &str) -> bool {
138        let event_type = event_type.trim();
139        if event_type.is_empty() {
140            return false;
141        }
142        match event_type {
143            "status.idle" | "status.stopped" | STREAM_END | "idle_timeout" | "query_timeout"
144            | "stream_closed" => true,
145            _ if event_type == EVENT_FINAL_REPORT => true,
146            _ => {
147                if let Some(phase) = event_type.strip_prefix("soothe.protocol.message.") {
148                    self.is_deliverable_loop_phase(phase)
149                } else {
150                    event_type.contains("soothe.output") && event_type.contains("responded")
151                }
152            }
153        }
154    }
155
156    /// Report whether trimmed assistant text is long enough to persist as final.
157    pub fn is_substantive_assistant_reply(&self, content: &str) -> bool {
158        content.trim().chars().count() >= self.cfg.min_deliverable_runes
159    }
160
161    /// Pick the user-visible reply for a completed query.
162    ///
163    /// Falls back to accumulated streamed text when the deliverable event has empty
164    /// content (common after StrangeLoop streamed the answer on non-deliverable phases).
165    pub fn resolve_deliverable_final_content(
166        &self,
167        event_result: &ChatEventResult,
168        accumulated: &str,
169    ) -> Option<String> {
170        if event_result.terminal != ChatEventTerminal::DeliverableComplete {
171            return None;
172        }
173        if !self.is_deliverable_completion_event(&event_result.completion_event) {
174            return None;
175        }
176        let trimmed = event_result.content.trim();
177        if !trimmed.is_empty() {
178            return Some(trimmed.to_string());
179        }
180        let acc = accumulated.trim();
181        if !acc.is_empty() && self.is_substantive_assistant_reply(acc) {
182            return Some(acc.to_string());
183        }
184        None
185    }
186
187    fn is_deliverable_loop_phase(&self, phase: &str) -> bool {
188        self.cfg.deliverable_phases.contains(phase)
189    }
190
191    fn deliverable_result(
192        &self,
193        content: impl Into<String>,
194        completion_event: impl Into<String>,
195    ) -> ChatEventResult {
196        ChatEventResult {
197            content: content.into(),
198            terminal: ChatEventTerminal::DeliverableComplete,
199            completion_event: completion_event.into(),
200            ..ChatEventResult::default()
201        }
202    }
203
204    fn continue_result(&self, content: impl Into<String>) -> ChatEventResult {
205        ChatEventResult {
206            content: content.into(),
207            terminal: ChatEventTerminal::Continue,
208            ..ChatEventResult::default()
209        }
210    }
211
212    fn failed_result(&self, err: impl fmt::Display) -> ChatEventResult {
213        ChatEventResult {
214            terminal: ChatEventTerminal::FailedComplete,
215            error: Some(err.to_string()),
216            ..ChatEventResult::default()
217        }
218    }
219
220    fn process_chat_event(
221        &self,
222        msg: &Value,
223        accumulated: &str,
224        gate: Option<&TurnLifecycleGate>,
225    ) -> ChatEventResult {
226        let msg = &unwrap_next(msg);
227
228        if is_status_message(msg) {
229            return self.process_status(msg, accumulated, gate);
230        }
231
232        let msg_type = msg.get("type").and_then(|v| v.as_str()).unwrap_or("");
233        match msg_type {
234            "error" => self.process_envelope_error(msg),
235            "response" | "next" | "complete" | "receipt_response" => ChatEventResult::default(),
236            "event" | "" if msg.get("mode").is_some() => {
237                self.process_event_message(msg, accumulated, gate)
238            }
239            _ if msg.get("code").is_some() && msg.get("message").is_some() => {
240                let code = msg
241                    .get("code")
242                    .and_then(|v| {
243                        v.as_str()
244                            .map(String::from)
245                            .or_else(|| v.as_i64().map(|n| n.to_string()))
246                    })
247                    .unwrap_or_else(|| "unknown".to_string());
248                let message = msg.get("message").and_then(|v| v.as_str()).unwrap_or("");
249                self.failed_result(format!("daemon error [{code}]: {message}"))
250            }
251            _ => ChatEventResult::default(),
252        }
253    }
254
255    fn process_status(
256        &self,
257        msg: &Value,
258        accumulated: &str,
259        gate: Option<&TurnLifecycleGate>,
260    ) -> ChatEventResult {
261        let state = msg.get("state").and_then(|v| v.as_str()).unwrap_or("");
262        if self.cfg.treat_status_idle_as_complete
263            && state.eq_ignore_ascii_case("idle")
264            && self.is_substantive_assistant_reply(accumulated)
265        {
266            let idle_turn = crate::turn_boundary::frame_turn_id(Some(msg));
267            if self.cfg.gate_turn_end_signals
268                && !gate
269                    .map(|g| g.allow_idle_complete(idle_turn.as_deref()))
270                    .unwrap_or(false)
271            {
272                return ChatEventResult::default();
273            }
274            return self.deliverable_result(accumulated.trim(), "status.idle");
275        }
276        ChatEventResult::default()
277    }
278
279    fn process_envelope_error(&self, msg: &Value) -> ChatEventResult {
280        let mut code = -32603_i64;
281        let mut message = String::new();
282        if let Some(err) = msg.get("error").and_then(|v| v.as_object()) {
283            if let Some(c) = err.get("code").and_then(|v| v.as_i64()) {
284                code = c;
285            }
286            if let Some(m) = err.get("message").and_then(|v| v.as_str()) {
287                message = m.to_string();
288            }
289        }
290        self.failed_result(format!("daemon error [{code}]: {message}"))
291    }
292
293    fn process_event_message(
294        &self,
295        msg: &Value,
296        accumulated: &str,
297        gate: Option<&TurnLifecycleGate>,
298    ) -> ChatEventResult {
299        let mode = msg.get("mode").and_then(|v| v.as_str()).unwrap_or("");
300        let data = msg.get("data").cloned().unwrap_or(Value::Null);
301        let event_type = event_message_event_type(msg);
302
303        if let Some(data_map) = normalize_event_data(&data) {
304            let data_type = data_map
305                .get("type")
306                .and_then(|v| v.as_str())
307                .filter(|s| !s.is_empty())
308                .unwrap_or(&event_type);
309
310            if data_type == EVENT_LOOP_HISTORY_REPLAYED {
311                return ChatEventResult::default();
312            }
313            if let Some(step) = thinking_step::extract_thinking_step(
314                self.cfg.thinking_step_events.as_ref(),
315                data_type,
316                &data_map,
317            ) {
318                return thinking_step_result(step);
319            }
320        }
321
322        if mode == "custom" && is_turn_end_custom_data(&data) {
323            let data_turn = crate::turn_boundary::frame_turn_id(Some(&data));
324            let outer_turn = crate::turn_boundary::frame_turn_id(Some(msg));
325            let frame_turn = data_turn.as_deref().or(outer_turn.as_deref());
326            if self.cfg.treat_stream_end_as_complete
327                && self.is_substantive_assistant_reply(accumulated)
328                && gate
329                    .map(|g| g.allow_stream_end(frame_turn))
330                    .unwrap_or(false)
331            {
332                return self.deliverable_result(accumulated.trim(), STREAM_END);
333            }
334            return ChatEventResult::default();
335        }
336
337        if mode == "messages" {
338            let (msg_type, raw_content, phase, has_payload) = first_message_payload(&data);
339            if has_payload && !raw_content.is_empty() && is_streaming_message_type(&msg_type) {
340                return self.continue_result(raw_content);
341            }
342
343            if let Ok(em) = serde_json::from_value::<EventMessage>(msg.clone()) {
344                if let Some(loop_msg) = em.loop_ai_message() {
345                    let content = loop_msg.loop_ai_text();
346                    if !content.is_empty() {
347                        let loop_type = loop_msg.r#type.as_deref().unwrap_or("");
348                        if is_streaming_message_type(loop_type) {
349                            return self.continue_result(content);
350                        }
351                        if let Some(phase) = loop_msg.phase.as_deref() {
352                            if self.is_deliverable_loop_phase(phase)
353                                && self.is_substantive_assistant_reply(&content)
354                            {
355                                return self.deliverable_result(
356                                    content,
357                                    format!("soothe.protocol.message.{phase}"),
358                                );
359                            }
360                        }
361                        return self.continue_result(content);
362                    }
363                }
364            }
365
366            if let Some(content) = messages_mode_assistant_content(msg) {
367                return self.continue_result(content);
368            }
369
370            if has_payload && !raw_content.is_empty() {
371                if (is_terminal_message_type(&msg_type) || msg_type.is_empty())
372                    && self.is_deliverable_loop_phase(&phase)
373                    && self.is_substantive_assistant_reply(&raw_content)
374                {
375                    return self.deliverable_result(
376                        raw_content,
377                        format!("soothe.protocol.message.{phase}"),
378                    );
379                }
380                return self.continue_result(raw_content);
381            }
382        }
383
384        let Some(data_map) = normalize_event_data(&data) else {
385            return ChatEventResult::default();
386        };
387
388        let data_type = data_map.get("type").and_then(|v| v.as_str()).unwrap_or("");
389        let ns = event_type.as_str();
390        let mut completion_event = data_type.to_string();
391        if completion_event.is_empty() {
392            completion_event.clone_from(&event_type);
393        }
394
395        if is_namespace_match(ns, data_type, "soothe.output")
396            || is_namespace_match(ns, data_type, "responded")
397        {
398            if let Some(content) = extract_content_from_data(&data_map) {
399                if self.is_final_output_event(data_type, ns) {
400                    return self.deliverable_result(content, completion_event);
401                }
402                return self.continue_result(content);
403            }
404        }
405
406        if is_namespace_match(ns, data_type, "agent_loop.completed")
407            || is_namespace_match(ns, data_type, "agent_loop.reasoned")
408            || is_namespace_match(ns, data_type, "loop.completed")
409        {
410            if let Some(content) = extract_content_from_data(&data_map) {
411                return self.continue_result(content);
412            }
413        }
414
415        if is_namespace_match(ns, data_type, "final_report") {
416            if let Some(content) = extract_content_from_data(&data_map) {
417                return self.deliverable_result(content, completion_event);
418            }
419        }
420
421        if data_type.contains("soothe.error.") || ns.contains("soothe.error.") {
422            let err_type = if data_type.is_empty() { ns } else { data_type };
423            if let Some(message) = data_map.get("message").and_then(|v| v.as_str()) {
424                if !message.is_empty() {
425                    return self.failed_result(format!("{err_type}: {message}"));
426                }
427            }
428            if let Some(content) = extract_content_from_data(&data_map) {
429                return self.failed_result(format!("{err_type}: {content}"));
430            }
431            return self.failed_result(err_type);
432        }
433
434        if is_namespace_match(ns, data_type, "stream")
435            || is_namespace_match(ns, data_type, "progress")
436            || is_namespace_match(ns, data_type, EVENT_TOOL_CALL_UPDATES_BATCH)
437            || is_namespace_match(ns, data_type, EVENT_STREAM_TOOL_CALL_UPDATE)
438        {
439            if let Some(delta) = data_map.get("delta").and_then(|v| v.as_str()) {
440                return self.continue_result(delta);
441            }
442        }
443
444        if is_namespace_match(ns, data_type, "heartbeat")
445            || is_namespace_match(ns, data_type, "system.daemon")
446            || is_namespace_match(ns, data_type, "agent_loop.started")
447            || is_namespace_match(ns, data_type, "intent.classified")
448        {
449            return ChatEventResult::default();
450        }
451
452        ChatEventResult::default()
453    }
454
455    fn is_final_output_event(&self, data_type: &str, ns: &str) -> bool {
456        let combined = format!("{data_type} {ns}");
457        if combined.contains("final_report") {
458            return true;
459        }
460        self.cfg
461            .deliverable_phases
462            .iter()
463            .any(|phase| combined.contains(phase.as_str()))
464    }
465}
466
467fn thinking_step_result(step: String) -> ChatEventResult {
468    ChatEventResult {
469        thinking_step: step.trim().to_string(),
470        terminal: ChatEventTerminal::Continue,
471        ..ChatEventResult::default()
472    }
473}
474
475fn is_status_message(msg: &Value) -> bool {
476    msg.get("type").and_then(|v| v.as_str()) == Some("status")
477        || (msg.get("state").is_some() && msg.get("mode").is_none())
478}
479
480fn event_message_event_type(msg: &Value) -> String {
481    if let Some(data) = msg.get("data").and_then(|d| d.as_object()) {
482        if let Some(t) = data.get("type").and_then(|v| v.as_str()) {
483            if !t.is_empty() {
484                return t.to_string();
485            }
486        }
487    }
488    match msg.get("namespace") {
489        Some(Value::String(s)) if !s.is_empty() => s.clone(),
490        Some(Value::Array(arr)) => arr
491            .iter()
492            .filter_map(|v| v.as_str())
493            .collect::<Vec<_>>()
494            .join("."),
495        _ => String::new(),
496    }
497}
498
499fn is_streaming_message_type(msg_type: &str) -> bool {
500    matches!(msg_type, "AIMessageChunk" | "ai_chunk" | "message_chunk")
501}
502
503fn is_terminal_message_type(msg_type: &str) -> bool {
504    matches!(msg_type, "AIMessage" | "ai" | "assistant")
505}
506
507fn messages_mode_assistant_content(msg: &Value) -> Option<String> {
508    if msg.get("mode").and_then(|v| v.as_str()) != Some("messages") {
509        return None;
510    }
511    let data = msg.get("data")?;
512    let items = data.as_array()?;
513    let msg_map = items.first()?.as_object()?;
514    let phase = msg_map
515        .get("phase")
516        .and_then(|v| v.as_str())
517        .unwrap_or("")
518        .trim();
519    if !phase.is_empty() {
520        return None;
521    }
522    let msg_type = msg_map.get("type").and_then(|v| v.as_str()).unwrap_or("");
523    if !msg_type.is_empty() && !is_terminal_message_type(msg_type) {
524        return None;
525    }
526    let content = extract_content_from_message(msg_map);
527    let content = content.trim();
528    if content.is_empty() {
529        None
530    } else {
531        Some(content.to_string())
532    }
533}
534
535fn first_message_payload(data: &Value) -> (String, String, String, bool) {
536    let Some(items) = data.as_array() else {
537        return (String::new(), String::new(), String::new(), false);
538    };
539    let Some(msg_map) = items.first().and_then(|v| v.as_object()) else {
540        return (String::new(), String::new(), String::new(), false);
541    };
542    let msg_type = msg_map
543        .get("type")
544        .and_then(|v| v.as_str())
545        .unwrap_or("")
546        .to_string();
547    let phase = msg_map
548        .get("phase")
549        .and_then(|v| v.as_str())
550        .unwrap_or("")
551        .to_string();
552    let content = extract_content_from_message(msg_map);
553    (msg_type, content, phase, true)
554}
555
556fn extract_content_from_message(msg_map: &serde_json::Map<String, Value>) -> String {
557    if let Some(c) = msg_map.get("content").and_then(|v| v.as_str()) {
558        if !c.is_empty() {
559            return c.to_string();
560        }
561    }
562    if let Some(arr) = msg_map.get("content").and_then(|v| v.as_array()) {
563        let mut buf = String::new();
564        for item in arr {
565            if let Some(s) = item.as_str() {
566                buf.push_str(s);
567                continue;
568            }
569            if let Some(blk) = item.as_object() {
570                if let Some(t) = blk.get("text").and_then(|v| v.as_str()) {
571                    buf.push_str(t);
572                }
573            }
574        }
575        if !buf.is_empty() {
576            return buf;
577        }
578    }
579    if let Some(blocks) = msg_map.get("content_blocks").and_then(|v| v.as_array()) {
580        let mut buf = String::new();
581        for blk in blocks {
582            if let Some(m) = blk.as_object() {
583                if let Some(t) = m.get("text").and_then(|v| v.as_str()) {
584                    buf.push_str(t);
585                }
586            }
587        }
588        return buf;
589    }
590    String::new()
591}
592
593fn is_subscription_metadata_map(data: &serde_json::Map<String, Value>) -> bool {
594    if !data.contains_key("loop_id") || !data.contains_key("latest_seq") {
595        return false;
596    }
597    for key in [
598        "content", "text", "response", "output", "message", "report", "answer",
599    ] {
600        if let Some(v) = data.get(key).and_then(|v| v.as_str()) {
601            if !v.trim().is_empty() {
602                return false;
603            }
604        }
605    }
606    true
607}
608
609fn extract_content_from_data(data: &serde_json::Map<String, Value>) -> Option<String> {
610    if is_subscription_metadata_map(data) {
611        return None;
612    }
613    for key in [
614        "final_stdout_message",
615        "completion_summary",
616        "content",
617        "text",
618        "response",
619        "output",
620        "message",
621        "report",
622    ] {
623        if let Some(val) = data.get(key).and_then(|v| v.as_str()) {
624            if !val.is_empty() {
625                return Some(val.to_string());
626            }
627        }
628    }
629    if let Some(nested) = data.get("data").and_then(|v| v.as_object()) {
630        if is_subscription_metadata_map(nested) {
631            return None;
632        }
633        for key in [
634            "final_stdout_message",
635            "completion_summary",
636            "content",
637            "text",
638            "response",
639            "output",
640            "message",
641            "report",
642        ] {
643            if let Some(val) = nested.get(key).and_then(|v| v.as_str()) {
644                if !val.is_empty() {
645                    return Some(val.to_string());
646                }
647            }
648        }
649    }
650    None
651}
652
653fn is_namespace_match(ns: &str, data_type: &str, pattern: &str) -> bool {
654    data_type.contains(pattern) || ns.contains(pattern)
655}
656
657fn normalize_event_data(data: &Value) -> Option<serde_json::Map<String, Value>> {
658    match data {
659        Value::Null => None,
660        Value::Object(map) => Some(map.clone()),
661        Value::String(s) => serde_json::from_str(s).ok(),
662        _ => None,
663    }
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669    use serde_json::json;
670
671    fn default_classifier() -> EventClassifier {
672        EventClassifier::with_defaults()
673    }
674
675    #[test]
676    fn status_idle_after_content_opt_in() {
677        let cl = EventClassifier::new(ClassifierConfig {
678            deliverable_phases: DEFAULT_DELIVERABLE_PHASES
679                .iter()
680                .map(|p| (*p).to_string())
681                .collect(),
682            treat_status_idle_as_complete: true,
683            ..ClassifierConfig::default()
684        })
685        .unwrap();
686        let msg = json!({"type": "status", "state": "idle", "loop_id": "L1"});
687        let r = cl.classify(&msg, "Hello, this is enough text.");
688        assert_eq!(r.terminal, ChatEventTerminal::DeliverableComplete);
689        assert_eq!(r.completion_event, "status.idle");
690    }
691
692    #[test]
693    fn status_idle_no_content_ignored() {
694        let cl = EventClassifier::new(ClassifierConfig {
695            deliverable_phases: DEFAULT_DELIVERABLE_PHASES
696                .iter()
697                .map(|p| (*p).to_string())
698                .collect(),
699            treat_status_idle_as_complete: true,
700            ..ClassifierConfig::default()
701        })
702        .unwrap();
703        let msg = json!({"type": "status", "state": "idle"});
704        let r = cl.classify(&msg, "");
705        assert_eq!(r.terminal, ChatEventTerminal::Continue);
706    }
707
708    #[test]
709    fn goal_completion_deliverable() {
710        let cl = default_classifier();
711        let msg = json!({
712            "type": "event",
713            "mode": "messages",
714            "data": [{"type": "ai", "phase": "goal_completion", "content": "The answer is forty-two."}]
715        });
716        let r = cl.classify(&msg, "");
717        assert_eq!(r.terminal, ChatEventTerminal::DeliverableComplete);
718    }
719
720    #[test]
721    fn plan_direct_not_deliverable_by_default() {
722        let cl = default_classifier();
723        let msg = json!({
724            "type": "event",
725            "mode": "messages",
726            "data": [{"type": "ai", "phase": "plan_direct", "content": "I will count the files next."}]
727        });
728        let r = cl.classify(&msg, "");
729        assert_ne!(r.terminal, ChatEventTerminal::DeliverableComplete);
730    }
731
732    #[test]
733    fn stream_end_opt_in_gated() {
734        let cl = EventClassifier::new(ClassifierConfig {
735            deliverable_phases: DEFAULT_DELIVERABLE_PHASES
736                .iter()
737                .map(|p| (*p).to_string())
738                .collect(),
739            treat_stream_end_as_complete: true,
740            ..ClassifierConfig::default()
741        })
742        .unwrap();
743        let mut gate = TurnLifecycleGate::default();
744        let accumulated = "Dali weather is sunny, twenty-eight degrees Celsius today.";
745        let end = json!({
746            "type": "event",
747            "mode": "custom",
748            "turn_id": "L1:1",
749            "data": {"type": STREAM_END, "scope": "turn", "turn_id": "L1:1"}
750        });
751
752        let r = cl.classify_turn(&end, accumulated, Some(&mut gate));
753        assert_eq!(r.terminal, ChatEventTerminal::Continue);
754
755        gate.observe(&json!({
756            "type": "status", "state": "running", "loop_id": "L1", "turn_id": "L1:1"
757        }));
758        let chunk = json!({
759            "type": "event",
760            "mode": "messages",
761            "turn_id": "L1:1",
762            "data": [{"type": "AIMessageChunk", "content": "Dali weather"}]
763        });
764        let _ = cl.classify_turn(&chunk, "", Some(&mut gate));
765        assert!(gate.saw_turn_progress);
766
767        let r = cl.classify_turn(&end, accumulated, Some(&mut gate));
768        assert_eq!(r.terminal, ChatEventTerminal::DeliverableComplete);
769        assert_eq!(r.completion_event, STREAM_END);
770        assert!(cl.is_deliverable_completion_event(&r.completion_event));
771    }
772
773    #[test]
774    fn resolve_deliverable_final_content_falls_back_to_accumulated() {
775        let cl = EventClassifier::new(ClassifierConfig {
776            deliverable_phases: DEFAULT_DELIVERABLE_PHASES
777                .iter()
778                .map(|p| (*p).to_string())
779                .collect(),
780            min_deliverable_runes: 1,
781            ..ClassifierConfig::default()
782        })
783        .unwrap();
784        let empty_deliverable = ChatEventResult {
785            terminal: ChatEventTerminal::DeliverableComplete,
786            completion_event: "soothe.protocol.message.goal_completion".to_string(),
787            ..ChatEventResult::default()
788        };
789        let final_content = cl
790            .resolve_deliverable_final_content(&empty_deliverable, "1, 2, 3, 4, 5")
791            .unwrap();
792        assert_eq!(final_content, "1, 2, 3, 4, 5");
793    }
794
795    #[test]
796    fn skips_subscription_metadata_map() {
797        let cl = default_classifier();
798        let msg = json!({
799            "type": "event",
800            "namespace": ["soothe", "system"],
801            "mode": "custom",
802            "data": {"loop_id": "L1", "latest_seq": 3}
803        });
804        let r = cl.classify(&msg, "");
805        assert!(r.content.is_empty());
806        assert_eq!(r.terminal, ChatEventTerminal::Continue);
807    }
808}