malvin 0.2.5

Non-interactive research and coding agent
use serde_json::json;

use super::map_event::map_codex_stream_events;
use crate::bridge_protocol::BridgeEvent;

#[test]
fn maps_assistant_and_reasoning_deltas() {
    let assistant = map_codex_stream_events(
        "item/agentMessage/delta",
        &json!({ "delta": "hello", "turnId": "t1" }),
    );
    assert!(matches!(
        assistant.as_slice(),
        [BridgeEvent::Assistant { text }] if text == "hello"
    ));
    let thinking = map_codex_stream_events(
        "item/reasoning/textDelta",
        &json!({ "delta": "ponder", "turnId": "t1" }),
    );
    assert!(matches!(
        thinking.as_slice(),
        [BridgeEvent::Thinking { text }] if text == "ponder"
    ));
    let summary = map_codex_stream_events(
        "item/reasoning/summaryTextDelta",
        &json!({ "delta": "plan", "turnId": "t1" }),
    );
    assert!(matches!(
        summary.as_slice(),
        [BridgeEvent::Thinking { text }] if text == "plan"
    ));
    assert!(map_codex_stream_events("item/agentMessage/delta", &json!({ "delta": "" })).is_empty());
    let reasoning_item = map_codex_stream_events(
        "item/completed",
        &json!({
            "item": {
                "id": "r1",
                "type": "reasoning",
                "content": ["think"],
                "summary": ["plan"]
            }
        }),
    );
    assert!(matches!(
        reasoning_item.as_slice(),
        [BridgeEvent::Thinking { text }] if text == "think"
    ));
    let usage = map_codex_stream_events(
        "thread/tokenUsage/updated",
        &json!({
            "tokenUsage": {
                "last": {
                    "inputTokens": 4,
                    "outputTokens": 2,
                    "cachedInputTokens": 1,
                    "cacheWriteInputTokens": 0,
                    "reasoningOutputTokens": 3,
                    "totalTokens": 10
                }
            }
        }),
    );
    match usage.as_slice() {
        [BridgeEvent::Usage { usage }] => {
            assert_eq!(usage["inputTokens"], 4);
            assert_eq!(usage["cacheReadTokens"], 1);
            assert_eq!(usage["reasoningTokens"], 3);
        }
        other => panic!("unexpected {other:?}"),
    }
    assert!(map_codex_stream_events("thread/tokenUsage/updated", &json!({})).is_empty());
    let objects = map_codex_stream_events(
        "item/completed",
        &json!({
            "item": {
                "id": "r2",
                "type": "reasoning",
                "content": [{"text": "why"}]
            }
        }),
    );
    assert!(matches!(
        objects.as_slice(),
        [BridgeEvent::Thinking { text }] if text == "why"
    ));
    let wrapped = map_codex_stream_events(
        "item/started",
        &json!({
            "item": {
                "id": "c2",
                "type": "commandExecution",
                "command": "/bin/bash -lc cat README.md"
            }
        }),
    );
    assert!(matches!(
        wrapped.as_slice(),
        [BridgeEvent::ToolCall { name, summary, .. }]
            if name.as_deref() == Some("read") && summary.as_deref() == Some("Read cat README.md")
    ));
}

#[test]
fn maps_command_execution_start_and_failure() {
    let start = map_codex_stream_events(
        "item/started",
        &json!({
            "turnId": "t1",
            "item": {
                "id": "c1",
                "type": "commandExecution",
                "command": "ls -la",
                "status": "inProgress"
            }
        }),
    );
    assert!(matches!(
        start.as_slice(),
        [BridgeEvent::ToolCall { phase, name, summary, tool_call_id }]
            if phase == "start"
                && name.as_deref() == Some("shell")
                && summary.as_deref() == Some("Run ls -la")
                && tool_call_id.as_deref() == Some("c1")
    ));
    let failed = map_codex_stream_events(
        "item/completed",
        &json!({
            "item": {
                "id": "c1",
                "type": "commandExecution",
                "command": "false",
                "status": "failed"
            }
        }),
    );
    assert!(matches!(
        failed.as_slice(),
        [BridgeEvent::ToolCall { phase, .. }] if phase == "error"
    ));
}

#[test]
fn maps_file_mcp_and_search_tools() {
    let edit = map_codex_stream_events(
        "item/completed",
        &json!({
            "item": {
                "id": "f1",
                "type": "fileChange",
                "status": "completed",
                "changes": [{ "path": "src/lib.rs", "kind": "update", "diff": "" }]
            }
        }),
    );
    assert!(matches!(
        edit.as_slice(),
        [BridgeEvent::ToolCall { phase, name, summary, .. }]
            if phase == "complete"
                && name.as_deref() == Some("edit")
                && summary.as_deref() == Some("Edit src/lib.rs")
    ));
    let mcp = map_codex_stream_events(
        "item/started",
        &json!({
            "item": {
                "id": "m1",
                "type": "mcpToolCall",
                "server": "docs",
                "tool": "read",
                "arguments": { "path": "README.md" },
                "status": "inProgress"
            }
        }),
    );
    assert!(matches!(
        mcp.as_slice(),
        [BridgeEvent::ToolCall { name, summary, .. }]
            if name.as_deref() == Some("read")
                && summary.as_deref() == Some("Read README.md")
    ));
    let search = map_codex_stream_events(
        "item/started",
        &json!({
            "item": { "id": "s1", "type": "webSearch", "query": "codex app-server" }
        }),
    );
    assert!(matches!(
        search.as_slice(),
        [BridgeEvent::ToolCall { name, summary, .. }]
            if name.as_deref() == Some("webSearch")
                && summary.as_deref() == Some("Search codex app-server")
    ));
}