codex-mobile-bridge 0.3.11

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

use serde_json::json;
use tempfile::tempdir;

use super::history::enrich_thread_history_from_rollout;
use crate::bridge_protocol::ThreadRenderNode;
use crate::state::render::build_thread_render_snapshot;

#[test]
fn enrich_thread_history_backfills_missing_aggregated_output() {
    let temp_dir = tempdir().expect("创建临时目录失败");
    let rollout_path = temp_dir.path().join("rollout-thread-a.jsonl");
    write_rollout(
        &rollout_path,
        &[json!({
            "type": "event_msg",
            "payload": {
                "type": "exec_command_end",
                "call_id": "call-a",
                "turn_id": "turn-a",
                "command": ["/bin/bash", "-lc", "pwd"],
                "cwd": "/tmp/work",
                "parsed_cmd": [{"type": "unknown", "cmd": "pwd"}],
                "aggregated_output": "/tmp/work\n",
                "exit_code": 0,
                "status": "completed"
            }
        })],
    );

    let mut thread = json!({
        "id": "thread-a",
        "path": rollout_path.display().to_string(),
        "turns": [{
            "id": "turn-a",
            "items": [{
                "id": "call-a",
                "type": "commandExecution",
                "status": "completed",
                "command": "pwd",
                "commandActions": [{"type": "run", "command": "pwd"}],
                "aggregatedOutput": null
            }]
        }]
    });

    enrich_thread_history_from_rollout(&mut thread, None);

    let item = &thread["turns"][0]["items"][0];
    assert_eq!(item["aggregatedOutput"], json!("/tmp/work\n"));
    assert_eq!(item["exitCode"], json!(0));
}

#[test]
fn enrich_thread_history_keeps_existing_aggregated_output() {
    let temp_dir = tempdir().expect("创建临时目录失败");
    let rollout_path = temp_dir.path().join("rollout-thread-keep.jsonl");
    write_rollout(
        &rollout_path,
        &[json!({
            "type": "event_msg",
            "payload": {
                "type": "exec_command_end",
                "call_id": "call-keep",
                "turn_id": "turn-keep",
                "command": ["/bin/bash", "-lc", "pwd"],
                "cwd": "/tmp/work",
                "parsed_cmd": [{"type": "unknown", "cmd": "pwd"}],
                "aggregated_output": "fallback output\n",
                "exit_code": 0,
                "status": "completed"
            }
        })],
    );

    let mut thread = json!({
        "id": "thread-keep",
        "path": rollout_path.display().to_string(),
        "turns": [{
            "id": "turn-keep",
            "items": [{
                "id": "call-keep",
                "type": "commandExecution",
                "status": "completed",
                "command": "pwd",
                "commandActions": [{"type": "run", "command": "pwd"}],
                "aggregatedOutput": "official output\n"
            }]
        }]
    });

    enrich_thread_history_from_rollout(&mut thread, None);

    assert_eq!(
        thread["turns"][0]["items"][0]["aggregatedOutput"],
        json!("official output\n")
    );
}

#[test]
fn enrich_thread_history_inserts_missing_command_before_final_answer() {
    let temp_dir = tempdir().expect("创建临时目录失败");
    let rollout_path = temp_dir.path().join("rollout-thread-b.jsonl");
    write_rollout(
        &rollout_path,
        &[
            json!({
                "type": "turn_context",
                "payload": {
                    "turn_id": "turn-b"
                }
            }),
            json!({
                "type": "response_item",
                "payload": {
                    "type": "function_call",
                    "name": "exec_command",
                    "call_id": "call-b",
                    "arguments": "{\"cmd\":\"pwd\",\"workdir\":\"/tmp/work\"}"
                }
            }),
            json!({
                "type": "response_item",
                "payload": {
                    "type": "function_call_output",
                    "call_id": "call-b",
                    "output": "Command: /bin/bash -lc pwd\nProcess exited with code 0\nOutput:\n/tmp/work\n"
                }
            }),
        ],
    );

    let mut thread = json!({
        "id": "thread-b",
        "path": rollout_path.display().to_string(),
        "turns": [{
            "id": "turn-b",
            "items": [
                {
                    "id": "item-user",
                    "type": "userMessage",
                    "content": [{"type": "text", "text": "继续"}]
                },
                {
                    "id": "item-commentary",
                    "type": "agentMessage",
                    "text": "先查一下",
                    "phase": "commentary"
                },
                {
                    "id": "item-final",
                    "type": "agentMessage",
                    "text": "结论如下",
                    "phase": "final_answer"
                }
            ]
        }]
    });

    enrich_thread_history_from_rollout(&mut thread, None);

    let items = thread["turns"][0]["items"]
        .as_array()
        .expect("应存在 items");
    assert_eq!(items[2]["type"], json!("commandExecution"));
    assert_eq!(items[2]["id"], json!("call-b"));
    assert_eq!(items[2]["aggregatedOutput"], json!("/tmp/work\n"));
    assert_eq!(items[3]["id"], json!("item-final"));

    let snapshot =
        build_thread_render_snapshot("runtime-test", &thread).expect("构建 render snapshot 失败");
    let exec_node = snapshot
        .nodes
        .iter()
        .find_map(|node| match node {
            ThreadRenderNode::ExecGroup {
                title,
                output_text,
                commands,
                ..
            } => Some((title.clone(), output_text.clone(), commands.clone())),
            _ => None,
        })
        .expect("应存在补建后的 exec group");
    assert_eq!(exec_node.0, "Explored");
    assert_eq!(exec_node.1, Some("/tmp/work\n".to_string()));
    assert_eq!(exec_node.2[0].label, "List");
}

