tea-protocol 0.1.0

Canonical protocol types for the tea-rs project
Documentation
use std::str::FromStr;

use serde_json::{Value, json};
use tea_protocol::{
    CanonicalMessage, ContentBlock, ExternalSource, HostedToolActivity, HostedToolError,
    HostedToolOutcome, MessageId, MessageRole, ProtocolTimestamp, ProviderContinuation,
    SourceCitation, StopReason, ToolCallId, ToolFailure,
};

const MESSAGE_ID: &str = "0195a0b1-5e3d-7bb4-863a-0aa7aa000003";
const TOOL_CALL_ID: &str = "0195a0b1-5e45-75be-8284-0aa7aa000011";
const TIMESTAMP: &str = "2026-07-23T09:30:12.124Z";

#[test]
fn content_blocks_use_internal_type_tags() {
    let text = ContentBlock::text("hello").unwrap();
    let thinking = ContentBlock::thinking("checking").unwrap();
    let image = ContentBlock::inline_image("image/png", "aGVsbG8=").unwrap();
    let reference = ContentBlock::image_reference("image/jpeg", "artifact:image-1").unwrap();
    let tool_call = ContentBlock::tool_call(
        ToolCallId::from_str(TOOL_CALL_ID).unwrap(),
        "read_file",
        json!({"path": "/workspace/README.md"}),
    )
    .unwrap();
    let provider_tool_call = ContentBlock::tool_call_with_provider_id(
        ToolCallId::from_str(TOOL_CALL_ID).unwrap(),
        "call_provider_123",
        "read_file",
        json!({"path": "/workspace/README.md"}),
    )
    .unwrap();

    assert_eq!(
        serde_json::to_value(text).unwrap(),
        json!({"type":"text","text":"hello"})
    );
    assert_eq!(
        serde_json::to_value(thinking).unwrap(),
        json!({"type":"thinking","text":"checking"})
    );
    assert_eq!(
        serde_json::to_value(image).unwrap(),
        json!({
            "type":"image",
            "mimeType":"image/png",
            "source":{"type":"inline_base64","data":"aGVsbG8="}
        })
    );
    assert_eq!(
        serde_json::to_value(reference).unwrap(),
        json!({
            "type":"image",
            "mimeType":"image/jpeg",
            "source":{"type":"reference","reference":"artifact:image-1"}
        })
    );
    assert_eq!(
        serde_json::to_value(tool_call).unwrap(),
        json!({
            "type":"tool_call",
            "toolCallId":TOOL_CALL_ID,
            "toolName":"read_file",
            "arguments":{"path":"/workspace/README.md"}
        })
    );
    let provider_tool_call = serde_json::to_value(provider_tool_call).unwrap();
    assert_eq!(provider_tool_call["providerCallId"], "call_provider_123");
    assert_eq!(
        serde_json::from_value::<ContentBlock>(provider_tool_call)
            .unwrap()
            .provider_call_id(),
        Some("call_provider_123")
    );
}

#[test]
fn provider_tool_call_ids_are_bounded() {
    assert!(
        ContentBlock::tool_call_with_provider_id(
            ToolCallId::from_str(TOOL_CALL_ID).unwrap(),
            "",
            "read_file",
            json!({}),
        )
        .is_err()
    );
}

