use std::num::NonZeroU32;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgentEvent {
pub event_id: String,
pub occurred_at: DateTime<Utc>,
pub device: DeviceContext,
pub agent: AgentContext,
pub session_id: String,
pub turn_index: NonZeroU32,
pub llm: LlmContext,
pub side: AgentEventSide,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
pub token_usage: TokenUsage,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<ToolCall>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_results: Vec<ToolResult>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<ImageAttachment>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct DeviceContext {
pub host_name: String,
pub platform: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub os_version: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgentContext {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct LlmContext {
pub provider: LlmProvider,
pub model: String,
}
#[derive(Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum LlmProvider {
OpenAi,
Anthropic,
Other(String),
}
impl LlmProvider {
#[must_use]
pub fn from_wire_name(provider: String) -> Self {
match provider.as_str() {
"openai" => Self::OpenAi,
"anthropic" => Self::Anthropic,
_ => Self::Other(provider),
}
}
#[must_use]
pub fn wire_name(&self) -> &str {
match self {
Self::OpenAi => "openai",
Self::Anthropic => "anthropic",
Self::Other(provider) => provider,
}
}
}
impl Serialize for LlmProvider {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.wire_name())
}
}
impl<'de> Deserialize<'de> for LlmProvider {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
String::deserialize(deserializer).map(Self::from_wire_name)
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum AgentEventSide {
Request,
Response,
}
impl AgentEventSide {
#[must_use]
pub const fn wire_name(self) -> &'static str {
match self {
Self::Request => "request",
Self::Response => "response",
}
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ToolCall {
pub call_id: String,
pub name: String,
pub input: String,
pub input_sha256: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ToolResult {
pub call_id: String,
pub output: String,
pub output_sha256: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct TokenUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_tokens: u64,
pub cache_write_tokens: u64,
pub reasoning_tokens: u64,
pub total_tokens: u64,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ImageAttachment {
pub position: u32,
pub media_type: ImageMediaType,
pub byte_size: u64,
pub sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_base64: Option<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[non_exhaustive]
pub enum ImageMediaType {
#[serde(rename = "image/png")]
Png,
#[serde(rename = "image/jpeg")]
Jpeg,
#[serde(rename = "image/webp")]
Webp,
#[serde(rename = "image/gif")]
Gif,
}
impl ImageMediaType {
#[must_use]
pub const fn wire_name(self) -> &'static str {
match self {
Self::Png => "image/png",
Self::Jpeg => "image/jpeg",
Self::Webp => "image/webp",
Self::Gif => "image/gif",
}
}
}
#[cfg(test)]
mod tests {
use std::num::NonZeroU32;
use super::{
AgentContext, AgentEvent, AgentEventSide, DeviceContext, LlmContext, LlmProvider,
TokenUsage, ToolCall, ToolResult,
};
use chrono::{TimeZone as _, Utc};
#[test]
fn agent_event_serializes_as_one_flat_typed_payload() {
let event = sample_event(LlmProvider::OpenAi);
let serialized = serde_json::to_value(event).expect("Agent event should serialize");
assert!(
serialized.get("schema_version").is_none(),
"Agent events should be versioned by the plugin protocol"
);
assert_eq!(
serialized["side"], "request",
"the event side should be a direct typed field"
);
assert!(
serialized.get("event_type").is_none() && serialized.get("payload").is_none(),
"a single event kind should not add a speculative discriminator or payload wrapper"
);
assert!(
serialized.get("metadata").is_none(),
"the public event must not expose an unstructured metadata object"
);
assert!(
serialized.get("text").is_none(),
"absent optional content should be omitted"
);
}
#[test]
fn tool_activity_serializes_as_named_structures() {
let mut event = sample_event(LlmProvider::OpenAi);
event.tool_calls.push(ToolCall {
call_id: "call-1".to_owned(),
name: "exec".to_owned(),
input: "pwd".to_owned(),
input_sha256: "call-hash".to_owned(),
});
event.tool_results.push(ToolResult {
call_id: "call-1".to_owned(),
output: "/workspace".to_owned(),
output_sha256: "result-hash".to_owned(),
});
let serialized = serde_json::to_value(event).expect("Agent event should serialize");
assert_eq!(serialized["tool_calls"][0]["name"], "exec");
assert_eq!(serialized["tool_calls"][0]["input"], "pwd");
assert_eq!(serialized["tool_results"][0]["call_id"], "call-1");
assert_eq!(serialized["tool_results"][0]["output"], "/workspace");
}
#[test]
fn agent_event_rejects_an_unstructured_metadata_object() {
let mut serialized = serde_json::to_value(sample_event(LlmProvider::OpenAi))
.expect("event should serialize");
serialized["metadata"] = serde_json::json!({"provider_private_field": true});
let error = serde_json::from_value::<AgentEvent>(serialized)
.expect_err("undeclared metadata should be rejected");
assert!(
error.to_string().contains("unknown field `metadata`"),
"error should identify metadata as outside the public contract"
);
}
#[test]
fn extension_provider_namespace_round_trips() {
let serialized = serde_json::to_value(sample_event(LlmProvider::Other(
"customer-private-llm".to_owned(),
)))
.expect("Agent event should serialize");
let decoded: AgentEvent =
serde_json::from_value(serialized).expect("Agent event should deserialize");
assert_eq!(
decoded.llm.provider,
LlmProvider::Other("customer-private-llm".to_owned()),
"unknown provider namespaces should be preserved"
);
}
fn sample_event(provider: LlmProvider) -> AgentEvent {
AgentEvent {
event_id: "evt-test".to_owned(),
occurred_at: Utc
.with_ymd_and_hms(2026, 8, 19, 10, 0, 0)
.single()
.expect("sample timestamp should be valid"),
device: DeviceContext {
host_name: "test-host".to_owned(),
platform: "macos".to_owned(),
os_version: None,
},
agent: AgentContext {
name: "codex".to_owned(),
version: None,
},
session_id: "session-1".to_owned(),
turn_index: NonZeroU32::new(1).expect("one should be non-zero"),
llm: LlmContext {
provider,
model: "gpt-test".to_owned(),
},
side: AgentEventSide::Request,
text: None,
token_usage: TokenUsage {
input_tokens: 10,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
total_tokens: 10,
},
tool_calls: Vec::new(),
tool_results: Vec::new(),
attachments: Vec::new(),
}
}
}