codex-mobile-bridge 0.3.9

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

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

use crate::app_server::AppServerInbound;
use crate::storage::PRIMARY_RUNTIME_ID;

use super::super::events::handle_app_server_message;
use super::support::{bootstrap_test_state, primary_runtime};

#[tokio::test]
async fn app_server_starting_message_updates_status_without_deadlock() {
    let state = bootstrap_test_state().await;

    timeout(
        Duration::from_secs(2),
        handle_app_server_message(
            &state,
            AppServerInbound::Starting {
                runtime_id: PRIMARY_RUNTIME_ID.to_string(),
            },
        ),
    )
    .await
    .expect("处理 Starting 消息超时")
    .expect("处理 Starting 消息失败");

    let runtime = primary_runtime(&state).await;
    let status = runtime.status.read().await.clone();
    assert_eq!(status.status, "starting");
    assert_eq!(status.app_server_handshake.state, "starting");
}

#[tokio::test]
async fn permissions_server_request_is_persisted_for_clients() {
    let state = bootstrap_test_state().await;

    timeout(
        Duration::from_secs(2),
        handle_app_server_message(
            &state,
            AppServerInbound::ServerRequest {
                runtime_id: PRIMARY_RUNTIME_ID.to_string(),
                id: json!("req-permissions-1"),
                method: "item/permissions/requestApproval".to_string(),
                params: json!({
                    "threadId": "thread-1",
                    "turnId": "turn-1",
                    "itemId": "item-1",
                    "reason": "需要额外权限",
                    "permissions": {
                        "network": { "enabled": true },
                        "fileSystem": {
                            "read": ["/srv/workspace/docs"],
                            "write": ["/srv/workspace/tmp"]
                        }
                    }
                }),
            },
        ),
    )
    .await
    .expect("处理 permissions request 超时")
    .expect("处理 permissions request 失败");

    let pending = state
        .storage
        .list_pending_requests()
        .expect("读取 pending requests 失败");
    assert_eq!(1, pending.len());
    assert_eq!("item/permissions/requestApproval", pending[0].request_type);
    assert_eq!(Some("thread-1"), pending[0].thread_id.as_deref());
    assert!(pending[0].permissions.is_some());
}

#[tokio::test]
async fn legacy_server_request_is_normalized_into_pending_request() {
    let state = bootstrap_test_state().await;

    timeout(
        Duration::from_secs(2),
        handle_app_server_message(
            &state,
            AppServerInbound::ServerRequest {
                runtime_id: PRIMARY_RUNTIME_ID.to_string(),
                id: json!("req-exec-1"),
                method: "execCommandApproval".to_string(),
                params: json!({
                    "conversationId": "thread-legacy",
                    "callId": "call-exec-1",
                    "approvalId": "approval-1",
                    "command": ["git", "status"],
                    "cwd": "/srv/workspace",
                    "reason": "需要执行命令",
                    "parsedCmd": []
                }),
            },
        ),
    )
    .await
    .expect("处理 legacy request 超时")
    .expect("处理 legacy request 失败");

    let pending = state
        .storage
        .list_pending_requests()
        .expect("读取 pending requests 失败");
    assert_eq!(1, pending.len());
    assert_eq!("execCommandApproval", pending[0].request_type);
    assert_eq!(Some("thread-legacy"), pending[0].thread_id.as_deref());
    assert_eq!(Some("git status"), pending[0].command.as_deref());
    assert_eq!(
        vec!["approved", "approved_for_session", "denied", "abort"],
        pending[0].available_decisions,
    );
}

#[tokio::test]
async fn turn_completed_notification_cleans_up_staged_turn_inputs() {
    let state = bootstrap_test_state().await;
    let stage_response = state
        .handle_request(
            "stage_input_image",
            json!({
                "fileName": "cleanup.png",
                "mimeType": "image/png",
                "base64Data": "aGVsbG8="
            }),
        )
        .await
        .expect("stage_input_image 请求失败");
    let local_path = stage_response["image"]["localPath"]
        .as_str()
        .expect("应返回 localPath")
        .to_string();
    assert!(Path::new(&local_path).exists());
    state.register_staged_turn_inputs("turn-cleanup", vec![local_path.clone().into()]);

    timeout(
        Duration::from_secs(2),
        handle_app_server_message(
            &state,
            AppServerInbound::Notification {
                runtime_id: PRIMARY_RUNTIME_ID.to_string(),
                method: "turn/completed".to_string(),
                params: json!({
                    "threadId": "thread-1",
                    "turnId": "turn-cleanup",
                    "status": { "type": "completed" }
                }),
            },
        ),
    )
    .await
    .expect("处理 turn/completed 超时")
    .expect("处理 turn/completed 失败");

    assert!(!Path::new(&local_path).exists());
}