#[test]
fn hosted_activity_sources_citations_and_continuation_round_trip() {
    let tool_call_id = ToolCallId::from_str(TOOL_CALL_ID).unwrap();
    let source = ExternalSource::new("https://docs.rs/serde/latest/serde/")
        .unwrap()
        .with_title("Serde documentation")
        .unwrap()
        .with_snippet("Serialization framework for Rust")
        .unwrap();
    let continuation = ProviderContinuation::new(
        "anthropic",
        "anthropic.messages.web_search.v1",
        json!({"encrypted_content":"secret-provider-state"}),
    )
    .unwrap();
    assert!(!format!("{continuation:?}").contains("secret-provider-state"));

    let activity = HostedToolActivity::new(
        tool_call_id,
        "srvtoolu_123",
        "web_search",
        json!({"query":"serde documentation"}),
        HostedToolOutcome::Success,
        vec![source.clone()],
        Some(continuation.clone()),
    )
    .unwrap();
    let citation = SourceCitation::new(source)
        .with_tool_call_id(tool_call_id)
        .with_range(0, 19)
        .unwrap()
        .with_cited_text("Serde documentation")
        .unwrap()
        .with_continuation(continuation);

    let hosted = ContentBlock::hosted_tool(activity);
    let cited = ContentBlock::citation(citation);
    let hosted_json = serde_json::to_value(&hosted).unwrap();
    assert_eq!(hosted_json["type"], "hosted_tool");
    assert_eq!(hosted_json["activity"]["toolName"], "web_search");
    assert_eq!(
        hosted_json["activity"]["sources"][0]["url"],
        "https://docs.rs/serde/latest/serde/"
    );
    assert_eq!(
        serde_json::from_value::<ContentBlock>(hosted_json).unwrap(),
        hosted
    );
    assert_eq!(
        serde_json::from_value::<ContentBlock>(serde_json::to_value(&cited).unwrap()).unwrap(),
        cited
    );
}

#[test]
fn hosted_content_validation_fails_closed() {
    assert!(ExternalSource::new("ftp://example.com/result").is_err());
    assert!(ExternalSource::new("https://example.com/\nresult").is_err());
    assert!(ExternalSource::new("https://user:secret@example.com/result").is_err());
    assert!(ExternalSource::new("https://").is_err());
    assert_eq!(
        ExternalSource::new("HTTPS://Example.COM:443/a/../result")
            .unwrap()
            .url(),
        "https://example.com/result"
    );
    assert!(
        SourceCitation::new(ExternalSource::new("https://example.com").unwrap())
            .with_range(4, 4)
            .is_err()
    );
    assert!(
        HostedToolError::new("Bad Code", "provider failed").is_err(),
        "error codes must be canonical"
    );
    assert!(
        ProviderContinuation::new(
            "anthropic",
            "anthropic.messages.web_search.v1",
            json!({"payload":"x".repeat(4 * 1024 * 1024)}),
        )
        .is_err()
    );
}

#[test]
fn hosted_content_is_assistant_only() {
    let timestamp = ProtocolTimestamp::from_str(TIMESTAMP).unwrap();
    let message_id = MessageId::from_str(MESSAGE_ID).unwrap();
    let activity = HostedToolActivity::new(
        ToolCallId::from_str(TOOL_CALL_ID).unwrap(),
        "ws_123",
        "web_search",
        json!({"query":"tea-rs"}),
        HostedToolOutcome::Error(HostedToolError::new("unavailable", "try later").unwrap()),
        Vec::new(),
        None,
    )
    .unwrap();
    let block = ContentBlock::hosted_tool(activity);

    assert!(CanonicalMessage::user(message_id, vec![block.clone()], timestamp).is_err());
    CanonicalMessage::assistant(message_id, vec![block], StopReason::Completed, timestamp).unwrap();
}

#[test]
fn canonical_messages_have_stable_shapes() {
    let message_id = MessageId::from_str(MESSAGE_ID).unwrap();
    let timestamp = ProtocolTimestamp::from_str(TIMESTAMP).unwrap();
    let user = CanonicalMessage::user(
        message_id,
        vec![ContentBlock::text("Inspect the workspace.").unwrap()],
        timestamp,
    )
    .unwrap();

    assert_eq!(user.role(), MessageRole::User);
    assert_eq!(
        serde_json::to_value(&user).unwrap(),
        json!({
            "id":MESSAGE_ID,
            "type":"user",
            "content":[{"type":"text","text":"Inspect the workspace."}],
            "timestamp":TIMESTAMP
        })
    );

    let assistant = CanonicalMessage::assistant(
        message_id,
        vec![ContentBlock::text("Done.").unwrap()],
        StopReason::Completed,
        timestamp,
    )
    .unwrap();
    assert_eq!(assistant.role(), MessageRole::Assistant);
    assert_eq!(
        serde_json::to_value(&assistant).unwrap()["stopReason"],
        "completed"
    );

    let success = CanonicalMessage::tool_result_success(
        message_id,
        ToolCallId::from_str(TOOL_CALL_ID).unwrap(),
        "read_file",
        vec![ContentBlock::text("contents").unwrap()],
        timestamp,
    )
    .unwrap();
    let success_json = serde_json::to_value(success).unwrap();
    assert_eq!(success_json["type"], "tool_result");
    assert_eq!(success_json["isError"], false);
    assert!(success_json.get("error").is_none());

    let failure = CanonicalMessage::tool_result_failure(
        message_id,
        ToolCallId::from_str(TOOL_CALL_ID).unwrap(),
        "read_file",
        vec![ContentBlock::text("Unable to read file.").unwrap()],
        ToolFailure::new("not_found", "file does not exist").unwrap(),
        timestamp,
    )
    .unwrap();
    let failure_json = serde_json::to_value(failure).unwrap();
    assert_eq!(failure_json["isError"], true);
    assert_eq!(failure_json["error"]["code"], "not_found");
}

