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::{
    AgentEvent, AgentEventType, CodeChange, CodeChangeKind, EventCompatibility, EventDecodeError,
    EventDelta, EventEnvelope, EventInspection, HostedToolOutcome, MAX_EVENT_DELTA_BYTES,
    MAX_HOSTED_TOOL_SOURCES, ProtocolMetadata, SessionSequence, ToolCallId, ToolPresentation,
};

const EVENT_ID: &str = "0195a0b1-5e41-7e75-bdc7-0aa7aa000007";
const SESSION_ID: &str = "0195a0b1-5e3a-7d72-a902-c4e85d828bf1";
const RUN_ID: &str = "0195a0b1-5e40-7136-8ae0-0aa7aa000006";
const TIMESTAMP: &str = "2026-07-23T09:30:12.300Z";

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 event_fixtures_round_trip_with_explicit_shapes() {
    for name in [
        "event-run-started.json",
        "event-message-delta.json",
        "event-tool-call-requested.json",
        "event-approval-requested.json",
        "event-tool-execution-progress.json",
        "event-turn-checkpointed.json",
        "event-session-compacted.json",
        "event-session-forked.json",
        "event-run-finished.json",
    ] {
        let value = fixture(name);
        let envelope: EventEnvelope = serde_json::from_value(value.clone()).unwrap();
        assert_eq!(serde_json::to_value(envelope).unwrap(), value, "{name}");
    }
}

#[test]
fn initial_event_types_have_stable_classifications() {
    let expected = [
        (
            AgentEventType::RunStarted,
            "run_started",
            EventCompatibility::RequiredStateBearing,
        ),
        (
            AgentEventType::MessageDelta,
            "message_delta",
            EventCompatibility::SkippableObservation,
        ),
        (
            AgentEventType::ToolCallRequested,
            "tool_call_requested",
            EventCompatibility::RequiredStateBearing,
        ),
        (
            AgentEventType::ApprovalRequested,
            "approval_requested",
            EventCompatibility::RequiredStateBearing,
        ),
        (
            AgentEventType::ToolExecutionProgress,
            "tool_execution_progress",
            EventCompatibility::SkippableObservation,
        ),
        (
            AgentEventType::ToolExecutionPreview,
            "tool_execution_preview",
            EventCompatibility::SkippableObservation,
        ),
        (
            AgentEventType::HostedToolStarted,
            "hosted_tool_started",
            EventCompatibility::SkippableObservation,
        ),
        (
            AgentEventType::HostedToolCompleted,
            "hosted_tool_completed",
            EventCompatibility::SkippableObservation,
        ),
        (
            AgentEventType::ModelRetryScheduled,
            "model_retry_scheduled",
            EventCompatibility::SkippableObservation,
        ),
        (
            AgentEventType::ModelRetryStarted,
            "model_retry_started",
            EventCompatibility::SkippableObservation,
        ),
        (
            AgentEventType::TurnCheckpointed,
            "turn_checkpointed",
            EventCompatibility::RequiredStateBearing,
        ),
        (
            AgentEventType::SessionCompacted,
            "session_compacted",
            EventCompatibility::RequiredStateBearing,
        ),
        (
            AgentEventType::SessionForked,
            "session_forked",
            EventCompatibility::RequiredStateBearing,
        ),
        (
            AgentEventType::RunFinished,
            "run_finished",
            EventCompatibility::RequiredStateBearing,
        ),
    ];
    assert_eq!(AgentEventType::ALL, expected.map(|(kind, _, _)| kind));
    for (kind, wire, compatibility) in expected {
        assert_eq!(serde_json::to_value(kind).unwrap(), wire);
        assert_eq!(kind.compatibility(), compatibility);
    }
}

