codex-mobile-bridge 0.3.9

Remote bridge and service manager for codex-mobile.
Documentation
use std::fs;
use std::path::Path;

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

use serde_json::json;
use tempfile::tempdir;
use tokio::time::{Duration, timeout};

use super::request_payloads::build_thread_list_request_payload;
use crate::config::Config;
use crate::state::BridgeState;

#[test]
fn thread_list_request_payload_omits_absent_optional_strings() {
    let payload = build_thread_list_request_payload(None, 50, false, None);

    assert_eq!(
        payload,
        json!({
            "limit": 50,
            "archived": false,
        }),
    );
}

#[test]
fn thread_list_request_payload_keeps_present_optional_strings() {
    let payload = build_thread_list_request_payload(Some("cursor-1"), 20, true, Some("abc"));

    assert_eq!(
        payload,
        json!({
            "cursor": "cursor-1",
            "limit": 20,
            "archived": true,
            "searchTerm": "abc",
        }),
    );
}

#[tokio::test]
async fn start_thread_falls_back_when_empty_thread_cannot_include_turns() {
    let temp_dir = tempdir().expect("创建临时目录失败");
    let workspace = temp_dir.path().join("workspace");
    let db_path = temp_dir.path().join("bridge.db");
    let log_path = temp_dir.path().join("requests.json");
    let script_path = temp_dir.path().join("fake-codex");
    fs::create_dir_all(&workspace).expect("创建工作目录失败");
    fs::write(
        &script_path,
        format!(
            r#"#!/usr/bin/env python3
import json
import pathlib
import sys

log_path = pathlib.Path({log_path:?})
messages = []
thread_name = ""
thread_cwd = ""

def write_log():
    log_path.write_text(json.dumps(messages))

def thread_payload():
    return {{
        "id": "thread-empty",
        "name": thread_name or None,
        "preview": "",
        "cwd": thread_cwd,
        "status": {{"type": "notLoaded"}},
        "modelProvider": "openai",
        "source": "mobile",
        "createdAt": 1,
        "updatedAt": 2
    }}

for raw_line in sys.stdin:
    line = raw_line.strip()
    if not line:
        continue
    message = json.loads(line)
    messages.append(message)
    write_log()
    method = message.get("method")
    if method == "initialize":
        print(json.dumps({{
            "jsonrpc": "2.0",
            "id": message["id"],
            "result": {{
                "userAgent": "codex-test",
                "codexHome": "/tmp/codex-home",
                "platformFamily": "unix",
                "platformOs": "linux"
            }}
        }}), flush=True)
    elif method == "thread/start":
        thread_cwd = message["params"]["cwd"]
        print(json.dumps({{
            "jsonrpc": "2.0",
            "id": message["id"],
            "result": {{
                "thread": thread_payload()
            }}
        }}), flush=True)
    elif method == "thread/name/set":
        thread_name = message["params"].get("name") or ""
        print(json.dumps({{
            "jsonrpc": "2.0",
            "id": message["id"],
            "result": {{}}
        }}), flush=True)
    elif method == "thread/read":
        if message["params"].get("includeTurns"):
            print(json.dumps({{
                "jsonrpc": "2.0",
                "id": message["id"],
                "error": {{
                    "code": -32600,
                    "message": "thread thread-empty is not materialized yet; includeTurns is unavailable before first user message"
                }}
            }}), flush=True)
        else:
            print(json.dumps({{
                "jsonrpc": "2.0",
                "id": message["id"],
                "result": {{
                    "thread": thread_payload()
                }}
            }}), flush=True)
"#,
            log_path = log_path.display().to_string(),
        ),
    )
    .expect("写入 fake codex 脚本失败");
    #[cfg(unix)]
    {
        fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))
            .expect("设置脚本权限失败");
    }

    let state = BridgeState::bootstrap(Config {
        listen_addr: "127.0.0.1:0".to_string(),
        token: "test-token".to_string(),
        runtime_limit: 4,
        db_path,
        codex_home: None,
        codex_binary: script_path.display().to_string(),
        directory_bookmarks: Vec::new(),
    })
    .await
    .expect("bootstrap 测试 BridgeState 失败");

    let response = timeout(
        Duration::from_secs(5),
        state.handle_request(
            "start_thread",
            json!({
                "cwd": workspace.display().to_string(),
                "name": "空线程",
            }),
        ),
    )
    .await
    .expect("start_thread 超时")
    .expect("start_thread 返回错误");

    assert_eq!(response["thread"]["id"], json!("thread-empty"));
    assert_eq!(response["thread"]["name"], json!("空线程"));
    assert!(response.get("entries").is_none());
    assert_eq!(
        response["renderSnapshot"]["threadId"],
        json!("thread-empty")
    );

    let messages = wait_for_recorded_messages(&log_path, 6).await;
    let reads = messages
        .iter()
        .filter(|message| message["method"] == "thread/read")
        .collect::<Vec<_>>();

    assert_eq!(reads.len(), 2);
    assert_eq!(reads[0]["params"]["includeTurns"], json!(true));
    assert_eq!(reads[1]["params"]["includeTurns"], json!(false));
}

async fn wait_for_recorded_messages(
    log_path: &Path,
    minimum_messages: usize,
) -> Vec<serde_json::Value> {
    timeout(Duration::from_secs(5), async {
        loop {
            let messages = fs::read_to_string(log_path)
                .ok()
                .and_then(|content| serde_json::from_str::<Vec<serde_json::Value>>(&content).ok());
            if let Some(messages) = messages.filter(|messages| messages.len() >= minimum_messages) {
                return messages;
            }
            tokio::time::sleep(Duration::from_millis(50)).await;
        }
    })
    .await
    .expect("等待 fake codex 记录请求超时")
}