aura-gui 0.3.0

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;
use crate::ai::types::ToolSpec;

fn decode(stream: &str) -> Result<Vec<Event>, AiError> {
    let (mut parser, mut events, mut out) = (SseParser::default(), Vec::new(), Vec::new());
    let mut decoder = AnthropicDecoder::default();
    parser.push(stream.as_bytes(), &mut events);
    for event in &events {
        decoder.feed(event, &mut out)?;
    }
    decoder.finish(&mut out);
    Ok(out)
}
const TEXT: &str = "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":12}}}\n\n\
    event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Hola\"}}\n\n\
    event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\" mundo\"}}\n\n\
    event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n\
    event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";

#[test]
fn texto_en_streaming_con_uso_y_fin() {
    let events = decode(TEXT).unwrap();
    assert_eq!(
        events,
        [
            Event::Text("Hola".into()),
            Event::Text(" mundo".into()),
            Event::Usage(Usage { input_tokens: 12, output_tokens: 5 }),
            Event::Done(StopReason::EndTurn),
        ]
    );
}

#[test]
fn el_uso_incluye_los_tokens_de_cache() {
    let stream = "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":5,\"cache_read_input_tokens\":1000,\"cache_creation_input_tokens\":20}}}\n\n\
        event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
    assert!(decode(stream).unwrap().contains(&Event::Usage(Usage { input_tokens: 1025, output_tokens: 0 })));
}

#[test]
fn herramienta_con_argumentos_en_trozos() {
    let stream = "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"content_block\":{\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"read_file\"}}\n\n\
        event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"path\\\":\"}}\n\n\
        event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"a.rs\\\"}\"}}\n\n\
        event: content_block_stop\ndata: {\"type\":\"content_block_stop\"}\n\n\
        event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":9}}\n\n\
        event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
    let events = decode(stream).unwrap();
    assert_eq!(events[0], Event::ToolUse { id: "t1".into(), name: "read_file".into(), input: json!({"path": "a.rs"}) });
    assert_eq!(events.last(), Some(&Event::Done(StopReason::ToolUse)));
}

#[test]
fn error_del_proveedor_dentro_del_stream() {
    let error =
        decode("event: error\ndata: {\"type\":\"error\",\"error\":{\"message\":\"saturado\"}}\n\n").unwrap_err();
    assert!(matches!(error, AiError::Api(message) if message == "saturado"));
}

#[test]
fn la_suscripcion_antepone_la_identidad_y_usa_token_bearer() {
    let request = Request {
        model: "m".into(),
        system: Some("Mi sistema".into()),
        messages: vec![Message::user("hola")],
        tools: Vec::new(),
        max_tokens: 10,
    };
    let body = subscription_body(&request);
    assert_eq!(body["system"][0]["text"], SUBSCRIPTION_IDENTITY);
    assert_eq!(body["system"][1]["text"], "Mi sistema");
    let headers = subscription_headers("tok");
    assert!(headers.contains(&("authorization".into(), "Bearer tok".into())));
    assert!(headers.iter().any(|(name, value)| name == "anthropic-beta" && value.contains("oauth-2025-04-20")));
}

#[test]
fn las_imagenes_van_como_bloques_base64() {
    let request = Request {
        model: "claude".into(),
        system: None,
        messages: vec![Message {
            role: Role::User,
            blocks: vec![
                Block::Image { media_type: "image/png".into(), data_base64: "AAA".into(), name: None },
                Block::Text("¿qué ves?".into()),
            ],
        }],
        tools: Vec::new(),
        max_tokens: 10,
    };
    let body = body(&request);
    assert_eq!(body["messages"][0]["content"][0]["type"], "image");
    assert_eq!(body["messages"][0]["content"][0]["source"]["media_type"], "image/png");
    assert_eq!(body["messages"][0]["content"][0]["source"]["data"], "AAA");
}

#[test]
fn cuerpo_incluye_sistema_herramientas_y_resultados() {
    let request = Request {
        model: "claude-sonnet-5-5".into(),
        system: Some("Eres un editor".into()),
        messages: vec![
            Message::user("hola"),
            Message::assistant(vec![Block::ToolUse { id: "t".into(), name: "ls".into(), input: json!({}) }]),
            Message {
                role: Role::User,
                blocks: vec![Block::ToolResult { id: "t".into(), content: "a".into(), is_error: false }],
            },
        ],
        tools: vec![ToolSpec { name: "ls".into(), description: "lista".into(), schema: json!({"type": "object"}) }],
        max_tokens: 100,
    };
    let body = body(&request);
    assert_eq!(body["system"][0]["text"], "Eres un editor");
    assert_eq!(body["system"][0]["cache_control"]["type"], "ephemeral");
    assert_eq!(body["tools"][0]["cache_control"]["type"], "ephemeral");
    assert_eq!(body["messages"][2]["content"][0]["cache_control"]["type"], "ephemeral");
    assert!(body["messages"][0]["content"][0].get("cache_control").is_none());
    assert_eq!(body["stream"], true);
    assert_eq!(body["tools"][0]["input_schema"]["type"], "object");
    assert_eq!(body["messages"][2]["content"][0]["tool_use_id"], "t");
    assert_eq!(body["messages"][1]["role"], "assistant");
}