codex-mobile-contracts 0.3.6

Shared domain contracts for codex-mobile client and bridge.
Documentation
use codex_mobile_contracts::{ExecCommandLine, ThreadRenderNode, ThreadRenderSnapshot};
use serde_json::json;

#[test]
fn thread_render_snapshot_serializes_exec_group_fields_in_camel_case() {
    let snapshot = ThreadRenderSnapshot {
        runtime_id: "primary".to_string(),
        thread_id: "thread-1".to_string(),
        revision: 7,
        status_surface: None,
        nodes: vec![ThreadRenderNode::ExecGroup {
            id: "exec-1".to_string(),
            turn_id: Some("turn-1".to_string()),
            item_id: Some("item-1".to_string()),
            title: "Ran".to_string(),
            kind: "ran".to_string(),
            state: "completed".to_string(),
            commands: vec![ExecCommandLine {
                label: "Run".to_string(),
                text: "uname -a".to_string(),
            }],
            output_text: Some("Linux\n".to_string()),
            exit_code: Some(0),
        }],
    };

    let value = serde_json::to_value(&snapshot).expect("snapshot 应可序列化");
    let node = &value["nodes"][0];

    assert_eq!(node["turnId"], json!("turn-1"));
    assert_eq!(node["itemId"], json!("item-1"));
    assert_eq!(node["outputText"], json!("Linux\n"));
    assert_eq!(node["exitCode"], json!(0));
    assert!(node.get("turn_id").is_none());
    assert!(node.get("item_id").is_none());
    assert!(node.get("output_text").is_none());
    assert!(node.get("exit_code").is_none());
}

#[test]
fn thread_render_node_rejects_legacy_snake_case_exec_fields() {
    let error = serde_json::from_value::<ThreadRenderNode>(json!({
        "type": "exec_group",
        "id": "exec-legacy",
        "turn_id": "turn-legacy",
        "item_id": "item-legacy",
        "title": "Ran",
        "kind": "ran",
        "state": "completed",
        "commands": [
            {
                "label": "Run",
                "text": "pwd"
            }
        ],
        "output_text": "/tmp/work\n",
        "exit_code": 0
    }))
    .expect_err("旧版 exec_group JSON 应被拒绝");

    assert!(error.to_string().contains("unknown field"));
}

#[test]
fn thread_render_nodes_serialize_and_deserialize_detail_lines_and_saved_path() {
    let collab_value = serde_json::to_value(ThreadRenderNode::CollabEvent {
        id: "collab-1".to_string(),
        turn_id: Some("turn-2".to_string()),
        item_id: Some("item-2".to_string()),
        title: "Subagent".to_string(),
        detail_lines: vec!["step 1".to_string(), "step 2".to_string()],
    })
    .expect("collab event 应可序列化");
    let image_value = serde_json::to_value(ThreadRenderNode::ImageGeneration {
        id: "image-1".to_string(),
        turn_id: Some("turn-3".to_string()),
        item_id: Some("item-3".to_string()),
        title: "Image generation".to_string(),
        state: "completed".to_string(),
        prompt: Some("draw it".to_string()),
        result: "ok".to_string(),
        saved_path: Some("/tmp/image.png".to_string()),
    })
    .expect("image generation 应可序列化");

    assert_eq!(collab_value["detailLines"], json!(["step 1", "step 2"]));
    assert!(collab_value.get("detail_lines").is_none());
    assert_eq!(image_value["savedPath"], json!("/tmp/image.png"));
    assert!(image_value.get("saved_path").is_none());

    let collab_error = serde_json::from_value::<ThreadRenderNode>(json!({
        "type": "collab_event",
        "id": "collab-legacy",
        "turn_id": "turn-legacy",
        "item_id": "item-legacy",
        "title": "Subagent",
        "detail_lines": ["legacy line"]
    }))
    .expect_err("旧版 collab_event JSON 应被拒绝");
    let image_error = serde_json::from_value::<ThreadRenderNode>(json!({
        "type": "image_generation",
        "id": "image-legacy",
        "turn_id": "turn-legacy",
        "item_id": "item-legacy",
        "title": "Image generation",
        "state": "completed",
        "result": "ok",
        "saved_path": "/tmp/legacy.png"
    }))
    .expect_err("旧版 image_generation JSON 应被拒绝");

    assert!(collab_error.to_string().contains("unknown field"));
    assert!(image_error.to_string().contains("unknown field"));
}