aura-gui 0.2.2

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Normalización común antes de hablar con cualquier proveedor: Anthropic, OpenAI y Codex rechazan con un 400
//! detalles que el historial puede acumular (texto vacío, herramientas sin respuesta tras un fallo o al reabrir una
//! conversación, argumentos que no son JSON). Se corrige aquí una sola vez y sin copiar si no hace falta.

use crate::ai::types::{Block, Message, Request, Role};
use serde_json::{Value, json};
use std::borrow::Cow;

/// Clave con la que viajan unos argumentos que no eran JSON: el análisis de la herramienta lo explica al modelo.
pub const INVALID_ARGUMENTS: &str = "__invalid_arguments";
const MISSING_RESULT: &str = "La herramienta no llegó a ejecutarse (se interrumpió el turno).";
const EMPTY_RESULT: &str = "(sin salida)";

/// Argumentos de una llamada tal como llegan del stream. Un JSON roto no corta el turno: el modelo recibe el error
/// como resultado de la herramienta y puede reintentar.
pub fn tool_input(raw: &str) -> Value {
    if raw.trim().is_empty() {
        return json!({});
    }
    serde_json::from_str(raw).unwrap_or_else(|_| json!({ INVALID_ARGUMENTS: raw }))
}

fn is_empty_text(block: &Block) -> bool {
    matches!(block, Block::Text(text) if text.trim().is_empty())
}

fn tool_uses(message: &Message) -> impl Iterator<Item = &str> {
    message
        .blocks
        .iter()
        .filter_map(|block| if let Block::ToolUse { id, .. } = block { Some(id.as_str()) } else { None })
}

fn answers(message: Option<&Message>, id: &str) -> bool {
    message.is_some_and(|message| {
        message.role == Role::User
            && message.blocks.iter().any(|block| matches!(block, Block::ToolResult { id: done, .. } if done == id))
    })
}

/// `true` si el historial ya cumple lo que piden todas las APIs (el caso normal: no se copia nada).
fn is_clean(messages: &[Message]) -> bool {
    messages.iter().enumerate().all(|(index, message)| {
        let previous = index.checked_sub(1).map(|index| &messages[index]);
        !message.blocks.is_empty()
            && !message.blocks.iter().any(is_empty_text)
            && tool_uses(message).all(|id| answers(messages.get(index + 1), id))
            && message.blocks.iter().all(|block| match block {
                Block::ToolResult { id, content, .. } => {
                    !content.is_empty() && previous.is_some_and(|previous| tool_uses(previous).any(|used| used == id))
                }
                _ => true,
            })
    })
}

fn repair(messages: &[Message]) -> Vec<Message> {
    let mut out: Vec<Message> = Vec::with_capacity(messages.len() + 1);
    let mut asked: Vec<String> = Vec::new();
    for message in messages {
        let mut blocks: Vec<Block> = Vec::with_capacity(message.blocks.len());
        if message.role == Role::User {
            // Primero los resultados que faltaban del turno anterior, en el orden en que se pidieron.
            for id in &asked {
                if !message.blocks.iter().any(|block| matches!(block, Block::ToolResult { id: done, .. } if done == id))
                {
                    blocks.push(Block::ToolResult { id: id.clone(), content: MISSING_RESULT.into(), is_error: true });
                }
            }
        } else if !asked.is_empty() {
            out.push(Message { role: Role::User, blocks: missing_results(&asked) });
        }
        for block in &message.blocks {
            match block {
                _ if is_empty_text(block) => {}
                Block::ToolResult { id, .. } if !asked.contains(id) => {}
                Block::ToolResult { id, content, is_error } if content.is_empty() => {
                    blocks.push(Block::ToolResult { id: id.clone(), content: EMPTY_RESULT.into(), is_error: *is_error })
                }
                other => blocks.push(other.clone()),
            }
        }
        asked =
            if message.role == Role::Assistant { tool_uses(message).map(str::to_owned).collect() } else { Vec::new() };
        if !blocks.is_empty() {
            out.push(Message { role: message.role, blocks });
        }
    }
    if !asked.is_empty() {
        out.push(Message { role: Role::User, blocks: missing_results(&asked) });
    }
    out
}

