codex-mobile-bridge 0.3.6

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;

#[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,
    );
}

#[tokio::test]
async fn hook_notifications_upsert_single_render_node() {
    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: "hook/started".to_string(),
                params: json!({
                    "threadId": "thread-render-hook",
                    "turnId": "turn-hook",
                    "run": {
                        "id": "hook-run-1",
                        "eventName": "preToolUse",
                        "statusMessage": "checking tool arguments"
                    }
                }),
            },
        ),
    )
    .await
    .expect("处理 hook/started 超时")
    .expect("处理 hook/started 失败");

    timeout(
        Duration::from_secs(2),
        handle_app_server_message(
            &state,
            AppServerInbound::Notification {
                runtime_id: PRIMARY_RUNTIME_ID.to_string(),
                method: "hook/completed".to_string(),
                params: json!({
                    "threadId": "thread-render-hook",
                    "turnId": "turn-hook",
                    "run": {
                        "id": "hook-run-1",
                        "eventName": "preToolUse",
                        "status": "completed",
                        "statusMessage": "checking tool arguments",
                        "entries": [
                            {
                                "kind": "feedback",
                                "text": "validated"
                            }
                        ]
                    }
                }),
            },
        ),
    )
    .await
    .expect("处理 hook/completed 超时")
    .expect("处理 hook/completed 失败");

    let snapshots = state
        .thread_render_snapshots
        .lock()
        .expect("thread render snapshots poisoned");
    let snapshot = snapshots
        .get("thread-render-hook")
        .expect("应缓存 hook thread render snapshot");
    let hook_nodes = snapshot
        .nodes
        .iter()
        .filter_map(|node| match node {
            crate::bridge_protocol::ThreadRenderNode::HookEvent {
                title,
                state,
                detail_lines,
                ..
            } => Some((title.clone(), state.clone(), detail_lines.clone())),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(1, hook_nodes.len());
    assert_eq!("PreToolUse hook (completed)", hook_nodes[0].0);
    assert_eq!("completed", hook_nodes[0].1);
    assert_eq!(
        vec![
            "checking tool arguments".to_string(),
            "feedback: validated".to_string(),
        ],
        hook_nodes[0].2,
    );
}

#[tokio::test]
async fn terminal_interaction_notification_preserves_command_and_input() {
    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-terminal",
                    "turnId": "turn-terminal",
                    "item": {
                        "id": "item-exec-terminal",
                        "type": "commandExecution",
                        "status": "completed",
                        "command": "python manage.py migrate"
                    }
                }),
            },
        ),
    )
    .await
    .expect("处理 terminal command item/completed 超时")
    .expect("处理 terminal command item/completed 失败");

    timeout(
        Duration::from_secs(2),
        handle_app_server_message(
            &state,
            AppServerInbound::Notification {
                runtime_id: PRIMARY_RUNTIME_ID.to_string(),
                method: "item/commandExecution/terminalInteraction".to_string(),
                params: json!({
                    "threadId": "thread-render-terminal",
                    "turnId": "turn-terminal",
                    "itemId": "item-exec-terminal",
                    "stdin": "yes\ncontinue\n"
                }),
            },
        ),
    )
    .await
    .expect("处理 terminalInteraction 超时")
    .expect("处理 terminalInteraction 失败");

    let snapshots = state
        .thread_render_snapshots
        .lock()
        .expect("thread render snapshots poisoned");
    let snapshot = snapshots
        .get("thread-render-terminal")
        .expect("应缓存 terminal thread render snapshot");
    let terminal = snapshot
        .nodes
        .iter()
        .find_map(|node| match node {
            crate::bridge_protocol::ThreadRenderNode::TerminalInteraction {
                title,
                command,
                stdin,
                waited,
                ..
            } => Some((title.clone(), command.clone(), stdin.clone(), *waited)),
            _ => None,
        })
        .expect("应存在 terminal interaction 节点");
    assert_eq!("Interacted with background terminal", terminal.0);
    assert_eq!(Some("python manage.py migrate".to_string()), terminal.1);
    assert_eq!("yes\ncontinue\n".to_string(), terminal.2);
    assert!(!terminal.3);
}

#[tokio::test]
async fn approval_review_notifications_upsert_single_render_node() {
    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/autoApprovalReview/started".to_string(),
                params: json!({
                    "threadId": "thread-render-approval",
                    "turnId": "turn-approval",
                    "targetItemId": "item-command-1",
                    "reviewId": "review-1",
                    "action": {
                        "type": "command",
                        "command": "git status",
                        "cwd": "/workspace"
                    },
                    "review": {
                        "status": "inProgress",
                        "riskLevel": "medium"
                    }
                }),
            },
        ),
    )
    .await
    .expect("处理 approval review started 超时")
    .expect("处理 approval review started 失败");

    timeout(
        Duration::from_secs(2),
        handle_app_server_message(
            &state,
            AppServerInbound::Notification {
                runtime_id: PRIMARY_RUNTIME_ID.to_string(),
                method: "item/autoApprovalReview/completed".to_string(),
                params: json!({
                    "threadId": "thread-render-approval",
                    "turnId": "turn-approval",
                    "targetItemId": "item-command-1",
                    "reviewId": "review-1",
                    "decisionSource": "guardian",
                    "action": {
                        "type": "command",
                        "command": "git status",
                        "cwd": "/workspace"
                    },
                    "review": {
                        "status": "approved",
                        "riskLevel": "medium",
                        "rationale": "matches allowlist"
                    }
                }),
            },
        ),
    )
    .await
    .expect("处理 approval review completed 超时")
    .expect("处理 approval review completed 失败");

    let snapshots = state
        .thread_render_snapshots
        .lock()
        .expect("thread render snapshots poisoned");
    let snapshot = snapshots
        .get("thread-render-approval")
        .expect("应缓存 approval thread render snapshot");
    let approval_nodes = snapshot
        .nodes
        .iter()
        .filter_map(|node| match node {
            crate::bridge_protocol::ThreadRenderNode::ApprovalReview {
                title,
                state,
                detail_lines,
                ..
            } => Some((title.clone(), state.clone(), detail_lines.clone())),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(1, approval_nodes.len());
    assert_eq!("Reviewed command approval request", approval_nodes[0].0);
    assert_eq!("approved", approval_nodes[0].1);
    assert_eq!(
        vec![
            "command: git status @ /workspace".to_string(),
            "status: approved".to_string(),
            "risk: medium".to_string(),
            "matches allowlist".to_string(),
            "decision source: guardian".to_string(),
        ],
        approval_nodes[0].2,
    );
}