#[test]
fn message_role_content_invariants_are_enforced() {
    let message_id = MessageId::from_str(MESSAGE_ID).unwrap();
    let timestamp = ProtocolTimestamp::from_str(TIMESTAMP).unwrap();

    assert!(CanonicalMessage::user(message_id, Vec::new(), timestamp).is_err());
    assert!(
        CanonicalMessage::user(
            message_id,
            vec![ContentBlock::thinking("private").unwrap()],
            timestamp,
        )
        .is_err()
    );
    assert!(
        CanonicalMessage::tool_result_success(
            message_id,
            ToolCallId::from_str(TOOL_CALL_ID).unwrap(),
            "read_file",
            vec![
                ContentBlock::tool_call(
                    ToolCallId::from_str(TOOL_CALL_ID).unwrap(),
                    "nested",
                    json!({}),
                )
                .unwrap()
            ],
            timestamp,
        )
        .is_err()
    );
}

#[test]
fn invalid_content_is_rejected_during_construction_and_deserialization() {
    assert!(ContentBlock::text("").is_err());
    assert!(ContentBlock::inline_image("image/png", "not base64!").is_err());
    assert!(ContentBlock::image_reference("invalid", "artifact:1").is_err());
    assert!(
        ContentBlock::tool_call(
            ToolCallId::from_str(TOOL_CALL_ID).unwrap(),
            "invalid tool name",
            json!({}),
        )
        .is_err()
    );

    let invalid_message: Value = json!({
        "id":MESSAGE_ID,
        "type":"user",
        "content":[{"type":"thinking","text":"not allowed"}],
        "timestamp":TIMESTAMP
    });
    assert!(serde_json::from_value::<CanonicalMessage>(invalid_message).is_err());
}

#[test]
fn unknown_stop_reasons_are_preserved_but_not_successful() {
    let reason: StopReason = serde_json::from_str(r#""provider_specific_stop""#).unwrap();
    assert_eq!(reason.as_str(), "provider_specific_stop");
    assert!(!reason.is_success());
    assert_eq!(
        serde_json::to_string(&reason).unwrap(),
        r#""provider_specific_stop""#
    );
}

#[test]
fn provider_pause_turn_is_a_stable_nonterminal_reason() {
    let reason: StopReason = serde_json::from_str(r#""pause_turn""#).unwrap();
    assert_eq!(reason, StopReason::PauseTurn);
    assert_eq!(reason.as_str(), "pause_turn");
    assert!(!reason.is_success());
    assert_eq!(serde_json::to_string(&reason).unwrap(), r#""pause_turn""#);
}

#[test]
fn direct_invalid_enum_construction_cannot_cross_the_wire() {
    let timestamp = ProtocolTimestamp::from_str(TIMESTAMP).unwrap();
    let message_id = MessageId::from_str(MESSAGE_ID).unwrap();

    let empty_text = ContentBlock::Text {
        text: String::new(),
    };
    assert!(serde_json::to_value(empty_text).is_err());

    let empty_message = CanonicalMessage::User {
        id: message_id,
        content: Vec::new(),
        timestamp,
    };
    assert!(serde_json::to_value(empty_message).is_err());

    let invalid_reason = StopReason::Unknown("Invalid Reason".to_owned());
    assert!(serde_json::to_value(invalid_reason).is_err());
}