#[test]
fn model_retry_lifecycle_is_a_bounded_skippable_observation() {
    let message_id = "0195a0b1-5e3d-73de-b461-0aa7aa000004".parse().unwrap();
    let turn_id = "0195a0b1-5e42-7b38-af7c-0aa7aa000008".parse().unwrap();
    let scheduled = EventEnvelope::new(
        EVENT_ID.parse().unwrap(),
        SESSION_ID.parse().unwrap(),
        Some(RUN_ID.parse().unwrap()),
        Some(turn_id),
        SessionSequence::new(7),
        TIMESTAMP.parse().unwrap(),
        ProtocolMetadata::default(),
        AgentEvent::ModelRetryScheduled {
            message_id,
            attempt: 1,
            max_retries: 3,
            delay_ms: 2_000,
        },
    )
    .unwrap();
    let value = serde_json::to_value(&scheduled).unwrap();
    assert_eq!(value["type"], "model_retry_scheduled");
    assert_eq!(value["compatibility"], "skippable_observation");
    assert_eq!(value["payload"]["messageId"], message_id.to_string());
    assert_eq!(value["payload"]["attempt"], 1);
    assert_eq!(value["payload"]["maxRetries"], 3);
    assert_eq!(value["payload"]["delayMs"], 2_000);
    assert_eq!(
        serde_json::from_value::<EventEnvelope>(value).unwrap(),
        scheduled
    );

    let started = EventEnvelope::new(
        "0195a0b1-5e41-7e75-bdc7-0aa7aa000008".parse().unwrap(),
        SESSION_ID.parse().unwrap(),
        Some(RUN_ID.parse().unwrap()),
        Some(turn_id),
        SessionSequence::new(8),
        TIMESTAMP.parse().unwrap(),
        ProtocolMetadata::default(),
        AgentEvent::ModelRetryStarted {
            message_id,
            attempt: 1,
            max_retries: 3,
        },
    )
    .unwrap();
    assert_eq!(started.event_type(), AgentEventType::ModelRetryStarted);
    assert_eq!(
        serde_json::to_value(started).unwrap()["compatibility"],
        "skippable_observation"
    );

    assert!(
        EventEnvelope::new(
            "0195a0b1-5e41-7e75-bdc7-0aa7aa000009".parse().unwrap(),
            SESSION_ID.parse().unwrap(),
            Some(RUN_ID.parse().unwrap()),
            Some(turn_id),
            SessionSequence::new(9),
            TIMESTAMP.parse().unwrap(),
            ProtocolMetadata::default(),
            AgentEvent::ModelRetryScheduled {
                message_id,
                attempt: 0,
                max_retries: 3,
                delay_ms: 2_000,
            },
        )
        .is_err()
    );
}

#[test]
fn tool_preview_is_a_skippable_typed_observation() {
    let event = EventEnvelope::new(
        EVENT_ID.parse().unwrap(),
        SESSION_ID.parse().unwrap(),
        Some(RUN_ID.parse().unwrap()),
        Some("0195a0b1-5e42-7b38-af7c-0aa7aa000008".parse().unwrap()),
        SessionSequence::new(7),
        TIMESTAMP.parse().unwrap(),
        ProtocolMetadata::default(),
        AgentEvent::ToolExecutionPreview {
            tool_call_id: ToolCallId::from_str("0195a0b1-5e45-75be-8284-0aa7aa000011").unwrap(),
            presentation: ToolPresentation::CodeChange(
                CodeChange::new(
                    "notes.txt",
                    CodeChangeKind::Update,
                    Vec::new(),
                    false,
                    None,
                    None,
                    None,
                )
                .unwrap(),
            ),
        },
    )
    .unwrap();
    let mut value = serde_json::to_value(event).unwrap();
    assert_eq!(value["compatibility"], "skippable_observation");
    assert!(matches!(
        EventEnvelope::inspect_value(value.clone()).unwrap(),
        EventInspection::Known(_)
    ));

    value["type"] = json!("tool_execution_preview_future");
    assert!(matches!(
        EventEnvelope::inspect_value(value).unwrap(),
        EventInspection::UnknownSkippable(_)
    ));
}

#[test]
fn hosted_tool_lifecycle_is_skippable_and_excludes_provider_continuation() {
    let event = EventEnvelope::new(
        EVENT_ID.parse().unwrap(),
        SESSION_ID.parse().unwrap(),
        Some(RUN_ID.parse().unwrap()),
        Some("0195a0b1-5e42-7b38-af7c-0aa7aa000008".parse().unwrap()),
        SessionSequence::new(7),
        TIMESTAMP.parse().unwrap(),
        ProtocolMetadata::default(),
        AgentEvent::HostedToolCompleted {
            tool_call_id: "0195a0b1-5e45-75be-8284-0aa7aa000011".parse().unwrap(),
            tool_name: "web_search".to_owned(),
            arguments: json!({"query":"current tea runtime"}),
            outcome: HostedToolOutcome::Success,
            source_count: 2,
        },
    )
    .unwrap();

    let value = serde_json::to_value(&event).unwrap();
    assert_eq!(value["type"], "hosted_tool_completed");
    assert_eq!(value["compatibility"], "skippable_observation");
    assert_eq!(value["payload"]["sourceCount"], 2);
    assert!(value["payload"].get("continuation").is_none());
    assert_eq!(
        serde_json::from_value::<EventEnvelope>(value).unwrap(),
        event
    );
}

