//! `lucid-train serve` — a machine-consumable front-end: newline-delimited
//! JSON over stdio, one session per process. A desktop app (or any client)
//! writes request lines to stdin and reads serialized [`AgentEvent`]s plus
//! protocol messages from stdout.
//!
//! Incoming (client → agent), one JSON object per line:
//!   {"type":"session.new","cwd":"…","mode":"code|plan|research","model":"…"}
//!   {"type":"user.message","text":"…","images":["data:image/png;base64,…"]}
//!   {"type":"approval.respond","id":"approval-1","decision":"allow|deny|always"}
//!   {"type":"session.interrupt"}
//!   {"type":"session.clear"}                    reset conversation history
//!   {"type":"session.compact"}                  summarize + shrink context
//!   {"type":"config.set","model":"…","provider":"…","approval":"…",
//!          "reasoning_effort":"…","mode":"…","api_key":"…"}
//!   {"type":"mcp.add","name":"grafana","command":"…","args":[…],"env":{…}}
//!   {"type":"mcp.remove","name":"grafana"}
//!
//! Outgoing (agent → client), one JSON object per line:
//!   {"type":"session.created","id":"…"}       after session.new
//!   {"type":"turn.started"}                    when a user.message begins
//!   {"type":"mcp.added","name":"…"} / {"type":"mcp.removed","name":"…"}
//!   every serialized AgentEvent as it streams (tagged "type", snake_case)
//!   {"type":"error","message":"…"}             for protocol errors
//!
//! stdin EOF → clean exit. Unknown incoming types → error line, keep running.

use crate::agent::{Agent, AgentEvent, ApprovalDecision, ApprovalRegistry};
use crate::config::{
    apply_model, remove_mcp_server, save_config_values, save_mcp_server, ApprovalPolicy, Config,
    Mode, Provider,
};
use crate::mcp::McpServerConfig;
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};
use tokio::io::AsyncBufReadExt;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;

/// Commands routed to the agent worker task (mirrors the TUI's WorkerCmd).
enum SessionCmd {
    User { text: String, images: Vec<String> },
    Reconfigure(Config),
    ClearHistory,
    Compact,
    Shutdown,
}

/// One live session: the worker task owning the Agent, plus the handles the
/// stdio loop needs to drive it.
struct Session {
    cfg: Config,
    cmd_tx: mpsc::UnboundedSender<SessionCmd>,
    approvals: ApprovalRegistry,
    cancel: Arc<Mutex<CancellationToken>>,
    worker: tokio::task::JoinHandle<()>,
}

impl Session {
    /// Build a session from the base config plus session.new overrides, spawn
    /// the agent worker and its event forwarder. Reuses the same Agent
    /// construction path as the TUI/exec (config → Agent::with_approvals;
    /// MCP/repo-map init stays lazy inside run_turn, so no credentials are
    /// needed until the first user.message).
    fn spawn(cfg: Config, out: &mpsc::UnboundedSender<String>) -> Self {
        let approvals = ApprovalRegistry::new();
        let cancel = Arc::new(Mutex::new(CancellationToken::new()));
        let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<SessionCmd>();
        let (ev_tx, mut ev_rx) = mpsc::unbounded_channel::<AgentEvent>();

        // Event forwarder: serializes every AgentEvent to one stdout line as
        // it streams (independent task so a long turn can't buffer output).
        let ev_out = out.clone();
        tokio::spawn(async move {
            while let Some(ev) = ev_rx.recv().await {
                if let Ok(line) = serde_json::to_string(&ev) {
                    let _ = ev_out.send(line);
                }
            }
        });

        let worker_cfg = cfg.clone();
        let worker_approvals = approvals.clone();
        let worker_cancel = cancel.clone();
        let worker = tokio::spawn(async move {
            let mut agent = Agent::with_approvals(worker_cfg, worker_approvals);
            while let Some(cmd) = cmd_rx.recv().await {
                match cmd {
                    SessionCmd::User { text, images } => {
                        let token = CancellationToken::new();
                        *worker_cancel.lock().unwrap() = token.clone();
                        if images.is_empty() {
                            agent.run_turn(text, &ev_tx, &token).await;
                        } else {
                            agent.run_turn_with_images(text, images, &ev_tx, &token).await;
                        }
                    }
                    SessionCmd::Reconfigure(cfg) => {
                        // Reconnect MCP servers if their set changed (mirrors the
                        // TUI worker), so `mcp.add`/`mcp.remove` take effect.
                        let mcp_changed = cfg.mcp_servers.len() != agent.cfg.mcp_servers.len()
                            || cfg.mcp_servers.iter().zip(agent.cfg.mcp_servers.iter()).any(
                                |(a, b)| {
                                    a.name != b.name
                                        || a.command != b.command
                                        || a.args != b.args
                                        || a.env != b.env
                                },
                            );
                        agent.reconfigure(cfg);
                        if mcp_changed {
                            agent.reset_mcp();
                        }
                    }
                    SessionCmd::ClearHistory => agent.clear_history(),
                    SessionCmd::Compact => agent.compact(&ev_tx).await,
                    SessionCmd::Shutdown => {
                        agent.bg.shutdown().await;
                        break;
                    }
                }
            }
        });

        Session { cfg, cmd_tx, approvals, cancel, worker }
    }
}