fn missing_results(ids: &[String]) -> Vec<Block> {
    ids.iter().map(|id| Block::ToolResult { id: id.clone(), content: MISSING_RESULT.into(), is_error: true }).collect()
}

/// La petición lista para codificar: la misma si ya era válida, o una copia corregida.
pub fn prepare(request: &Request) -> Cow<'_, Request> {
    if is_clean(&request.messages) {
        return Cow::Borrowed(request);
    }
    Cow::Owned(Request { messages: repair(&request.messages), ..request.clone() })
}

/// Esquema de argumentos aceptable para todas las APIs: objeto con `properties` (OpenAI lo exige).
pub fn object_schema(schema: &Value) -> Value {
    let mut schema = if schema.is_object() { schema.clone() } else { json!({}) };
    schema["type"] = json!("object");
    if !schema["properties"].is_object() {
        schema["properties"] = json!({});
    }
    schema
}

/// Nombre de herramienta válido en todas las APIs: `[a-zA-Z0-9_-]`, como mucho 64 caracteres.
pub fn tool_name(name: &str) -> String {
    let clean: String =
        name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect();
    clean.chars().take(64).collect()
}

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

    fn tool_use(id: &str) -> Block {
        Block::ToolUse { id: id.into(), name: "ls".into(), input: json!({}) }
    }

    fn result(id: &str, content: &str) -> Block {
        Block::ToolResult { id: id.into(), content: content.into(), is_error: false }
    }

    fn request(messages: Vec<Message>) -> Request {
        Request { model: "m".into(), system: None, messages, tools: Vec::new(), max_tokens: 1 }
    }

    #[test]
    fn un_historial_valido_no_se_copia() {
        let ok = request(vec![
            Message::user("hola"),
            Message::assistant(vec![tool_use("a")]),
            Message { role: Role::User, blocks: vec![result("a", "x")] },
        ]);
        assert!(matches!(prepare(&ok), Cow::Borrowed(_)));
    }

    #[test]
    fn repara_herramientas_sin_respuesta_texto_vacio_y_resultados_huerfanos() {
        let broken = request(vec![
            Message::user("hola"),
            Message::assistant(vec![Block::Text(" ".into()), tool_use("a"), tool_use("b")]),
            Message { role: Role::User, blocks: vec![result("b", ""), result("zz", "huérfano")] },
            Message::assistant(vec![Block::Text(String::new())]),
            Message::assistant(vec![tool_use("c")]),
        ]);
        let fixed = prepare(&broken).into_owned();
        assert_eq!(fixed.messages[1].blocks, [tool_use("a"), tool_use("b")]);
        let Block::ToolResult { id, is_error, .. } = &fixed.messages[2].blocks[0] else { panic!() };
        assert_eq!((id.as_str(), *is_error), ("a", true), "el que faltaba va primero");
        assert_eq!(fixed.messages[2].blocks[1], result("b", EMPTY_RESULT));
        assert_eq!(fixed.messages[2].blocks.len(), 2, "el huérfano se quita");
        assert_eq!(fixed.messages.len(), 5, "el asistente vacío desaparece y se cierra la última llamada");
        assert!(matches!(&fixed.messages[4].blocks[0], Block::ToolResult { id, .. } if id == "c"));
        assert!(is_clean(&fixed.messages));
    }

    #[test]
    fn argumentos_rotos_esquemas_y_nombres() {
        assert_eq!(tool_input(""), json!({}));
        assert_eq!(tool_input("{\"a\":1}"), json!({"a": 1}));
        assert_eq!(tool_input("{\"a\":"), json!({ INVALID_ARGUMENTS: "{\"a\":" }));
        assert_eq!(object_schema(&json!({"type": "object"})), json!({"type": "object", "properties": {}}));
        assert_eq!(object_schema(&Value::Null)["type"], "object");
        assert_eq!(tool_name("mcp__my.server__read file"), "mcp__my_server__read_file");
        assert_eq!(tool_name(&"x".repeat(80)).len(), 64);
    }
}