sim-codec-chat 0.1.4

Canonical chat transcript codec for SIM.
Documentation
use sim_codec::{Input, decode_with_codec};
use sim_kernel::{Expr, ReadPolicy, Symbol};

use crate::{
    AnthropicRequestOptions, decode_anthropic_response, decode_anthropic_stream,
    decode_anthropic_stream_events, encode_anthropic_request,
};

use super::{cx, message_expr};

#[test]
fn anthropic_runtime_codec_decodes_messages_request() {
    let mut cx = cx();
    let decoded = decode_with_codec(
        &mut cx,
        &Symbol::qualified("codec", "anthropic"),
        Input::Text(
            r#"{"model":"claude-sonnet-4-20250514","max_tokens":64,"system":"Answer briefly.","messages":[{"role":"user","content":"hello"}]}"#
                .to_owned(),
        ),
        ReadPolicy::default(),
    )
    .unwrap();

    crate::validate_chat_transcript(&decoded).unwrap();
    assert!(format!("{decoded:?}").contains("model-request"));
    assert!(format!("{decoded:?}").contains("Answer briefly."));
    assert!(format!("{decoded:?}").contains("hello"));
}

#[test]
fn anthropic_request_encoder_splits_system_and_tool_schema() {
    let request = Expr::Map(vec![
        (Expr::Symbol(Symbol::new("model-request")), Expr::Bool(true)),
        (
            Expr::Symbol(Symbol::new("task")),
            Expr::String("check the weather".to_owned()),
        ),
        (
            Expr::Symbol(Symbol::new("messages")),
            Expr::List(vec![
                message_expr("system", "Be concise."),
                message_expr("user", "Use the weather tool."),
            ]),
        ),
        (
            Expr::Symbol(Symbol::new("tools")),
            Expr::List(vec![Expr::Map(vec![
                (
                    Expr::Symbol(Symbol::new("name")),
                    Expr::String("get_weather".to_owned()),
                ),
                (
                    Expr::Symbol(Symbol::new("description")),
                    Expr::String("Read a city forecast.".to_owned()),
                ),
                (
                    Expr::Symbol(Symbol::new("parameters")),
                    Expr::Map(vec![
                        (
                            Expr::Symbol(Symbol::new("type")),
                            Expr::String("object".to_owned()),
                        ),
                        (
                            Expr::Symbol(Symbol::new("properties")),
                            Expr::Map(vec![(
                                Expr::Symbol(Symbol::new("location")),
                                Expr::Map(vec![(
                                    Expr::Symbol(Symbol::new("type")),
                                    Expr::String("string".to_owned()),
                                )]),
                            )]),
                        ),
                    ]),
                ),
            ])]),
        ),
    ]);
    let body = encode_anthropic_request(
        &request,
        &AnthropicRequestOptions::new("claude-sonnet-4-20250514", 256, true, true),
    )
    .unwrap();
    let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert_eq!(json["model"], "claude-sonnet-4-20250514");
    assert_eq!(json["max_tokens"], 256);
    assert_eq!(json["stream"], true);
    assert_eq!(json["system"], "Be concise.");
    assert_eq!(json["messages"][0]["role"], "user");
    assert_eq!(
        json["messages"][1]["content"][0]["text"],
        "check the weather"
    );
    assert_eq!(json["tools"][0]["name"], "get_weather");
    assert_eq!(
        json["tools"][0]["input_schema"]["properties"]["location"]["type"],
        "string"
    );
}

#[test]
fn anthropic_messages_response_decodes() {
    let body = br#"{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":3,"output_tokens":1}}"#;
    let expr = decode_anthropic_response(
        Symbol::qualified("runner", "anthropic"),
        "claude-sonnet-4-20250514",
        body,
        true,
    )
    .unwrap();

    crate::validate_chat_transcript(&expr).unwrap();
    assert!(format!("{expr:?}").contains("ok"));
    assert!(format!("{expr:?}").contains("end-turn"));
    assert!(format!("{expr:?}").contains("input-tokens"));
    assert!(format!("{expr:?}").contains("raw-provider-response"));
}

#[test]
fn anthropic_tool_use_response_decodes_to_tool_call_part() {
    let body = br#"{"id":"msg_tool","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"location":"Stockholm"}}],"stop_reason":"tool_use","usage":{"input_tokens":9,"output_tokens":4}}"#;
    let expr = decode_anthropic_response(
        Symbol::qualified("runner", "anthropic"),
        "claude-sonnet-4-20250514",
        body,
        false,
    )
    .unwrap();

    crate::validate_chat_transcript(&expr).unwrap();
    assert!(format!("{expr:?}").contains("tool-call"));
    assert!(format!("{expr:?}").contains("toolu_1"));
    assert!(format!("{expr:?}").contains("Stockholm"));
    assert!(format!("{expr:?}").contains("tool-use"));
}

#[test]
fn anthropic_stream_decoder_emits_events_and_final_response() {
    let body = br#"event: message_start
data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[]}}

event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello "}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}

event: content_block_stop
data: {"type":"content_block_stop","index":0}

event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":2}}

event: message_stop
data: {"type":"message_stop"}
"#;
    let events = decode_anthropic_stream_events(
        Symbol::qualified("runner", "anthropic"),
        "claude-sonnet-4-20250514",
        body,
        true,
    )
    .unwrap();
    assert!(
        events
            .iter()
            .any(|event| format!("{event:?}").contains("delta"))
    );
    assert!(
        events
            .iter()
            .any(|event| format!("{event:?}").contains("usage"))
    );
    let final_response = decode_anthropic_stream(
        Symbol::qualified("runner", "anthropic"),
        "claude-sonnet-4-20250514",
        body,
        true,
    )
    .unwrap();
    crate::validate_chat_transcript(&final_response).unwrap();
    assert!(format!("{final_response:?}").contains("hello world"));
    assert!(format!("{final_response:?}").contains("raw-provider-response"));
}

#[test]
fn anthropic_error_envelope_decodes_to_model_error() {
    let expr = decode_anthropic_response(
        Symbol::qualified("runner", "anthropic"),
        "claude-sonnet-4-20250514",
        br#"{"type":"error","error":{"type":"invalid_request_error","message":"bad key"}}"#,
        false,
    )
    .unwrap();

    crate::validate_chat_transcript(&expr).unwrap();
    assert!(format!("{expr:?}").contains("bad key"));
    assert!(format!("{expr:?}").contains("shape-ok"));
}