//! Provider hook normalization and fenced terminal/thread state transitions.

use serde_json::Value;

use crate::routes::intercept::watch::{self, ThreadStatus};
use crate::routes::threads::db::{
    RtTerminalLogicalState, RtTerminalPhysicalState, RtTerminalSessionCasOutcome,
    RtTerminalSessionCommit, RtThread, RtThreadFence, ThreadsDb, RT_THREAD_STATUS_COMPLETED,
    RT_THREAD_STATUS_FAILED, RT_THREAD_STATUS_IN_PROGRESS, RT_THREAD_STATUS_REQUIRES_ACTION,
};

// Claude Code and compatible harnesses inject internal machinery as user-role
// turns. These events still describe real agent activity, but they must not be
// treated as prompts the user submitted (for example, for auto-titling).
// Keep this inventory narrow: unknown tags can be genuine pasted user input.
const KNOWN_HARNESS_TAG_NAMES: &[&str] = &[
    "agent-message",
    "bash-input",
    "bash-stderr",
    "bash-stdout",
    "command-args",
    "command-message",
    "command-name",
    "cross-session-message",
    "fork-boilerplate",
    "local-command-caveat",
    "local-command-stderr",
    "local-command-stdout",
    "mcp-polling-update",
    "mcp-resource-update",
    "system-reminder",
    "task-notification",
    "teammate-message",
    "user-memory-input",
    "user-prompt-submit-hook",
];

// The harness emits `<channel>` machinery only in its attributed form. A bare
// `<channel>` can be real RSS/XML pasted by the user, so it deliberately is not
// part of KNOWN_HARNESS_TAG_NAMES.
const HARNESS_INJECTED_TURN_PREFIXES: &[&str] = &[
    "<channel source=",
    "[request interrupted",
    "a message arrived from ",
    "another claude session sent a message",
    "no response requested.",
    "caveat: the messages below were generated by the user while running local commands",
    "this session is being continued from a previous conversation",
];

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookObservation {
    pub event_name: String,
    pub provider_session_id: Option<String>,
    pub provider_title: Option<String>,
    pub logical_state: Option<RtTerminalLogicalState>,
    pub prompt: Option<String>,
    pub is_explicit_user_prompt: bool,
}

