codex-mobile-bridge 0.3.3

Remote bridge and service manager for codex-mobile.
Documentation
use anyhow::Result;
use serde_json::{Value, json};

use super::super::helpers::{normalize_name, optional_string, required_string};
use super::metadata::{
    timeline_collapse_hint, timeline_lifecycle_info, timeline_metadata, timeline_render_kind,
    timeline_stream_metadata,
};
use crate::bridge_protocol::{ThreadStatusInfo, ThreadSummary, ThreadTokenUsage};

pub(super) fn normalize_thread(
    runtime_id: &str,
    thread: &Value,
    archived: bool,
) -> Result<ThreadSummary> {
    let cwd = required_string(thread, "cwd")?.to_string();
    let status_value = thread.get("status").unwrap_or(&Value::Null);
    let status_info = normalize_thread_status_info(status_value);
    let status = status_info.kind.clone();
    let is_loaded = status != "notLoaded";
    let is_active = status == "active";

    let source = match thread.get("source") {
        Some(Value::String(value)) => value.clone(),
        Some(other) => other.to_string(),
        None => "unknown".to_string(),
    };

    Ok(ThreadSummary {
        id: required_string(thread, "id")?.to_string(),
        runtime_id: runtime_id.to_string(),
        name: normalize_name(optional_string(thread, "name")),
        preview: optional_string(thread, "preview").unwrap_or_default(),
        cwd,
        status,
        status_info,
        token_usage: thread
            .get("tokenUsage")
            .or_else(|| thread.get("usage"))
            .map(normalize_thread_token_usage),
        model_provider: optional_string(thread, "modelProvider")
            .unwrap_or_else(|| "unknown".to_string()),
        source,
        created_at: thread
            .get("createdAt")
            .and_then(Value::as_i64)
            .unwrap_or_default(),
        updated_at: thread
            .get("updatedAt")
            .and_then(Value::as_i64)
            .unwrap_or_default(),
        is_loaded,
        is_active,
        archived,
    })
}

pub(super) fn normalize_delta_payload(
    runtime_id: &str,
    params: Value,
    entry_type: &str,
    title: Option<String>,
    status: Option<String>,
    raw_type: &str,
    payload: Value,
    summary_index: Option<i64>,
    content_index: Option<i64>,
) -> Value {
    let delta = params
        .get("delta")
        .and_then(Value::as_str)
        .unwrap_or_default();
    let lifecycle = timeline_lifecycle_info(false, status.as_deref(), !delta.trim().is_empty());
    json!({
        "runtimeId": runtime_id,
        "threadId": params.get("threadId"),
        "turnId": params.get("turnId"),
        "itemId": params.get("itemId"),
        "delta": params.get("delta"),
        "entryType": entry_type,
        "title": title,
        "status": status,
        "metadata": timeline_metadata(
            "stream_event",
            raw_type,
            timeline_render_kind(entry_type),
            timeline_collapse_hint(timeline_render_kind(entry_type), entry_type),
            timeline_stream_metadata(true, false, None, summary_index, content_index),
            Some(&lifecycle),
            payload,
            None,
            None,
            params,
        ),
        "summaryIndex": summary_index,
        "contentIndex": content_index,
    })
}

pub(super) fn timeline_entry_id(
    turn_id: Option<&str>,
    item_id: Option<&str>,
    raw_type: &str,
) -> String {
    format!(
        "{}:{}",
        turn_id.unwrap_or("turn"),
        item_id.unwrap_or(raw_type)
    )
}

pub(super) fn canonical_timeline_entry_type(raw_type: &str, item: &Value) -> String {
    match raw_type {
        "message" => {
            return match message_role(item).as_deref() {
                Some("user") => "userMessage".to_string(),
                Some("assistant") => "agentMessage".to_string(),
                Some("system") | Some("developer") => "hookPrompt".to_string(),
                _ => raw_type.to_string(),
            };
        }
        "user_message" => return "userMessage".to_string(),
        "agent_message" => return "agentMessage".to_string(),
        "collabAgentToolCall" => return "collabToolCall".to_string(),
        "command_output" => return "commandExecution".to_string(),
        "file_change_output" => return "fileChange".to_string(),
        _ => {}
    }
    raw_type.to_string()
}

fn message_role(item: &Value) -> Option<String> {
    optional_string(item, "role").map(|role| role.trim().to_lowercase())
}

fn normalize_thread_status_info(status_value: &Value) -> ThreadStatusInfo {
    ThreadStatusInfo {
        kind: status_value
            .get("type")
            .and_then(Value::as_str)
            .unwrap_or_else(|| status_value.as_str().unwrap_or("unknown"))
            .to_string(),
        reason: optional_string(status_value, "reason"),
        raw: status_value.clone(),
    }
}

fn normalize_thread_token_usage(value: &Value) -> ThreadTokenUsage {
    ThreadTokenUsage {
        input_tokens: timeline_first_i64(value, &["inputTokens", "input_tokens"]),
        cached_input_tokens: timeline_first_i64(
            value,
            &["cachedInputTokens", "cached_input_tokens"],
        ),
        output_tokens: timeline_first_i64(value, &["outputTokens", "output_tokens"]),
        reasoning_tokens: timeline_first_i64(value, &["reasoningTokens", "reasoning_tokens"]),
        total_tokens: timeline_first_i64(value, &["totalTokens", "total_tokens"]),
        raw: value.clone(),
        updated_at_ms: value
            .get("updatedAtMs")
            .and_then(Value::as_i64)
            .unwrap_or_default(),
    }
}

fn timeline_first_i64(value: &Value, keys: &[&str]) -> Option<i64> {
    keys.iter()
        .find_map(|key| value.get(*key).and_then(Value::as_i64))
}