tea-protocol 0.1.0

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

use serde_json::{Value, json};
use tea_protocol::{
    AgentCommand, AgentCommandType, AgentErrorCode, CommandDecodeError, CommandEnvelope,
    CorrelationId, MessageRole, ModelId, ProfileId, ReasoningEffort,
};

const COMMAND_ID: &str = "0195a0b1-5e3c-70a1-927f-0aa7aa000002";
const SESSION_ID: &str = "0195a0b1-5e3a-7d72-a902-c4e85d828bf1";
const TIMESTAMP: &str = "2026-07-23T09:30:12.124Z";
const CORRELATION_ID: &str = "0195a0b1-5e3b-7ef0-8ec1-0aa7aa000001";

fn fixture(name: &str) -> Value {
    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures/v1.0")
        .join(name);
    serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
}

#[test]
fn command_fixtures_round_trip_with_explicit_shapes() {
    for name in [
        "command-create-session.json",
        "command-prompt.json",
        "command-steer.json",
        "command-follow-up.json",
        "command-abort.json",
        "command-resolve-approval.json",
        "command-set-model.json",
        "command-set-profile.json",
        "command-compact-session.json",
        "command-fork-session.json",
    ] {
        let value = fixture(name);
        let envelope: CommandEnvelope = serde_json::from_value(value.clone()).unwrap();
        assert_eq!(serde_json::to_value(envelope).unwrap(), value, "{name}");
    }

    let prompt: CommandEnvelope = serde_json::from_value(fixture("command-prompt.json")).unwrap();
    assert_eq!(prompt.command_type(), AgentCommandType::Prompt);
    let AgentCommand::Prompt { message } = prompt.command() else {
        panic!("expected prompt command");
    };
    assert_eq!(message.role(), MessageRole::User);
}

#[test]
fn every_initial_command_type_has_a_stable_wire_value() {
    let expected = [
        (AgentCommandType::CreateSession, "create_session"),
        (AgentCommandType::Prompt, "prompt"),
        (AgentCommandType::Steer, "steer"),
        (AgentCommandType::FollowUp, "follow_up"),
        (AgentCommandType::Abort, "abort"),
        (AgentCommandType::ResolveApproval, "resolve_approval"),
        (AgentCommandType::SetModel, "set_model"),
        (AgentCommandType::SetReasoningEffort, "set_reasoning_effort"),
        (AgentCommandType::SetProfile, "set_profile"),
        (AgentCommandType::CompactSession, "compact_session"),
        (AgentCommandType::ForkSession, "fork_session"),
    ];
    assert_eq!(AgentCommandType::ALL, expected.map(|(kind, _)| kind));
    for (kind, wire) in expected {
        assert_eq!(serde_json::to_value(kind).unwrap(), wire);
    }
}

#[test]
fn set_reasoning_effort_has_a_typed_wire_payload() {
    let command = CommandEnvelope::new(
        tea_protocol::CommandId::from_str(COMMAND_ID).unwrap(),
        Some(tea_protocol::SessionId::from_str(SESSION_ID).unwrap()),
        tea_protocol::ProtocolTimestamp::from_str(TIMESTAMP).unwrap(),
        AgentCommand::SetReasoningEffort {
            reasoning_effort: ReasoningEffort::ExtraHigh,
        },
    )
    .unwrap();
    let value = serde_json::to_value(&command).unwrap();
    assert_eq!(value["type"], "set_reasoning_effort");
    assert_eq!(value["payload"]["reasoningEffort"], "xhigh");
    assert_eq!(
        serde_json::from_value::<CommandEnvelope>(value)
            .unwrap()
            .command(),
        &AgentCommand::SetReasoningEffort {
            reasoning_effort: ReasoningEffort::ExtraHigh,
        }
    );
}

