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.
use super::*;
use crate::ai::sse::SseParser;

fn decode(stream: &str) -> Result<Vec<Event>, AiError> {
    let (mut parser, mut events, mut out) = (SseParser::default(), Vec::new(), Vec::new());
    let mut decoder = OpenAiDecoder::default();
    parser.push(stream.as_bytes(), &mut events);
    for event in &events {
        decoder.feed(event, &mut out)?;
    }
    decoder.finish(&mut out);
    Ok(out)
}

#[test]
fn texto_y_uso_final() {
    let stream = "data: {\"choices\":[{\"delta\":{\"content\":\"Ho\"}}]}\n\n\
        data: {\"choices\":[{\"delta\":{\"content\":\"la\"},\"finish_reason\":\"stop\"}]}\n\n\
        data: {\"choices\":[],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":2}}\n\n\
        data: [DONE]\n\n";
    assert_eq!(
        decode(stream).unwrap(),
        [
            Event::Text("Ho".into()),
            Event::Text("la".into()),
            Event::Usage(Usage { input_tokens: 7, output_tokens: 2 }),
            Event::Done(StopReason::EndTurn),
        ]
    );
}

#[test]
fn llamada_a_herramienta_con_argumentos_en_trozos() {
    let stream = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"c1\",\"function\":{\"name\":\"grep\",\"arguments\":\"{\\\"q\\\":\"}}]}}]}\n\n\
        data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"fn\\\"}\"}}]}}]}\n\n\
        data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n\
        data: [DONE]\n\n";
    let events = decode(stream).unwrap();
    assert_eq!(events[0], Event::ToolUse { id: "c1".into(), name: "grep".into(), input: json!({"q": "fn"}) });
    assert_eq!(events.last(), Some(&Event::Done(StopReason::ToolUse)));
}

#[test]
fn dos_herramientas_en_paralelo() {
    let stream = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"a\",\"function\":{\"name\":\"x\",\"arguments\":\"{}\"}},{\"index\":1,\"id\":\"b\",\"function\":{\"name\":\"y\",\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n";
    let names: Vec<String> = decode(stream)
        .unwrap()
        .into_iter()
        .filter_map(|event| if let Event::ToolUse { name, .. } = event { Some(name) } else { None })
        .collect();
    assert_eq!(names, ["x", "y"]);
}

#[test]
fn las_imagenes_usan_partes_de_contenido() {
    let user = Message {
        role: Role::User,
        blocks: vec![
            Block::Image { media_type: "image/jpeg".into(), data_base64: "AAA".into(), name: None },
            Block::Text("mira".into()),
        ],
    };
    let all: Vec<Value> = messages(&user);
    assert_eq!(all[0]["content"][0]["type"], "image_url");
    assert_eq!(all[0]["content"][0]["image_url"]["url"], "data:image/jpeg;base64,AAA");
    assert_eq!(all[0]["content"][1], json!({"type": "text", "text": "mira"}));
}

#[test]
fn mensajes_con_herramientas_se_aplanan_al_formato_openai() {
    let history = [
        Message::assistant(vec![
            Block::Text("miro".into()),
            Block::ToolUse { id: "c".into(), name: "ls".into(), input: json!({"p": 1}) },
        ]),
        Message {
            role: Role::User,
            blocks: vec![Block::ToolResult { id: "c".into(), content: "ok".into(), is_error: false }],
        },
    ];
    let all: Vec<Value> = history.iter().flat_map(messages).collect();
    assert_eq!(all[0]["tool_calls"][0]["function"]["arguments"], "{\"p\":1}");
    assert_eq!(all[1], json!({"role": "tool", "tool_call_id": "c", "content": "ok"}));
    assert_eq!(all.len(), 2, "un resultado no genera mensaje de usuario vacío");
}

#[test]
fn error_en_el_stream() {
    assert!(matches!(decode("data: {\"error\":{\"message\":\"cuota\"}}\n\n"), Err(AiError::Api(_))));
}

#[test]
fn llamadas_sin_indice_ni_id_y_fin_con_stop_siguen_siendo_herramientas() {
    // Así responde Ollama: cada llamada entera en un trozo, sin `index`, y `finish_reason: "stop"`.
    let stream = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"name\":\"ls\",\"arguments\":\"{}\"}}]}}]}\n\n\
        data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"b\",\"function\":{\"name\":\"grep\",\"arguments\":\"{\\\"pattern\\\":\"}}]}}]}\n\n\
        data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n";
    let events = decode(stream).unwrap();
    assert!(matches!(&events[0], Event::ToolUse { id, name, .. } if id == "call_0" && name == "ls"));
    assert!(
        matches!(&events[1], Event::ToolUse { id, input, .. } if id == "b" && input[crate::ai::wire::INVALID_ARGUMENTS].is_string())
    );
    assert_eq!(events.last(), Some(&Event::Done(StopReason::ToolUse)));
}