codex-mobile-bridge 0.3.3

Remote bridge and service manager for codex-mobile.
Documentation
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 command_execution_render_snapshot_preserves_exec_kind() {
    let state = bootstrap_test_state().await;

    timeout(
        Duration::from_secs(2),
        handle_app_server_message(
            &state,
            AppServerInbound::Notification {
                runtime_id: PRIMARY_RUNTIME_ID.to_string(),
                method: "item/completed".to_string(),
                params: json!({
                    "threadId": "thread-render-1",
                    "turnId": "turn-1",
                    "item": {
                        "id": "item-explored",
                        "type": "commandExecution",
                        "status": "completed",
                        "command": "ls -la",
                        "aggregatedOutput": "file-a\nfile-b\n",
                        "exitCode": 0,
                        "commandActions": [
                            {
                                "type": "listFiles",
                                "command": "ls -la",
                                "path": "."
                            }
                        ]
                    }
                }),
            },
        ),
    )
    .await
    .expect("处理 commandExecution item/completed 超时")
    .expect("处理 commandExecution item/completed 失败");

    let snapshots = state
        .thread_render_snapshots
        .lock()
        .expect("thread render snapshots poisoned");
    let snapshot = snapshots
        .get("thread-render-1")
        .expect("应缓存 thread render snapshot");
    let exec_node = snapshot
        .nodes
        .iter()
        .find_map(|node| match node {
            crate::bridge_protocol::ThreadRenderNode::ExecGroup {
                kind,
                output_text,
                ..
            } => Some((kind.clone(), output_text.clone())),
            _ => None,
        })
        .expect("应存在 exec group 节点");
    assert_eq!("explored", exec_node.0);
    assert_eq!(Some("file-a\nfile-b\n".to_string()), exec_node.1);
}

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

    timeout(
        Duration::from_secs(2),
        handle_app_server_message(
            &state,
            AppServerInbound::Notification {
                runtime_id: PRIMARY_RUNTIME_ID.to_string(),
                method: "item/completed".to_string(),
                params: json!({
                    "threadId": "thread-render-2",
                    "turnId": "turn-2",
                    "item": {
                        "id": "item-file-change",
                        "type": "fileChange",
                        "status": "completed",
                        "changes": [
                            {
                                "path": "/srv/workspace/src/main.rs",
                                "kind": {
                                    "update": {}
                                },
                                "diff": "diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1 +1 @@\n-old\n+new\n"
                            }
                        ]
                    }
                }),
            },
        ),
    )
    .await
    .expect("处理 fileChange item/completed 超时")
    .expect("处理 fileChange item/completed 失败");

    let snapshots = state
        .thread_render_snapshots
        .lock()
        .expect("thread render snapshots poisoned");
    let snapshot = snapshots
        .get("thread-render-2")
        .expect("应缓存 thread render snapshot");
    let change = snapshot
        .nodes
        .iter()
        .find_map(|node| match node {
            crate::bridge_protocol::ThreadRenderNode::FileChange { changes, .. } => {
                changes.first().cloned()
            }
            _ => None,
        })
        .expect("应存在 file change 节点");
    assert_eq!("/srv/workspace/src/main.rs", change.path);
    assert_eq!(
        Some(
            "diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1 +1 @@\n-old\n+new\n"
                .to_string()
        ),
        change.diff,
    );
}