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