#[test]
fn enrich_thread_history_falls_back_to_codex_home_when_thread_path_missing() {
    let temp_dir = tempdir().expect("创建临时目录失败");
    let rollout_path = temp_dir
        .path()
        .join("sessions/2026/04/15/rollout-2026-04-15T10-00-00-thread-c.jsonl");
    if let Some(parent) = rollout_path.parent() {
        fs::create_dir_all(parent).expect("创建 sessions 目录失败");
    }
    write_rollout(
        &rollout_path,
        &[
            json!({
                "type": "turn_context",
                "payload": {
                    "turn_id": "turn-c"
                }
            }),
            json!({
                "type": "response_item",
                "payload": {
                    "type": "function_call",
                    "name": "exec_command",
                    "call_id": "call-c",
                    "arguments": "{\"cmd\":\"rg --files -g 'AGENTS.md' .\",\"workdir\":\"/tmp/work\"}"
                }
            }),
            json!({
                "type": "response_item",
                "payload": {
                    "type": "function_call_output",
                    "call_id": "call-c",
                    "output": "Command: /bin/bash -lc \"rg --files -g 'AGENTS.md' .\"\nProcess exited with code 0\nOutput:\n./AGENTS.md\n"
                }
            }),
        ],
    );

    let mut thread = json!({
        "id": "thread-c",
        "turns": [{
            "id": "turn-c",
            "items": [{
                "id": "item-final",
                "type": "agentMessage",
                "text": "done",
                "phase": "final_answer"
            }]
        }]
    });

    enrich_thread_history_from_rollout(
        &mut thread,
        Some(temp_dir.path().to_string_lossy().as_ref()),
    );

    let items = thread["turns"][0]["items"]
        .as_array()
        .expect("应存在 items");
    assert_eq!(items[0]["id"], json!("call-c"));
    assert_eq!(items[0]["aggregatedOutput"], json!("./AGENTS.md\n"));
}

#[test]
fn enrich_thread_history_extracts_text_from_function_call_output_body_arrays() {
    let temp_dir = tempdir().expect("创建临时目录失败");
    let rollout_path = temp_dir.path().join("rollout-thread-d.jsonl");
    write_rollout(
        &rollout_path,
        &[
            json!({
                "type": "turn_context",
                "payload": {
                    "turn_id": "turn-d"
                }
            }),
            json!({
                "type": "response_item",
                "payload": {
                    "type": "function_call",
                    "name": "exec_command",
                    "call_id": "call-d",
                    "arguments": "{\"cmd\":\"printf hello\",\"workdir\":\"/tmp/work\"}"
                }
            }),
            json!({
                "type": "response_item",
                "payload": {
                    "type": "function_call_output",
                    "call_id": "call-d",
                    "output": {
                        "body": [
                            {
                                "type": "input_text",
                                "text": "Command: /bin/bash -lc 'printf hello'\nProcess exited with code 0\nOutput:\nhello\n"
                            },
                            {
                                "type": "input_image",
                                "image_url": "data:image/png;base64,abc"
                            }
                        ]
                    }
                }
            }),
        ],
    );

    let mut thread = json!({
        "id": "thread-d",
        "path": rollout_path.display().to_string(),
        "turns": [{
            "id": "turn-d",
            "items": [{
                "id": "item-final",
                "type": "agentMessage",
                "text": "done",
                "phase": "final_answer"
            }]
        }]
    });

    enrich_thread_history_from_rollout(&mut thread, None);

    let items = thread["turns"][0]["items"]
        .as_array()
        .expect("应存在 items");
    assert_eq!(items[0]["id"], json!("call-d"));
    assert_eq!(items[0]["aggregatedOutput"], json!("hello\n"));
    assert_eq!(items[0]["exitCode"], json!(0));
}

#[test]
fn enrich_thread_history_does_not_fake_output_for_image_only_function_call_output() {
    let temp_dir = tempdir().expect("创建临时目录失败");
    let rollout_path = temp_dir.path().join("rollout-thread-e.jsonl");
    write_rollout(
        &rollout_path,
        &[
            json!({
                "type": "turn_context",
                "payload": {
                    "turn_id": "turn-e"
                }
            }),
            json!({
                "type": "response_item",
                "payload": {
                    "type": "function_call",
                    "name": "exec_command",
                    "call_id": "call-e",
                    "arguments": "{\"cmd\":\"git status --short\",\"workdir\":\"/tmp/work\"}"
                }
            }),
            json!({
                "type": "response_item",
                "payload": {
                    "type": "function_call_output",
                    "call_id": "call-e",
                    "output": [
                        {
                            "type": "input_image",
                            "image_url": "data:image/png;base64,abc"
                        }
                    ]
                }
            }),
        ],
    );

    let mut thread = json!({
        "id": "thread-e",
        "path": rollout_path.display().to_string(),
        "turns": [{
            "id": "turn-e",
            "items": [{
                "id": "call-e",
                "type": "commandExecution",
                "status": "completed",
                "command": "git status --short",
                "commandActions": [{"type": "run", "command": "git status --short"}],
                "aggregatedOutput": null
            }]
        }]
    });

    enrich_thread_history_from_rollout(&mut thread, None);

    let item = &thread["turns"][0]["items"][0];
    assert_eq!(item["id"], json!("call-e"));
    assert!(item.get("aggregatedOutput").is_none() || item["aggregatedOutput"].is_null());
    assert!(item.get("exitCode").is_none() || item["exitCode"].is_null());
}

fn write_rollout(path: &Path, lines: &[serde_json::Value]) {
    let content = lines
        .iter()
        .map(|line| serde_json::to_string(line).expect("序列化 rollout 行失败"))
        .collect::<Vec<_>>()
        .join("\n");
    fs::write(path, format!("{content}\n")).expect("写入 rollout 文件失败");
}