1use 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
18const EVENT_LOOP_HISTORY_REPLAYED: &str = "soothe.lifecycle.loop.history.replayed";
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum ChatEventTerminal {
24 #[default]
26 Continue,
27 DeliverableComplete,
29 FailedComplete,
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub struct ChatEventResult {
36 pub content: String,
38 pub thinking_step: String,
40 pub terminal: ChatEventTerminal,
42 pub completion_event: String,
44 pub error: Option<String>,
46}
47
48#[derive(Debug, Clone)]
50pub struct ClassifierConfig {
51 pub deliverable_phases: HashSet<String>,
53 pub min_deliverable_runes: usize,
55 pub thinking_step_events: Option<HashSet<String>>,
57 pub treat_status_idle_as_complete: bool,
59 pub treat_stream_end_as_complete: bool,
61 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#[derive(Debug, Clone, Copy, thiserror::Error)]
80#[error("ClassifierConfig.deliverable_phases must not be empty")]
81pub struct ClassifierConfigError;
82
83#[derive(Debug, Clone)]
85pub struct EventClassifier {
86 cfg: ClassifierConfig,
87}
88
89impl EventClassifier {
90 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 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 pub fn classify(&self, msg: &Value, accumulated: &str) -> ChatEventResult {
119 self.classify_turn(msg, accumulated, None)
120 }
121
122 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 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 pub fn is_substantive_assistant_reply(&self, content: &str) -> bool {
158 content.trim().chars().count() >= self.cfg.min_deliverable_runes
159 }
160
161 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}