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 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}