pub fn normalize_hook(payload: &Value) -> Result<HookObservation, &'static str> {
    if payload.get("provider").and_then(Value::as_str) == Some("opencode") {
        return normalize_opencode_hook(payload);
    }
    let event_name = payload
        .get("hook_event_name")
        .or_else(|| payload.get("hookEventName"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or("hook_event_name is required")?;
    let provider_session_id = payload
        .get("session_id")
        .or_else(|| payload.get("sessionId"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(str::to_string);
    let prompt = payload
        .get("prompt")
        .or_else(|| payload.get("user_prompt"))
        .and_then(Value::as_str)
        .map(|value| value.trim_matches(is_ecmascript_whitespace))
        .filter(|value| !value.is_empty())
        .map(str::to_string);
    let is_explicit_user_prompt = event_name == "UserPromptSubmit"
        && prompt
            .as_deref()
            .is_some_and(|value| !is_known_harness_injected_user_turn(value));

    let logical_state = match event_name {
        "SessionStart" => Some(RtTerminalLogicalState::Idle),
        "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "PostToolUseFailure" => {
            Some(RtTerminalLogicalState::Working)
        }
        "PermissionRequest" => Some(RtTerminalLogicalState::WaitingInput),
        "Notification" if notification_waits_for_input(payload) => {
            Some(RtTerminalLogicalState::WaitingInput)
        }
        "Stop" => Some(RtTerminalLogicalState::Completed),
        "StopFailure" => Some(RtTerminalLogicalState::Failed),
        "SessionEnd" | "Notification" => None,
        _ => None,
    };

    Ok(HookObservation {
        event_name: event_name.to_string(),
        provider_session_id,
        provider_title: None,
        logical_state,
        prompt,
        is_explicit_user_prompt,
    })
}

fn normalize_opencode_hook(payload: &Value) -> Result<HookObservation, &'static str> {
    let event = payload
        .get("event")
        .and_then(Value::as_object)
        .ok_or("OpenCode hook event is required")?;
    let event_name = event
        .get("type")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or("OpenCode hook event type is required")?;
    let properties = event.get("properties").and_then(Value::as_object);
    let info = properties
        .and_then(|properties| properties.get("info"))
        .and_then(Value::as_object);

    // Defense in depth for the generated plugin's root-session filter. A
    // subagent session can emit the same busy/idle/title events as its parent,
    // but it must never mutate the Studio chat that owns the root PTY.
    let is_child_session = info
        .and_then(|info| info.get("parentID"))
        .and_then(Value::as_str)
        .is_some_and(|parent_id| !parent_id.trim().is_empty());
    if is_child_session {
        return Ok(HookObservation {
            event_name: event_name.to_string(),
            provider_session_id: None,
            provider_title: None,
            logical_state: None,
            prompt: None,
            is_explicit_user_prompt: false,
        });
    }

    let provider_session_id = properties
        .and_then(|properties| properties.get("sessionID"))
        .or_else(|| info.and_then(|info| info.get("id")))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(str::to_string);
    // `session.created` can carry a provisional provider title. Only the
    // authoritative update may consume Studio's default-title CAS.
    let provider_title = (event_name == "session.updated")
        .then(|| {
            info.and_then(|info| info.get("title"))
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(str::to_string)
        })
        .flatten();
    let status = properties
        .and_then(|properties| properties.get("status"))
        .and_then(Value::as_object)
        .and_then(|status| status.get("type"))
        .and_then(Value::as_str);
    // OpenCode's session.error schema permits a missing sessionID. Such an
    // event is global/ambiguous and must never fail whichever Studio chat
    // happens to own the hook endpoint. The receiver independently validates
    // every observed id against its root checkpoint.
    let logical_state = provider_session_id.as_ref().and_then(|_| match event_name {
        "session.created" => Some(RtTerminalLogicalState::Idle),
        "session.status" if matches!(status, Some("busy" | "retry")) => {
            Some(RtTerminalLogicalState::Working)
        }
        "session.status" if status == Some("idle") => Some(RtTerminalLogicalState::Completed),
        "session.idle" => Some(RtTerminalLogicalState::Completed),
        "session.error" => Some(RtTerminalLogicalState::Failed),
        "permission.asked" | "question.asked" => Some(RtTerminalLogicalState::WaitingInput),
        "permission.replied" | "question.replied" | "question.rejected" => {
            Some(RtTerminalLogicalState::Working)
        }
        _ => None,
    });

    Ok(HookObservation {
        event_name: event_name.to_string(),
        provider_session_id,
        provider_title,
        logical_state,
        prompt: None,
        is_explicit_user_prompt: false,
    })
}

fn logical_transition_allowed(
    current: RtTerminalLogicalState,
    requested: RtTerminalLogicalState,
) -> bool {
    match requested {
        RtTerminalLogicalState::Idle => current == RtTerminalLogicalState::Idle,
        // A permission/question hook can be the first successfully delivered
        // event for a turn. Admit it from Idle so a lost busy hook cannot hide
        // an approval request from the sidebar.
        RtTerminalLogicalState::WaitingInput => matches!(
            current,
            RtTerminalLogicalState::Idle | RtTerminalLogicalState::Working
        ),
        RtTerminalLogicalState::Completed => matches!(
            current,
            RtTerminalLogicalState::Idle
                | RtTerminalLogicalState::Working
                | RtTerminalLogicalState::WaitingInput
                | RtTerminalLogicalState::Completed
        ),
        RtTerminalLogicalState::Working
        | RtTerminalLogicalState::Failed
        | RtTerminalLogicalState::Interrupted => true,
    }
}

/// Match only user-role turns known to be injected by the agent harness. This
/// is a faithful port of Orca's fail-open classifier: matching is
/// case-insensitive, only a leading known tag/prefix counts, and arbitrary
/// custom elements remain explicit user prompts.
fn is_known_harness_injected_user_turn(text: &str) -> bool {
    let normalized = text.trim_matches(is_ecmascript_whitespace).to_lowercase();
    if normalized.is_empty() {
        return false;
    }
    if leading_tag_name(&normalized)
        .is_some_and(|tag_name| KNOWN_HARNESS_TAG_NAMES.contains(&tag_name))
    {
        return true;
    }
    HARNESS_INJECTED_TURN_PREFIXES
        .iter()
        .any(|prefix| normalized.starts_with(prefix))
}

fn leading_tag_name(text: &str) -> Option<&str> {
    let body = text.strip_prefix('<')?;
    let mut chars = body.char_indices();
    let (_, first) = chars.next()?;
    if !first.is_ascii_lowercase() {
        return None;
    }

    let mut tag_name_end = first.len_utf8();
    for (index, character) in chars {
        if character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' {
            tag_name_end = index + character.len_utf8();
            continue;
        }
        if character == '>' || is_ecmascript_whitespace(character) {
            return Some(&body[..index]);
        }
        return None;
    }
    Some(&body[..tag_name_end])
}