fn error_line(out: &mpsc::UnboundedSender<String>, message: impl std::fmt::Display) {
    let _ = out.send(json!({ "type": "error", "message": message.to_string() }).to_string());
}

/// Map a wire mode string to [`Mode`] (`code`/`agent`, `plan`, `research`).
fn parse_mode(s: &str) -> Option<Mode> {
    match s {
        "code" | "agent" => Some(Mode::Agent),
        "plan" => Some(Mode::Plan),
        "research" => Some(Mode::Research),
        _ => None,
    }
}

/// Parse an `env` object (`{"KEY":"value",…}`) into the `(key, value)` pairs
/// [`McpServerConfig`] expects.
fn parse_env(v: &Value) -> Vec<(String, String)> {
    v.as_object()
        .map(|o| {
            o.iter()
                .filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string())))
                .collect()
        })
        .unwrap_or_default()
}

/// Process-unique session id.
fn new_session_id() -> String {
    let mut b = [0u8; 8];
    let _ = getrandom::getrandom(&mut b);
    format!("session-{}", b.iter().map(|x| format!("{x:02x}")).collect::<String>())
}

/// Handle one incoming protocol line. Emits response/error lines on `out`;
/// agent events flow separately through the session's forwarder.
fn handle_line(
    line: &str,
    session: &mut Option<Session>,
    base_cfg: &Config,
    out: &mpsc::UnboundedSender<String>,
) {
    let v: Value = match serde_json::from_str(line) {
        Ok(v) => v,
        Err(e) => return error_line(out, format!("invalid JSON: {e}")),
    };
    let Some(msg_type) = v["type"].as_str() else {
        return error_line(out, "missing \"type\" field");
    };
    match msg_type {
        "session.new" => {
            if session.is_some() {
                return error_line(out, "session already created (one session per process)");
            }
            let mut cfg = base_cfg.clone();
            if let Some(cwd) = v["cwd"].as_str() {
                cfg.cwd = std::path::PathBuf::from(cwd);
            }
            if let Some(mode) = v["mode"].as_str() {
                match parse_mode(mode) {
                    Some(m) => cfg.mode = m,
                    None => {
                        return error_line(out, format!("unknown mode {mode:?} (use code|plan|research)"));
                    }
                }
            }
            if let Some(model) = v["model"].as_str() {
                apply_model(&mut cfg, model, false, false);
            }
            let id = new_session_id();
            *session = Some(Session::spawn(cfg, out));
            let _ = out.send(json!({ "type": "session.created", "id": id }).to_string());
        }
        "user.message" => {
            let Some(s) = session.as_ref() else {
                return error_line(out, "no session — send session.new first");
            };
            let Some(text) = v["text"].as_str() else {
                return error_line(out, "user.message requires a \"text\" string");
            };
            let images: Vec<String> = v["images"]
                .as_array()
                .map(|a| a.iter().filter_map(|i| i.as_str().map(str::to_string)).collect())
                .unwrap_or_default();
            let _ = out.send(json!({ "type": "turn.started" }).to_string());
            let _ = s.cmd_tx.send(SessionCmd::User { text: text.to_string(), images });
        }
        "approval.respond" => {
            let Some(s) = session.as_ref() else {
                return error_line(out, "no session — send session.new first");
            };
            let Some(id) = v["id"].as_str() else {
                return error_line(out, "approval.respond requires an \"id\" string");
            };
            let decision = match v["decision"].as_str() {
                Some("allow") => ApprovalDecision::Approve,
                Some("always") => ApprovalDecision::ApproveForSession,
                Some("deny") => ApprovalDecision::Deny,
                other => {
                    return error_line(
                        out,
                        format!("unknown decision {other:?} (use allow|deny|always)"),
                    );
                }
            };
            if !s.approvals.respond(id, decision) {
                error_line(out, format!("no pending approval with id {id:?}"));
            }
        }
        "session.interrupt" => {
            let Some(s) = session.as_ref() else {
                return error_line(out, "no session — send session.new first");
            };
            s.cancel.lock().unwrap().cancel();
        }
        "config.set" => {
            let Some(s) = session.as_mut() else {
                return error_line(out, "no session — send session.new first");
            };
            let mut changed = false;
            if let Some(model) = v["model"].as_str() {
                apply_model(&mut s.cfg, model, false, false);
                changed = true;
            }
            if let Some(provider) = v["provider"].as_str().and_then(Provider::parse) {
                s.cfg.provider = provider;
                s.cfg.base_url = provider.default_base_url();
                changed = true;
            }
            if let Some(approval) = v["approval"].as_str().and_then(ApprovalPolicy::parse) {
                s.cfg.approval = approval;
                changed = true;
            }
            if let Some(effort) = v["reasoning_effort"].as_str() {
                s.cfg.reasoning_effort = effort.to_string();
                changed = true;
            }
            if let Some(mode) = v["mode"].as_str() {
                match parse_mode(mode) {
                    Some(m) => {
                        s.cfg.mode = m;
                        changed = true;
                    }
                    None => {
                        return error_line(out, format!("unknown mode {mode:?} (use code|plan|research)"));
                    }
                }
            }
            if let Some(api_key) = v["api_key"].as_str().filter(|k| !k.is_empty()) {
                // Mirror the TUI /apikey path: persist (merges, never clobbers)
                // and flip an Ollama/LucidCloud provider to OpenRouter so the key
                // is actually used on the next turn.
                s.cfg.api_key = api_key.to_string();
                if matches!(s.cfg.provider, Provider::Ollama | Provider::LucidCloud) {
                    s.cfg.provider = Provider::OpenRouter;
                    s.cfg.base_url = Provider::OpenRouter.default_base_url();
                }
                let _ = save_config_values(&[
                    ("api_key", s.cfg.api_key.as_str()),
                    ("provider", s.cfg.provider.label()),
                    ("base_url", s.cfg.base_url.as_str()),
                ]);
                changed = true;
            }
            if !changed {
                return error_line(out, "config.set: no supported keys (supported: model, provider, approval, reasoning_effort, mode, api_key)");
            }
            let _ = s.cmd_tx.send(SessionCmd::Reconfigure(s.cfg.clone()));
        }
        "session.clear" => {
            let Some(s) = session.as_ref() else {
                return error_line(out, "no session — send session.new first");
            };
            let _ = s.cmd_tx.send(SessionCmd::ClearHistory);
        }
        "session.compact" => {
            let Some(s) = session.as_ref() else {
                return error_line(out, "no session — send session.new first");
            };
            let _ = s.cmd_tx.send(SessionCmd::Compact);
        }
        "mcp.add" => {
            let Some(s) = session.as_mut() else {
                return error_line(out, "no session — send session.new first");
            };
            let (Some(name), Some(command)) = (v["name"].as_str(), v["command"].as_str()) else {
                return error_line(out, "mcp.add requires \"name\" and \"command\" strings");
            };
            let args: Vec<String> = v["args"]
                .as_array()
                .map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
                .unwrap_or_default();
            let env = parse_env(&v["env"]);
            if let Err(e) = save_mcp_server(name, command, &args, &env) {
                return error_line(out, format!("failed to persist MCP server: {e}"));
            }
            s.cfg.mcp_servers.retain(|m| m.name != name);
            s.cfg.mcp_servers.push(McpServerConfig {
                name: name.to_string(),
                command: command.to_string(),
                args,
                env,
            });
            let _ = s.cmd_tx.send(SessionCmd::Reconfigure(s.cfg.clone()));
            let _ = out.send(json!({ "type": "mcp.added", "name": name }).to_string());
        }
        "mcp.remove" => {
            let Some(s) = session.as_mut() else {
                return error_line(out, "no session — send session.new first");
            };
            let Some(name) = v["name"].as_str() else {
                return error_line(out, "mcp.remove requires a \"name\" string");
            };
            match remove_mcp_server(name) {
                Ok(_) => {
                    s.cfg.mcp_servers.retain(|m| m.name != name);
                    let _ = s.cmd_tx.send(SessionCmd::Reconfigure(s.cfg.clone()));
                    let _ = out.send(json!({ "type": "mcp.removed", "name": name }).to_string());
                }
                Err(e) => error_line(out, format!("failed to remove MCP server: {e}")),
            }
        }
        other => error_line(out, format!("unknown message type {other:?}")),
    }
}

