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            if self.cfg.gate_turn_end_signals
267                && !gate
268                    .map(TurnLifecycleGate::allow_idle_complete)
269                    .unwrap_or(false)
270            {
271                return ChatEventResult::default();
272            }
273            return self.deliverable_result(accumulated.trim(), "status.idle");
274        }
275        ChatEventResult::default()
276    }
277
278    fn process_envelope_error(&self, msg: &Value) -> ChatEventResult {
279        let mut code = -32603_i64;
280        let mut message = String::new();
281        if let Some(err) = msg.get("error").and_then(|v| v.as_object()) {
282            if let Some(c) = err.get("code").and_then(|v| v.as_i64()) {
283                code = c;
284            }
285            if let Some(m) = err.get("message").and_then(|v| v.as_str()) {
286                message = m.to_string();
287            }
288        }
289        self.failed_result(format!("daemon error [{code}]: {message}"))
290    }
291
292    fn process_event_message(
293        &self,
294        msg: &Value,
295        accumulated: &str,
296        gate: Option<&TurnLifecycleGate>,
297    ) -> ChatEventResult {
298        let mode = msg.get("mode").and_then(|v| v.as_str()).unwrap_or("");
299        let data = msg.get("data").cloned().unwrap_or(Value::Null);
300        let event_type = event_message_event_type(msg);
301
302        if let Some(data_map) = normalize_event_data(&data) {
303            let data_type = data_map
304                .get("type")
305                .and_then(|v| v.as_str())
306                .filter(|s| !s.is_empty())
307                .unwrap_or(&event_type);
308
309            if data_type == EVENT_LOOP_HISTORY_REPLAYED {
310                return ChatEventResult::default();
311            }
312            if let Some(step) = thinking_step::extract_thinking_step(
313                self.cfg.thinking_step_events.as_ref(),
314                data_type,
315                &data_map,
316            ) {
317                return thinking_step_result(step);
318            }
319        }
320
321        if mode == "custom" && is_turn_end_custom_data(&data) {
322            if self.cfg.treat_stream_end_as_complete
323                && self.is_substantive_assistant_reply(accumulated)
324                && gate
325                    .map(TurnLifecycleGate::allow_stream_end)
326                    .unwrap_or(false)
327            {
328                return self.deliverable_result(accumulated.trim(), STREAM_END);
329            }
330            return ChatEventResult::default();
331        }
332
333        if mode == "messages" {
334            let (msg_type, raw_content, phase, has_payload) = first_message_payload(&data);
335            if has_payload && !raw_content.is_empty() && is_streaming_message_type(&msg_type) {
336                return self.continue_result(raw_content);
337            }
338
339            if let Ok(em) = serde_json::from_value::<EventMessage>(msg.clone()) {
340                if let Some(loop_msg) = em.loop_ai_message() {
341                    let content = loop_msg.loop_ai_text();
342                    if !content.is_empty() {
343                        let loop_type = loop_msg.r#type.as_deref().unwrap_or("");
344                        if is_streaming_message_type(loop_type) {
345                            return self.continue_result(content);
346                        }
347                        if let Some(phase) = loop_msg.phase.as_deref() {
348                            if self.is_deliverable_loop_phase(phase)
349                                && self.is_substantive_assistant_reply(&content)
350                            {
351                                return self.deliverable_result(
352                                    content,
353                                    format!("soothe.protocol.message.{phase}"),
354                                );
355                            }
356                        }
357                        return self.continue_result(content);
358                    }
359                }
360            }
361
362            if let Some(content) = messages_mode_assistant_content(msg) {
363                return self.continue_result(content);
364            }
365
366            if has_payload && !raw_content.is_empty() {
367                if (is_terminal_message_type(&msg_type) || msg_type.is_empty())
368                    && self.is_deliverable_loop_phase(&phase)
369                    && self.is_substantive_assistant_reply(&raw_content)
370                {
371                    return self.deliverable_result(
372                        raw_content,
373                        format!("soothe.protocol.message.{phase}"),
374                    );
375                }
376                return self.continue_result(raw_content);
377            }
378        }
379
380        let Some(data_map) = normalize_event_data(&data) else {
381            return ChatEventResult::default();
382        };
383
384        let data_type = data_map.get("type").and_then(|v| v.as_str()).unwrap_or("");
385        let ns = event_type.as_str();
386        let mut completion_event = data_type.to_string();
387        if completion_event.is_empty() {
388            completion_event.clone_from(&event_type);
389        }
390
391        if is_namespace_match(ns, data_type, "soothe.output")
392            || is_namespace_match(ns, data_type, "responded")
393        {
394            if let Some(content) = extract_content_from_data(&data_map) {
395                if self.is_final_output_event(data_type, ns) {
396                    return self.deliverable_result(content, completion_event);
397                }
398                return self.continue_result(content);
399            }
400        }
401
402        if is_namespace_match(ns, data_type, "agent_loop.completed")
403            || is_namespace_match(ns, data_type, "agent_loop.reasoned")
404            || is_namespace_match(ns, data_type, "loop.completed")
405        {
406            if let Some(content) = extract_content_from_data(&data_map) {
407                return self.continue_result(content);
408            }
409        }
410
411        if is_namespace_match(ns, data_type, "final_report") {
412            if let Some(content) = extract_content_from_data(&data_map) {
413                return self.deliverable_result(content, completion_event);
414            }
415        }
416
417        if data_type.contains("soothe.error.") || ns.contains("soothe.error.") {
418            let err_type = if data_type.is_empty() { ns } else { data_type };
419            if let Some(message) = data_map.get("message").and_then(|v| v.as_str()) {
420                if !message.is_empty() {
421                    return self.failed_result(format!("{err_type}: {message}"));
422                }
423            }
424            if let Some(content) = extract_content_from_data(&data_map) {
425                return self.failed_result(format!("{err_type}: {content}"));
426            }
427            return self.failed_result(err_type);
428        }
429
430        if is_namespace_match(ns, data_type, "stream")
431            || is_namespace_match(ns, data_type, "progress")
432            || is_namespace_match(ns, data_type, EVENT_TOOL_CALL_UPDATES_BATCH)
433            || is_namespace_match(ns, data_type, EVENT_STREAM_TOOL_CALL_UPDATE)
434        {
435            if let Some(delta) = data_map.get("delta").and_then(|v| v.as_str()) {
436                return self.continue_result(delta);
437            }
438        }
439
440        if is_namespace_match(ns, data_type, "heartbeat")
441            || is_namespace_match(ns, data_type, "system.daemon")
442            || is_namespace_match(ns, data_type, "agent_loop.started")
443            || is_namespace_match(ns, data_type, "intent.classified")
444        {
445            return ChatEventResult::default();
446        }
447
448        ChatEventResult::default()
449    }
450
451    fn is_final_output_event(&self, data_type: &str, ns: &str) -> bool {
452        let combined = format!("{data_type} {ns}");
453        if combined.contains("final_report") {
454            return true;
455        }
456        self.cfg
457            .deliverable_phases
458            .iter()
459            .any(|phase| combined.contains(phase.as_str()))
460    }
461}
462
463fn thinking_step_result(step: String) -> ChatEventResult {
464    ChatEventResult {
465        thinking_step: step.trim().to_string(),
466        terminal: ChatEventTerminal::Continue,
467        ..ChatEventResult::default()
468    }
469}
470
471fn is_status_message(msg: &Value) -> bool {
472    msg.get("type").and_then(|v| v.as_str()) == Some("status")
473        || (msg.get("state").is_some() && msg.get("mode").is_none())
474}
475
476fn event_message_event_type(msg: &Value) -> String {
477    if let Some(data) = msg.get("data").and_then(|d| d.as_object()) {
478        if let Some(t) = data.get("type").and_then(|v| v.as_str()) {
479            if !t.is_empty() {
480                return t.to_string();
481            }
482        }
483    }
484    match msg.get("namespace") {
485        Some(Value::String(s)) if !s.is_empty() => s.clone(),
486        Some(Value::Array(arr)) => arr
487            .iter()
488            .filter_map(|v| v.as_str())
489            .collect::<Vec<_>>()
490            .join("."),
491        _ => String::new(),
492    }
493}
494
495fn is_streaming_message_type(msg_type: &str) -> bool {
496    matches!(msg_type, "AIMessageChunk" | "ai_chunk" | "message_chunk")
497}
498
499fn is_terminal_message_type(msg_type: &str) -> bool {
500    matches!(msg_type, "AIMessage" | "ai" | "assistant")
501}
502
503fn messages_mode_assistant_content(msg: &Value) -> Option<String> {
504    if msg.get("mode").and_then(|v| v.as_str()) != Some("messages") {
505        return None;
506    }
507    let data = msg.get("data")?;
508    let items = data.as_array()?;
509    let msg_map = items.first()?.as_object()?;
510    let phase = msg_map
511        .get("phase")
512        .and_then(|v| v.as_str())
513        .unwrap_or("")
514        .trim();
515    if !phase.is_empty() {
516        return None;
517    }
518    let msg_type = msg_map.get("type").and_then(|v| v.as_str()).unwrap_or("");
519    if !msg_type.is_empty() && !is_terminal_message_type(msg_type) {
520        return None;
521    }
522    let content = extract_content_from_message(msg_map);
523    let content = content.trim();
524    if content.is_empty() {
525        None
526    } else {
527        Some(content.to_string())
528    }
529}
530
531fn first_message_payload(data: &Value) -> (String, String, String, bool) {
532    let Some(items) = data.as_array() else {
533        return (String::new(), String::new(), String::new(), false);
534    };
535    let Some(msg_map) = items.first().and_then(|v| v.as_object()) else {
536        return (String::new(), String::new(), String::new(), false);
537    };
538    let msg_type = msg_map
539        .get("type")
540        .and_then(|v| v.as_str())
541        .unwrap_or("")
542        .to_string();
543    let phase = msg_map
544        .get("phase")
545        .and_then(|v| v.as_str())
546        .unwrap_or("")
547        .to_string();
548    let content = extract_content_from_message(msg_map);
549    (msg_type, content, phase, true)
550}
551
552fn extract_content_from_message(msg_map: &serde_json::Map<String, Value>) -> String {
553    if let Some(c) = msg_map.get("content").and_then(|v| v.as_str()) {
554        if !c.is_empty() {
555            return c.to_string();
556        }
557    }
558    if let Some(arr) = msg_map.get("content").and_then(|v| v.as_array()) {
559        let mut buf = String::new();
560        for item in arr {
561            if let Some(s) = item.as_str() {
562                buf.push_str(s);
563                continue;
564            }
565            if let Some(blk) = item.as_object() {
566                if let Some(t) = blk.get("text").and_then(|v| v.as_str()) {
567                    buf.push_str(t);
568                }
569            }
570        }
571        if !buf.is_empty() {
572            return buf;
573        }
574    }
575    if let Some(blocks) = msg_map.get("content_blocks").and_then(|v| v.as_array()) {
576        let mut buf = String::new();
577        for blk in blocks {
578            if let Some(m) = blk.as_object() {
579                if let Some(t) = m.get("text").and_then(|v| v.as_str()) {
580                    buf.push_str(t);
581                }
582            }
583        }
584        return buf;
585    }
586    String::new()
587}
588
589fn is_subscription_metadata_map(data: &serde_json::Map<String, Value>) -> bool {
590    if !data.contains_key("loop_id") || !data.contains_key("latest_seq") {
591        return false;
592    }
593    for key in [
594        "content", "text", "response", "output", "message", "report", "answer",
595    ] {
596        if let Some(v) = data.get(key).and_then(|v| v.as_str()) {
597            if !v.trim().is_empty() {
598                return false;
599            }
600        }
601    }
602    true
603}
604
605fn extract_content_from_data(data: &serde_json::Map<String, Value>) -> Option<String> {
606    if is_subscription_metadata_map(data) {
607        return None;
608    }
609    for key in [
610        "final_stdout_message",
611        "completion_summary",
612        "content",
613        "text",
614        "response",
615        "output",
616        "message",
617        "report",
618    ] {
619        if let Some(val) = data.get(key).and_then(|v| v.as_str()) {
620            if !val.is_empty() {
621                return Some(val.to_string());
622            }
623        }
624    }
625    if let Some(nested) = data.get("data").and_then(|v| v.as_object()) {
626        if is_subscription_metadata_map(nested) {
627            return None;
628        }
629        for key in [
630            "final_stdout_message",
631            "completion_summary",
632            "content",
633            "text",
634            "response",
635            "output",
636            "message",
637            "report",
638        ] {
639            if let Some(val) = nested.get(key).and_then(|v| v.as_str()) {
640                if !val.is_empty() {
641                    return Some(val.to_string());
642                }
643            }
644        }
645    }
646    None
647}
648
649fn is_namespace_match(ns: &str, data_type: &str, pattern: &str) -> bool {
650    data_type.contains(pattern) || ns.contains(pattern)
651}
652
653fn normalize_event_data(data: &Value) -> Option<serde_json::Map<String, Value>> {
654    match data {
655        Value::Null => None,
656        Value::Object(map) => Some(map.clone()),
657        Value::String(s) => serde_json::from_str(s).ok(),
658        _ => None,
659    }
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use serde_json::json;
666
667    fn default_classifier() -> EventClassifier {
668        EventClassifier::with_defaults()
669    }
670
671    #[test]
672    fn status_idle_after_content_opt_in() {
673        let cl = EventClassifier::new(ClassifierConfig {
674            deliverable_phases: DEFAULT_DELIVERABLE_PHASES
675                .iter()
676                .map(|p| (*p).to_string())
677                .collect(),
678            treat_status_idle_as_complete: true,
679            ..ClassifierConfig::default()
680        })
681        .unwrap();
682        let msg = json!({"type": "status", "state": "idle", "loop_id": "L1"});
683        let r = cl.classify(&msg, "Hello, this is enough text.");
684        assert_eq!(r.terminal, ChatEventTerminal::DeliverableComplete);
685        assert_eq!(r.completion_event, "status.idle");
686    }
687
688    #[test]
689    fn status_idle_no_content_ignored() {
690        let cl = EventClassifier::new(ClassifierConfig {
691            deliverable_phases: DEFAULT_DELIVERABLE_PHASES
692                .iter()
693                .map(|p| (*p).to_string())
694                .collect(),
695            treat_status_idle_as_complete: true,
696            ..ClassifierConfig::default()
697        })
698        .unwrap();
699        let msg = json!({"type": "status", "state": "idle"});
700        let r = cl.classify(&msg, "");
701        assert_eq!(r.terminal, ChatEventTerminal::Continue);
702    }
703
704    #[test]
705    fn goal_completion_deliverable() {
706        let cl = default_classifier();
707        let msg = json!({
708            "type": "event",
709            "mode": "messages",
710            "data": [{"type": "ai", "phase": "goal_completion", "content": "The answer is forty-two."}]
711        });
712        let r = cl.classify(&msg, "");
713        assert_eq!(r.terminal, ChatEventTerminal::DeliverableComplete);
714    }
715
716    #[test]
717    fn plan_direct_not_deliverable_by_default() {
718        let cl = default_classifier();
719        let msg = json!({
720            "type": "event",
721            "mode": "messages",
722            "data": [{"type": "ai", "phase": "plan_direct", "content": "I will count the files next."}]
723        });
724        let r = cl.classify(&msg, "");
725        assert_ne!(r.terminal, ChatEventTerminal::DeliverableComplete);
726    }
727
728    #[test]
729    fn stream_end_opt_in_gated() {
730        let cl = EventClassifier::new(ClassifierConfig {
731            deliverable_phases: DEFAULT_DELIVERABLE_PHASES
732                .iter()
733                .map(|p| (*p).to_string())
734                .collect(),
735            treat_stream_end_as_complete: true,
736            ..ClassifierConfig::default()
737        })
738        .unwrap();
739        let mut gate = TurnLifecycleGate::default();
740        let accumulated = "Dali weather is sunny, twenty-eight degrees Celsius today.";
741        let end = json!({
742            "type": "event",
743            "mode": "custom",
744            "data": {"type": STREAM_END, "scope": "turn"}
745        });
746
747        let r = cl.classify_turn(&end, accumulated, Some(&mut gate));
748        assert_eq!(r.terminal, ChatEventTerminal::Continue);
749
750        gate.observe(&json!({"type": "status", "state": "running", "loop_id": "L1"}));
751        let chunk = json!({
752            "type": "event",
753            "mode": "messages",
754            "data": [{"type": "AIMessageChunk", "content": "Dali weather"}]
755        });
756        let _ = cl.classify_turn(&chunk, "", Some(&mut gate));
757        assert!(gate.saw_turn_progress);
758
759        let r = cl.classify_turn(&end, accumulated, Some(&mut gate));
760        assert_eq!(r.terminal, ChatEventTerminal::DeliverableComplete);
761        assert_eq!(r.completion_event, STREAM_END);
762        assert!(cl.is_deliverable_completion_event(&r.completion_event));
763    }
764
765    #[test]
766    fn resolve_deliverable_final_content_falls_back_to_accumulated() {
767        let cl = EventClassifier::new(ClassifierConfig {
768            deliverable_phases: DEFAULT_DELIVERABLE_PHASES
769                .iter()
770                .map(|p| (*p).to_string())
771                .collect(),
772            min_deliverable_runes: 1,
773            ..ClassifierConfig::default()
774        })
775        .unwrap();
776        let empty_deliverable = ChatEventResult {
777            terminal: ChatEventTerminal::DeliverableComplete,
778            completion_event: "soothe.protocol.message.goal_completion".to_string(),
779            ..ChatEventResult::default()
780        };
781        let final_content = cl
782            .resolve_deliverable_final_content(&empty_deliverable, "1, 2, 3, 4, 5")
783            .unwrap();
784        assert_eq!(final_content, "1, 2, 3, 4, 5");
785    }
786
787    #[test]
788    fn skips_subscription_metadata_map() {
789        let cl = default_classifier();
790        let msg = json!({
791            "type": "event",
792            "namespace": ["soothe", "system"],
793            "mode": "custom",
794            "data": {"loop_id": "L1", "latest_seq": 3}
795        });
796        let r = cl.classify(&msg, "");
797        assert!(r.content.is_empty());
798        assert_eq!(r.terminal, ChatEventTerminal::Continue);
799    }
800}