// ECMAScript's `\s`/String#trim set, used by Orca's TypeScript classifier.
// Rust's `char::is_whitespace` differs at U+0085 and U+FEFF, so spell the set
// out to preserve the classifier's fail-open boundary exactly.
fn is_ecmascript_whitespace(character: char) -> bool {
    matches!(
        character,
        '\u{0009}'
            | '\u{000A}'
            | '\u{000B}'
            | '\u{000C}'
            | '\u{000D}'
            | '\u{0020}'
            | '\u{00A0}'
            | '\u{1680}'
            | '\u{2000}'
            ..='\u{200A}'
                | '\u{2028}'
                | '\u{2029}'
                | '\u{202F}'
                | '\u{205F}'
                | '\u{3000}'
                | '\u{FEFF}'
    )
}

fn notification_waits_for_input(payload: &Value) -> bool {
    payload
        .get("notification_type")
        .or_else(|| payload.get("notificationType"))
        .and_then(Value::as_str)
        .is_some_and(|kind| {
            matches!(
                kind,
                "permission_prompt" | "elicitation_dialog" | "idle_prompt"
            )
        })
}

/// Apply a logical lifecycle event without losing a concurrent provider-id
/// checkpoint. SQLite's revision is the synchronization primitive; a stale
/// revision rereads and retries a small bounded number of times.
pub fn transition_logical(
    db: &'static ThreadsDb,
    fence: &RtThreadFence,
    terminal_session_id: &str,
    logical_state: RtTerminalLogicalState,
) -> Result<Option<RtTerminalSessionCommit>, String> {
    for _ in 0..8 {
        let Some(current) = db
            .rt_get_terminal_session_fenced(fence, terminal_session_id)
            .map_err(|error| error.to_string())?
        else {
            return Ok(None);
        };
        if current.physical_state == RtTerminalPhysicalState::Exited {
            return Ok(None);
        }
        if !logical_transition_allowed(current.logical_state, logical_state) {
            return Ok(None);
        }
        match db
            .rt_compare_and_set_terminal_session_state(
                fence,
                terminal_session_id,
                current.revision,
                current.physical_state,
                logical_state,
                None,
                None,
            )
            .map_err(|error| error.to_string())?
        {
            RtTerminalSessionCasOutcome::Updated(commit) => {
                emit_commit(fence, &commit);
                return Ok(Some(*commit));
            }
            RtTerminalSessionCasOutcome::Stale(_) => continue,
            RtTerminalSessionCasOutcome::Missing => return Ok(None),
        }
    }
    Err("terminal state changed too many times while applying a hook".to_string())
}

pub fn mark_running(
    db: &'static ThreadsDb,
    fence: &RtThreadFence,
    terminal_session_id: &str,
) -> Result<Option<RtTerminalSessionCommit>, String> {
    for _ in 0..8 {
        let Some(current) = db
            .rt_get_terminal_session_fenced(fence, terminal_session_id)
            .map_err(|error| error.to_string())?
        else {
            return Ok(None);
        };
        if current.physical_state != RtTerminalPhysicalState::Starting {
            return Ok(None);
        }
        match db
            .rt_compare_and_set_terminal_session_state(
                fence,
                terminal_session_id,
                current.revision,
                RtTerminalPhysicalState::Running,
                current.logical_state,
                None,
                None,
            )
            .map_err(|error| error.to_string())?
        {
            RtTerminalSessionCasOutcome::Updated(commit) => {
                emit_commit(fence, &commit);
                return Ok(Some(*commit));
            }
            RtTerminalSessionCasOutcome::Stale(_) => continue,
            RtTerminalSessionCasOutcome::Missing => return Ok(None),
        }
    }
    Err("terminal state changed too many times while marking it running".to_string())
}