pub async fn run_serve(base_cfg: Config) -> i32 {
    // Single stdout writer so protocol lines and streamed events never
    // interleave mid-line.
    let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();
    let writer = tokio::spawn(async move {
        use std::io::Write;
        while let Some(line) = out_rx.recv().await {
            let mut so = std::io::stdout().lock();
            let _ = writeln!(so, "{line}");
            let _ = so.flush();
        }
    });

    let mut session: Option<Session> = None;
    let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines();
    loop {
        match lines.next_line().await {
            Ok(Some(line)) => {
                if line.trim().is_empty() {
                    continue;
                }
                handle_line(&line, &mut session, &base_cfg, &out_tx);
            }
            Ok(None) => break, // EOF → clean exit
            Err(e) => {
                error_line(&out_tx, format!("stdin read failed: {e}"));
                break;
            }
        }
    }

    // Shut the session down cleanly: cancel any in-flight turn, then let the
    // worker drain (kills background processes via bg.shutdown()).
    if let Some(s) = session.take() {
        s.cancel.lock().unwrap().cancel();
        let _ = s.cmd_tx.send(SessionCmd::Shutdown);
        let _ = s.worker.await;
    }
    drop(out_tx);
    let _ = writer.await;
    0
}

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

    fn drain(rx: &mut mpsc::UnboundedReceiver<String>) -> Vec<Value> {
        let mut lines = Vec::new();
        while let Ok(l) = rx.try_recv() {
            lines.push(serde_json::from_str(&l).expect("output lines are valid JSON"));
        }
        lines
    }

    #[tokio::test]
    async fn session_new_emits_session_created() {
        let (out, mut rx) = mpsc::unbounded_channel();
        let mut session = None;
        let cfg = Config::default();
        handle_line(r#"{"type":"session.new","mode":"plan"}"#, &mut session, &cfg, &out);
        let lines = drain(&mut rx);
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0]["type"], "session.created");
        assert!(lines[0]["id"].as_str().unwrap().starts_with("session-"));
        let s = session.as_ref().unwrap();
        assert_eq!(s.cfg.mode, Mode::Plan);

        // A second session.new is a protocol error.
        handle_line(r#"{"type":"session.new"}"#, &mut session, &cfg, &out);
        let lines = drain(&mut rx);
        assert_eq!(lines[0]["type"], "error");
    }

    #[tokio::test]
    async fn approval_respond_resolves_pending_registry_entry() {
        let (out, mut rx) = mpsc::unbounded_channel();
        let mut session = None;
        let cfg = Config::default();
        handle_line(r#"{"type":"session.new"}"#, &mut session, &cfg, &out);
        drain(&mut rx);

        // Simulate the agent registering an approval request.
        let registry = session.as_ref().unwrap().approvals.clone();
        let (id, resp_rx) = registry.register();

        let line = format!(r#"{{"type":"approval.respond","id":"{id}","decision":"always"}}"#);
        handle_line(&line, &mut session, &cfg, &out);
        assert!(drain(&mut rx).is_empty(), "successful respond emits nothing");
        assert_eq!(resp_rx.await.unwrap(), ApprovalDecision::ApproveForSession);

        // Unknown id → error line, process keeps running.
        handle_line(
            r#"{"type":"approval.respond","id":"approval-999","decision":"deny"}"#,
            &mut session,
            &cfg,
            &out,
        );
        let lines = drain(&mut rx);
        assert_eq!(lines[0]["type"], "error");
    }

    #[tokio::test]
    async fn unknown_and_malformed_lines_yield_error_lines() {
        let (out, mut rx) = mpsc::unbounded_channel();
        let mut session = None;
        let cfg = Config::default();

        handle_line(r#"{"type":"bogus.op"}"#, &mut session, &cfg, &out);
        handle_line("not json at all", &mut session, &cfg, &out);
        handle_line(r#"{"no_type":true}"#, &mut session, &cfg, &out);
        handle_line(r#"{"type":"user.message","text":"hi"}"#, &mut session, &cfg, &out); // no session

        let lines = drain(&mut rx);
        assert_eq!(lines.len(), 4);
        assert!(lines.iter().all(|l| l["type"] == "error"));
    }

    #[tokio::test]
    async fn user_message_emits_turn_started_and_queues_turn() {
        let (out, mut rx) = mpsc::unbounded_channel();
        let mut session = None;
        let cfg = Config::default();
        handle_line(r#"{"type":"session.new"}"#, &mut session, &cfg, &out);
        drain(&mut rx);

        // Swap in a probe receiver so we don't drive a real turn (no LLM).
        let (probe_tx, mut probe_rx) = mpsc::unbounded_channel::<SessionCmd>();
        session.as_mut().unwrap().cmd_tx = probe_tx;

        handle_line(r#"{"type":"user.message","text":"hello"}"#, &mut session, &cfg, &out);
        let lines = drain(&mut rx);
        assert_eq!(lines[0]["type"], "turn.started");
        match probe_rx.try_recv().unwrap() {
            SessionCmd::User { text, images } => {
                assert_eq!(text, "hello");
                assert!(images.is_empty());
            }
            _ => panic!("expected SessionCmd::User"),
        }
    }

    #[tokio::test]
    async fn config_set_mode_switches_and_reconfigures() {
        let (out, mut rx) = mpsc::unbounded_channel();
        let mut session = None;
        let cfg = Config::default();
        handle_line(r#"{"type":"session.new","mode":"agent"}"#, &mut session, &cfg, &out);
        drain(&mut rx);

        let (probe_tx, mut probe_rx) = mpsc::unbounded_channel::<SessionCmd>();
        session.as_mut().unwrap().cmd_tx = probe_tx;

        // Mid-session mode switch is now legal (was session.new-only).
        handle_line(r#"{"type":"config.set","mode":"plan"}"#, &mut session, &cfg, &out);
        assert!(drain(&mut rx).is_empty(), "config.set emits no line on success");
        assert_eq!(session.as_ref().unwrap().cfg.mode, Mode::Plan);
        assert!(matches!(probe_rx.try_recv().unwrap(), SessionCmd::Reconfigure(_)));

        // A bad mode is a protocol error and does not reconfigure.
        handle_line(r#"{"type":"config.set","mode":"bogus"}"#, &mut session, &cfg, &out);
        assert_eq!(drain(&mut rx)[0]["type"], "error");
    }

    #[tokio::test]
    async fn session_clear_and_compact_queue_commands() {
        let (out, mut rx) = mpsc::unbounded_channel();
        let mut session = None;
        let cfg = Config::default();
        handle_line(r#"{"type":"session.new"}"#, &mut session, &cfg, &out);
        drain(&mut rx);
        let (probe_tx, mut probe_rx) = mpsc::unbounded_channel::<SessionCmd>();
        session.as_mut().unwrap().cmd_tx = probe_tx;

        handle_line(r#"{"type":"session.clear"}"#, &mut session, &cfg, &out);
        handle_line(r#"{"type":"session.compact"}"#, &mut session, &cfg, &out);
        assert!(matches!(probe_rx.try_recv().unwrap(), SessionCmd::ClearHistory));
        assert!(matches!(probe_rx.try_recv().unwrap(), SessionCmd::Compact));
    }

    #[tokio::test]
    async fn mcp_add_and_remove_roundtrip() {
        // Persisting writes to config_dir(); isolate it to a temp dir. Hold the
        // shared env lock so we don't race config's own LUCID_TRAIN_HOME tests.
        let _env = crate::config::TEST_ENV_LOCK.lock().unwrap();
        let prev = std::env::var("LUCID_TRAIN_HOME").ok();
        let tmp = std::env::temp_dir().join(format!("lucid-serve-mcp-{}", std::process::id()));
        std::env::set_var("LUCID_TRAIN_HOME", &tmp);

        let (out, mut rx) = mpsc::unbounded_channel();
        let mut session = None;
        let cfg = Config::default();
        handle_line(r#"{"type":"session.new"}"#, &mut session, &cfg, &out);
        drain(&mut rx);
        let (probe_tx, mut probe_rx) = mpsc::unbounded_channel::<SessionCmd>();
        session.as_mut().unwrap().cmd_tx = probe_tx;

        handle_line(
            r#"{"type":"mcp.add","name":"grafana","command":"mcp-grafana","args":["-t","stdio"],"env":{"GRAFANA_URL":"http://localhost:3000","GRAFANA_SERVICE_ACCOUNT_TOKEN":"tok"}}"#,
            &mut session,
            &cfg,
            &out,
        );
        assert_eq!(drain(&mut rx)[0]["type"], "mcp.added");
        assert!(matches!(probe_rx.try_recv().unwrap(), SessionCmd::Reconfigure(_)));
        let servers = &session.as_ref().unwrap().cfg.mcp_servers;
        assert_eq!(servers.len(), 1);
        assert_eq!(servers[0].name, "grafana");
        assert_eq!(servers[0].args, vec!["-t".to_string(), "stdio".to_string()]);
        assert!(servers[0]
            .env
            .iter()
            .any(|(k, v)| k == "GRAFANA_URL" && v == "http://localhost:3000"));

        handle_line(r#"{"type":"mcp.remove","name":"grafana"}"#, &mut session, &cfg, &out);
        assert_eq!(drain(&mut rx)[0]["type"], "mcp.removed");
        assert!(session.as_ref().unwrap().cfg.mcp_servers.is_empty());

        match prev {
            Some(p) => std::env::set_var("LUCID_TRAIN_HOME", p),
            None => std::env::remove_var("LUCID_TRAIN_HOME"),
        }
        let _ = std::fs::remove_dir_all(&tmp);
    }

    /// AgentEvent must serialize with stable snake_case "type" tags — this is
    /// the wire contract for `serve` consumers.
    #[test]
    fn agent_event_json_tags_are_stable() {
        let ev = AgentEvent::AssistantDelta { text: "hi".into() };
        assert_eq!(
            serde_json::to_string(&ev).unwrap(),
            r#"{"type":"assistant_delta","text":"hi"}"#
        );

        let ev = AgentEvent::ApprovalRequest {
            request_id: "approval-1".into(),
            command: "rm -rf build".into(),
            risk_label: "caution".into(),
            reason: "deletes files".into(),
        };
        assert_eq!(
            serde_json::to_string(&ev).unwrap(),
            r#"{"type":"approval_request","request_id":"approval-1","command":"rm -rf build","risk_label":"caution","reason":"deletes files"}"#
        );

        let ev = AgentEvent::ToolBegin {
            call_id: "c1".into(),
            command: "ls".into(),
            description: "list".into(),
            background: false,
        };
        assert_eq!(
            serde_json::to_string(&ev).unwrap(),
            r#"{"type":"tool_begin","call_id":"c1","command":"ls","description":"list","background":false}"#
        );

        let ev = AgentEvent::TurnComplete;
        assert_eq!(serde_json::to_string(&ev).unwrap(), r#"{"type":"turn_complete"}"#);

        let ev = AgentEvent::Plan { steps: vec![("step 1".into(), "in_progress".into())] };
        assert_eq!(
            serde_json::to_string(&ev).unwrap(),
            r#"{"type":"plan","steps":[["step 1","in_progress"]]}"#
        );

        let ev = AgentEvent::SubagentStatus {
            id: 0,
            task: "map the auth flow".into(),
            state: "started".into(),
        };
        assert_eq!(
            serde_json::to_string(&ev).unwrap(),
            r#"{"type":"subagent_status","id":0,"task":"map the auth flow","state":"started"}"#
        );
    }
}
