main
John Lauer Publish 0.2.24 5b1c5bf
//! Which recorder the desktop has (issue #106). AI Flow films each step with Adom Bridge's native
//! recorder: `desktop_record_window_start` / `desktop_record_start` to begin a take,
//! `desktop_record_status` to watch it and `desktop_record_stop` to finish it (the Windows Adom
//! Bridge 2.x names). An older or macOS Adom Bridge (1.9.224 on macOS) has none of the stop and
//! status verbs: it offers `desktop_record_window_start` and `desktop_record_window_status` instead,
//! and answers a 2.x name with "is not a built-in desktop verb. Did you mean: ...". Started there, a
//! take can never be stopped, so every step warned at its stop and no step had a clip.
//!
//! The run asks once, with a read-only `desktop_record_status {}` (no take id: it lists the takes,
//! starts and stops nothing), and keeps the answer in the run:
//! - `Desktop`: the 2.x verbs answer; clips work as before.
//! - `None`: the box does not know the 2.x verbs. No take is started, the run says so once, and
//!   each step keeps its start and end screenshots. The macOS window-recorder verbs the box names
//!   are recorded, but not used: their stop verb and reply shape are not documented anywhere AI
//!   Flow can confirm them, and a take that cannot be stopped is worse than no take.
//! - `Unknown`: the probe got no clear answer (the box is offline, the call timed out). Nothing is
//!   cached and the run behaves as before.

use serde_json::{json, Value};

/// What `desktop_record_status {}` said about the desktop's recorder.
#[derive(Debug, Clone, PartialEq)]
pub enum RecorderKind {
    /// The Windows 2.x recorder verbs answer.
    Desktop,
    /// The box does not know the recorder verbs AI Flow uses. `offered` lists the recorder verbs
    /// the box suggested instead (from its "Did you mean" hint), `said` its own words.
    None { offered: Vec<String>, said: String },
    /// No clear answer: try again later.
    Unknown(String),
}

/// The reply of a verb the box does not have ("is not a built-in desktop verb", "Unknown desktop
/// command", an unknown-verb error code).
pub fn unknown_verb(reply: &Value) -> bool {
    let code = reply["errorCode"].as_str().or(reply["code"].as_str()).unwrap_or("").to_ascii_lowercase();
    if matches!(code.as_str(), "unknown_command" | "unknown_verb" | "verb_not_found" | "not_a_builtin_verb" | "unknown_desktop_command") { return true; }
    let text = reply.to_string().to_ascii_lowercase();
    text.contains("not a built-in desktop verb") || text.contains("unknown desktop command") || text.contains("unknown command") || text.contains("unknown verb")
}

/// The verb names of a "Did you mean: a, b, c?" hint.
pub fn did_you_mean(reply: &Value) -> Vec<String> {
    let text = reply.to_string();
    let Some(i) = text.find("Did you mean") else { return Vec::new() };
    text[i..].split(|c: char| !(c.is_ascii_alphanumeric() || c == '_')).filter(|w| w.contains('_')).map(str::to_string).collect()
}

/// Read the recorder from the reply of `desktop_record_status {}`.
pub fn classify(status: &Value) -> RecorderKind {
    if unknown_verb(status) {
        let mut offered: Vec<String> = did_you_mean(status).into_iter().filter(|v| v.contains("record")).collect();
        offered.dedup();
        let said = status["error"].as_str().or(status["message"].as_str()).map(str::to_string).unwrap_or_else(|| status.to_string().chars().take(240).collect());
        return RecorderKind::None { offered, said };
    }
    let f = crate::reply_fields(status);
    let answered = crate::Bridge::ok(status) || f.get("recordings").map(|v| v.is_array()).unwrap_or(false) || status.get("recordings").map(|v| v.is_array()).unwrap_or(false) || f.get("active").is_some();
    if answered { return RecorderKind::Desktop; }
    RecorderKind::Unknown(status["error"].as_str().or(status["errorCode"].as_str()).or(status["status"].as_str()).map(str::to_string).unwrap_or_else(|| status.to_string().chars().take(160).collect()))
}

/// What a run keeps about the box's recorder (`run.json` "recorder"), or None for `Unknown`.
pub fn record_of(kind: &RecorderKind, target: &str, at: &str) -> Option<Value> {
    match kind {
        RecorderKind::Desktop => Some(json!({"kind": "desktop", "target": target, "checked": at})),
        RecorderKind::None { offered, said } => Some(json!({"kind": "none", "target": target, "checked": at, "offered": offered, "said": said})),
        RecorderKind::Unknown(_) => None,
    }
}

/// The one line that says a box cannot record clips.
pub fn no_recorder_line(rec: &Value) -> String {
    let box_ = rec["target"].as_str().unwrap_or("the test box");
    let offered: Vec<&str> = rec["offered"].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect()).unwrap_or_default();
    let other = if offered.is_empty() { String::new() } else { format!(" (it offers {} instead, which AI Flow does not drive yet)", offered.join(", ")) };
    format!("no recorder on {box_}: its Adom Bridge does not answer desktop_record_status / desktop_record_stop{other}, so this run records no clips; each step keeps its start and end screenshots. Update Adom Bridge on {box_} to 2.1.35 or later for clips")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_windows_2x_box_has_the_desktop_recorder() {
        assert_eq!(classify(&json!({"success": true, "recordings": [], "active": 0})), RecorderKind::Desktop);
        assert_eq!(classify(&json!({"success": true, "data": {"recordings": [{"recordingId": "rec-1", "running": true}]}})), RecorderKind::Desktop);
    }

    #[test]
    fn a_macos_1_9_box_has_none_and_names_its_window_verbs() {
        // Adom Bridge 1.9.224 on macOS (issue #106, Drew's reply)
        let mac = json!({"success": false, "error": "'desktop_record_status' is not a built-in desktop verb. Did you mean: desktop_record_window_status, desktop_permission_status, desktop_record_window_start?"});
        let RecorderKind::None { offered, said } = classify(&mac) else { panic!("{:?}", classify(&mac)) };
        assert_eq!(offered, vec!["desktop_record_window_status", "desktop_record_window_start"]);
        assert!(said.contains("not a built-in desktop verb"));
        let rec = record_of(&classify(&mac), "MacBook-Air", "2026-10-27T10:00:00Z").unwrap();
        assert_eq!(rec["kind"], "none");
        let line = no_recorder_line(&rec);
        assert!(line.starts_with("no recorder on MacBook-Air:") && line.contains("desktop_record_window_start") && line.contains("start and end screenshots"), "{line}");
        // the older wording of the same answer
        assert!(matches!(classify(&json!({"status": "error", "error": "Unknown desktop command: desktop_record_status"})), RecorderKind::None { .. }));
    }

    #[test]
    fn no_clear_answer_is_unknown_and_never_cached() {
        for r in [json!({"success": false, "errorCode": "aiflow_timeout", "error": "adom-bridge desktop_record_status gave no answer in 60 s"}),
                  json!({"status": "parse_error", "raw": ""}),
                  json!({"status": "error", "error": "target offline"})] {
            let k = classify(&r);
            assert!(matches!(k, RecorderKind::Unknown(_)), "{r}: {k:?}");
            assert!(record_of(&k, "box", "t").is_none());
        }
    }
}