pub fn mark_exited(
    db: &'static ThreadsDb,
    fence: &RtThreadFence,
    terminal_session_id: &str,
    exit_code: Option<i32>,
    requested: bool,
    error: Option<&str>,
) -> Result<Option<RtTerminalSessionCommit>, String> {
    for _ in 0..8 {
        let Some(current) = db
            .rt_get_terminal_session_fenced(fence, terminal_session_id)
            .map_err(|db_error| db_error.to_string())?
        else {
            return Ok(None);
        };
        if current.physical_state == RtTerminalPhysicalState::Exited {
            return Ok(None);
        }
        let logical_state = if requested {
            RtTerminalLogicalState::Interrupted
        } else if error.is_some()
            || exit_code.is_some_and(|code| code != 0)
            || matches!(
                current.logical_state,
                RtTerminalLogicalState::Working | RtTerminalLogicalState::WaitingInput
            )
        {
            RtTerminalLogicalState::Failed
        } else {
            RtTerminalLogicalState::Completed
        };
        match db
            .rt_compare_and_set_terminal_session_state(
                fence,
                terminal_session_id,
                current.revision,
                RtTerminalPhysicalState::Exited,
                logical_state,
                exit_code,
                error,
            )
            .map_err(|db_error| db_error.to_string())?
        {
            RtTerminalSessionCasOutcome::Updated(commit) => {
                emit_commit(fence, &commit);
                return Ok(Some(*commit));
            }
            RtTerminalSessionCasOutcome::Stale(_) => continue,
            RtTerminalSessionCasOutcome::Missing => return Ok(None),
        }
    }
    Err("terminal state changed too many times while marking it exited".to_string())
}

pub fn emit_thread(fence: &RtThreadFence, thread: &RtThread) {
    if let Some(status) = watch_status(&thread.status) {
        watch::emit_thread_status(fence, status, thread, None);
    }
}

fn emit_commit(fence: &RtThreadFence, commit: &RtTerminalSessionCommit) {
    emit_thread(fence, &commit.thread);
}