#[test]
fn unknown_commands_are_rejected_and_classified() {
    let value = json!({
        "protocolVersion":"1.0",
        "type":"launch_missiles",
        "commandId":COMMAND_ID,
        "sessionId":SESSION_ID,
        "timestamp":TIMESTAMP,
        "payload":{}
    });
    let error = CommandEnvelope::decode_value(value).unwrap_err();
    assert!(matches!(error, CommandDecodeError::UnsupportedType { .. }));

    let protocol_error =
        error.into_protocol_error(CorrelationId::from_str(CORRELATION_ID).unwrap());
    assert_eq!(protocol_error.code(), AgentErrorCode::UnsupportedCommand);
    assert_eq!(
        protocol_error.details()["dev.tea-rs.protocol"]["unsupportedType"],
        "launch_missiles"
    );
}

#[test]
fn incompatible_major_takes_precedence_over_unknown_command_type() {
    let value = json!({
        "protocolVersion":"2.0",
        "type":"future_command",
        "commandId":COMMAND_ID,
        "sessionId":SESSION_ID,
        "timestamp":TIMESTAMP,
        "payload":{}
    });
    let error = CommandEnvelope::decode_value(value).unwrap_err();
    assert!(matches!(
        error,
        CommandDecodeError::UnsupportedVersion { .. }
    ));
    assert_eq!(
        error
            .into_protocol_error(CorrelationId::from_str(CORRELATION_ID).unwrap())
            .code(),
        AgentErrorCode::UnsupportedProtocolVersion
    );
}

#[test]
fn command_session_and_payload_invariants_are_enforced() {
    let create_with_session = json!({
        "protocolVersion":"1.0",
        "type":"create_session",
        "commandId":COMMAND_ID,
        "sessionId":SESSION_ID,
        "timestamp":TIMESTAMP,
        "payload":{"profileId":"minimal-assistant"}
    });
    assert!(serde_json::from_value::<CommandEnvelope>(create_with_session).is_err());

    let prompt_without_session = json!({
        "protocolVersion":"1.0",
        "type":"prompt",
        "commandId":COMMAND_ID,
        "timestamp":TIMESTAMP,
        "payload":fixture("command-prompt.json")["payload"].clone()
    });
    assert!(serde_json::from_value::<CommandEnvelope>(prompt_without_session).is_err());

    let assistant_prompt = json!({
        "protocolVersion":"1.0",
        "type":"prompt",
        "commandId":COMMAND_ID,
        "sessionId":SESSION_ID,
        "timestamp":TIMESTAMP,
        "payload":{
            "message":{
                "id":"0195a0b1-5e3d-7bb4-863a-0aa7aa000003",
                "type":"assistant",
                "content":[{"type":"text","text":"not user input"}],
                "stopReason":"completed",
                "timestamp":TIMESTAMP
            }
        }
    });
    assert!(serde_json::from_value::<CommandEnvelope>(assistant_prompt).is_err());
}

#[test]
fn direct_invalid_command_construction_cannot_cross_the_wire() {
    let assistant: tea_protocol::CanonicalMessage = serde_json::from_value(json!({
        "id":"0195a0b1-5e3d-7bb4-863a-0aa7aa000003",
        "type":"assistant",
        "content":[{"type":"text","text":"not user input"}],
        "stopReason":"completed",
        "timestamp":TIMESTAMP
    }))
    .unwrap();
    let invalid = AgentCommand::Prompt { message: assistant };
    assert!(serde_json::to_value(invalid).is_err());
}

#[test]
fn selectors_and_command_payloads_are_bounded() {
    assert!(ProfileId::from_str("minimal-assistant").is_ok());
    assert!(ProfileId::from_str("").is_err());
    assert!(ProfileId::from_str(&"x".repeat(129)).is_err());
    assert!(ProfileId::from_str("Invalid Profile").is_err());
    assert!(ModelId::from_str("llama3.1:8b").is_ok());
    assert!(ProfileId::from_str("profile:variant").is_err());

    let oversized_profile = json!({
        "protocolVersion":"1.0",
        "type":"set_profile",
        "commandId":COMMAND_ID,
        "sessionId":SESSION_ID,
        "timestamp":TIMESTAMP,
        "payload":{"profileId":"x".repeat(129)}
    });
    assert!(serde_json::from_value::<CommandEnvelope>(oversized_profile).is_err());
}