xagent-pi 0.2.0

Self-contained local brain (chat UI + API + SSE) for the Pi agent, tunneled into xagent-service.
//! Convert Pi events / accumulator flushes into simple chunks.
//! The driver maps these to brain messages + SSE events.

use serde_json::Value;

/// An internal chunk produced by the accumulator or direct event conversion.
#[derive(Debug, PartialEq, Eq)]
pub enum Chunk {
    Text(String),
    Reasoning(String),
    ToolCall { id: String, name: String, input: Value },
    ToolResult { id: String, name: String, result: Value, is_error: bool },
}

fn str_field(v: &Value, k: &str) -> String {
    v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string()
}

/// Convert a non-`message_*` event directly to chunks (tool execution, etc.).
/// `message_*` events return empty here — they are handled by the accumulator.
pub fn convert_event(event: &Value) -> Vec<Chunk> {
    let t = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
    match t {
        "tool_execution_start" => vec![Chunk::ToolCall {
            id: str_field(event, "toolCallId"),
            name: str_field(event, "toolName"),
            input: event.get("args").cloned().unwrap_or(Value::Null),
        }],
        "tool_execution_end" => {
            let is_error = event.get("isError").and_then(|v| v.as_bool()).unwrap_or(false);
            vec![Chunk::ToolResult {
                id: str_field(event, "toolCallId"),
                name: str_field(event, "toolName"),
                result: event.get("result").cloned().unwrap_or(Value::Null),
                is_error,
            }]
        }
        _ => vec![],
    }
}

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

    #[test]
    fn tool_execution_start_yields_tool_call() {
        let chunks = convert_event(&json!({ "type": "tool_execution_start", "toolCallId": "call-1", "toolName": "read", "args": { "path": "a.rs" } }));
        assert_eq!(chunks, vec![Chunk::ToolCall { id: "call-1".into(), name: "read".into(), input: json!({ "path": "a.rs" }) }]);
    }

    #[test]
    fn tool_execution_end_yields_result() {
        let chunks = convert_event(&json!({
            "type": "tool_execution_end",
            "toolCallId": "call-1",
            "toolName": "read",
            "result": { "ok": true }
        }));
        assert_eq!(
            chunks,
            vec![Chunk::ToolResult {
                id: "call-1".into(),
                name: "read".into(),
                result: json!({ "ok": true }),
                is_error: false,
            }]
        );
    }

    #[test]
    fn tool_execution_end_flags_error() {
        let chunks = convert_event(&json!({
            "type": "tool_execution_end",
            "toolCallId": "call-2",
            "toolName": "read",
            "isError": true,
            "result": "boom"
        }));
        assert_eq!(
            chunks,
            vec![Chunk::ToolResult {
                id: "call-2".into(),
                name: "read".into(),
                result: json!("boom"),
                is_error: true,
            }]
        );
    }

    #[test]
    fn missing_fields_default_to_empty() {
        let chunks = convert_event(&json!({ "type": "tool_execution_end" }));
        assert_eq!(
            chunks,
            vec![Chunk::ToolResult {
                id: "".into(),
                name: "".into(),
                result: Value::Null,
                is_error: false,
            }]
        );
    }

    #[test]
    fn message_events_are_skipped() {
        // message_* events belong to the accumulator, not direct conversion.
        assert!(convert_event(&json!({
            "type": "message_update",
            "assistantMessageEvent": { "type": "text_delta", "delta": "hi" }
        }))
        .is_empty());
    }

    #[test]
    fn unknown_events_are_skipped() {
        assert!(convert_event(&json!({ "type": "ping" })).is_empty());
        assert!(convert_event(&json!({})).is_empty());
    }
}