use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use uuid::Uuid;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
use crate::localization::localized_tool_display_name;
use crate::typed_id::{AgentId, EventId, ExecId, HarnessId, MessageId, ModelId, SessionId, TurnId};
use crate::user_facing_error::{UserFacingError, UserFacingErrorFields};
pub const INPUT_MESSAGE: &str = "input.message";
pub const OUTPUT_MESSAGE_STARTED: &str = "output.message.started";
pub const OUTPUT_MESSAGE_DELTA: &str = "output.message.delta";
pub const OUTPUT_MESSAGE_COMPLETED: &str = "output.message.completed";
pub const OUTPUT_MESSAGE_REPLACED: &str = "output.message.replaced";
pub const TURN_STARTED: &str = "turn.started";
pub const TURN_COMPLETED: &str = "turn.completed";
pub const TURN_FAILED: &str = "turn.failed";
pub const TURN_CANCELLED: &str = "turn.cancelled";
pub const REASON_STARTED: &str = "reason.started";
pub const REASON_COMPLETED: &str = "reason.completed";
pub const CAPABILITY_USAGE: &str = "capability.usage";
pub const ACT_STARTED: &str = "act.started";
pub const ACT_COMPLETED: &str = "act.completed";
pub const TOOL_STARTED: &str = "tool.started";
pub const TOOL_COMPLETED: &str = "tool.completed";
pub const TOOL_PROGRESS: &str = "tool.progress";
pub const TOOL_OUTPUT_DELTA: &str = "tool.output.delta";
pub const TOOL_CALL_REQUESTED: &str = "tool.call_requested";
pub const LLM_GENERATION: &str = "llm.generation";
fn is_ephemeral_event_type(event_type: &str) -> bool {
matches!(
event_type,
OUTPUT_MESSAGE_DELTA
| REASON_THINKING_DELTA
| TOOL_OUTPUT_DELTA
| VOICE_INPUT_TRANSCRIPT_DELTA
| VOICE_OUTPUT_TRANSCRIPT_DELTA
)
}
pub const REASON_THINKING_STARTED: &str = "reason.thinking.started";
pub const REASON_THINKING_DELTA: &str = "reason.thinking.delta";
pub const REASON_THINKING_COMPLETED: &str = "reason.thinking.completed";
pub const REASON_ITEM: &str = "reason.item";
pub const SESSION_STARTED: &str = "session.started";
pub const SESSION_ACTIVATED: &str = "session.activated";
pub const SESSION_IDLED: &str = "session.idled";
pub const SCHEDULE_TRIGGERED: &str = "schedule.triggered";
pub const SUBAGENT_SPAWNED: &str = "subagent.spawned";
pub const SUBAGENT_COMPLETED: &str = "subagent.completed";
pub const SUBAGENT_FAILED: &str = "subagent.failed";
pub const SUBAGENT_CANCELLED: &str = "subagent.cancelled";
pub const CONTEXT_COMPACTING: &str = "context.compacting";
pub const CONTEXT_COMPACTED: &str = "context.compacted";
pub const FILE_WRITTEN: &str = "file.written";
pub const BUDGET_WARNING: &str = "budget.warning";
pub const BUDGET_PAUSED: &str = "budget.paused";
pub const BUDGET_EXHAUSTED: &str = "budget.exhausted";
pub const BUDGET_RESUMED: &str = "budget.resumed";
pub const VOICE_SESSION_STARTED: &str = "voice.session.started";
pub const VOICE_INPUT_TRANSCRIPT_DELTA: &str = "voice.input_transcript.delta";
pub const VOICE_INPUT_TRANSCRIPT_COMPLETED: &str = "voice.input_transcript.completed";
pub const VOICE_OUTPUT_TRANSCRIPT_DELTA: &str = "voice.output_transcript.delta";
pub const VOICE_OUTPUT_TRANSCRIPT_COMPLETED: &str = "voice.output_transcript.completed";
pub const VOICE_SESSION_ENDED: &str = "voice.session.ended";
pub const VOICE_SESSION_FAILED: &str = "voice.session.failed";
pub const VALID_EVENT_TYPES: &[&str] = &[
INPUT_MESSAGE,
OUTPUT_MESSAGE_STARTED,
OUTPUT_MESSAGE_DELTA,
OUTPUT_MESSAGE_COMPLETED,
OUTPUT_MESSAGE_REPLACED,
TURN_STARTED,
TURN_COMPLETED,
TURN_FAILED,
TURN_CANCELLED,
REASON_STARTED,
REASON_COMPLETED,
ACT_STARTED,
ACT_COMPLETED,
TOOL_STARTED,
TOOL_COMPLETED,
TOOL_PROGRESS,
TOOL_OUTPUT_DELTA,
TOOL_CALL_REQUESTED,
LLM_GENERATION,
REASON_THINKING_STARTED,
REASON_THINKING_DELTA,
REASON_THINKING_COMPLETED,
REASON_ITEM,
SESSION_STARTED,
SESSION_ACTIVATED,
SESSION_IDLED,
SCHEDULE_TRIGGERED,
SUBAGENT_SPAWNED,
SUBAGENT_COMPLETED,
SUBAGENT_FAILED,
SUBAGENT_CANCELLED,
CONTEXT_COMPACTING,
CONTEXT_COMPACTED,
BUDGET_WARNING,
BUDGET_PAUSED,
BUDGET_EXHAUSTED,
BUDGET_RESUMED,
VOICE_SESSION_STARTED,
VOICE_INPUT_TRANSCRIPT_DELTA,
VOICE_INPUT_TRANSCRIPT_COMPLETED,
VOICE_OUTPUT_TRANSCRIPT_DELTA,
VOICE_OUTPUT_TRANSCRIPT_COMPLETED,
VOICE_SESSION_ENDED,
VOICE_SESSION_FAILED,
FILE_WRITTEN,
];
use crate::atoms::AtomContext;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct EventContext {
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "turn_01933b5a00007000800000000000001"))]
pub turn_id: Option<TurnId>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "message_01933b5a00007000800000000000001"))]
pub input_message_id: Option<MessageId>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "exec_01933b5a00007000800000000000001"))]
pub exec_id: Option<ExecId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub span_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_span_id: Option<String>,
}
impl EventContext {
pub fn empty() -> Self {
Self::default()
}
pub fn from_atom_context(ctx: &AtomContext) -> Self {
Self {
turn_id: Some(ctx.turn_id),
input_message_id: Some(ctx.input_message_id),
exec_id: Some(ctx.exec_id),
trace_id: None,
span_id: None,
parent_span_id: None,
}
}
pub fn turn(turn_id: TurnId, input_message_id: MessageId) -> Self {
Self {
turn_id: Some(turn_id),
input_message_id: Some(input_message_id),
exec_id: None,
trace_id: None,
span_id: None,
parent_span_id: None,
}
}
pub fn with_span(
mut self,
trace_id: String,
span_id: String,
parent_span_id: Option<String>,
) -> Self {
self.trace_id = Some(trace_id);
self.span_id = Some(span_id);
self.parent_span_id = parent_span_id;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct Event {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "event_01933b5a00007000800000000000001"))]
pub id: EventId,
#[serde(rename = "type")]
pub event_type: String,
pub ts: DateTime<Utc>,
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "session_01933b5a00007000800000000000001"))]
pub session_id: SessionId,
pub context: EventContext,
pub data: EventData,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sequence: Option<i32>,
}
impl Event {
pub fn new(session_id: SessionId, context: EventContext, data: impl Into<EventData>) -> Self {
let data = data.into();
let event_type = data.event_type().to_string();
Self {
id: EventId::new(),
event_type,
ts: Utc::now(),
session_id,
context,
data,
metadata: None,
tags: None,
sequence: None,
}
}
pub fn with_id(
id: EventId,
session_id: SessionId,
context: EventContext,
data: impl Into<EventData>,
) -> Self {
let data = data.into();
let event_type = data.event_type().to_string();
Self {
id,
event_type,
ts: Utc::now(),
session_id,
context,
data,
metadata: None,
tags: None,
sequence: None,
}
}
pub fn with_sequence(mut self, sequence: i32) -> Self {
self.sequence = Some(sequence);
self
}
pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
self.metadata = Some(metadata);
self
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = Some(tags);
self
}
pub fn session_uuid(&self) -> Uuid {
self.session_id.uuid()
}
pub fn is_message_event(&self) -> bool {
self.event_type == INPUT_MESSAGE || self.event_type == OUTPUT_MESSAGE_COMPLETED
}
pub fn is_ephemeral(&self) -> bool {
is_ephemeral_event_type(&self.event_type)
}
pub fn is_input_event(&self) -> bool {
self.event_type.starts_with("input.")
}
pub fn is_output_event(&self) -> bool {
self.event_type.starts_with("output.")
}
pub fn is_atom_event(&self) -> bool {
matches!(
self.event_type.as_str(),
REASON_STARTED
| REASON_COMPLETED
| ACT_STARTED
| ACT_COMPLETED
| TOOL_STARTED
| TOOL_COMPLETED
| TOOL_PROGRESS
| TOOL_CALL_REQUESTED
)
}
pub fn is_turn_event(&self) -> bool {
self.event_type.starts_with("turn.")
}
pub fn is_session_event(&self) -> bool {
self.event_type.starts_with("session.")
}
pub fn is_unsupported(&self) -> bool {
self.data.is_unsupported()
}
}
use crate::message::{ContentPart, Message};
use crate::tool_narration::{
ToolNarrationPhase, render_group_headline_with_locale, render_tool_narration_with_locale,
};
use crate::tool_types::ToolCall;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ModelMetadata {
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct TokenUsage {
pub input_tokens: u32,
pub output_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_read_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_creation_tokens: Option<u32>,
}
impl TokenUsage {
pub fn new(input_tokens: u32, output_tokens: u32) -> Self {
Self {
input_tokens,
output_tokens,
cache_read_tokens: None,
cache_creation_tokens: None,
}
}
pub fn with_cache(
input_tokens: u32,
output_tokens: u32,
cache_read_tokens: Option<u32>,
cache_creation_tokens: Option<u32>,
) -> Self {
Self {
input_tokens,
output_tokens,
cache_read_tokens,
cache_creation_tokens,
}
}
pub fn total_tokens(&self) -> u32 {
self.input_tokens + self.output_tokens
}
pub fn add(&mut self, other: &TokenUsage) {
self.input_tokens += other.input_tokens;
self.output_tokens += other.output_tokens;
if let Some(cache) = other.cache_read_tokens {
*self.cache_read_tokens.get_or_insert(0) += cache;
}
if let Some(cache) = other.cache_creation_tokens {
*self.cache_creation_tokens.get_or_insert(0) += cache;
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct InputMessageData {
pub message: Message,
}
impl InputMessageData {
pub fn new(message: Message) -> Self {
Self { message }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct OutputMessageStartedData {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
pub turn_id: TurnId,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iteration: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct OutputMessageDeltaData {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
pub turn_id: TurnId,
pub delta: String,
pub accumulated: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct OutputMessageCompletedData {
pub message: Message,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<ModelMetadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
pub error_fields: Option<UserFacingErrorFields>,
}
impl OutputMessageCompletedData {
pub fn new(message: Message) -> Self {
Self {
message,
metadata: None,
usage: None,
error_code: None,
error_fields: None,
}
}
pub fn with_metadata(mut self, metadata: ModelMetadata) -> Self {
self.metadata = Some(metadata);
self
}
pub fn with_usage(mut self, usage: TokenUsage) -> Self {
self.usage = Some(usage);
self
}
pub fn with_user_facing_error(mut self, error: &UserFacingError) -> Self {
error.apply_to_event_fields(&mut self.error_code, &mut self.error_fields);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct OutputMessageReplacedData {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
pub turn_id: TurnId,
pub guardrail_capability_id: String,
pub guardrail_id: String,
pub reason_code: String,
pub replacement: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ReasonStartedData {
pub harness_id: HarnessId,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<AgentId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<ModelMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ReasonCompletedData {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_preview: Option<String>,
pub has_tool_calls: bool,
pub tool_call_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
}
impl ReasonCompletedData {
pub fn success(
text: &str,
has_tool_calls: bool,
tool_call_count: u32,
duration_ms: Option<u64>,
usage: Option<TokenUsage>,
) -> Self {
let text_preview = if text.is_empty() {
None
} else {
Some(text.chars().take(200).collect())
};
Self {
success: true,
text_preview,
has_tool_calls,
tool_call_count,
error: None,
duration_ms,
usage,
}
}
pub fn failure(error: String, duration_ms: Option<u64>) -> Self {
Self {
success: false,
text_preview: None,
has_tool_calls: false,
tool_call_count: 0,
error: Some(error),
duration_ms,
usage: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub enum CapabilityUsageKind {
Configured,
Resolved,
Exposed,
Invoked,
EffectRan,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct CapabilityUsageRecord {
pub capability_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability_name: Option<String>,
pub usage_kind: CapabilityUsageKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct CapabilityUsageData {
pub records: Vec<CapabilityUsageRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolCallSummary {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub narration: Option<String>,
}
impl From<&ToolCall> for ToolCallSummary {
fn from(tc: &ToolCall) -> Self {
Self {
id: tc.id.clone(),
name: tc.name.clone(),
display_name: None,
narration: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolDefinitionSummary {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability_name: Option<String>,
pub description: String,
}
impl From<&crate::tool_types::ToolDefinition> for ToolDefinitionSummary {
fn from(tool: &crate::tool_types::ToolDefinition) -> Self {
let capability_attribution = tool.capability_attribution();
Self {
name: tool.name().to_string(),
display_name: tool.display_name().map(|s| s.to_string()),
category: tool.category().map(|s| s.to_string()),
capability_id: capability_attribution.map(|(id, _)| id.to_string()),
capability_name: capability_attribution.and_then(|(_, name)| name.map(str::to_string)),
description: tool.description().to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ActStartedData {
pub tool_calls: Vec<ToolCallSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headline: Option<String>,
}
impl ActStartedData {
pub fn new(tool_calls: &[ToolCall]) -> Self {
Self::new_with_locale(tool_calls, None)
}
pub fn new_with_locale(tool_calls: &[ToolCall], locale: Option<&str>) -> Self {
Self {
tool_calls: tool_calls.iter().map(ToolCallSummary::from).collect(),
headline: render_group_headline_with_locale(
tool_calls,
&[],
ToolNarrationPhase::Started,
locale,
),
}
}
pub fn with_definitions(
tool_calls: &[ToolCall],
tool_defs: &[crate::tool_types::ToolDefinition],
) -> Self {
Self::with_definitions_and_locale(tool_calls, tool_defs, None)
}
pub fn with_definitions_and_locale(
tool_calls: &[ToolCall],
tool_defs: &[crate::tool_types::ToolDefinition],
locale: Option<&str>,
) -> Self {
let def_map: std::collections::HashMap<&str, &crate::tool_types::ToolDefinition> =
tool_defs.iter().map(|d| (d.name(), d)).collect();
Self {
tool_calls: tool_calls
.iter()
.map(|tc| {
let tool_def = def_map.get(tc.name.as_str()).copied();
let display_name = localized_tool_display_name(
&tc.name,
tool_def.and_then(|d| d.display_name()),
locale,
);
ToolCallSummary {
id: tc.id.clone(),
name: tc.name.clone(),
display_name,
narration: Some(render_tool_narration_with_locale(
tool_def,
tc,
ToolNarrationPhase::Started,
locale,
)),
}
})
.collect(),
headline: render_group_headline_with_locale(
tool_calls,
tool_defs,
ToolNarrationPhase::Started,
locale,
),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ActCompletedData {
pub completed: bool,
pub success_count: u32,
pub error_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headline: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolStartedData {
pub tool_call: ToolCall,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub narration: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolCompletedData {
pub tool_call_id: String,
pub tool_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_result_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub success: bool,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Vec<ContentPart>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub narration: Option<String>,
}
impl ToolCompletedData {
pub fn success(
tool_call_id: String,
tool_name: String,
result: Vec<ContentPart>,
duration_ms: Option<u64>,
) -> Self {
Self {
tool_call_id,
tool_name,
tool_call_fingerprint: None,
tool_result_fingerprint: None,
display_name: None,
success: true,
status: "success".to_string(),
result: Some(result),
error: None,
duration_ms,
capability_id: None,
capability_name: None,
narration: None,
}
}
pub fn failure(
tool_call_id: String,
tool_name: String,
status: String,
error: String,
duration_ms: Option<u64>,
) -> Self {
Self {
tool_call_id,
tool_name,
tool_call_fingerprint: None,
tool_result_fingerprint: None,
display_name: None,
success: false,
status,
result: None,
error: Some(error),
duration_ms,
capability_id: None,
capability_name: None,
narration: None,
}
}
pub fn with_display_name(mut self, display_name: Option<String>) -> Self {
self.display_name = display_name;
self
}
pub fn with_fingerprints(
mut self,
tool_call_fingerprint: String,
tool_result_fingerprint: String,
) -> Self {
self.tool_call_fingerprint = Some(tool_call_fingerprint);
self.tool_result_fingerprint = Some(tool_result_fingerprint);
self
}
pub fn with_narration(mut self, narration: Option<String>) -> Self {
self.narration = narration;
self
}
pub fn with_capability_attribution(
mut self,
capability_id: Option<String>,
capability_name: Option<String>,
) -> Self {
self.capability_id = capability_id;
self.capability_name = capability_name;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolProgressData {
pub tool_call_id: String,
pub tool_name: String,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolOutputDeltaData {
pub tool_call_id: String,
pub tool_name: String,
pub delta: String,
pub stream: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolCallRequestedData {
pub tool_calls: Vec<ToolCall>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_summaries: Vec<ToolCallSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headline: Option<String>,
}
impl ToolCallRequestedData {
pub fn with_definitions(
tool_calls: &[ToolCall],
tool_defs: &[crate::tool_types::ToolDefinition],
) -> Self {
Self::with_definitions_and_locale(tool_calls, tool_defs, None)
}
pub fn with_definitions_and_locale(
tool_calls: &[ToolCall],
tool_defs: &[crate::tool_types::ToolDefinition],
locale: Option<&str>,
) -> Self {
let def_map: std::collections::HashMap<&str, &crate::tool_types::ToolDefinition> =
tool_defs.iter().map(|d| (d.name(), d)).collect();
let tool_summaries = tool_calls
.iter()
.map(|tool_call| {
let tool_def = def_map.get(tool_call.name.as_str()).copied();
ToolCallSummary {
id: tool_call.id.clone(),
name: tool_call.name.clone(),
display_name: localized_tool_display_name(
&tool_call.name,
tool_def.and_then(|def| def.display_name()),
locale,
),
narration: Some(render_tool_narration_with_locale(
tool_def,
tool_call,
ToolNarrationPhase::Waiting,
locale,
)),
}
})
.collect();
Self {
tool_calls: tool_calls.to_vec(),
tool_summaries,
headline: render_group_headline_with_locale(
tool_calls,
tool_defs,
ToolNarrationPhase::Waiting,
locale,
),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct LlmGenerationOutput {
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<ToolCall>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct LlmRequestOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache: Option<LlmPromptCacheInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_search: Option<LlmToolSearchInfo>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub provider_options: HashMap<String, Value>,
}
impl LlmRequestOptions {
pub fn is_empty(&self) -> bool {
self.prompt_cache.is_none()
&& self.tool_search.is_none()
&& self.provider_options.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct LlmPromptCacheInfo {
pub enabled: bool,
pub strategy: crate::llm_driver_registry::PromptCacheStrategy,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_mode: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct LlmToolSearchInfo {
pub enabled: bool,
pub threshold: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct LlmGenerationMetadata {
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub time_to_first_token_ms: Option<u64>,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reasons: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry: Option<LlmRetryInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compaction: Option<LlmCompactionInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_options: Option<LlmRequestOptions>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct LlmRetryInfo {
pub attempts: u32,
pub total_wait_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct LlmCompactionInfo {
pub compacted: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_tokens_before: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_tokens_after: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
impl LlmCompactionInfo {
pub fn new(
input_tokens_before: Option<u32>,
input_tokens_after: Option<u32>,
duration_ms: Option<u64>,
) -> Self {
Self {
compacted: true,
input_tokens_before,
input_tokens_after,
duration_ms,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct LlmGenerationData {
pub messages: Vec<Message>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<ToolDefinitionSummary>,
pub output: LlmGenerationOutput,
pub metadata: LlmGenerationMetadata,
}
impl LlmGenerationData {
#[allow(clippy::too_many_arguments)]
pub fn success(
messages: Vec<Message>,
tools: Vec<ToolDefinitionSummary>,
text: Option<String>,
tool_calls: Vec<ToolCall>,
model: String,
provider: Option<String>,
usage: Option<TokenUsage>,
duration_ms: Option<u64>,
time_to_first_token_ms: Option<u64>,
) -> Self {
let finish_reasons = if !tool_calls.is_empty() {
Some(vec!["tool_calls".to_string()])
} else {
Some(vec!["stop".to_string()])
};
Self {
messages,
tools,
output: LlmGenerationOutput { text, tool_calls },
metadata: LlmGenerationMetadata {
model,
provider,
usage,
duration_ms,
time_to_first_token_ms,
success: true,
error: None,
finish_reasons,
response_id: None,
retry: None,
compaction: None,
request_options: None,
},
}
}
#[allow(clippy::too_many_arguments)]
pub fn success_with_metadata(
messages: Vec<Message>,
tools: Vec<ToolDefinitionSummary>,
text: Option<String>,
tool_calls: Vec<ToolCall>,
model: String,
provider: Option<String>,
usage: Option<TokenUsage>,
duration_ms: Option<u64>,
time_to_first_token_ms: Option<u64>,
finish_reasons: Option<Vec<String>>,
response_id: Option<String>,
) -> Self {
Self {
messages,
tools,
output: LlmGenerationOutput { text, tool_calls },
metadata: LlmGenerationMetadata {
model,
provider,
usage,
duration_ms,
time_to_first_token_ms,
success: true,
error: None,
finish_reasons,
response_id,
retry: None,
compaction: None,
request_options: None,
},
}
}
#[allow(clippy::too_many_arguments)]
pub fn success_with_retry(
messages: Vec<Message>,
tools: Vec<ToolDefinitionSummary>,
text: Option<String>,
tool_calls: Vec<ToolCall>,
model: String,
provider: Option<String>,
usage: Option<TokenUsage>,
duration_ms: Option<u64>,
time_to_first_token_ms: Option<u64>,
finish_reasons: Option<Vec<String>>,
response_id: Option<String>,
retry: Option<LlmRetryInfo>,
) -> Self {
Self {
messages,
tools,
output: LlmGenerationOutput { text, tool_calls },
metadata: LlmGenerationMetadata {
model,
provider,
usage,
duration_ms,
time_to_first_token_ms,
success: true,
error: None,
finish_reasons,
response_id,
retry,
compaction: None,
request_options: None,
},
}
}
pub fn failure(
messages: Vec<Message>,
tools: Vec<ToolDefinitionSummary>,
model: String,
provider: Option<String>,
error: String,
duration_ms: Option<u64>,
time_to_first_token_ms: Option<u64>,
) -> Self {
Self {
messages,
tools,
output: LlmGenerationOutput {
text: None,
tool_calls: vec![],
},
metadata: LlmGenerationMetadata {
model,
provider,
usage: None,
duration_ms,
time_to_first_token_ms,
success: false,
error: Some(error),
finish_reasons: Some(vec!["error".to_string()]),
response_id: None,
retry: None,
compaction: None,
request_options: None,
},
}
}
pub fn with_compaction(mut self, compaction: LlmCompactionInfo) -> Self {
self.metadata.compaction = Some(compaction);
self
}
pub fn with_retry(mut self, retry: LlmRetryInfo) -> Self {
self.metadata.retry = Some(retry);
self
}
pub fn with_request_options(mut self, request_options: LlmRequestOptions) -> Self {
if !request_options.is_empty() {
self.metadata.request_options = Some(request_options);
}
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ReasonThinkingStartedData {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
pub turn_id: TurnId,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ReasonThinkingDeltaData {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
pub turn_id: TurnId,
pub delta: String,
pub accumulated: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ReasonThinkingCompletedData {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
pub turn_id: TurnId,
pub thinking: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ReasonItemData {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
pub turn_id: TurnId,
pub provider: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub item_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub encrypted_content: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub summary: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_count: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct TurnStartedData {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
pub turn_id: TurnId,
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "message_01933b5a00007000800000000000001"))]
pub input_message_id: MessageId,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_content: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct TurnCompletedData {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
pub turn_id: TurnId,
pub iterations: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "message_01933b5a00007000800000000000001"))]
pub final_message_id: Option<MessageId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub final_answer_preview: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub time_to_first_token_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub llm_call_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct TurnFailedData {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
pub turn_id: TurnId,
pub error: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
pub error_fields: Option<UserFacingErrorFields>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct TurnCancelledData {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
pub turn_id: TurnId,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct SessionStartedData {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "harness_01933b5a00007000800000000000001"))]
pub harness_id: HarnessId,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agent_01933b5a00007000800000000000001"))]
pub agent_id: Option<AgentId>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "model_01933b5a00007000800000000000001"))]
pub model_id: Option<ModelId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct SessionActivatedData {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
pub turn_id: TurnId,
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "message_01933b5a00007000800000000000001"))]
pub input_message_id: MessageId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct SessionIdledData {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
pub turn_id: TurnId,
#[serde(skip_serializing_if = "Option::is_none")]
pub iterations: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct SubagentEventData {
#[cfg_attr(feature = "openapi", schema(value_type = String))]
pub subagent_session_id: SessionId,
pub subagent_name: String,
pub task: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl From<SubagentEventData> for EventData {
fn from(data: SubagentEventData) -> Self {
match data.status.as_str() {
"completed" => EventData::SubagentCompleted(data),
"failed" => EventData::SubagentFailed(data),
"cancelled" => EventData::SubagentCancelled(data),
_ => EventData::SubagentSpawned(data),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum CompactionReason {
ProactiveBudget,
RequestTooLarge,
Manual,
}
impl std::fmt::Display for CompactionReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ProactiveBudget => write!(f, "proactive_budget"),
Self::RequestTooLarge => write!(f, "request_too_large"),
Self::Manual => write!(f, "manual"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ContextCompactingData {
pub reason: CompactionReason,
pub strategy: String,
pub messages_before: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct CompactionStepData {
pub strategy: String,
pub messages_after: usize,
pub duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ContextCompactedData {
pub strategy_used: String,
pub messages_before: usize,
pub messages_after: usize,
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub steps: Vec<CompactionStepData>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct FileWrittenData {
pub path: String,
pub operation: String,
pub size_bytes: i64,
pub created: bool,
}
pub const FILE_OP_CREATE: &str = "create";
pub const FILE_OP_UPDATE: &str = "update";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct BudgetEventData {
pub budget_id: String,
pub balance: f64,
pub limit: f64,
pub currency: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub soft_limit: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct VoiceSessionStartedData {
pub voice_connection_id: String,
pub model: String,
pub voice: String,
pub reasoning_effort: String,
pub transport: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct VoiceTranscriptData {
pub voice_connection_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub item_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub delta: String,
pub accumulated: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct VoiceSessionEndedData {
pub voice_connection_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct VoiceSessionFailedData {
pub voice_connection_id: String,
pub error: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(feature = "openapi", schema(
title = "EventData",
description = "Event-specific payload. The schema depends on the event type field.",
example = json!({"message": {"id": "...", "role": "user", "content": []}})
))]
pub enum EventData {
InputMessage(InputMessageData),
OutputMessageDelta(OutputMessageDeltaData),
OutputMessageStarted(OutputMessageStartedData),
OutputMessageReplaced(OutputMessageReplacedData),
OutputMessageCompleted(OutputMessageCompletedData),
TurnStarted(TurnStartedData),
TurnCompleted(TurnCompletedData),
TurnFailed(TurnFailedData),
ReasonStarted(ReasonStartedData),
ReasonCompleted(ReasonCompletedData),
CapabilityUsage(CapabilityUsageData),
ActStarted(ActStartedData),
ActCompleted(ActCompletedData),
ToolStarted(ToolStartedData),
ToolCompleted(ToolCompletedData),
ToolProgress(ToolProgressData),
ToolOutputDelta(ToolOutputDeltaData),
ToolCallRequested(ToolCallRequestedData),
LlmGeneration(LlmGenerationData),
ReasonThinkingDelta(ReasonThinkingDeltaData),
ReasonItem(ReasonItemData),
ReasonThinkingStarted(ReasonThinkingStartedData),
ReasonThinkingCompleted(ReasonThinkingCompletedData),
TurnCancelled(TurnCancelledData),
SessionStarted(SessionStartedData),
SessionActivated(SessionActivatedData),
SessionIdled(SessionIdledData),
SubagentSpawned(SubagentEventData),
SubagentCompleted(SubagentEventData),
SubagentFailed(SubagentEventData),
SubagentCancelled(SubagentEventData),
ContextCompacting(ContextCompactingData),
ContextCompacted(ContextCompactedData),
FileWritten(FileWrittenData),
BudgetWarning(BudgetEventData),
BudgetPaused(BudgetEventData),
BudgetExhausted(BudgetEventData),
BudgetResumed(BudgetEventData),
VoiceSessionStarted(VoiceSessionStartedData),
VoiceInputTranscriptDelta(VoiceTranscriptData),
VoiceInputTranscriptCompleted(VoiceTranscriptData),
VoiceOutputTranscriptDelta(VoiceTranscriptData),
VoiceOutputTranscriptCompleted(VoiceTranscriptData),
VoiceSessionEnded(VoiceSessionEndedData),
VoiceSessionFailed(VoiceSessionFailedData),
#[serde(skip)]
Unsupported {
event_type: String,
data: serde_json::Value,
},
}
impl EventData {
pub fn event_type(&self) -> &'static str {
match self {
EventData::InputMessage(_) => INPUT_MESSAGE,
EventData::OutputMessageStarted(_) => OUTPUT_MESSAGE_STARTED,
EventData::OutputMessageDelta(_) => OUTPUT_MESSAGE_DELTA,
EventData::OutputMessageReplaced(_) => OUTPUT_MESSAGE_REPLACED,
EventData::OutputMessageCompleted(_) => OUTPUT_MESSAGE_COMPLETED,
EventData::TurnStarted(_) => TURN_STARTED,
EventData::TurnCompleted(_) => TURN_COMPLETED,
EventData::TurnFailed(_) => TURN_FAILED,
EventData::TurnCancelled(_) => TURN_CANCELLED,
EventData::ReasonStarted(_) => REASON_STARTED,
EventData::ReasonCompleted(_) => REASON_COMPLETED,
EventData::CapabilityUsage(_) => CAPABILITY_USAGE,
EventData::ActStarted(_) => ACT_STARTED,
EventData::ActCompleted(_) => ACT_COMPLETED,
EventData::ToolStarted(_) => TOOL_STARTED,
EventData::ToolCompleted(_) => TOOL_COMPLETED,
EventData::ToolProgress(_) => TOOL_PROGRESS,
EventData::ToolOutputDelta(_) => TOOL_OUTPUT_DELTA,
EventData::ToolCallRequested(_) => TOOL_CALL_REQUESTED,
EventData::LlmGeneration(_) => LLM_GENERATION,
EventData::ReasonThinkingDelta(_) => REASON_THINKING_DELTA,
EventData::ReasonThinkingStarted(_) => REASON_THINKING_STARTED,
EventData::ReasonThinkingCompleted(_) => REASON_THINKING_COMPLETED,
EventData::ReasonItem(_) => REASON_ITEM,
EventData::SessionStarted(_) => SESSION_STARTED,
EventData::SessionActivated(_) => SESSION_ACTIVATED,
EventData::SessionIdled(_) => SESSION_IDLED,
EventData::SubagentSpawned(_) => SUBAGENT_SPAWNED,
EventData::SubagentCompleted(_) => SUBAGENT_COMPLETED,
EventData::SubagentFailed(_) => SUBAGENT_FAILED,
EventData::SubagentCancelled(_) => SUBAGENT_CANCELLED,
EventData::ContextCompacting(_) => CONTEXT_COMPACTING,
EventData::ContextCompacted(_) => CONTEXT_COMPACTED,
EventData::FileWritten(_) => FILE_WRITTEN,
EventData::BudgetWarning(_) => BUDGET_WARNING,
EventData::BudgetPaused(_) => BUDGET_PAUSED,
EventData::BudgetExhausted(_) => BUDGET_EXHAUSTED,
EventData::BudgetResumed(_) => BUDGET_RESUMED,
EventData::VoiceSessionStarted(_) => VOICE_SESSION_STARTED,
EventData::VoiceInputTranscriptDelta(_) => VOICE_INPUT_TRANSCRIPT_DELTA,
EventData::VoiceInputTranscriptCompleted(_) => VOICE_INPUT_TRANSCRIPT_COMPLETED,
EventData::VoiceOutputTranscriptDelta(_) => VOICE_OUTPUT_TRANSCRIPT_DELTA,
EventData::VoiceOutputTranscriptCompleted(_) => VOICE_OUTPUT_TRANSCRIPT_COMPLETED,
EventData::VoiceSessionEnded(_) => VOICE_SESSION_ENDED,
EventData::VoiceSessionFailed(_) => VOICE_SESSION_FAILED,
EventData::Unsupported { .. } => "unsupported",
}
}
pub fn is_unsupported(&self) -> bool {
matches!(self, EventData::Unsupported { .. })
}
pub fn unsupported(event_type: String, data: serde_json::Value) -> Self {
tracing::warn!(
event_type = %event_type,
"Encountered unsupported event type - will be filtered from API responses"
);
EventData::Unsupported { event_type, data }
}
}
pub fn deserialize_event_data(event_type: &str, data: serde_json::Value) -> EventData {
let result =
match event_type {
INPUT_MESSAGE => serde_json::from_value::<InputMessageData>(data.clone())
.map(EventData::InputMessage),
OUTPUT_MESSAGE_STARTED => {
serde_json::from_value::<OutputMessageStartedData>(data.clone())
.map(EventData::OutputMessageStarted)
}
OUTPUT_MESSAGE_DELTA => serde_json::from_value::<OutputMessageDeltaData>(data.clone())
.map(EventData::OutputMessageDelta),
OUTPUT_MESSAGE_REPLACED => {
serde_json::from_value::<OutputMessageReplacedData>(data.clone())
.map(EventData::OutputMessageReplaced)
}
OUTPUT_MESSAGE_COMPLETED => {
serde_json::from_value::<OutputMessageCompletedData>(data.clone())
.map(EventData::OutputMessageCompleted)
}
TURN_STARTED => {
serde_json::from_value::<TurnStartedData>(data.clone()).map(EventData::TurnStarted)
}
TURN_COMPLETED => serde_json::from_value::<TurnCompletedData>(data.clone())
.map(EventData::TurnCompleted),
TURN_FAILED => {
serde_json::from_value::<TurnFailedData>(data.clone()).map(EventData::TurnFailed)
}
TURN_CANCELLED => serde_json::from_value::<TurnCancelledData>(data.clone())
.map(EventData::TurnCancelled),
REASON_STARTED => serde_json::from_value::<ReasonStartedData>(data.clone())
.map(EventData::ReasonStarted),
REASON_COMPLETED => serde_json::from_value::<ReasonCompletedData>(data.clone())
.map(EventData::ReasonCompleted),
CAPABILITY_USAGE => serde_json::from_value::<CapabilityUsageData>(data.clone())
.map(EventData::CapabilityUsage),
ACT_STARTED => {
serde_json::from_value::<ActStartedData>(data.clone()).map(EventData::ActStarted)
}
ACT_COMPLETED => serde_json::from_value::<ActCompletedData>(data.clone())
.map(EventData::ActCompleted),
TOOL_STARTED => {
serde_json::from_value::<ToolStartedData>(data.clone()).map(EventData::ToolStarted)
}
TOOL_COMPLETED => serde_json::from_value::<ToolCompletedData>(data.clone())
.map(EventData::ToolCompleted),
TOOL_PROGRESS => serde_json::from_value::<ToolProgressData>(data.clone())
.map(EventData::ToolProgress),
TOOL_OUTPUT_DELTA => serde_json::from_value::<ToolOutputDeltaData>(data.clone())
.map(EventData::ToolOutputDelta),
TOOL_CALL_REQUESTED => serde_json::from_value::<ToolCallRequestedData>(data.clone())
.map(EventData::ToolCallRequested),
LLM_GENERATION => serde_json::from_value::<LlmGenerationData>(data.clone())
.map(EventData::LlmGeneration),
REASON_THINKING_STARTED => {
serde_json::from_value::<ReasonThinkingStartedData>(data.clone())
.map(EventData::ReasonThinkingStarted)
}
REASON_THINKING_DELTA => {
serde_json::from_value::<ReasonThinkingDeltaData>(data.clone())
.map(EventData::ReasonThinkingDelta)
}
REASON_THINKING_COMPLETED => {
serde_json::from_value::<ReasonThinkingCompletedData>(data.clone())
.map(EventData::ReasonThinkingCompleted)
}
REASON_ITEM => {
serde_json::from_value::<ReasonItemData>(data.clone()).map(EventData::ReasonItem)
}
SESSION_STARTED => serde_json::from_value::<SessionStartedData>(data.clone())
.map(EventData::SessionStarted),
SESSION_ACTIVATED => serde_json::from_value::<SessionActivatedData>(data.clone())
.map(EventData::SessionActivated),
SESSION_IDLED => serde_json::from_value::<SessionIdledData>(data.clone())
.map(EventData::SessionIdled),
CONTEXT_COMPACTING => serde_json::from_value::<ContextCompactingData>(data.clone())
.map(EventData::ContextCompacting),
CONTEXT_COMPACTED => serde_json::from_value::<ContextCompactedData>(data.clone())
.map(EventData::ContextCompacted),
FILE_WRITTEN => {
serde_json::from_value::<FileWrittenData>(data.clone()).map(EventData::FileWritten)
}
BUDGET_WARNING => serde_json::from_value::<BudgetEventData>(data.clone())
.map(EventData::BudgetWarning),
BUDGET_PAUSED => {
serde_json::from_value::<BudgetEventData>(data.clone()).map(EventData::BudgetPaused)
}
BUDGET_EXHAUSTED => serde_json::from_value::<BudgetEventData>(data.clone())
.map(EventData::BudgetExhausted),
BUDGET_RESUMED => serde_json::from_value::<BudgetEventData>(data.clone())
.map(EventData::BudgetResumed),
VOICE_SESSION_STARTED => {
serde_json::from_value::<VoiceSessionStartedData>(data.clone())
.map(EventData::VoiceSessionStarted)
}
VOICE_INPUT_TRANSCRIPT_DELTA => {
serde_json::from_value::<VoiceTranscriptData>(data.clone())
.map(EventData::VoiceInputTranscriptDelta)
}
VOICE_INPUT_TRANSCRIPT_COMPLETED => {
serde_json::from_value::<VoiceTranscriptData>(data.clone())
.map(EventData::VoiceInputTranscriptCompleted)
}
VOICE_OUTPUT_TRANSCRIPT_DELTA => {
serde_json::from_value::<VoiceTranscriptData>(data.clone())
.map(EventData::VoiceOutputTranscriptDelta)
}
VOICE_OUTPUT_TRANSCRIPT_COMPLETED => {
serde_json::from_value::<VoiceTranscriptData>(data.clone())
.map(EventData::VoiceOutputTranscriptCompleted)
}
VOICE_SESSION_ENDED => serde_json::from_value::<VoiceSessionEndedData>(data.clone())
.map(EventData::VoiceSessionEnded),
VOICE_SESSION_FAILED => serde_json::from_value::<VoiceSessionFailedData>(data.clone())
.map(EventData::VoiceSessionFailed),
_ => {
return EventData::unsupported(event_type.to_string(), data);
}
};
result.unwrap_or_else(|e| {
tracing::warn!(
event_type = %event_type,
error = %e,
"Failed to deserialize known event type - treating as unsupported"
);
EventData::Unsupported {
event_type: event_type.to_string(),
data,
}
})
}
macro_rules! impl_from_event_data {
($($data_type:ty => $variant:ident),* $(,)?) => {
$(
impl From<$data_type> for EventData {
fn from(data: $data_type) -> Self {
EventData::$variant(data)
}
}
)*
};
}
impl_from_event_data! {
InputMessageData => InputMessage,
OutputMessageStartedData => OutputMessageStarted,
OutputMessageDeltaData => OutputMessageDelta,
OutputMessageReplacedData => OutputMessageReplaced,
OutputMessageCompletedData => OutputMessageCompleted,
TurnStartedData => TurnStarted,
TurnCompletedData => TurnCompleted,
TurnFailedData => TurnFailed,
TurnCancelledData => TurnCancelled,
ReasonStartedData => ReasonStarted,
ReasonCompletedData => ReasonCompleted,
CapabilityUsageData => CapabilityUsage,
ActStartedData => ActStarted,
ActCompletedData => ActCompleted,
ToolStartedData => ToolStarted,
ToolCompletedData => ToolCompleted,
ToolProgressData => ToolProgress,
ToolOutputDeltaData => ToolOutputDelta,
ToolCallRequestedData => ToolCallRequested,
LlmGenerationData => LlmGeneration,
ReasonThinkingStartedData => ReasonThinkingStarted,
ReasonThinkingDeltaData => ReasonThinkingDelta,
ReasonThinkingCompletedData => ReasonThinkingCompleted,
ReasonItemData => ReasonItem,
SessionStartedData => SessionStarted,
SessionActivatedData => SessionActivated,
SessionIdledData => SessionIdled,
ContextCompactingData => ContextCompacting,
ContextCompactedData => ContextCompacted,
FileWrittenData => FileWritten,
VoiceSessionStartedData => VoiceSessionStarted,
VoiceSessionEndedData => VoiceSessionEnded,
VoiceSessionFailedData => VoiceSessionFailed,
}
impl EventData {
pub fn voice_transcript_event(data: VoiceTranscriptData, event_type: &str) -> Self {
match event_type {
VOICE_INPUT_TRANSCRIPT_DELTA => EventData::VoiceInputTranscriptDelta(data),
VOICE_INPUT_TRANSCRIPT_COMPLETED => EventData::VoiceInputTranscriptCompleted(data),
VOICE_OUTPUT_TRANSCRIPT_DELTA => EventData::VoiceOutputTranscriptDelta(data),
VOICE_OUTPUT_TRANSCRIPT_COMPLETED => EventData::VoiceOutputTranscriptCompleted(data),
_ => panic!("Unknown voice transcript event type: {event_type}"),
}
}
}
impl EventData {
pub fn budget_event(data: BudgetEventData, event_type: &str) -> Self {
match event_type {
BUDGET_WARNING => EventData::BudgetWarning(data),
BUDGET_PAUSED => EventData::BudgetPaused(data),
BUDGET_EXHAUSTED => EventData::BudgetExhausted(data),
BUDGET_RESUMED => EventData::BudgetResumed(data),
_ => panic!("Unknown budget event type: {event_type}"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct EventRequest {
#[serde(rename = "type")]
pub event_type: String,
pub ts: DateTime<Utc>,
pub session_id: SessionId,
pub context: EventContext,
pub data: EventData,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}
impl EventRequest {
pub fn new(session_id: SessionId, context: EventContext, data: impl Into<EventData>) -> Self {
let data = data.into();
let event_type = data.event_type().to_string();
Self {
event_type,
ts: Utc::now(),
session_id,
context,
data,
metadata: None,
tags: None,
}
}
pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
self.metadata = Some(metadata);
self
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = Some(tags);
self
}
pub fn is_ephemeral(&self) -> bool {
is_ephemeral_event_type(&self.event_type)
}
pub fn into_event(self, id: EventId, sequence: i32) -> Event {
Event {
id,
event_type: self.event_type,
ts: self.ts,
session_id: self.session_id,
context: self.context,
data: self.data,
metadata: self.metadata,
tags: self.tags,
sequence: Some(sequence),
}
}
}
pub struct EventBuilder {
session_id: SessionId,
context: EventContext,
}
impl EventBuilder {
pub fn new(session_id: SessionId) -> Self {
Self {
session_id,
context: EventContext::empty(),
}
}
pub fn with_turn(mut self, turn_id: TurnId, input_message_id: MessageId) -> Self {
self.context.turn_id = Some(turn_id);
self.context.input_message_id = Some(input_message_id);
self
}
pub fn with_exec(mut self, exec_id: ExecId) -> Self {
self.context.exec_id = Some(exec_id);
self
}
pub fn build(self, data: impl Into<EventData>) -> Event {
Event::new(self.session_id, self.context, data)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm_driver_registry::PromptCacheStrategy;
use serde_json::json;
use std::collections::HashMap;
#[test]
fn test_event_creation() {
let session_id = SessionId::new();
let context = EventContext::empty();
let data = InputMessageData::new(Message::user("test"));
let event = Event::new(session_id, context, data);
assert_eq!(event.event_type, "input.message");
assert_eq!(event.session_uuid(), session_id.uuid());
assert!(event.is_input_event());
assert!(event.is_message_event());
}
#[test]
fn test_event_context_from_atom_context() {
let session_id = SessionId::new();
let turn_id = TurnId::new();
let input_message_id = MessageId::new();
let atom_ctx = AtomContext::new(session_id, turn_id, input_message_id);
let context = EventContext::from_atom_context(&atom_ctx);
assert_eq!(context.turn_id, Some(turn_id));
assert_eq!(context.input_message_id, Some(input_message_id));
assert_eq!(context.exec_id, Some(atom_ctx.exec_id));
}
#[test]
fn test_event_serialization() {
let session_id = SessionId::new();
let context = EventContext::empty();
let event = Event::new(
session_id,
context,
InputMessageData::new(Message::user("test")),
);
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"input.message\""));
assert!(json.contains("\"session_id\""));
assert!(json.contains("\"context\""));
assert!(json.contains("\"data\""));
}
#[test]
fn test_event_builder() {
let session_id = SessionId::new();
let turn_id = TurnId::new();
let input_message_id = MessageId::new();
let exec_id = ExecId::new();
let event = EventBuilder::new(session_id)
.with_turn(turn_id, input_message_id)
.with_exec(exec_id)
.build(ReasonStartedData {
harness_id: HarnessId::from_seed(1),
agent_id: Some(AgentId::new()),
metadata: Some(ModelMetadata {
model: "gpt-4o".to_string(),
model_id: None,
provider_id: None,
}),
});
assert_eq!(event.event_type, "reason.started");
assert_eq!(event.session_id, session_id);
assert_eq!(event.context.turn_id, Some(turn_id));
assert_eq!(event.context.exec_id, Some(exec_id));
}
#[test]
fn test_reason_completed_data() {
let data = ReasonCompletedData::success("Hello world", true, 2, Some(1000), None);
assert!(data.success);
assert_eq!(data.text_preview, Some("Hello world".to_string()));
assert!(data.has_tool_calls);
assert_eq!(data.tool_call_count, 2);
assert_eq!(data.duration_ms, Some(1000));
assert!(data.usage.is_none());
let data = ReasonCompletedData::failure("Network error".to_string(), Some(500));
assert!(!data.success);
assert_eq!(data.error, Some("Network error".to_string()));
assert_eq!(data.duration_ms, Some(500));
}
#[test]
fn test_input_output_event_types() {
assert_eq!(INPUT_MESSAGE, "input.message");
assert_eq!(OUTPUT_MESSAGE_STARTED, "output.message.started");
assert_eq!(OUTPUT_MESSAGE_DELTA, "output.message.delta");
assert_eq!(OUTPUT_MESSAGE_COMPLETED, "output.message.completed");
}
#[test]
fn test_turn_event_types() {
assert_eq!(TURN_STARTED, "turn.started");
assert_eq!(TURN_COMPLETED, "turn.completed");
assert_eq!(TURN_FAILED, "turn.failed");
assert_eq!(TURN_CANCELLED, "turn.cancelled");
}
#[test]
fn test_turn_cancelled_data() {
let data = TurnCancelledData {
turn_id: TurnId::from_uuid(Uuid::now_v7()),
reason: Some("User requested cancellation".to_string()),
usage: Some(TokenUsage::new(100, 50)),
};
let event_data: EventData = data.into();
assert_eq!(event_data.event_type(), TURN_CANCELLED);
}
#[test]
fn test_tool_event_types() {
assert_eq!(TOOL_STARTED, "tool.started");
assert_eq!(TOOL_COMPLETED, "tool.completed");
}
#[test]
fn test_llm_generation_event_type() {
assert_eq!(LLM_GENERATION, "llm.generation");
}
#[test]
fn test_llm_generation_data_success() {
let messages = vec![Message::user("Hello"), Message::assistant("Hi there!")];
let tools = vec![ToolDefinitionSummary {
name: "get_weather".to_string(),
display_name: None,
category: None,
capability_id: None,
capability_name: None,
description: "Get weather for a city".to_string(),
}];
let tool_calls = vec![];
let data = LlmGenerationData::success(
messages.clone(),
tools,
Some("Hi there!".to_string()),
tool_calls,
"gpt-4o".to_string(),
Some("openai".to_string()),
Some(TokenUsage {
input_tokens: 10,
output_tokens: 5,
cache_read_tokens: None,
cache_creation_tokens: None,
}),
Some(100),
Some(25), );
assert_eq!(data.messages.len(), 2);
assert_eq!(data.tools.len(), 1);
assert_eq!(data.tools[0].name, "get_weather");
assert_eq!(data.output.text, Some("Hi there!".to_string()));
assert!(data.output.tool_calls.is_empty());
assert!(data.metadata.success);
assert_eq!(data.metadata.model, "gpt-4o");
assert_eq!(data.metadata.provider, Some("openai".to_string()));
assert!(data.metadata.error.is_none());
assert_eq!(data.metadata.finish_reasons, Some(vec!["stop".to_string()]));
assert!(data.metadata.response_id.is_none());
}
#[test]
fn test_llm_generation_data_with_full_metadata() {
let messages = vec![Message::user("Hello")];
let data = LlmGenerationData::success_with_metadata(
messages,
vec![],
Some("Hi!".to_string()),
vec![],
"claude-3-opus".to_string(),
Some("anthropic".to_string()),
Some(TokenUsage {
input_tokens: 5,
output_tokens: 3,
cache_read_tokens: None,
cache_creation_tokens: None,
}),
Some(50),
Some(25), Some(vec!["end_turn".to_string()]),
Some("msg_12345".to_string()),
);
assert!(data.metadata.success);
assert_eq!(data.metadata.model, "claude-3-opus");
assert_eq!(data.metadata.provider, Some("anthropic".to_string()));
assert_eq!(data.metadata.time_to_first_token_ms, Some(25));
assert_eq!(
data.metadata.finish_reasons,
Some(vec!["end_turn".to_string()])
);
assert_eq!(data.metadata.response_id, Some("msg_12345".to_string()));
}
#[test]
fn test_llm_generation_data_failure() {
let messages = vec![Message::user("Hello")];
let data = LlmGenerationData::failure(
messages,
vec![],
"gpt-4o".to_string(),
Some("openai".to_string()),
"Rate limit exceeded".to_string(),
Some(50),
None, );
assert!(!data.metadata.success);
assert_eq!(data.metadata.error, Some("Rate limit exceeded".to_string()));
assert!(data.output.text.is_none());
assert!(data.output.tool_calls.is_empty());
}
#[test]
fn test_llm_generation_event_data() {
let data = LlmGenerationData::success(
vec![Message::user("test")],
vec![],
Some("response".to_string()),
vec![],
"model".to_string(),
None,
None,
None,
None, );
let event_data: EventData = data.into();
assert_eq!(event_data.event_type(), LLM_GENERATION);
}
#[test]
fn test_llm_generation_is_durable_not_ephemeral() {
let session_id = SessionId::new();
let data = LlmGenerationData::success(
vec![Message::user("test")],
vec![],
Some("response".to_string()),
vec![],
"model".to_string(),
None,
None,
None,
None,
);
let request = EventRequest::new(session_id, EventContext::empty(), data);
assert!(!request.is_ephemeral());
}
#[test]
fn test_delta_events_are_ephemeral() {
let session_id = SessionId::new();
let turn_id = TurnId::new();
let output_delta = EventRequest::new(
session_id,
EventContext::empty(),
OutputMessageDeltaData {
turn_id,
delta: "hel".to_string(),
accumulated: "hel".to_string(),
},
);
assert!(output_delta.is_ephemeral());
let thinking_delta = EventRequest::new(
session_id,
EventContext::empty(),
ReasonThinkingDeltaData {
turn_id,
delta: "step".to_string(),
accumulated: "step".to_string(),
},
);
assert!(thinking_delta.is_ephemeral());
let tool_delta = EventRequest::new(
session_id,
EventContext::empty(),
ToolOutputDeltaData {
tool_call_id: "call_123".to_string(),
tool_name: "bash".to_string(),
delta: "line".to_string(),
stream: "stdout".to_string(),
},
);
assert!(tool_delta.is_ephemeral());
}
#[test]
fn test_llm_generation_data_with_request_options() {
let mut provider_options = HashMap::new();
provider_options.insert(
"openai".to_string(),
json!({ "previous_response_id": true }),
);
let data = LlmGenerationData::success(
vec![Message::user("Hello")],
vec![],
Some("Hi".to_string()),
vec![],
"gpt-5.4".to_string(),
Some("openai".to_string()),
None,
Some(42),
Some(12),
)
.with_request_options(LlmRequestOptions {
prompt_cache: Some(LlmPromptCacheInfo {
enabled: true,
strategy: PromptCacheStrategy::Auto,
provider_mode: Some("prompt_cache_key".to_string()),
}),
tool_search: Some(LlmToolSearchInfo {
enabled: true,
threshold: 8,
}),
provider_options,
});
let json = serde_json::to_value(&data).unwrap();
assert_eq!(
json["metadata"]["request_options"]["prompt_cache"]["provider_mode"],
"prompt_cache_key"
);
assert_eq!(
json["metadata"]["request_options"]["tool_search"]["threshold"],
8
);
assert_eq!(
json["metadata"]["request_options"]["provider_options"]["openai"]["previous_response_id"],
true
);
}
#[test]
fn test_extended_thinking_event_types() {
assert_eq!(REASON_THINKING_STARTED, "reason.thinking.started");
assert_eq!(REASON_THINKING_DELTA, "reason.thinking.delta");
assert_eq!(REASON_THINKING_COMPLETED, "reason.thinking.completed");
}
#[test]
fn test_output_message_started_data() {
let turn_id = TurnId::from_uuid(Uuid::now_v7());
let data = OutputMessageStartedData {
turn_id,
model: Some("claude-4-opus".to_string()),
iteration: None,
};
let event_data: EventData = data.into();
assert_eq!(event_data.event_type(), OUTPUT_MESSAGE_STARTED);
let json = serde_json::to_string(&event_data).unwrap();
assert!(json.contains("turn_id"));
assert!(json.contains("claude-4-opus"));
}
#[test]
fn test_output_message_started_data_without_model() {
let turn_id = TurnId::from_uuid(Uuid::now_v7());
let data = OutputMessageStartedData {
turn_id,
model: None,
iteration: None,
};
let json = serde_json::to_string(&data).unwrap();
assert!(!json.contains("model"));
}
#[test]
fn test_reason_thinking_started_data() {
let turn_id = TurnId::from_uuid(Uuid::now_v7());
let data = ReasonThinkingStartedData {
turn_id,
model: Some("claude-4-opus".to_string()),
};
let event_data: EventData = data.into();
assert_eq!(event_data.event_type(), REASON_THINKING_STARTED);
let json = serde_json::to_string(&event_data).unwrap();
assert!(json.contains("turn_id"));
assert!(json.contains("claude-4-opus"));
}
#[test]
fn test_reason_thinking_delta_data() {
let turn_id = TurnId::from_uuid(Uuid::now_v7());
let data = ReasonThinkingDeltaData {
turn_id,
delta: "thinking step 1".to_string(),
accumulated: "thinking step 1".to_string(),
};
let event_data: EventData = data.into();
assert_eq!(event_data.event_type(), REASON_THINKING_DELTA);
let json = serde_json::to_string(&event_data).unwrap();
assert!(json.contains("turn_id"));
assert!(json.contains("delta"));
assert!(json.contains("accumulated"));
}
#[test]
fn test_reason_thinking_completed_data() {
let turn_id = TurnId::from_uuid(Uuid::now_v7());
let data = ReasonThinkingCompletedData {
turn_id,
thinking: "Full thinking content here".to_string(),
};
let event_data: EventData = data.into();
assert_eq!(event_data.event_type(), REASON_THINKING_COMPLETED);
let json = serde_json::to_string(&event_data).unwrap();
assert!(json.contains("turn_id"));
assert!(json.contains("thinking"));
}
#[test]
fn test_output_message_delta_data() {
let turn_id = TurnId::from_uuid(Uuid::now_v7());
let data = OutputMessageDeltaData {
turn_id,
delta: "Hello".to_string(),
accumulated: "Hello".to_string(),
};
let event_data: EventData = data.into();
assert_eq!(event_data.event_type(), OUTPUT_MESSAGE_DELTA);
let json = serde_json::to_string(&event_data).unwrap();
assert!(json.contains("turn_id"));
assert!(json.contains("delta"));
assert!(json.contains("accumulated"));
}
#[test]
fn test_output_message_delta_deserialization_preserves_fields() {
let turn_id = TurnId::from_uuid(Uuid::now_v7());
let data = OutputMessageDeltaData {
turn_id,
delta: "Hello world".to_string(),
accumulated: "Hello world".to_string(),
};
let json = serde_json::to_value(EventData::OutputMessageDelta(data.clone())).unwrap();
let deserialized: EventData = serde_json::from_value(json).unwrap();
match deserialized {
EventData::OutputMessageDelta(td) => {
assert_eq!(td.turn_id, turn_id);
assert_eq!(td.delta, "Hello world");
assert_eq!(td.accumulated, "Hello world");
}
_ => panic!("Expected OutputMessageDelta, got different variant"),
}
}
#[test]
fn test_output_message_started_deserialization() {
let turn_id = TurnId::from_uuid(Uuid::now_v7());
let data = OutputMessageStartedData {
turn_id,
model: Some("claude-3".to_string()),
iteration: None,
};
let json = serde_json::to_value(EventData::OutputMessageStarted(data.clone())).unwrap();
let deserialized: EventData = serde_json::from_value(json).unwrap();
match deserialized {
EventData::OutputMessageStarted(at) => {
assert_eq!(at.turn_id, turn_id);
assert_eq!(at.model, Some("claude-3".to_string()));
}
_ => panic!("Expected OutputMessageStarted, got different variant"),
}
}
#[test]
fn test_reason_thinking_started_deserialization() {
let turn_id = TurnId::from_uuid(Uuid::now_v7());
let data = ReasonThinkingStartedData {
turn_id,
model: Some("claude-3".to_string()),
};
let json = serde_json::to_value(&data).unwrap();
let deserialized = deserialize_event_data(REASON_THINKING_STARTED, json);
match deserialized {
EventData::ReasonThinkingStarted(at) => {
assert_eq!(at.turn_id, turn_id);
assert_eq!(at.model, Some("claude-3".to_string()));
}
other => panic!("Expected ReasonThinkingStarted, got {}", other.event_type()),
}
}
#[test]
fn test_llm_generation_with_ttft() {
let messages = vec![Message::user("Hello")];
let data = LlmGenerationData::success_with_metadata(
messages,
vec![],
Some("Hi!".to_string()),
vec![],
"gpt-4o".to_string(),
Some("openai".to_string()),
Some(TokenUsage {
input_tokens: 10,
output_tokens: 5,
cache_read_tokens: None,
cache_creation_tokens: None,
}),
Some(500), Some(120), Some(vec!["stop".to_string()]),
None,
);
assert!(data.metadata.success);
assert_eq!(data.metadata.duration_ms, Some(500));
assert_eq!(data.metadata.time_to_first_token_ms, Some(120));
}
#[test]
fn test_llm_generation_ttft_serialization() {
let messages = vec![Message::user("test")];
let data = LlmGenerationData::success_with_metadata(
messages,
vec![],
Some("response".to_string()),
vec![],
"model".to_string(),
None,
None,
Some(1000),
Some(150), None,
None,
);
let json = serde_json::to_string(&data).unwrap();
assert!(json.contains("time_to_first_token_ms"));
assert!(json.contains("150"));
}
#[test]
fn test_reason_item_data_event_type_and_serialization() {
let turn_id = TurnId::from_uuid(Uuid::now_v7());
let data = ReasonItemData {
turn_id,
provider: "openai".to_string(),
model: Some("gpt-5.5".to_string()),
item_id: "rs_abc".to_string(),
encrypted_content: Some("OPAQUE_BLOB".to_string()),
summary: vec!["safe summary".to_string()],
token_count: Some(123),
};
let event_data: EventData = data.into();
assert_eq!(event_data.event_type(), REASON_ITEM);
let json = serde_json::to_string(&event_data).unwrap();
assert!(json.contains("turn_id"));
assert!(json.contains("openai"));
assert!(json.contains("rs_abc"));
assert!(json.contains("OPAQUE_BLOB"));
assert!(json.contains("safe summary"));
}
#[test]
fn test_reason_item_data_round_trip_uses_typed_dispatch() {
let turn_id = TurnId::from_uuid(Uuid::now_v7());
let data = ReasonItemData {
turn_id,
provider: "openai".to_string(),
model: Some("gpt-5".to_string()),
item_id: "rs_xyz".to_string(),
encrypted_content: Some("ENC".to_string()),
summary: vec![],
token_count: None,
};
let json = serde_json::to_value(&data).unwrap();
let deserialized = deserialize_event_data(REASON_ITEM, json);
match deserialized {
EventData::ReasonItem(out) => {
assert_eq!(out.turn_id, turn_id);
assert_eq!(out.provider, "openai");
assert_eq!(out.item_id, "rs_xyz");
assert_eq!(out.encrypted_content.as_deref(), Some("ENC"));
}
other => panic!("Expected ReasonItem, got {}", other.event_type()),
}
}
#[test]
fn test_reason_item_variant_precedes_reason_thinking_started() {
let turn_id = TurnId::from_uuid(Uuid::now_v7());
let json = serde_json::json!({
"turn_id": turn_id.to_string(),
"provider": "openai",
"model": "gpt-5",
"item_id": "rs_keep",
"encrypted_content": "ENC",
"summary": ["s"],
"token_count": 7,
});
let as_thinking: ReasonThinkingStartedData =
serde_json::from_value(json.clone()).expect("thinking ignores extra fields");
assert_eq!(as_thinking.turn_id, turn_id);
assert_eq!(as_thinking.model.as_deref(), Some("gpt-5"));
let as_item: ReasonItemData =
serde_json::from_value(json.clone()).expect("ReasonItem accepts payload");
assert_eq!(as_item.item_id, "rs_keep");
assert_eq!(as_item.provider, "openai");
let event_data = deserialize_event_data(REASON_ITEM, json);
match event_data {
EventData::ReasonItem(out) => {
assert_eq!(out.item_id, "rs_keep");
assert_eq!(out.provider, "openai");
}
other => panic!(
"Typed dispatcher must select ReasonItem for {REASON_ITEM}, got {}",
other.event_type()
),
}
}
#[test]
fn test_reason_item_data_excludes_plaintext_reasoning() {
let turn_id = TurnId::from_uuid(Uuid::now_v7());
let data = ReasonItemData {
turn_id,
provider: "openai".to_string(),
model: Some("gpt-5".to_string()),
item_id: "rs_secret".to_string(),
encrypted_content: Some("opaque_blob_thinking_content_reasoning_text".to_string()),
summary: vec!["safe summary mentioning content and thinking".to_string()],
token_count: Some(1),
};
let value = serde_json::to_value(&data).expect("serializable");
let object = value.as_object().expect("data serializes to JSON object");
for forbidden in [
"content",
"reasoning_text",
"thinking",
"reasoning_content",
"raw_reasoning",
] {
assert!(
!object.contains_key(forbidden),
"ReasonItemData JSON must not expose `{forbidden}` key, got: {object:?}",
);
}
assert!(object.contains_key("encrypted_content"));
assert!(object.contains_key("summary"));
}
#[test]
fn test_llm_generation_ttft_omitted_when_none() {
let messages = vec![Message::user("test")];
let data = LlmGenerationData::success(
messages,
vec![],
Some("response".to_string()),
vec![],
"model".to_string(),
None,
None,
None,
None, );
assert!(data.metadata.time_to_first_token_ms.is_none());
let json = serde_json::to_string(&data).unwrap();
assert!(!json.contains("time_to_first_token_ms"));
}
}
#[cfg(test)]
mod contract_tests {
use super::*;
use insta::{assert_json_snapshot, with_settings};
fn test_session_id() -> SessionId {
SessionId::from_uuid(uuid::Uuid::from_u128(
0x0000_0000_0000_0000_0000_0000_0000_0001,
))
}
fn test_turn_id() -> TurnId {
TurnId::from_uuid(uuid::Uuid::from_u128(
0x0000_0000_0000_0000_0000_0000_0000_0002,
))
}
fn test_message_id() -> MessageId {
MessageId::from_uuid(uuid::Uuid::from_u128(
0x0000_0000_0000_0000_0000_0000_0000_0003,
))
}
fn test_agent_id() -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(
0x0000_0000_0000_0000_0000_0000_0000_0004,
))
}
fn test_harness_id() -> HarnessId {
HarnessId::from_uuid(uuid::Uuid::from_u128(
0x0000_0000_0000_0000_0000_0000_0000_0005,
))
}
#[test]
fn snapshot_input_message() {
let data = InputMessageData::new(Message::user("Hello, world!"));
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_input_message", data, {
".message.id" => "[MESSAGE_ID]",
".message.created_at" => "[TIMESTAMP]"
});
});
}
#[test]
fn snapshot_output_message_started() {
let data = OutputMessageStartedData {
turn_id: test_turn_id(),
model: Some("gpt-4o".to_string()),
iteration: None,
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_output_message_started", data);
});
}
#[test]
fn snapshot_output_message_delta() {
let data = OutputMessageDeltaData {
turn_id: test_turn_id(),
delta: "Hello".to_string(),
accumulated: "Hello".to_string(),
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_output_message_delta", data);
});
}
#[test]
fn snapshot_output_message_completed() {
let data = OutputMessageCompletedData::new(Message::assistant("Hello!"));
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_output_message_completed", data, {
".message.id" => "[MESSAGE_ID]",
".message.created_at" => "[TIMESTAMP]"
});
});
}
#[test]
fn snapshot_turn_started() {
let data = TurnStartedData {
turn_id: test_turn_id(),
input_message_id: test_message_id(),
input_content: Some("Hello".to_string()),
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_turn_started", data);
});
}
#[test]
fn snapshot_turn_completed() {
let data = TurnCompletedData {
turn_id: test_turn_id(),
iterations: 3,
duration_ms: Some(1500),
usage: Some(TokenUsage::new(100, 50)),
input_content: None,
final_message_id: Some(test_message_id()),
final_answer_preview: Some("Done.".to_string()),
time_to_first_token_ms: Some(120),
tool_call_count: Some(2),
llm_call_count: Some(3),
status: Some("completed".to_string()),
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_turn_completed", data);
});
}
#[test]
fn snapshot_turn_failed() {
let data = TurnFailedData {
turn_id: test_turn_id(),
error: "Rate limit exceeded".to_string(),
error_code: Some("RATE_LIMIT".to_string()),
error_fields: None,
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_turn_failed", data);
});
}
#[test]
fn snapshot_turn_cancelled() {
let data = TurnCancelledData {
turn_id: test_turn_id(),
reason: Some("User requested".to_string()),
usage: Some(TokenUsage::new(50, 25)),
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_turn_cancelled", data);
});
}
#[test]
fn snapshot_reason_started() {
let data = ReasonStartedData {
harness_id: test_harness_id(),
agent_id: Some(test_agent_id()),
metadata: Some(ModelMetadata {
model: "gpt-4o".to_string(),
model_id: None,
provider_id: None,
}),
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_reason_started", data);
});
}
#[test]
fn snapshot_reason_completed() {
let data = ReasonCompletedData::success(
"Hello world",
true,
2,
Some(1000),
Some(TokenUsage::new(100, 50)),
);
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_reason_completed", data);
});
}
#[test]
fn snapshot_act_started() {
let data = ActStartedData {
tool_calls: vec![ToolCallSummary {
id: "tc_1".to_string(),
name: "get_weather".to_string(),
display_name: None,
narration: None,
}],
headline: None,
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_act_started", data);
});
}
#[test]
fn snapshot_act_completed() {
let data = ActCompletedData {
completed: true,
success_count: 2,
error_count: 0,
duration_ms: Some(500),
headline: None,
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_act_completed", data);
});
}
#[test]
fn snapshot_tool_started() {
let data = ToolStartedData {
tool_call: ToolCall {
id: "tc_1".to_string(),
name: "get_weather".to_string(),
arguments: serde_json::json!({"city": "London"}),
},
tool_call_fingerprint: None,
display_name: None,
narration: None,
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_tool_started", data);
});
}
#[test]
fn snapshot_tool_completed() {
let data = ToolCompletedData::success(
"tc_1".to_string(),
"get_weather".to_string(),
vec![crate::message::ContentPart::text("Sunny, 22°C")],
Some(250),
);
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_tool_completed", data);
});
}
#[test]
fn snapshot_llm_generation() {
let data = LlmGenerationData::success(
vec![Message::user("Hello")],
vec![ToolDefinitionSummary {
name: "tool1".to_string(),
display_name: None,
category: None,
capability_id: None,
capability_name: None,
description: "A tool".to_string(),
}],
Some("Hi there!".to_string()),
vec![],
"gpt-4o".to_string(),
Some("openai".to_string()),
Some(TokenUsage::new(10, 5)),
Some(100),
Some(25),
);
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_llm_generation", data, {
".messages[].id" => "[MESSAGE_ID]",
".messages[].created_at" => "[TIMESTAMP]"
});
});
}
#[test]
fn snapshot_reason_thinking_started() {
let data = ReasonThinkingStartedData {
turn_id: test_turn_id(),
model: Some("claude-4-opus".to_string()),
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_reason_thinking_started", data);
});
}
#[test]
fn snapshot_reason_thinking_delta() {
let data = ReasonThinkingDeltaData {
turn_id: test_turn_id(),
delta: "Let me think...".to_string(),
accumulated: "Let me think...".to_string(),
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_reason_thinking_delta", data);
});
}
#[test]
fn snapshot_reason_thinking_completed() {
let data = ReasonThinkingCompletedData {
turn_id: test_turn_id(),
thinking: "I need to consider...".to_string(),
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_reason_thinking_completed", data);
});
}
#[test]
fn snapshot_reason_item() {
let data = ReasonItemData {
turn_id: test_turn_id(),
provider: "openai".to_string(),
model: Some("gpt-5.5".to_string()),
item_id: "rs_test".to_string(),
encrypted_content: Some("OPAQUE".to_string()),
summary: vec!["safe summary".to_string()],
token_count: Some(42),
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_reason_item", data);
});
}
#[test]
fn snapshot_session_started() {
let data = SessionStartedData {
harness_id: test_harness_id(),
agent_id: Some(test_agent_id()),
model_id: None,
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_session_started", data);
});
}
#[test]
fn snapshot_session_activated() {
let data = SessionActivatedData {
turn_id: test_turn_id(),
input_message_id: test_message_id(),
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_session_activated", data);
});
}
#[test]
fn snapshot_session_idled() {
let data = SessionIdledData {
turn_id: test_turn_id(),
iterations: Some(3),
usage: Some(TokenUsage::new(500, 200)),
};
with_settings!({
sort_maps => true,
}, {
assert_json_snapshot!("event_data_session_idled", data);
});
}
#[test]
fn tool_call_summary_with_display_name() {
let summary = ToolCallSummary {
id: "tc_1".to_string(),
name: "get_weather".to_string(),
display_name: Some("Get Weather".to_string()),
narration: None,
};
let json = serde_json::to_value(&summary).unwrap();
assert_eq!(json["display_name"], "Get Weather");
let deserialized: ToolCallSummary = serde_json::from_value(json).unwrap();
assert_eq!(deserialized.display_name.as_deref(), Some("Get Weather"));
}
#[test]
fn tool_call_summary_without_display_name_omits_field() {
let summary = ToolCallSummary {
id: "tc_1".to_string(),
name: "get_weather".to_string(),
display_name: None,
narration: None,
};
let json = serde_json::to_string(&summary).unwrap();
assert!(!json.contains("display_name"));
let json_without = r#"{"id":"tc_1","name":"get_weather"}"#;
let deserialized: ToolCallSummary = serde_json::from_str(json_without).unwrap();
assert_eq!(deserialized.display_name, None);
}
#[test]
fn act_started_with_definitions_populates_display_names() {
use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolPolicy};
let tool_calls = vec![
ToolCall {
id: "tc_1".to_string(),
name: "get_weather".to_string(),
arguments: serde_json::json!({}),
},
ToolCall {
id: "tc_2".to_string(),
name: "unknown_tool".to_string(),
arguments: serde_json::json!({}),
},
];
let tool_defs = vec![crate::tool_types::ToolDefinition::Builtin(BuiltinTool {
name: "get_weather".to_string(),
display_name: Some("Get Weather".to_string()),
description: "Gets weather".to_string(),
parameters: serde_json::json!({}),
policy: ToolPolicy::Auto,
category: None,
deferrable: DeferrablePolicy::default(),
hints: crate::tool_types::ToolHints::default(),
})];
let data = ActStartedData::with_definitions(&tool_calls, &tool_defs);
assert_eq!(data.tool_calls.len(), 2);
assert_eq!(
data.tool_calls[0].display_name.as_deref(),
Some("Get Weather")
);
assert_eq!(data.tool_calls[1].display_name, None);
}
#[test]
fn tool_completed_with_display_name_roundtrip() {
let data = ToolCompletedData::success(
"tc_1".to_string(),
"get_weather".to_string(),
vec![crate::message::ContentPart::text("Sunny")],
Some(100),
)
.with_display_name(Some("Get Weather".to_string()));
assert_eq!(data.display_name.as_deref(), Some("Get Weather"));
let json = serde_json::to_value(&data).unwrap();
assert_eq!(json["display_name"], "Get Weather");
let deserialized: ToolCompletedData = serde_json::from_value(json).unwrap();
assert_eq!(deserialized.display_name.as_deref(), Some("Get Weather"));
}
#[test]
fn tool_started_display_name_serialization() {
let data = ToolStartedData {
tool_call: ToolCall {
id: "tc_1".to_string(),
name: "bash".to_string(),
arguments: serde_json::json!({"command": "ls"}),
},
tool_call_fingerprint: None,
display_name: Some("Bash".to_string()),
narration: None,
};
let json = serde_json::to_value(&data).unwrap();
assert_eq!(json["display_name"], "Bash");
}
#[test]
fn tool_definition_summary_display_name() {
use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolPolicy};
let def = crate::tool_types::ToolDefinition::Builtin(BuiltinTool {
name: "read_file".to_string(),
display_name: Some("Read File".to_string()),
description: "Reads a file".to_string(),
parameters: serde_json::json!({}),
policy: ToolPolicy::Auto,
category: None,
deferrable: DeferrablePolicy::default(),
hints: crate::tool_types::ToolHints::default(),
});
let summary = ToolDefinitionSummary::from(&def);
assert_eq!(summary.display_name.as_deref(), Some("Read File"));
let json = serde_json::to_value(&summary).unwrap();
assert_eq!(json["display_name"], "Read File");
}
#[test]
fn forward_compat_unknown_fields_ignored() {
let json = r#"{
"turn_id": "turn_00000000000000000000000000000002",
"iterations": 3,
"duration_ms": 1500,
"usage": {"input_tokens": 100, "output_tokens": 50},
"future_field": "should be ignored",
"another_new_field": 42
}"#;
let data: TurnCompletedData = serde_json::from_str(json).unwrap();
assert_eq!(data.iterations, 3);
assert_eq!(data.duration_ms, Some(1500));
}
#[test]
fn forward_compat_unknown_event_type_becomes_unsupported() {
let json = serde_json::json!({"some_field": "value"});
let data = deserialize_event_data("future.event.type", json);
assert!(data.is_unsupported());
assert_eq!(data.event_type(), "unsupported");
}
#[test]
fn forward_compat_unsupported_preserves_data() {
let original = serde_json::json!({"key": "value", "nested": {"a": 1}});
let data = deserialize_event_data("unknown.event", original.clone());
match data {
EventData::Unsupported { event_type, data } => {
assert_eq!(event_type, "unknown.event");
assert_eq!(data, original);
}
_ => panic!("Expected Unsupported variant"),
}
}
#[test]
fn forward_compat_optional_fields_absent() {
let json = r#"{
"turn_id": "turn_00000000000000000000000000000002",
"iterations": 3
}"#;
let data: TurnCompletedData = serde_json::from_str(json).unwrap();
assert_eq!(data.iterations, 3);
assert!(data.duration_ms.is_none());
assert!(data.usage.is_none());
assert!(data.input_content.is_none());
assert!(data.final_message_id.is_none());
assert!(data.final_answer_preview.is_none());
assert!(data.time_to_first_token_ms.is_none());
assert!(data.tool_call_count.is_none());
assert!(data.llm_call_count.is_none());
assert!(data.status.is_none());
}
#[test]
fn round_trip_all_event_data_types() {
let test_cases: Vec<(&str, EventData)> = vec![
(
INPUT_MESSAGE,
InputMessageData::new(Message::user("test")).into(),
),
(
OUTPUT_MESSAGE_STARTED,
OutputMessageStartedData {
turn_id: test_turn_id(),
model: None,
iteration: None,
}
.into(),
),
(
OUTPUT_MESSAGE_DELTA,
OutputMessageDeltaData {
turn_id: test_turn_id(),
delta: "x".to_string(),
accumulated: "x".to_string(),
}
.into(),
),
(
OUTPUT_MESSAGE_COMPLETED,
OutputMessageCompletedData::new(Message::assistant("hi")).into(),
),
(
TURN_STARTED,
TurnStartedData {
turn_id: test_turn_id(),
input_message_id: test_message_id(),
input_content: None,
}
.into(),
),
(
TURN_COMPLETED,
TurnCompletedData {
turn_id: test_turn_id(),
iterations: 1,
duration_ms: None,
usage: None,
input_content: None,
final_message_id: None,
final_answer_preview: None,
time_to_first_token_ms: None,
tool_call_count: None,
llm_call_count: None,
status: None,
}
.into(),
),
(
TURN_FAILED,
TurnFailedData {
turn_id: test_turn_id(),
error: "err".to_string(),
error_code: None,
error_fields: None,
}
.into(),
),
(
TURN_CANCELLED,
TurnCancelledData {
turn_id: test_turn_id(),
reason: None,
usage: None,
}
.into(),
),
(
REASON_STARTED,
ReasonStartedData {
harness_id: test_harness_id(),
agent_id: Some(test_agent_id()),
metadata: None,
}
.into(),
),
(
REASON_COMPLETED,
ReasonCompletedData::success("", false, 0, None, None).into(),
),
(
ACT_STARTED,
ActStartedData {
tool_calls: vec![],
headline: None,
}
.into(),
),
(
ACT_COMPLETED,
ActCompletedData {
completed: true,
success_count: 0,
error_count: 0,
duration_ms: None,
headline: None,
}
.into(),
),
(
SESSION_STARTED,
SessionStartedData {
harness_id: test_harness_id(),
agent_id: Some(test_agent_id()),
model_id: None,
}
.into(),
),
(
SESSION_ACTIVATED,
SessionActivatedData {
turn_id: test_turn_id(),
input_message_id: test_message_id(),
}
.into(),
),
(
SESSION_IDLED,
SessionIdledData {
turn_id: test_turn_id(),
iterations: None,
usage: None,
}
.into(),
),
];
for (event_type, original) in test_cases {
let json = serde_json::to_value(&original).unwrap();
let deserialized = deserialize_event_data(event_type, json);
assert_eq!(
original.event_type(),
deserialized.event_type(),
"Event type mismatch for {}",
event_type
);
}
}
#[test]
fn event_structure_has_required_fields() {
let session_id = test_session_id();
let context = EventContext::turn(test_turn_id(), test_message_id());
let event = Event::new(
session_id,
context,
InputMessageData::new(Message::user("test")),
);
let json = serde_json::to_value(&event).unwrap();
assert!(json.get("id").is_some(), "Missing id field");
assert!(json.get("type").is_some(), "Missing type field");
assert!(json.get("ts").is_some(), "Missing ts field");
assert!(json.get("session_id").is_some(), "Missing session_id field");
assert!(json.get("context").is_some(), "Missing context field");
assert!(json.get("data").is_some(), "Missing data field");
}
#[test]
fn event_context_span_fields() {
let context = EventContext::empty().with_span(
"trace123".to_string(),
"span456".to_string(),
Some("parent789".to_string()),
);
let json = serde_json::to_value(&context).unwrap();
assert_eq!(
json.get("trace_id").and_then(|v| v.as_str()),
Some("trace123")
);
assert_eq!(
json.get("span_id").and_then(|v| v.as_str()),
Some("span456")
);
assert_eq!(
json.get("parent_span_id").and_then(|v| v.as_str()),
Some("parent789")
);
}
#[test]
fn is_unsupported_returns_false_for_known_types() {
let data = InputMessageData::new(Message::user("test"));
let event_data: EventData = data.into();
assert!(!event_data.is_unsupported());
}
#[test]
fn is_unsupported_returns_true_for_unsupported() {
let data = deserialize_event_data("unknown.type", serde_json::json!({}));
assert!(data.is_unsupported());
}
}