Skip to main content

soothe_client/appkit/
turn_runner.rs

1//! TurnRunner: single-flight execute over ConnectionPool (Go appkit parity).
2
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use serde_json::{json, Map, Value};
8use tokio::sync::mpsc;
9
10use crate::client::{unwrap_next_frame, SendInputOptions};
11use crate::errors::{Error, Result};
12use crate::intent_hints::validate_loop_input_intent_hint;
13use crate::stream_terminal::is_turn_end_custom_data;
14
15use super::attachments::{compact_attachments, CompactImageOptions};
16use super::broadcaster::{SseBroadcaster, SseEvent};
17use super::classifier::{ChatEventTerminal, EventClassifier};
18use super::pool::ConnectionPool;
19use super::query_gate::{CancelFn, QueryGate, SendCancelFn};
20use super::session_store::SessionStore;
21use super::turn_boundary::{is_daemon_turn_end_event, TurnBoundary};
22
23/// Timeout policy for idle / query / stream-close.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum TimeoutPolicy {
26    /// Fail the turn.
27    #[default]
28    Fail,
29    /// Soft-complete with whatever was collected.
30    SoftComplete,
31}
32
33/// Turn runner configuration.
34#[derive(Debug, Clone)]
35pub struct TurnConfig {
36    /// Absolute query timeout.
37    pub query_timeout: Duration,
38    /// Idle silence timeout (0 = off).
39    pub idle_timeout: Duration,
40    /// Floor idle timeout when attachments are present (0 = no floor).
41    pub min_idle_timeout_with_attachments: Duration,
42    /// Idle timeout policy.
43    pub on_idle_timeout: TimeoutPolicy,
44    /// Query timeout policy.
45    pub on_query_timeout: TimeoutPolicy,
46    /// Stream close policy.
47    pub on_stream_close: TimeoutPolicy,
48    /// Compact image attachments before send.
49    pub compact_attachments_before_send: bool,
50    /// Options for attachment compaction.
51    pub compact_image_opts: Option<CompactImageOptions>,
52}
53
54impl Default for TurnConfig {
55    fn default() -> Self {
56        Self {
57            query_timeout: Duration::from_secs(30 * 60),
58            idle_timeout: Duration::ZERO,
59            min_idle_timeout_with_attachments: Duration::ZERO,
60            on_idle_timeout: TimeoutPolicy::Fail,
61            on_query_timeout: TimeoutPolicy::Fail,
62            on_stream_close: TimeoutPolicy::Fail,
63            compact_attachments_before_send: false,
64            compact_image_opts: None,
65        }
66    }
67}
68
69/// Optional input knobs for TurnRunner.
70#[derive(Debug, Clone, Default)]
71pub struct InputOpts {
72    /// Intent hint.
73    pub intent_hint: Option<String>,
74    /// Preferred subagent.
75    pub preferred_subagent: Option<String>,
76    /// Response schema.
77    pub response_schema: Option<Value>,
78    /// Schema name.
79    pub response_schema_name: Option<String>,
80    /// Strict schema.
81    pub response_schema_strict: Option<bool>,
82}
83
84type OnCompleteFn = Arc<dyn Fn(&str, &str, &str, &str, i64) + Send + Sync>;
85type OnErrorFn = Arc<dyn Fn(&str, &str, &Error) + Send + Sync>;
86type ErrorDataFn = Arc<dyn Fn(&Error) -> Value + Send + Sync>;
87type InputBuilderFn =
88    Arc<dyn Fn(&str, &str, Option<&Value>, Option<&InputOpts>) -> Map<String, Value> + Send + Sync>;
89
90/// Executes a turn against a pooled connection.
91pub struct TurnRunner<S: SessionStore> {
92    pool: Arc<ConnectionPool<S>>,
93    gate: Arc<QueryGate>,
94    classifier: EventClassifier,
95    store: Arc<S>,
96    broadcaster: Option<Arc<SseBroadcaster>>,
97    cfg: TurnConfig,
98    on_complete: Option<OnCompleteFn>,
99    on_error: Option<OnErrorFn>,
100    error_data: Option<ErrorDataFn>,
101    input_builder: Option<InputBuilderFn>,
102}
103
104impl<S: SessionStore + 'static> TurnRunner<S> {
105    /// Create a runner. `cfg` defaults when `None`.
106    ///
107    /// `gate` is shared (`Arc`) so callers can [`QueryGate::acquire`] before
108    /// spawning work and then call [`Self::execute_reserved`] (Go appkit parity).
109    pub fn new(
110        pool: Arc<ConnectionPool<S>>,
111        gate: Arc<QueryGate>,
112        classifier: EventClassifier,
113        store: Arc<S>,
114        cfg: Option<TurnConfig>,
115    ) -> Self {
116        Self {
117            pool,
118            gate,
119            classifier,
120            store,
121            broadcaster: None,
122            cfg: cfg.unwrap_or_default(),
123            on_complete: None,
124            on_error: None,
125            error_data: None,
126            input_builder: None,
127        }
128    }
129
130    /// Shared query gate (same instance used by [`Self::execute`] / [`Self::execute_reserved`]).
131    pub fn gate(&self) -> &Arc<QueryGate> {
132        &self.gate
133    }
134
135    /// Attach an SSE broadcaster for delta / thinking / complete / error fan-out.
136    pub fn with_broadcaster(mut self, b: Arc<SseBroadcaster>) -> Self {
137        self.broadcaster = Some(b);
138        self
139    }
140
141    /// Override the loop_input payload builder.
142    pub fn with_input_builder(mut self, f: InputBuilderFn) -> Self {
143        self.input_builder = Some(f);
144        self
145    }
146
147    /// Completion hook (`app_key`, `loop_id`, `content`, `completion_event`, `elapsed_ms`).
148    pub fn with_on_complete(mut self, f: OnCompleteFn) -> Self {
149        self.on_complete = Some(f);
150        self
151    }
152
153    /// Error hook.
154    pub fn with_on_error(mut self, f: OnErrorFn) -> Self {
155        self.on_error = Some(f);
156        self
157    }
158
159    /// Formatter for SSE `query_error` payloads.
160    pub fn with_error_data(mut self, f: ErrorDataFn) -> Self {
161        self.error_data = Some(f);
162        self
163    }
164
165    /// Execute one turn; returns concatenated assistant text on success.
166    pub async fn execute(
167        &self,
168        session_id: &str,
169        message: &str,
170        user_id: &str,
171        workspace_id: &str,
172        attachments: Option<Value>,
173        opts: Option<InputOpts>,
174    ) -> Result<String> {
175        self.validate_opts(opts.as_ref())?;
176        let cancelled = Arc::new(AtomicBool::new(false));
177        let cancel_flag = cancelled.clone();
178        let cancel_fn: CancelFn = Arc::new(move || {
179            cancel_flag.store(true, Ordering::SeqCst);
180        });
181        self.gate
182            .acquire(session_id, cancel_fn, None)
183            .map_err(|_| Error::msg("query busy"))?;
184        let result = self
185            .run_turn(
186                session_id,
187                message,
188                user_id,
189                workspace_id,
190                attachments,
191                opts,
192                cancelled,
193            )
194            .await;
195        self.gate.release(session_id);
196        result
197    }
198
199    /// Run a turn when the caller already reserved the gate via [`QueryGate::acquire`].
200    ///
201    /// Releases the gate on all exit paths (including `validate_opts` failure), matching
202    /// Go `ExecuteReserved`.
203    pub async fn execute_reserved(
204        &self,
205        session_id: &str,
206        message: &str,
207        user_id: &str,
208        workspace_id: &str,
209        attachments: Option<Value>,
210        opts: Option<InputOpts>,
211    ) -> Result<String> {
212        if !self.gate.is_active(session_id) {
213            return Err(Error::msg(format!(
214                "appkit: ExecuteReserved requires an active QueryGate reservation for {session_id}"
215            )));
216        }
217        if let Err(e) = self.validate_opts(opts.as_ref()) {
218            self.gate.release(session_id);
219            return Err(e);
220        }
221        let cancelled = Arc::new(AtomicBool::new(false));
222        let cancel_flag = cancelled.clone();
223        let cancel_fn: CancelFn = Arc::new(move || {
224            cancel_flag.store(true, Ordering::SeqCst);
225        });
226        self.gate.replace_cancel(session_id, cancel_fn);
227        let result = self
228            .run_turn(
229                session_id,
230                message,
231                user_id,
232                workspace_id,
233                attachments,
234                opts,
235                cancelled,
236            )
237            .await;
238        self.gate.release(session_id);
239        result
240    }
241
242    fn validate_opts(&self, opts: Option<&InputOpts>) -> Result<()> {
243        if let Some(hint) = opts.and_then(|o| o.intent_hint.as_deref()) {
244            if let Some(msg) = validate_loop_input_intent_hint(hint) {
245                return Err(Error::msg(msg));
246            }
247        }
248        Ok(())
249    }
250
251    #[allow(clippy::too_many_arguments)]
252    async fn run_turn(
253        &self,
254        session_id: &str,
255        message: &str,
256        user_id: &str,
257        workspace_id: &str,
258        attachments: Option<Value>,
259        opts: Option<InputOpts>,
260        cancelled: Arc<AtomicBool>,
261    ) -> Result<String> {
262        let conn = self.pool.acquire(session_id, workspace_id, user_id).await?;
263        let loop_id = conn.get_loop_id().await;
264
265        let loop_id_for_cancel = loop_id.clone();
266        let client_for_cancel = conn.client.clone();
267        let send_cancel: SendCancelFn = Arc::new(move || {
268            let client = client_for_cancel.clone();
269            let loop_id = loop_id_for_cancel.clone();
270            Box::pin(async move { client.command_cancel(&loop_id).await.map(|_| ()) })
271        });
272        self.gate.set_send_cancel(session_id, send_cancel);
273
274        let has_attachments = attachments
275            .as_ref()
276            .map(|v| v.as_array().map(|a| !a.is_empty()).unwrap_or(true))
277            .unwrap_or(false);
278
279        let mut atts = attachments;
280        if self.cfg.compact_attachments_before_send {
281            if let Some(Value::Array(arr)) = atts.take() {
282                let maps: Vec<Map<String, Value>> = arr
283                    .into_iter()
284                    .filter_map(|v| v.as_object().cloned())
285                    .collect();
286                let compacted = compact_attachments(&maps, self.cfg.compact_image_opts.as_ref());
287                atts = Some(Value::Array(
288                    compacted.into_iter().map(Value::Object).collect(),
289                ));
290            }
291        }
292
293        // Pre-send settle-drain of pooled leftovers (Go 0.4.8).
294        {
295            let mut rx_guard = conn.event_rx.lock().await;
296            if let Some(rx) = rx_guard.as_mut() {
297                drain_event_ch(rx, Duration::from_millis(5)).await;
298            } else {
299                let err = Error::msg(format!(
300                    "missing event stream for session {session_id} (loop {loop_id})"
301                ));
302                self.fail_turn(session_id, &loop_id, &err).await;
303                return Err(err);
304            }
305        }
306
307        let input_opts = SendInputOptions {
308            loop_id: Some(loop_id.clone()),
309            intent_hint: opts.as_ref().and_then(|o| o.intent_hint.clone()),
310            preferred_subagent: opts.as_ref().and_then(|o| o.preferred_subagent.clone()),
311            response_schema: opts.as_ref().and_then(|o| o.response_schema.clone()),
312            response_schema_name: opts.as_ref().and_then(|o| o.response_schema_name.clone()),
313            response_schema_strict: opts.as_ref().and_then(|o| o.response_schema_strict),
314            attachments: atts.clone(),
315            ..Default::default()
316        };
317
318        if let Some(builder) = &self.input_builder {
319            let flat = builder(message, &loop_id, atts.as_ref(), opts.as_ref());
320            let mut params = Map::new();
321            for (k, v) in flat {
322                if k != "type" && k != "proto" && k != "method" {
323                    // Custom builders may return either flat params or a full envelope.
324                    if k == "params" {
325                        if let Value::Object(inner) = v {
326                            params.extend(inner);
327                            continue;
328                        }
329                    }
330                    params.insert(k, v);
331                }
332            }
333            if let Err(e) = conn.client.notify("loop_input", params).await {
334                let err = Error::msg(format!("send message: {e}"));
335                self.fail_turn(session_id, &loop_id, &err).await;
336                return Err(err);
337            }
338        } else if let Err(e) = conn.client.send_input(message, input_opts).await {
339            self.fail_turn(session_id, &loop_id, &e).await;
340            return Err(e);
341        }
342
343        self.store
344            .append_message(session_id, json!({"role":"user","content": message}))
345            .await;
346
347        let started_at = Instant::now();
348        let deadline = started_at + self.cfg.query_timeout;
349        let idle_for_turn = idle_timeout_for_turn(&self.cfg, has_attachments);
350        let mut last_event = Instant::now();
351        let mut collected = String::new();
352        let mut boundary = TurnBoundary::default();
353        let mut armed = false;
354
355        let result = loop {
356            if cancelled.load(Ordering::SeqCst) {
357                let err = Error::msg("query cancelled");
358                self.fail_turn(session_id, &loop_id, &err).await;
359                break Err(err);
360            }
361            if Instant::now() > deadline {
362                let _ = conn.client.command_cancel(&loop_id).await;
363                break self
364                    .finish_timeout(
365                        session_id,
366                        &loop_id,
367                        &collected,
368                        started_at,
369                        "query_timeout",
370                        self.cfg.on_query_timeout,
371                    )
372                    .await;
373            }
374            // Idle silence is only meaningful after the turn is armed (first
375            // non-stale event). Counting from query send treats LLM first-token
376            // latency as "idle" and SoftCompletes empty replies under load.
377            if armed && !idle_for_turn.is_zero() && last_event.elapsed() > idle_for_turn {
378                let _ = conn.client.command_cancel(&loop_id).await;
379                break self
380                    .finish_timeout(
381                        session_id,
382                        &loop_id,
383                        &collected,
384                        started_at,
385                        "idle_timeout",
386                        self.cfg.on_idle_timeout,
387                    )
388                    .await;
389            }
390
391            let wait = if !armed || idle_for_turn.is_zero() {
392                Duration::from_millis(500)
393            } else {
394                idle_for_turn
395                    .saturating_sub(last_event.elapsed())
396                    .min(Duration::from_millis(500))
397                    .max(Duration::from_millis(1))
398            };
399
400            let ev = {
401                let mut rx_guard = conn.event_rx.lock().await;
402                let Some(rx) = rx_guard.as_mut() else {
403                    break Err(Error::msg("event stream missing"));
404                };
405                match tokio::time::timeout(wait, rx.recv()).await {
406                    Ok(Some(v)) => Some(v),
407                    Ok(None) => None,
408                    Err(_) => continue,
409                }
410            };
411
412            let Some(ev) = ev else {
413                if !conn.event_stream_live().await || !conn.client.is_connection_alive() {
414                    if self.cfg.on_stream_close == TimeoutPolicy::SoftComplete
415                        && !collected.trim().is_empty()
416                    {
417                        break self
418                            .complete_turn(
419                                session_id,
420                                &loop_id,
421                                &collected,
422                                started_at,
423                                "stream_closed",
424                            )
425                            .await;
426                    }
427                    let err = Error::msg("event stream closed");
428                    self.fail_turn(session_id, &loop_id, &err).await;
429                    break Err(err);
430                }
431                continue;
432            };
433
434            if !armed {
435                if is_stale_turn_end_event(&ev) {
436                    // Keepalive / prior-turn idle — do not arm or start idle clock.
437                    continue;
438                }
439                if is_status_running_event(&ev) {
440                    let _ = feed_boundary(&mut boundary, &ev);
441                    // Daemon accepted the turn — arm idle from here so pre-accept
442                    // wait does not burn the silence budget, but post-accept hangs
443                    // still SoftComplete / Fail via idle_timeout.
444                    armed = true;
445                    last_event = Instant::now();
446                    continue;
447                }
448                armed = true;
449                last_event = Instant::now();
450            }
451
452            let ended = feed_boundary(&mut boundary, &ev);
453            if ended.is_some() && !armed {
454                boundary = TurnBoundary::default();
455                continue;
456            }
457
458            let event_result = self.classifier.classify(&ev, &collected);
459            // Heartbeats / empty catalog events must not postpone idle_timeout —
460            // otherwise StrangeLoop planner churn holds the single-flight gate forever.
461            if advances_idle_clock(&event_result) {
462                last_event = Instant::now();
463            }
464
465            if event_result.terminal == ChatEventTerminal::FailedComplete {
466                let err = Error::msg(
467                    event_result
468                        .error
469                        .unwrap_or_else(|| "process event failed".into()),
470                );
471                self.fail_turn(session_id, &loop_id, &err).await;
472                break Err(err);
473            }
474
475            if !event_result.thinking_step.trim().is_empty() {
476                self.broadcast_thinking_step(session_id, &event_result.thinking_step);
477            }
478
479            if !event_result.content.is_empty() {
480                let delta = if event_result.content.starts_with(&collected) {
481                    let d = event_result.content[collected.len()..].to_string();
482                    collected = event_result.content.clone();
483                    d
484                } else {
485                    collected.push_str(&event_result.content);
486                    event_result.content.clone()
487                };
488                if !delta.is_empty() {
489                    self.broadcast_delta(session_id, &delta);
490                }
491            }
492
493            if let Some(final_text) = self
494                .classifier
495                .resolve_deliverable_final_content(&event_result, &collected)
496            {
497                if !is_daemon_turn_end_event(&event_result.completion_event) {
498                    collected = final_text;
499                    let completion = event_result.completion_event;
500                    break self
501                        .complete_turn(session_id, &loop_id, &collected, started_at, &completion)
502                        .await;
503                }
504            }
505
506            if let Some(reason) = ended {
507                if collected.trim().is_empty() {
508                    let err =
509                        Error::msg(format!("turn ended ({reason}) with no assistant content"));
510                    self.fail_turn(session_id, &loop_id, &err).await;
511                    break Err(err);
512                }
513                break self
514                    .complete_turn(session_id, &loop_id, &collected, started_at, reason)
515                    .await;
516            }
517        };
518
519        // Post-turn non-blocking drain.
520        {
521            let mut rx_guard = conn.event_rx.lock().await;
522            if let Some(rx) = rx_guard.as_mut() {
523                drain_event_ch(rx, Duration::ZERO).await;
524            }
525        }
526
527        // Keep pooled connection for session reuse (Go Release is explicit).
528        let _ = conn;
529        result
530    }
531
532    async fn finish_timeout(
533        &self,
534        session_id: &str,
535        loop_id: &str,
536        content: &str,
537        started_at: Instant,
538        completion_event: &str,
539        policy: TimeoutPolicy,
540    ) -> Result<String> {
541        match policy {
542            // Soft-complete even with empty content so idle/query timeouts always
543            // release the QueryGate (callers map completion_event → chat.done codes).
544            TimeoutPolicy::SoftComplete => {
545                self.complete_turn(session_id, loop_id, content, started_at, completion_event)
546                    .await
547            }
548            _ => {
549                let err = Error::msg(completion_event);
550                self.fail_turn(session_id, loop_id, &err).await;
551                Err(err)
552            }
553        }
554    }
555
556    async fn complete_turn(
557        &self,
558        session_id: &str,
559        loop_id: &str,
560        content: &str,
561        started_at: Instant,
562        completion_event: &str,
563    ) -> Result<String> {
564        let elapsed_ms = started_at.elapsed().as_millis() as i64;
565        self.store
566            .append_message(
567                session_id,
568                json!({
569                    "role": "assistant",
570                    "content": content,
571                    "status": "completed",
572                    "completion_event": completion_event,
573                    "deliverable": true,
574                    "duration_ms": elapsed_ms,
575                }),
576            )
577            .await;
578        self.broadcast_complete(session_id, content);
579        if let Some(hook) = &self.on_complete {
580            hook(session_id, loop_id, content, completion_event, elapsed_ms);
581        }
582        Ok(content.to_string())
583    }
584
585    async fn fail_turn(&self, session_id: &str, loop_id: &str, err: &Error) {
586        self.store
587            .append_message(
588                session_id,
589                json!({
590                    "role": "error",
591                    "status": "failed",
592                    "error_message": err.to_string(),
593                }),
594            )
595            .await;
596        self.broadcast_error(session_id, err);
597        if let Some(hook) = &self.on_error {
598            hook(session_id, loop_id, err);
599        }
600    }
601
602    fn broadcast_delta(&self, app_key: &str, delta: &str) {
603        if let Some(b) = &self.broadcaster {
604            b.broadcast(
605                app_key,
606                SseEvent {
607                    event_type: "delta".into(),
608                    data: json!(delta),
609                },
610            );
611        }
612    }
613
614    fn broadcast_thinking_step(&self, app_key: &str, step: &str) {
615        if let Some(b) = &self.broadcaster {
616            b.broadcast(
617                app_key,
618                SseEvent {
619                    event_type: "thinking_step".into(),
620                    data: json!(format!("{step}\n")),
621                },
622            );
623        }
624    }
625
626    fn broadcast_complete(&self, app_key: &str, content: &str) {
627        if let Some(b) = &self.broadcaster {
628            b.broadcast(
629                app_key,
630                SseEvent {
631                    event_type: "complete".into(),
632                    data: json!(content),
633                },
634            );
635        }
636    }
637
638    fn broadcast_error(&self, app_key: &str, err: &Error) {
639        if let Some(b) = &self.broadcaster {
640            let data = if let Some(fmt) = &self.error_data {
641                fmt(err)
642            } else {
643                json!(err.to_string())
644            };
645            b.broadcast(
646                app_key,
647                SseEvent {
648                    event_type: "query_error".into(),
649                    data,
650                },
651            );
652        }
653    }
654}
655
656fn idle_timeout_for_turn(cfg: &TurnConfig, has_attachments: bool) -> Duration {
657    let idle = cfg.idle_timeout;
658    if idle.is_zero() {
659        return Duration::ZERO;
660    }
661    if has_attachments
662        && !cfg.min_idle_timeout_with_attachments.is_zero()
663        && idle < cfg.min_idle_timeout_with_attachments
664    {
665        return cfg.min_idle_timeout_with_attachments;
666    }
667    idle
668}
669
670/// Events that should postpone idle_timeout (assistant progress / turn end).
671fn advances_idle_clock(result: &super::classifier::ChatEventResult) -> bool {
672    use super::classifier::ChatEventTerminal;
673    if !result.content.trim().is_empty() {
674        return true;
675    }
676    if !result.thinking_step.trim().is_empty() {
677        return true;
678    }
679    matches!(
680        result.terminal,
681        ChatEventTerminal::DeliverableComplete | ChatEventTerminal::FailedComplete
682    )
683}
684
685/// Drain pooled event channel. When `settle > 0`, keep reading until quiet for `settle`.
686async fn drain_event_ch(rx: &mut mpsc::Receiver<Value>, settle: Duration) {
687    if settle.is_zero() {
688        while rx.try_recv().is_ok() {}
689        return;
690    }
691    let mut deadline = Instant::now() + settle;
692    loop {
693        let remaining = deadline.saturating_duration_since(Instant::now());
694        if remaining.is_zero() {
695            break;
696        }
697        match tokio::time::timeout(remaining, rx.recv()).await {
698            Ok(Some(_)) => {
699                deadline = Instant::now() + settle;
700            }
701            Ok(None) | Err(_) => break,
702        }
703    }
704}
705
706fn feed_boundary(boundary: &mut TurnBoundary, msg: &Value) -> Option<&'static str> {
707    let frame = if msg.get("type").and_then(|v| v.as_str()) == Some("next") {
708        unwrap_next_frame(msg)
709    } else {
710        msg.clone()
711    };
712    let event_type = frame.get("type").and_then(|v| v.as_str()).unwrap_or("");
713    if event_type == "status" {
714        let state = frame.get("state").and_then(|v| v.as_str()).unwrap_or("");
715        return boundary.feed_status(state);
716    }
717    if event_type == "event" {
718        let mode = frame.get("mode").and_then(|v| v.as_str()).unwrap_or("");
719        let data = frame.get("data").cloned().unwrap_or(Value::Null);
720        return boundary.feed_event(mode, &data);
721    }
722    None
723}
724
725fn is_status_running_event(msg: &Value) -> bool {
726    let frame = if msg.get("type").and_then(|v| v.as_str()) == Some("next") {
727        unwrap_next_frame(msg)
728    } else {
729        msg.clone()
730    };
731    frame.get("type").and_then(|v| v.as_str()) == Some("status")
732        && frame
733            .get("state")
734            .and_then(|v| v.as_str())
735            .unwrap_or("")
736            .eq_ignore_ascii_case("running")
737}
738
739fn is_stale_turn_end_event(msg: &Value) -> bool {
740    let frame = if msg.get("type").and_then(|v| v.as_str()) == Some("next") {
741        unwrap_next_frame(msg)
742    } else {
743        msg.clone()
744    };
745    let event_type = frame.get("type").and_then(|v| v.as_str()).unwrap_or("");
746    if event_type == "status" {
747        let state = frame
748            .get("state")
749            .and_then(|v| v.as_str())
750            .unwrap_or("")
751            .to_ascii_lowercase();
752        return matches!(state.as_str(), "idle" | "stopped");
753    }
754    if event_type == "event" {
755        let mode = frame.get("mode").and_then(|v| v.as_str()).unwrap_or("");
756        let data = frame.get("data").unwrap_or(&Value::Null);
757        return mode == "custom" && is_turn_end_custom_data(data);
758    }
759    false
760}