fn watch_status(status: &str) -> Option<ThreadStatus> {
    match status {
        RT_THREAD_STATUS_IN_PROGRESS => Some(ThreadStatus::InProgress),
        RT_THREAD_STATUS_COMPLETED => Some(ThreadStatus::Completed),
        RT_THREAD_STATUS_REQUIRES_ACTION => Some(ThreadStatus::RequiresAction),
        RT_THREAD_STATUS_FAILED => Some(ThreadStatus::Failed),
        _ => None,
    }
}

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

    fn user_prompt(prompt: &str) -> HookObservation {
        normalize_hook(&json!({
            "hook_event_name": "UserPromptSubmit",
            "prompt": prompt,
        }))
        .unwrap()
    }

    #[test]
    fn normalizes_both_provider_field_spellings() {
        let codex = normalize_hook(&json!({
            "hook_event_name": "UserPromptSubmit",
            "session_id": "codex-session",
            "prompt": "fix the tests",
        }))
        .unwrap();
        assert_eq!(codex.logical_state, Some(RtTerminalLogicalState::Working));
        assert_eq!(codex.provider_session_id.as_deref(), Some("codex-session"));
        assert!(codex.is_explicit_user_prompt);

        let claude = normalize_hook(&json!({
            "hookEventName": "PermissionRequest",
            "sessionId": "claude-session",
        }))
        .unwrap();
        assert_eq!(
            claude.logical_state,
            Some(RtTerminalLogicalState::WaitingInput)
        );
        assert!(!claude.is_explicit_user_prompt);
    }

    #[test]
    fn every_known_harness_tag_is_synthetic_but_still_marks_working() {
        let expected_tag_names = [
            "agent-message",
            "bash-input",
            "bash-stderr",
            "bash-stdout",
            "command-args",
            "command-message",
            "command-name",
            "cross-session-message",
            "fork-boilerplate",
            "local-command-caveat",
            "local-command-stderr",
            "local-command-stdout",
            "mcp-polling-update",
            "mcp-resource-update",
            "system-reminder",
            "task-notification",
            "teammate-message",
            "user-memory-input",
            "user-prompt-submit-hook",
        ];
        assert_eq!(KNOWN_HARNESS_TAG_NAMES, expected_tag_names);

        for tag_name in expected_tag_names {
            let prompt = format!("<{tag_name} source=\"fixture\">payload");
            let observation = user_prompt(&prompt);
            assert!(
                !observation.is_explicit_user_prompt,
                "{tag_name} must remain harness machinery"
            );
            assert_eq!(
                observation.logical_state,
                Some(RtTerminalLogicalState::Working),
                "{tag_name} must still report lifecycle activity"
            );
            assert_eq!(observation.prompt.as_deref(), Some(prompt.as_str()));
        }
    }

    #[test]
    fn every_known_harness_prefix_is_synthetic() {
        let expected_prefixes = [
            "<channel source=",
            "[request interrupted",
            "a message arrived from ",
            "another claude session sent a message",
            "no response requested.",
            "caveat: the messages below were generated by the user while running local commands",
            "this session is being continued from a previous conversation",
        ];
        assert_eq!(HARNESS_INJECTED_TURN_PREFIXES, expected_prefixes);

        for prefix in expected_prefixes {
            let prompt = format!("{prefix}fixture payload");
            assert!(
                !user_prompt(&prompt).is_explicit_user_prompt,
                "{prefix} must remain harness machinery"
            );
        }
    }

    #[test]
    fn synthetic_matching_is_case_insensitive_and_uses_ecmascript_whitespace() {
        let observation =
            user_prompt("\u{FEFF}\n<TASK-NOTIFICATION summary=\"Agent finished\">done\u{00A0}");
        assert!(!observation.is_explicit_user_prompt);
        assert_eq!(
            observation.prompt.as_deref(),
            Some("<TASK-NOTIFICATION summary=\"Agent finished\">done")
        );
    }

    #[test]
    fn blank_user_prompt_is_not_explicit() {
        for prompt in ["", "   \n\t", "\u{FEFF}\u{00A0}"] {
            let observation = user_prompt(prompt);
            assert!(!observation.is_explicit_user_prompt);
            assert_eq!(observation.prompt, None);
            assert_eq!(
                observation.logical_state,
                Some(RtTerminalLogicalState::Working)
            );
        }
    }

    #[test]
    fn unknown_or_nonleading_tags_fail_open_as_real_user_prompts() {
        for prompt in [
            "<my-custom-element> render the profile card",
            "<brand-new-harness-tag id=\"1\">payload",
            "<queued-notice>later",
            "<channel>general</channel> explain this feed element",
            "why does <task-notification> show in the sidebar?",
        ] {
            let observation = user_prompt(prompt);
            assert!(
                observation.is_explicit_user_prompt,
                "{prompt} must remain user input"
            );
            assert_eq!(observation.prompt.as_deref(), Some(prompt));
        }
    }

    #[test]
    fn normal_user_prompt_is_explicit() {
        let observation = user_prompt("  Fix the login bug  ");
        assert!(observation.is_explicit_user_prompt);
        assert_eq!(observation.prompt.as_deref(), Some("Fix the login bug"));
    }

    #[test]
    fn session_start_with_session_and_prompt_is_never_explicit() {
        let observation = normalize_hook(&json!({
            "hook_event_name": "SessionStart",
            "session_id": "provider-session",
            "prompt": "looks like a prompt but is session metadata",
        }))
        .unwrap();

        assert_eq!(
            observation.provider_session_id.as_deref(),
            Some("provider-session")
        );
        assert_eq!(
            observation.logical_state,
            Some(RtTerminalLogicalState::Idle)
        );
        assert_eq!(
            observation.prompt.as_deref(),
            Some("looks like a prompt but is session metadata")
        );
        assert!(!observation.is_explicit_user_prompt);
    }

    #[test]
    fn stop_completes_but_a_failed_stop_fails() {
        assert_eq!(
            normalize_hook(&json!({ "hook_event_name": "Stop" }))
                .unwrap()
                .logical_state,
            Some(RtTerminalLogicalState::Completed)
        );
        assert_eq!(
            normalize_hook(&json!({ "hook_event_name": "StopFailure" }))
                .unwrap()
                .logical_state,
            Some(RtTerminalLogicalState::Failed)
        );
    }

    #[test]
    fn unrelated_notifications_do_not_invent_work() {
        let observation = normalize_hook(&json!({
            "hook_event_name": "Notification",
            "notification_type": "auth_success",
        }))
        .unwrap();
        assert_eq!(observation.logical_state, None);
    }

    fn opencode_event(event_type: &str, properties: Value) -> HookObservation {
        normalize_hook(&json!({
            "provider": "opencode",
            "event": {
                "type": event_type,
                "properties": properties,
            },
        }))
        .unwrap()
    }

    #[test]
    fn opencode_session_events_checkpoint_root_identity_and_title() {
        let created = opencode_event(
            "session.created",
            json!({
                "sessionID": "ses_root",
                "info": {
                    "id": "ses_root",
                    "title": "Provisional provider title",
                },
            }),
        );
        assert_eq!(created.provider_session_id.as_deref(), Some("ses_root"));
        assert_eq!(created.provider_title, None);

        let observation = opencode_event(
            "session.updated",
            json!({
                "sessionID": "ses_root",
                "info": {
                    "id": "ses_root",
                    "title": "Fix terminal resume",
                },
            }),
        );
        assert_eq!(observation.provider_session_id.as_deref(), Some("ses_root"));
        assert_eq!(
            observation.provider_title.as_deref(),
            Some("Fix terminal resume")
        );
        assert_eq!(observation.logical_state, None);
    }

    #[test]
    fn waiting_input_recovers_when_the_working_hook_was_lost() {
        assert!(logical_transition_allowed(
            RtTerminalLogicalState::Idle,
            RtTerminalLogicalState::WaitingInput
        ));
        assert!(logical_transition_allowed(
            RtTerminalLogicalState::Working,
            RtTerminalLogicalState::WaitingInput
        ));
        assert!(!logical_transition_allowed(
            RtTerminalLogicalState::Completed,
            RtTerminalLogicalState::WaitingInput
        ));
    }

    #[test]
    fn opencode_lifecycle_maps_busy_retry_idle_waiting_and_errors() {
        for status in ["busy", "retry"] {
            assert_eq!(
                opencode_event(
                    "session.status",
                    json!({ "sessionID": "ses_root", "status": { "type": status } }),
                )
                .logical_state,
                Some(RtTerminalLogicalState::Working)
            );
        }
        assert_eq!(
            opencode_event("session.idle", json!({ "sessionID": "ses_root" })).logical_state,
            Some(RtTerminalLogicalState::Completed)
        );
        for event in ["permission.asked", "question.asked"] {
            assert_eq!(
                opencode_event(event, json!({ "sessionID": "ses_root" })).logical_state,
                Some(RtTerminalLogicalState::WaitingInput)
            );
        }
        assert_eq!(
            opencode_event("session.error", json!({ "sessionID": "ses_root" })).logical_state,
            Some(RtTerminalLogicalState::Failed)
        );
    }

    #[test]
    fn opencode_child_sessions_cannot_mutate_parent_lifecycle_or_title() {
        let child = opencode_event(
            "session.updated",
            json!({
                "sessionID": "ses_child",
                "info": {
                    "id": "ses_child",
                    "parentID": "ses_root",
                    "title": "Child title",
                },
            }),
        );
        assert_eq!(child.provider_session_id, None);
        assert_eq!(child.provider_title, None);
        assert_eq!(child.logical_state, None);
    }

    #[test]
    fn opencode_unknown_status_is_ignored_without_rejecting_the_hook() {
        let observation = opencode_event(
            "session.status",
            json!({ "sessionID": "ses_root", "status": { "type": "future" } }),
        );
        assert_eq!(observation.provider_session_id.as_deref(), Some("ses_root"));
        assert_eq!(observation.logical_state, None);
    }

    #[test]
    fn opencode_identity_less_errors_are_ambiguous_and_cannot_fail_a_chat() {
        let observation = opencode_event(
            "session.error",
            json!({ "error": { "name": "UnknownError", "message": "global" } }),
        );
        assert_eq!(observation.provider_session_id, None);
        assert_eq!(observation.logical_state, None);
        assert_eq!(observation.provider_title, None);
    }

    #[test]
    fn opencode_only_accepts_titles_from_session_identity_events() {
        let observation = opencode_event(
            "session.status",
            json!({
                "sessionID": "ses_root",
                "status": { "type": "busy" },
                "info": { "id": "ses_root", "title": "Wrong event title" },
            }),
        );
        assert_eq!(observation.provider_title, None);
    }
}
