loopflow 0.9.11

Run steps and flows with coding agents
Documentation
use serde_json::{json, Value};

use crate::lfd::sessions::types::{
    FileEdit, ItemDelta, ItemStatus, SessionItem, TurnStatus, TurnUsage,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ItemPhase {
    Started,
    Completed,
}

pub(super) fn extract_turn_id(params: &Value) -> Option<String> {
    params
        .get("turn")
        .and_then(|t| t.get("id"))
        .and_then(Value::as_str)
        .or_else(|| params.get("turnId").and_then(Value::as_str))
        .map(ToString::to_string)
}

pub(super) fn map_turn_status(params: &Value) -> TurnStatus {
    let turn_status = params
        .get("turn")
        .and_then(|t| t.get("status"))
        .and_then(Value::as_str)
        .or_else(|| params.get("status").and_then(Value::as_str))
        .unwrap_or_default();
    match turn_status {
        "interrupted" | "cancelled" => TurnStatus::Interrupted,
        "failed" | "error" => TurnStatus::Failed,
        _ => TurnStatus::Completed,
    }
}

pub(super) fn map_turn_usage(params: &Value) -> TurnUsage {
    TurnUsage {
        input_tokens: params
            .pointer("/usage/input_tokens")
            .and_then(Value::as_u64)
            .unwrap_or(0),
        output_tokens: params
            .pointer("/usage/output_tokens")
            .and_then(Value::as_u64)
            .unwrap_or(0),
        reasoning_tokens: params
            .pointer("/usage/reasoning_tokens")
            .and_then(Value::as_u64),
        cache_read_tokens: None,
        cache_write_tokens: None,
        model: params
            .get("model")
            .and_then(Value::as_str)
            .map(ToString::to_string),
        cost_usd: None,
    }
}

pub(super) fn map_item_id(params: &Value) -> String {
    item_payload(params)
        .get("id")
        .and_then(Value::as_str)
        .or_else(|| params.get("itemId").and_then(Value::as_str))
        .or_else(|| params.get("id").and_then(Value::as_str))
        .unwrap_or("unknown")
        .to_string()
}

pub(super) fn build_item(params: &Value, phase: ItemPhase) -> SessionItem {
    let id = map_item_id(params);
    let item_type = map_item_type(params);
    let item = item_payload(params);
    let status = map_item_status(params);
    let completed = phase == ItemPhase::Completed;

    match item_type {
        "commandExecution" => SessionItem::Command {
            id,
            command: parse_command(item),
            cwd: required_text_field(item, "cwd"),
            status,
            output: if completed {
                text_field(item, "aggregatedOutput")
            } else {
                None
            },
            exit_code: if completed {
                item.get("exitCode")
                    .and_then(Value::as_i64)
                    .map(|v| v as i32)
            } else {
                None
            },
            duration_ms: if completed {
                item.get("durationMs").and_then(Value::as_u64)
            } else {
                None
            },
        },
        "fileChange" => SessionItem::File {
            id,
            changes: parse_file_changes(item),
            status,
        },
        "mcpToolCall" => SessionItem::Tool {
            id,
            name: mcp_tool_name(item),
            status,
            input: Some(item.get("arguments").cloned().unwrap_or_else(|| json!({}))),
            output: if completed {
                text_field(item, "result").or_else(|| text_field(item, "error"))
            } else {
                None
            },
        },
        "agentMessage" => SessionItem::Message {
            id,
            text: required_text_field(item, "text"),
            phase: text_field(item, "phase"),
        },
        "plan" => SessionItem::Thought {
            id,
            text: required_text_field(item, "text"),
        },
        _ => SessionItem::Tool {
            id,
            name: item_type.to_string(),
            status,
            input: item.get("input").cloned(),
            output: if completed {
                text_field(item, "output")
            } else {
                None
            },
        },
    }
}

pub(super) fn map_item_delta(method: &str, params: &Value) -> Option<ItemDelta> {
    let content = text_content(params)?;
    match method {
        "item/commandExecution/outputDelta" | "item/fileChange/outputDelta" => {
            Some(ItemDelta::Output { content })
        }
        "item/plan/delta" => Some(ItemDelta::PlanText { content }),
        _ => None,
    }
}

pub(super) fn text_content(params: &Value) -> Option<String> {
    params
        .get("content")
        .and_then(Value::as_str)
        .or_else(|| params.get("delta").and_then(Value::as_str))
        .or_else(|| params.get("output").and_then(Value::as_str))
        .map(ToString::to_string)
}

fn item_payload(params: &Value) -> &Value {
    params.get("item").unwrap_or(params)
}

fn text_field(value: &Value, key: &str) -> Option<String> {
    value
        .get(key)
        .and_then(Value::as_str)
        .map(ToString::to_string)
}

fn required_text_field(value: &Value, key: &str) -> String {
    text_field(value, key).unwrap_or_default()
}

fn map_item_type(params: &Value) -> &str {
    item_payload(params)
        .get("type")
        .and_then(Value::as_str)
        .or_else(|| params.get("kind").and_then(Value::as_str))
        .unwrap_or("tool")
}

fn map_item_status(params: &Value) -> ItemStatus {
    let status = item_payload(params)
        .get("status")
        .and_then(Value::as_str)
        .or_else(|| params.get("status").and_then(Value::as_str))
        .unwrap_or("in_progress");
    match status {
        "completed" => ItemStatus::Completed,
        "failed" | "error" => ItemStatus::Failed,
        "declined" => ItemStatus::Declined,
        _ => ItemStatus::InProgress,
    }
}

fn parse_command(item: &Value) -> Vec<String> {
    item.get("command")
        .and_then(Value::as_array)
        .map(|arr| {
            arr.iter()
                .filter_map(Value::as_str)
                .map(ToString::to_string)
                .collect()
        })
        .unwrap_or_default()
}

fn mcp_tool_name(item: &Value) -> String {
    let tool = required_text_field(item, "tool");
    let server = text_field(item, "server").unwrap_or_default();
    if server.is_empty() {
        if tool.is_empty() {
            "mcp_tool_call".to_string()
        } else {
            tool
        }
    } else if tool.is_empty() {
        server
    } else {
        format!("{server}/{tool}")
    }
}

fn parse_file_changes(item: &Value) -> Vec<FileEdit> {
    item.get("changes")
        .and_then(Value::as_array)
        .map(|arr| {
            arr.iter()
                .map(|c| FileEdit {
                    path: c
                        .get("path")
                        .and_then(Value::as_str)
                        .unwrap_or_default()
                        .to_string(),
                    kind: c
                        .get("kind")
                        .and_then(Value::as_str)
                        .map(ToString::to_string),
                    diff: c
                        .get("diff")
                        .and_then(Value::as_str)
                        .map(ToString::to_string),
                })
                .collect()
        })
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn build_item_maps_file_change_to_file() {
        let params = json!({
            "item": {
                "id": "item_1",
                "type": "fileChange",
                "status": "completed",
                "changes": [
                    {"path": "src/main.rs", "kind": "update", "diff": "-a\n+b"}
                ]
            }
        });

        let item = build_item(&params, ItemPhase::Completed);
        match item {
            SessionItem::File {
                id,
                changes,
                status,
            } => {
                assert_eq!(id, "item_1");
                assert_eq!(status, ItemStatus::Completed);
                assert_eq!(changes.len(), 1);
                assert_eq!(changes[0].path, "src/main.rs");
            }
            other => panic!("expected file item, got {other:?}"),
        }
    }

    #[test]
    fn build_item_maps_agent_message_to_message() {
        let params = json!({
            "item": {
                "id": "item_2",
                "type": "agentMessage",
                "text": "Done",
                "phase": "final"
            }
        });

        let item = build_item(&params, ItemPhase::Completed);
        match item {
            SessionItem::Message { id, text, phase } => {
                assert_eq!(id, "item_2");
                assert_eq!(text, "Done");
                assert_eq!(phase.as_deref(), Some("final"));
            }
            other => panic!("expected message item, got {other:?}"),
        }
    }

    #[test]
    fn build_item_maps_plan_to_thought() {
        let params = json!({
            "item": {
                "id": "item_3",
                "type": "plan",
                "text": "Run tests first"
            }
        });

        let item = build_item(&params, ItemPhase::Completed);
        match item {
            SessionItem::Thought { id, text } => {
                assert_eq!(id, "item_3");
                assert_eq!(text, "Run tests first");
            }
            other => panic!("expected thought item, got {other:?}"),
        }
    }

    #[test]
    fn build_item_maps_mcp_tool_call_to_generic_tool() {
        let params = json!({
            "item": {
                "id": "item_4",
                "type": "mcpToolCall",
                "status": "completed",
                "server": "github",
                "tool": "search",
                "arguments": { "query": "regression" },
                "result": "ok"
            }
        });

        let item = build_item(&params, ItemPhase::Completed);
        match item {
            SessionItem::Tool {
                id,
                name,
                status,
                input,
                output,
            } => {
                assert_eq!(id, "item_4");
                assert_eq!(name, "github/search");
                assert_eq!(status, ItemStatus::Completed);
                assert_eq!(input, Some(json!({ "query": "regression" })));
                assert_eq!(output.as_deref(), Some("ok"));
            }
            other => panic!("expected tool item, got {other:?}"),
        }
    }

    #[test]
    fn map_turn_usage_reads_token_payload() {
        let usage = map_turn_usage(&json!({
            "model": "gpt-5.1-codex",
            "usage": {
                "input_tokens": 144,
                "output_tokens": 55,
                "reasoning_tokens": 3
            }
        }));

        assert_eq!(usage.input_tokens, 144);
        assert_eq!(usage.output_tokens, 55);
        assert_eq!(usage.reasoning_tokens, Some(3));
        assert_eq!(usage.model.as_deref(), Some("gpt-5.1-codex"));
    }
}