#[test]
fn observers_may_skip_only_explicit_unknown_observations() {
    let skippable = json!({
        "protocolVersion":"1.1",
        "type":"provider_heartbeat",
        "compatibility":"skippable_observation",
        "eventId":EVENT_ID,
        "sessionId":SESSION_ID,
        "runId":RUN_ID,
        "sequence":"7",
        "timestamp":TIMESTAMP,
        "payload":{"latencyMs":12}
    });
    let EventInspection::UnknownSkippable(unknown) =
        EventEnvelope::inspect_value(skippable).unwrap()
    else {
        panic!("expected skippable unknown event");
    };
    assert_eq!(unknown.event_type(), "provider_heartbeat");
    assert_eq!(unknown.sequence().get(), 7);

    let required = json!({
        "protocolVersion":"1.1",
        "type":"policy_changed",
        "eventId":EVENT_ID,
        "sessionId":SESSION_ID,
        "sequence":"8",
        "timestamp":TIMESTAMP,
        "payload":{}
    });
    assert!(matches!(
        EventEnvelope::inspect_value(required),
        Err(EventDecodeError::UnsupportedStateBearing { .. })
    ));
}

#[test]
fn malformed_unknown_events_are_never_skipped() {
    let malformed = json!({
        "protocolVersion":"2.0",
        "type":"provider_heartbeat",
        "compatibility":"skippable_observation",
        "eventId":EVENT_ID,
        "sessionId":SESSION_ID,
        "sequence":"7",
        "timestamp":TIMESTAMP,
        "payload":{}
    });
    assert!(matches!(
        EventEnvelope::inspect_value(malformed),
        Err(EventDecodeError::Invalid(_))
    ));

    let invalid_type = json!({
        "protocolVersion":"1.1",
        "type":"Provider Heartbeat",
        "compatibility":"skippable_observation",
        "eventId":EVENT_ID,
        "sessionId":SESSION_ID,
        "sequence":"7",
        "timestamp":TIMESTAMP,
        "payload":{}
    });
    assert!(matches!(
        EventEnvelope::inspect_value(invalid_type),
        Err(EventDecodeError::Invalid(_))
    ));
}

#[test]
fn direct_invalid_event_construction_cannot_cross_the_wire() {
    let invalid_delta = EventDelta::TextDelta {
        text: String::new(),
    };
    assert!(serde_json::to_value(invalid_delta).is_err());

    let invalid_progress = AgentEvent::ToolExecutionProgress {
        tool_call_id: "0195a0b1-5e45-75be-8284-0aa7aa000011".parse().unwrap(),
        message: "writing".to_owned(),
        completed_units: 2,
        total_units: Some(1),
    };
    assert!(serde_json::to_value(invalid_progress).is_err());

    let invalid_hosted = AgentEvent::HostedToolCompleted {
        tool_call_id: "0195a0b1-5e45-75be-8284-0aa7aa000011".parse().unwrap(),
        tool_name: "web_search".to_owned(),
        arguments: json!({"query":"tea"}),
        outcome: HostedToolOutcome::Success,
        source_count: u32::try_from(MAX_HOSTED_TOOL_SOURCES + 1).unwrap(),
    };
    assert!(serde_json::to_value(invalid_hosted).is_err());
}

#[test]
fn deltas_progress_and_collections_are_bounded() {
    let mut delta = fixture("event-message-delta.json");
    delta["payload"]["delta"]["text"] = Value::String("x".repeat(MAX_EVENT_DELTA_BYTES + 1));
    assert!(serde_json::from_value::<EventEnvelope>(delta).is_err());

    let mut progress = fixture("event-tool-execution-progress.json");
    progress["payload"]["completedUnits"] = json!(2);
    progress["payload"]["totalUnits"] = json!(1);
    assert!(serde_json::from_value::<EventEnvelope>(progress).is_err());

    let mut approval = fixture("event-approval-requested.json");
    approval["payload"]["capabilities"] = json!((0..65).map(|_| "fs.write").collect::<Vec<_>>());
    assert!(serde_json::from_value::<EventEnvelope>(approval).is_err());
}