Skip to main content

meerkat_core/
event.rs

1//! Agent events for streaming output
2//!
3//! These events form the streaming API for consumers.
4
5use crate::error::{
6    AgentError, LlmFailureReason, LlmProviderErrorKind, LlmProviderErrorRetryability,
7};
8use crate::hooks::{HookId, HookPoint, HookReasonCode};
9use crate::interaction::InteractionId;
10use crate::ops_lifecycle::{OperationStatus, OperationTerminalOutcome};
11use crate::retry::LlmRetrySchedule;
12use crate::skills::{CapabilityId, SkillError, SkillKey};
13use crate::time_compat::SystemTime;
14use crate::turn_execution_authority::{TurnTerminalCauseKind, TurnTerminalOutcome};
15use crate::types::{ContentBlock, ContentInput, SessionId, StopReason, Usage};
16use serde::de::{self, DeserializeOwned};
17use serde::ser::SerializeStruct;
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use serde_json::value::RawValue;
21use std::cmp::Ordering;
22use std::fmt;
23
24/// Canonical typed source identity for streamed agent events.
25///
26/// `source_id` on [`EventEnvelope`] is a compatibility/display projection.
27/// Callers that need source semantics must read this typed identity instead of
28/// parsing legacy strings such as `session:{uuid}`.
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(tag = "type", rename_all = "snake_case")]
32pub enum EventSourceIdentity {
33    Session { session_id: SessionId },
34    Runtime { runtime_id: String },
35    Interaction { interaction_id: InteractionId },
36    Callback,
37    External { source_id: String },
38}
39
40impl EventSourceIdentity {
41    #[must_use]
42    pub fn session(session_id: SessionId) -> Self {
43        Self::Session { session_id }
44    }
45
46    #[must_use]
47    pub fn runtime(runtime_id: impl Into<String>) -> Self {
48        Self::Runtime {
49            runtime_id: runtime_id.into(),
50        }
51    }
52
53    #[must_use]
54    pub fn interaction(interaction_id: InteractionId) -> Self {
55        Self::Interaction { interaction_id }
56    }
57
58    #[must_use]
59    pub fn callback() -> Self {
60        Self::Callback
61    }
62
63    #[must_use]
64    pub fn external(source_id: impl Into<String>) -> Self {
65        Self::External {
66            source_id: source_id.into(),
67        }
68    }
69
70    #[must_use]
71    pub fn session_id(&self) -> Option<&SessionId> {
72        match self {
73            Self::Session { session_id } => Some(session_id),
74            Self::Runtime { .. }
75            | Self::Interaction { .. }
76            | Self::Callback
77            | Self::External { .. } => None,
78        }
79    }
80
81    #[must_use]
82    pub fn legacy_source_id(&self) -> String {
83        match self {
84            Self::Session { session_id } => format!("session:{session_id}"),
85            Self::Runtime { runtime_id } => format!("runtime:{runtime_id}"),
86            Self::Interaction { interaction_id } => format!("interaction:{interaction_id}"),
87            Self::Callback => "callback".to_string(),
88            Self::External { source_id } => source_id.clone(),
89        }
90    }
91
92    fn canonical_sort_key(&self) -> String {
93        match self {
94            Self::Session { session_id } => format!("session:{session_id}"),
95            Self::Runtime { runtime_id } => format!("runtime:{runtime_id}"),
96            Self::Interaction { interaction_id } => format!("interaction:{interaction_id}"),
97            Self::Callback => "callback".to_string(),
98            Self::External { source_id } => format!("external:{source_id}"),
99        }
100    }
101}
102
103/// Canonical event envelope for stream transport and ordering.
104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct EventEnvelope<T> {
107    #[cfg_attr(feature = "schema", schemars(with = "String"))]
108    pub event_id: uuid::Uuid,
109    pub source: EventSourceIdentity,
110    /// Legacy display/compat projection. Do not parse for source semantics.
111    pub source_id: String,
112    pub seq: u64,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub mob_id: Option<String>,
115    pub timestamp_ms: u64,
116    pub payload: T,
117}
118
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122pub enum AgentErrorClass {
123    Llm,
124    Store,
125    Tool,
126    Mcp,
127    SessionNotFound,
128    Budget,
129    MaxTokens,
130    ContentFiltered,
131    MaxTurns,
132    Cancelled,
133    InvalidState,
134    OperationNotFound,
135    DepthLimit,
136    ConcurrencyLimit,
137    Config,
138    Internal,
139    Build,
140    Auth,
141    CallbackPending,
142    StructuredOutput,
143    InvalidOutputSchema,
144    Hook,
145    Terminal,
146    NoPendingBoundary,
147}
148
149#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum BackgroundJobTerminalStatus {
153    Completed,
154    Failed,
155    Aborted,
156    Cancelled,
157    Retired,
158    Terminated,
159}
160
161impl BackgroundJobTerminalStatus {
162    pub fn as_str(self) -> &'static str {
163        match self {
164            Self::Completed => "completed",
165            Self::Failed => "failed",
166            Self::Aborted => "aborted",
167            Self::Cancelled => "cancelled",
168            Self::Retired => "retired",
169            Self::Terminated => "terminated",
170        }
171    }
172
173    pub fn from_terminal_outcome(outcome: &OperationTerminalOutcome) -> Self {
174        match outcome {
175            OperationTerminalOutcome::Completed(_) => Self::Completed,
176            OperationTerminalOutcome::Failed { .. } => Self::Failed,
177            OperationTerminalOutcome::Aborted { .. } => Self::Aborted,
178            OperationTerminalOutcome::Cancelled { .. } => Self::Cancelled,
179            OperationTerminalOutcome::Retired => Self::Retired,
180            OperationTerminalOutcome::Terminated { .. } => Self::Terminated,
181        }
182    }
183
184    pub fn from_operation_status(status: OperationStatus) -> Option<Self> {
185        match status {
186            OperationStatus::Completed => Some(Self::Completed),
187            OperationStatus::Failed => Some(Self::Failed),
188            OperationStatus::Aborted => Some(Self::Aborted),
189            OperationStatus::Cancelled => Some(Self::Cancelled),
190            OperationStatus::Retired => Some(Self::Retired),
191            OperationStatus::Terminated => Some(Self::Terminated),
192            OperationStatus::Absent
193            | OperationStatus::Provisioning
194            | OperationStatus::Running
195            | OperationStatus::Retiring => None,
196        }
197    }
198}
199
200fn deserialize_legacy_background_job_status<'de, D>(
201    deserializer: D,
202) -> Result<Option<String>, D::Error>
203where
204    D: serde::Deserializer<'de>,
205{
206    let value = Option::<Value>::deserialize(deserializer)?;
207    Ok(value.and_then(|value| value.as_str().map(str::to_owned)))
208}
209
210#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
211#[derive(Debug, Clone, PartialEq, Serialize)]
212#[serde(transparent)]
213pub struct ToolCallArguments(
214    #[cfg_attr(
215        feature = "schema",
216        schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
217    )]
218    Value,
219);
220
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct ToolCallArgumentsError {
223    message: String,
224}
225
226impl ToolCallArgumentsError {
227    pub(crate) fn new(message: impl Into<String>) -> Self {
228        Self {
229            message: message.into(),
230        }
231    }
232}
233
234impl fmt::Display for ToolCallArgumentsError {
235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236        self.message.fmt(f)
237    }
238}
239
240impl std::error::Error for ToolCallArgumentsError {}
241
242impl ToolCallArguments {
243    pub fn from_value(value: Value) -> Result<Self, ToolCallArgumentsError> {
244        if value.is_object() {
245            Ok(Self(value))
246        } else {
247            Err(ToolCallArgumentsError::new(format!(
248                "tool call arguments must be a JSON object, got {}",
249                value_kind(&value)
250            )))
251        }
252    }
253
254    pub fn from_raw_json(raw: &RawValue) -> Result<Self, ToolCallArgumentsError> {
255        let value = serde_json::from_str(raw.get()).map_err(|error| {
256            ToolCallArgumentsError::new(format!("tool call arguments must be valid JSON: {error}"))
257        })?;
258        Self::from_value(value)
259    }
260
261    pub fn as_value(&self) -> &Value {
262        &self.0
263    }
264
265    pub fn into_value(self) -> Value {
266        self.0
267    }
268}
269
270impl<'de> Deserialize<'de> for ToolCallArguments {
271    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
272    where
273        D: serde::Deserializer<'de>,
274    {
275        Self::from_value(Value::deserialize(deserializer)?).map_err(de::Error::custom)
276    }
277}
278
279impl TryFrom<Value> for ToolCallArguments {
280    type Error = ToolCallArgumentsError;
281
282    fn try_from(value: Value) -> Result<Self, Self::Error> {
283        Self::from_value(value)
284    }
285}
286
287fn value_kind(value: &Value) -> &'static str {
288    match value {
289        Value::Null => "null",
290        Value::Bool(_) => "boolean",
291        Value::Number(_) => "number",
292        Value::String(_) => "string",
293        Value::Array(_) => "array",
294        Value::Object(_) => "object",
295    }
296}
297
298#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300#[serde(tag = "reason_type", rename_all = "snake_case")]
301pub enum AgentErrorReason {
302    LlmRateLimited {
303        #[serde(default, skip_serializing_if = "Option::is_none")]
304        retry_after_ms: Option<u64>,
305    },
306    LlmContextExceeded {
307        max: u32,
308        requested: u32,
309    },
310    LlmAuthError,
311    LlmInvalidModel {
312        model: String,
313    },
314    LlmProviderError {
315        provider_error_kind: LlmProviderErrorKind,
316        provider_error_retryability: LlmProviderErrorRetryability,
317        provider_error: Value,
318    },
319    LlmNetworkTimeout {
320        duration_ms: u64,
321    },
322    LlmCallTimeout {
323        duration_ms: u64,
324    },
325    HookDenied {
326        #[serde(default, skip_serializing_if = "Option::is_none")]
327        hook_id: Option<HookId>,
328        point: HookPoint,
329        reason_code: HookReasonCode,
330    },
331    HookTimeout {
332        hook_id: HookId,
333        timeout_ms: u64,
334    },
335    HookExecutionFailed {
336        hook_id: HookId,
337        reason: String,
338    },
339    HookConfigInvalid {
340        reason: String,
341    },
342    StructuredOutputValidationFailed {
343        attempts: u32,
344        reason: String,
345    },
346    InvalidOutputSchema {
347        reason: String,
348    },
349    AuthReauthRequired {
350        binding_key: String,
351        message: String,
352    },
353    CallbackPending {
354        tool_name: String,
355        args: Value,
356    },
357    TurnTerminalCause {
358        outcome: TurnTerminalOutcome,
359        cause_kind: TurnTerminalCauseKind,
360    },
361}
362
363impl AgentErrorReason {
364    fn from_llm_reason(reason: &LlmFailureReason) -> Self {
365        match reason {
366            LlmFailureReason::RateLimited { retry_after } => Self::LlmRateLimited {
367                retry_after_ms: retry_after
368                    .as_ref()
369                    .map(|duration| duration.as_millis() as u64),
370            },
371            LlmFailureReason::ContextExceeded { max, requested } => Self::LlmContextExceeded {
372                max: *max,
373                requested: *requested,
374            },
375            LlmFailureReason::AuthError => Self::LlmAuthError,
376            LlmFailureReason::InvalidModel(model) => Self::LlmInvalidModel {
377                model: model.clone(),
378            },
379            LlmFailureReason::ProviderError(provider_error) => Self::LlmProviderError {
380                provider_error_kind: provider_error.kind,
381                provider_error_retryability: provider_error.retryability,
382                provider_error: provider_error.details.clone(),
383            },
384            LlmFailureReason::NetworkTimeout { duration_ms } => Self::LlmNetworkTimeout {
385                duration_ms: *duration_ms,
386            },
387            LlmFailureReason::CallTimeout { duration_ms } => Self::LlmCallTimeout {
388                duration_ms: *duration_ms,
389            },
390        }
391    }
392
393    pub fn from_agent_error(error: &AgentError) -> Option<Self> {
394        match error {
395            AgentError::Llm { reason, .. } => Some(Self::from_llm_reason(reason)),
396            AgentError::HookDenied {
397                hook_id,
398                point,
399                reason_code,
400                ..
401            } => Some(Self::HookDenied {
402                hook_id: Some(hook_id.clone()),
403                point: *point,
404                reason_code: *reason_code,
405            }),
406            AgentError::HookTimeout {
407                hook_id,
408                timeout_ms,
409            } => Some(Self::HookTimeout {
410                hook_id: hook_id.clone(),
411                timeout_ms: *timeout_ms,
412            }),
413            AgentError::HookExecutionFailed { hook_id, reason } => {
414                Some(Self::HookExecutionFailed {
415                    hook_id: hook_id.clone(),
416                    reason: reason.clone(),
417                })
418            }
419            AgentError::HookConfigInvalid { reason } => Some(Self::HookConfigInvalid {
420                reason: reason.clone(),
421            }),
422            AgentError::StructuredOutputValidationFailed {
423                attempts, reason, ..
424            } => Some(Self::StructuredOutputValidationFailed {
425                attempts: *attempts,
426                reason: reason.clone(),
427            }),
428            AgentError::InvalidOutputSchema(reason) => Some(Self::InvalidOutputSchema {
429                reason: reason.clone(),
430            }),
431            AgentError::AuthReauthRequired {
432                binding_key,
433                message,
434            } => Some(Self::AuthReauthRequired {
435                binding_key: binding_key.clone(),
436                message: message.clone(),
437            }),
438            AgentError::CallbackPending { tool_name, args } => Some(Self::CallbackPending {
439                tool_name: tool_name.clone(),
440                args: args.clone(),
441            }),
442            AgentError::TerminalFailure {
443                outcome,
444                cause_kind,
445                ..
446            } => cause_kind
447                .is_specific_failure_cause()
448                .then_some(Self::TurnTerminalCause {
449                    outcome: *outcome,
450                    cause_kind: *cause_kind,
451                }),
452            _ => None,
453        }
454    }
455}
456
457impl From<&AgentError> for AgentErrorClass {
458    fn from(error: &AgentError) -> Self {
459        match error {
460            AgentError::Llm { .. } => Self::Llm,
461            AgentError::StoreError(_) => Self::Store,
462            AgentError::ToolError(_) => Self::Tool,
463            AgentError::McpError(_) => Self::Mcp,
464            AgentError::SessionNotFound(_) => Self::SessionNotFound,
465            AgentError::TokenBudgetExceeded { .. }
466            | AgentError::TimeBudgetExceeded { .. }
467            | AgentError::ToolCallBudgetExceeded { .. } => Self::Budget,
468            AgentError::MaxTokensReached { .. } => Self::MaxTokens,
469            AgentError::ContentFiltered { .. } => Self::ContentFiltered,
470            AgentError::MaxTurnsReached { .. } => Self::MaxTurns,
471            AgentError::Cancelled => Self::Cancelled,
472            AgentError::InvalidStateTransition { .. } => Self::InvalidState,
473            AgentError::OperationNotFound(_) => Self::OperationNotFound,
474            AgentError::DepthLimitExceeded { .. } => Self::DepthLimit,
475            AgentError::ConcurrencyLimitExceeded => Self::ConcurrencyLimit,
476            AgentError::ConfigError(_) => Self::Config,
477            AgentError::InvalidToolAccess { .. } => Self::Tool,
478            AgentError::InternalError(_) => Self::Internal,
479            AgentError::BuildError(_) => Self::Build,
480            AgentError::AuthReauthRequired { .. } => Self::Auth,
481            AgentError::CallbackPending { .. } => Self::CallbackPending,
482            AgentError::StructuredOutputValidationFailed { .. } => Self::StructuredOutput,
483            AgentError::InvalidOutputSchema(_) => Self::InvalidOutputSchema,
484            AgentError::HookDenied { .. }
485            | AgentError::HookTimeout { .. }
486            | AgentError::HookExecutionFailed { .. }
487            | AgentError::HookConfigInvalid { .. } => Self::Hook,
488            AgentError::TerminalFailure { cause_kind, .. } => {
489                if cause_kind.is_specific_failure_cause() {
490                    cause_kind.agent_error_class()
491                } else {
492                    Self::Internal
493                }
494            }
495            AgentError::NoPendingBoundary => Self::NoPendingBoundary,
496        }
497    }
498}
499
500#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
501#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
502pub struct AgentErrorReport {
503    pub class: AgentErrorClass,
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    pub reason: Option<AgentErrorReason>,
506    pub message: String,
507}
508
509impl AgentErrorReport {
510    pub fn from_agent_error(error: &AgentError) -> Self {
511        Self {
512            class: AgentErrorClass::from(error),
513            reason: AgentErrorReason::from_agent_error(error),
514            message: error.to_string(),
515        }
516    }
517}
518
519#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
520#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
521#[serde(tag = "reason_type", rename_all = "snake_case")]
522pub enum SkillResolutionFailureReason {
523    NotFound {
524        key: SkillKey,
525    },
526    CapabilityUnavailable {
527        key: SkillKey,
528        capability: CapabilityId,
529    },
530    Load {
531        message: String,
532    },
533    Parse {
534        message: String,
535    },
536    SourceUuidCollision {
537        source_uuid: String,
538        existing_fingerprint: String,
539        new_fingerprint: String,
540    },
541    SourceUuidMutationWithoutLineage {
542        fingerprint: String,
543        existing_source_uuid: String,
544        mutated_source_uuid: String,
545    },
546    MissingSkillRemaps {
547        event_id: String,
548        event_kind: String,
549    },
550    RemapWithoutLineage {
551        from_source_uuid: String,
552        from_skill_name: String,
553        to_source_uuid: String,
554        to_skill_name: String,
555    },
556    UnknownSkillAlias {
557        alias: String,
558    },
559    RemapCycle {
560        source_uuid: String,
561        skill_name: String,
562    },
563    Unknown {
564        message: String,
565    },
566}
567
568impl Default for SkillResolutionFailureReason {
569    fn default() -> Self {
570        Self::Unknown {
571            message: String::new(),
572        }
573    }
574}
575
576fn deserialize_skill_resolution_field<T, E>(value: &Value, field: &'static str) -> Result<T, E>
577where
578    T: DeserializeOwned,
579    E: de::Error,
580{
581    let field_value = value
582        .get(field)
583        .cloned()
584        .ok_or_else(|| E::missing_field(field))?;
585    serde_json::from_value(field_value).map_err(E::custom)
586}
587
588impl<'de> Deserialize<'de> for SkillResolutionFailureReason {
589    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
590    where
591        D: serde::Deserializer<'de>,
592    {
593        let value = Value::deserialize(deserializer)?;
594        let reason_type = value
595            .get("reason_type")
596            .and_then(Value::as_str)
597            .unwrap_or("unknown");
598
599        match reason_type {
600            "not_found" => Ok(Self::NotFound {
601                key: deserialize_skill_resolution_field(&value, "key")?,
602            }),
603            "capability_unavailable" => Ok(Self::CapabilityUnavailable {
604                key: deserialize_skill_resolution_field(&value, "key")?,
605                capability: deserialize_skill_resolution_field(&value, "capability")?,
606            }),
607            "load" => Ok(Self::Load {
608                message: deserialize_skill_resolution_field(&value, "message")?,
609            }),
610            "parse" => Ok(Self::Parse {
611                message: deserialize_skill_resolution_field(&value, "message")?,
612            }),
613            "source_uuid_collision" => Ok(Self::SourceUuidCollision {
614                source_uuid: deserialize_skill_resolution_field(&value, "source_uuid")?,
615                existing_fingerprint: deserialize_skill_resolution_field(
616                    &value,
617                    "existing_fingerprint",
618                )?,
619                new_fingerprint: deserialize_skill_resolution_field(&value, "new_fingerprint")?,
620            }),
621            "source_uuid_mutation_without_lineage" => Ok(Self::SourceUuidMutationWithoutLineage {
622                fingerprint: deserialize_skill_resolution_field(&value, "fingerprint")?,
623                existing_source_uuid: deserialize_skill_resolution_field(
624                    &value,
625                    "existing_source_uuid",
626                )?,
627                mutated_source_uuid: deserialize_skill_resolution_field(
628                    &value,
629                    "mutated_source_uuid",
630                )?,
631            }),
632            "missing_skill_remaps" => Ok(Self::MissingSkillRemaps {
633                event_id: deserialize_skill_resolution_field(&value, "event_id")?,
634                event_kind: deserialize_skill_resolution_field(&value, "event_kind")?,
635            }),
636            "remap_without_lineage" => Ok(Self::RemapWithoutLineage {
637                from_source_uuid: deserialize_skill_resolution_field(&value, "from_source_uuid")?,
638                from_skill_name: deserialize_skill_resolution_field(&value, "from_skill_name")?,
639                to_source_uuid: deserialize_skill_resolution_field(&value, "to_source_uuid")?,
640                to_skill_name: deserialize_skill_resolution_field(&value, "to_skill_name")?,
641            }),
642            "unknown_skill_alias" => Ok(Self::UnknownSkillAlias {
643                alias: deserialize_skill_resolution_field(&value, "alias")?,
644            }),
645            "remap_cycle" => Ok(Self::RemapCycle {
646                source_uuid: deserialize_skill_resolution_field(&value, "source_uuid")?,
647                skill_name: deserialize_skill_resolution_field(&value, "skill_name")?,
648            }),
649            "unknown" => Ok(Self::Unknown {
650                message: value
651                    .get("message")
652                    .and_then(Value::as_str)
653                    .unwrap_or_default()
654                    .to_string(),
655            }),
656            _ => Ok(Self::Unknown {
657                message: value
658                    .get("message")
659                    .and_then(Value::as_str)
660                    .unwrap_or_default()
661                    .to_string(),
662            }),
663        }
664    }
665}
666
667impl SkillResolutionFailureReason {
668    pub fn from_skill_error(error: &SkillError) -> Self {
669        match error {
670            SkillError::NotFound { key } => Self::NotFound { key: key.clone() },
671            SkillError::CapabilityUnavailable { key, capability } => Self::CapabilityUnavailable {
672                key: key.clone(),
673                capability: capability.clone(),
674            },
675            SkillError::Load(message) => Self::Load {
676                message: message.to_string(),
677            },
678            SkillError::Parse(message) => Self::Parse {
679                message: message.to_string(),
680            },
681            SkillError::SourceUuidCollision {
682                source_uuid,
683                existing_fingerprint,
684                new_fingerprint,
685            } => Self::SourceUuidCollision {
686                source_uuid: source_uuid.clone(),
687                existing_fingerprint: existing_fingerprint.clone(),
688                new_fingerprint: new_fingerprint.clone(),
689            },
690            SkillError::SourceUuidMutationWithoutLineage {
691                fingerprint,
692                existing_source_uuid,
693                mutated_source_uuid,
694            } => Self::SourceUuidMutationWithoutLineage {
695                fingerprint: fingerprint.clone(),
696                existing_source_uuid: existing_source_uuid.clone(),
697                mutated_source_uuid: mutated_source_uuid.clone(),
698            },
699            SkillError::MissingSkillRemaps {
700                event_id,
701                event_kind,
702            } => Self::MissingSkillRemaps {
703                event_id: event_id.clone(),
704                event_kind: (*event_kind).to_string(),
705            },
706            SkillError::RemapWithoutLineage {
707                from_source_uuid,
708                from_skill_name,
709                to_source_uuid,
710                to_skill_name,
711            } => Self::RemapWithoutLineage {
712                from_source_uuid: from_source_uuid.clone(),
713                from_skill_name: from_skill_name.clone(),
714                to_source_uuid: to_source_uuid.clone(),
715                to_skill_name: to_skill_name.clone(),
716            },
717            SkillError::UnknownSkillAlias { alias } => Self::UnknownSkillAlias {
718                alias: alias.clone(),
719            },
720            SkillError::RemapCycle {
721                source_uuid,
722                skill_name,
723            } => Self::RemapCycle {
724                source_uuid: source_uuid.clone(),
725                skill_name: skill_name.clone(),
726            },
727        }
728    }
729}
730
731impl std::fmt::Display for SkillResolutionFailureReason {
732    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
733        match self {
734            Self::NotFound { key } => write!(f, "skill not found: {key}"),
735            Self::CapabilityUnavailable { key, capability } => {
736                write!(
737                    f,
738                    "skill '{key}' requires unavailable capability: {capability}"
739                )
740            }
741            Self::Load { message } => write!(f, "skill loading failed: {message}"),
742            Self::Parse { message } => write!(f, "skill parse failed: {message}"),
743            Self::SourceUuidCollision {
744                source_uuid,
745                existing_fingerprint,
746                new_fingerprint,
747            } => write!(
748                f,
749                "source UUID collision for {source_uuid}: existing fingerprint '{existing_fingerprint}' conflicts with '{new_fingerprint}'"
750            ),
751            Self::SourceUuidMutationWithoutLineage {
752                fingerprint,
753                existing_source_uuid,
754                mutated_source_uuid,
755            } => write!(
756                f,
757                "source UUID mutation rejected for fingerprint '{fingerprint}': {existing_source_uuid} -> {mutated_source_uuid} without lineage"
758            ),
759            Self::MissingSkillRemaps {
760                event_id,
761                event_kind,
762            } => write!(
763                f,
764                "lineage event '{event_id}' ({event_kind}) requires explicit per-skill remap entries"
765            ),
766            Self::RemapWithoutLineage {
767                from_source_uuid,
768                from_skill_name,
769                to_source_uuid,
770                to_skill_name,
771            } => write!(
772                f,
773                "skill remap from {from_source_uuid}/{from_skill_name} to {to_source_uuid}/{to_skill_name} is not allowed by lineage"
774            ),
775            Self::UnknownSkillAlias { alias } => write!(f, "unknown skill alias '{alias}'"),
776            Self::RemapCycle {
777                source_uuid,
778                skill_name,
779            } => write!(
780                f,
781                "skill remap cycle detected for {source_uuid}/{skill_name}"
782            ),
783            Self::Unknown { message } if message.is_empty() => {
784                f.write_str("unknown skill resolution failure")
785            }
786            Self::Unknown { message } => f.write_str(message),
787        }
788    }
789}
790
791impl From<&SkillError> for SkillResolutionFailureReason {
792    fn from(error: &SkillError) -> Self {
793        Self::from_skill_error(error)
794    }
795}
796
797impl<T> EventEnvelope<T> {
798    /// Create a new envelope with a UUIDv7 id and current wall-clock timestamp.
799    pub fn new(source_id: impl Into<String>, seq: u64, mob_id: Option<String>, payload: T) -> Self {
800        Self::new_with_source(
801            EventSourceIdentity::external(source_id),
802            seq,
803            mob_id,
804            payload,
805        )
806    }
807
808    /// Create a new envelope from a typed source identity.
809    pub fn new_with_source(
810        source: EventSourceIdentity,
811        seq: u64,
812        mob_id: Option<String>,
813        payload: T,
814    ) -> Self {
815        let timestamp_ms = match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
816            Ok(duration) => duration.as_millis() as u64,
817            Err(_) => u64::MAX,
818        };
819        let source_id = source.legacy_source_id();
820        Self {
821            event_id: crate::time_compat::new_uuid_v7(),
822            source,
823            source_id,
824            seq,
825            mob_id,
826            timestamp_ms,
827            payload,
828        }
829    }
830
831    /// Create a new session-scoped envelope.
832    pub fn new_session(
833        session_id: SessionId,
834        seq: u64,
835        mob_id: Option<String>,
836        payload: T,
837    ) -> Self {
838        Self::new_with_source(
839            EventSourceIdentity::session(session_id),
840            seq,
841            mob_id,
842            payload,
843        )
844    }
845
846    /// Typed session source, when this event is session-scoped.
847    #[must_use]
848    pub fn source_session_id(&self) -> Option<&SessionId> {
849        self.source.session_id()
850    }
851}
852
853/// Canonical serialized event kind for SSE/RPC discriminators.
854///
855/// Intentionally exhaustive: when a new `AgentEvent` variant is added, this
856/// match should fail to compile until that variant gets an explicit wire name.
857pub fn agent_event_type(event: &AgentEvent) -> &'static str {
858    match event {
859        AgentEvent::RunStarted { .. } => "run_started",
860        AgentEvent::RunCompleted { .. } => "run_completed",
861        AgentEvent::ExtractionSucceeded { .. } => "extraction_succeeded",
862        AgentEvent::ExtractionFailed { .. } => "extraction_failed",
863        AgentEvent::RunFailed { .. } => "run_failed",
864        AgentEvent::HookStarted { .. } => "hook_started",
865        AgentEvent::HookCompleted { .. } => "hook_completed",
866        AgentEvent::HookFailed { .. } => "hook_failed",
867        AgentEvent::HookDenied { .. } => "hook_denied",
868        AgentEvent::TurnStarted { .. } => "turn_started",
869        AgentEvent::ReasoningDelta { .. } => "reasoning_delta",
870        AgentEvent::ReasoningComplete { .. } => "reasoning_complete",
871        AgentEvent::TextDelta { .. } => "text_delta",
872        AgentEvent::TextComplete { .. } => "text_complete",
873        AgentEvent::ToolCallRequested { .. } => "tool_call_requested",
874        AgentEvent::ToolResultReceived { .. } => "tool_result_received",
875        AgentEvent::TurnCompleted { .. } => "turn_completed",
876        AgentEvent::ToolExecutionStarted { .. } => "tool_execution_started",
877        AgentEvent::ToolExecutionCompleted { .. } => "tool_execution_completed",
878        AgentEvent::ToolExecutionTimedOut { .. } => "tool_execution_timed_out",
879        AgentEvent::CompactionStarted { .. } => "compaction_started",
880        AgentEvent::CompactionCompleted { .. } => "compaction_completed",
881        AgentEvent::CompactionFailed { .. } => "compaction_failed",
882        AgentEvent::BudgetWarning { .. } => "budget_warning",
883        AgentEvent::Retrying { .. } => "retrying",
884        AgentEvent::SkillsResolved { .. } => "skills_resolved",
885        AgentEvent::SkillResolutionFailed { .. } => "skill_resolution_failed",
886        AgentEvent::InteractionComplete { .. } => "interaction_complete",
887        AgentEvent::InteractionCallbackPending { .. } => "interaction_callback_pending",
888        AgentEvent::InteractionFailed { .. } => "interaction_failed",
889        AgentEvent::StreamTruncated { .. } => "stream_truncated",
890        AgentEvent::ToolConfigChanged { .. } => "tool_config_changed",
891        AgentEvent::BackgroundJobCompleted { .. } => "background_job_completed",
892    }
893}
894
895/// Deterministic total ordering comparator for event envelopes.
896pub fn compare_event_envelopes<T>(a: &EventEnvelope<T>, b: &EventEnvelope<T>) -> Ordering {
897    a.timestamp_ms
898        .cmp(&b.timestamp_ms)
899        .then_with(|| {
900            a.source
901                .canonical_sort_key()
902                .cmp(&b.source.canonical_sort_key())
903        })
904        .then_with(|| a.seq.cmp(&b.seq))
905        .then_with(|| a.event_id.cmp(&b.event_id))
906}
907
908/// Payload for tool configuration change notifications.
909#[derive(Debug, Clone, PartialEq, Eq)]
910pub struct ToolConfigChangedPayload {
911    pub operation: ToolConfigChangeOperation,
912    pub target: String,
913    status_info: ToolConfigChangeStatus,
914    pub persisted: bool,
915    pub applied_at_turn: Option<u32>,
916    pub domain: Option<ToolConfigChangeDomain>,
917    pub deferred_catalog_delta: Option<DeferredCatalogDelta>,
918}
919
920impl ToolConfigChangedPayload {
921    #[must_use]
922    pub fn new(
923        operation: ToolConfigChangeOperation,
924        target: impl Into<String>,
925        status_info: ToolConfigChangeStatus,
926        persisted: bool,
927    ) -> Self {
928        Self {
929            operation,
930            target: target.into(),
931            status_info,
932            persisted,
933            applied_at_turn: None,
934            domain: None,
935            deferred_catalog_delta: None,
936        }
937    }
938
939    #[must_use]
940    pub fn status_info(&self) -> &ToolConfigChangeStatus {
941        &self.status_info
942    }
943
944    #[must_use]
945    pub fn status_text(&self) -> String {
946        self.status_info.status_text()
947    }
948
949    #[must_use]
950    pub fn with_applied_at_turn(mut self, applied_at_turn: Option<u32>) -> Self {
951        self.applied_at_turn = applied_at_turn;
952        self
953    }
954
955    #[must_use]
956    pub fn with_domain(mut self, domain: Option<ToolConfigChangeDomain>) -> Self {
957        self.domain = domain;
958        self
959    }
960
961    #[must_use]
962    pub fn with_deferred_catalog_delta(
963        mut self,
964        deferred_catalog_delta: Option<DeferredCatalogDelta>,
965    ) -> Self {
966        self.deferred_catalog_delta = deferred_catalog_delta;
967        self
968    }
969}
970
971impl Serialize for ToolConfigChangedPayload {
972    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
973    where
974        S: serde::Serializer,
975    {
976        let mut state = serializer.serialize_struct("ToolConfigChangedPayload", 8)?;
977        state.serialize_field("operation", &self.operation)?;
978        state.serialize_field("target", &self.target)?;
979        state.serialize_field("status", &self.status_text())?;
980        state.serialize_field("status_info", &self.status_info)?;
981        state.serialize_field("persisted", &self.persisted)?;
982        if let Some(applied_at_turn) = self.applied_at_turn {
983            state.serialize_field("applied_at_turn", &applied_at_turn)?;
984        }
985        if let Some(domain) = &self.domain {
986            state.serialize_field("domain", domain)?;
987        }
988        if let Some(delta) = &self.deferred_catalog_delta {
989            state.serialize_field("deferred_catalog_delta", delta)?;
990        }
991        state.end()
992    }
993}
994
995impl<'de> Deserialize<'de> for ToolConfigChangedPayload {
996    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
997    where
998        D: serde::Deserializer<'de>,
999    {
1000        #[derive(Deserialize)]
1001        struct WirePayload {
1002            operation: ToolConfigChangeOperation,
1003            target: String,
1004            #[serde(default)]
1005            status: Option<String>,
1006            #[serde(default)]
1007            status_info: Option<ToolConfigChangeStatus>,
1008            persisted: bool,
1009            #[serde(default)]
1010            applied_at_turn: Option<u32>,
1011            #[serde(default)]
1012            domain: Option<ToolConfigChangeDomain>,
1013            #[serde(default)]
1014            deferred_catalog_delta: Option<DeferredCatalogDelta>,
1015        }
1016
1017        let wire = WirePayload::deserialize(deserializer)?;
1018        let status_info = wire
1019            .status_info
1020            .or_else(|| wire.status.map(ToolConfigChangeStatus::legacy_status))
1021            .ok_or_else(|| de::Error::missing_field("status_info"))?;
1022
1023        Ok(Self {
1024            operation: wire.operation,
1025            target: wire.target,
1026            status_info,
1027            persisted: wire.persisted,
1028            applied_at_turn: wire.applied_at_turn,
1029            domain: wire.domain,
1030            deferred_catalog_delta: wire.deferred_catalog_delta,
1031        })
1032    }
1033}
1034
1035#[cfg(feature = "schema")]
1036impl schemars::JsonSchema for ToolConfigChangedPayload {
1037    fn schema_name() -> std::borrow::Cow<'static, str> {
1038        "ToolConfigChangedPayload".into()
1039    }
1040
1041    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1042        /// Payload for tool configuration change notifications.
1043        #[allow(dead_code)]
1044        #[derive(schemars::JsonSchema)]
1045        struct ToolConfigChangedPayloadSchema {
1046            operation: ToolConfigChangeOperation,
1047            target: String,
1048            status: String,
1049            #[serde(default, skip_serializing_if = "Option::is_none")]
1050            status_info: Option<ToolConfigChangeStatus>,
1051            persisted: bool,
1052            #[serde(skip_serializing_if = "Option::is_none")]
1053            applied_at_turn: Option<u32>,
1054            #[serde(default, skip_serializing_if = "Option::is_none")]
1055            domain: Option<ToolConfigChangeDomain>,
1056            #[serde(default, skip_serializing_if = "Option::is_none")]
1057            deferred_catalog_delta: Option<DeferredCatalogDelta>,
1058        }
1059
1060        ToolConfigChangedPayloadSchema::json_schema(generator)
1061    }
1062}
1063
1064/// Optional typed domain for tool-configuration change payloads.
1065#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1066#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1067#[serde(rename_all = "snake_case")]
1068pub enum ToolConfigChangeDomain {
1069    ToolScope,
1070    DeferredCatalog,
1071}
1072
1073/// Additive hidden-catalog delta metadata for runtime notices.
1074#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1075#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1076pub struct DeferredCatalogDelta {
1077    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1078    pub added_hidden_names: Vec<String>,
1079    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1080    pub removed_hidden_names: Vec<String>,
1081    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1082    pub pending_sources: Vec<String>,
1083}
1084
1085/// Operation kind for live tool configuration changes.
1086#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1087#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1088#[serde(rename_all = "snake_case")]
1089pub enum ToolConfigChangeOperation {
1090    Add,
1091    Remove,
1092    Reload,
1093}
1094
1095/// Canonical lifecycle phase for external-tool boundary deltas.
1096#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1097#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
1098#[serde(rename_all = "snake_case")]
1099pub enum ExternalToolDeltaPhase {
1100    Pending,
1101    Applied,
1102    Draining,
1103    Forced,
1104    Failed,
1105}
1106
1107impl ExternalToolDeltaPhase {
1108    #[must_use]
1109    pub fn as_status(self) -> &'static str {
1110        match self {
1111            Self::Pending => "pending",
1112            Self::Applied => "applied",
1113            Self::Draining => "draining",
1114            Self::Forced => "forced",
1115            Self::Failed => "failed",
1116        }
1117    }
1118}
1119
1120/// Structured status data for live tool configuration change notifications.
1121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1123#[serde(tag = "kind", rename_all = "snake_case")]
1124pub enum ToolConfigChangeStatus {
1125    BoundaryApplied {
1126        base_changed: bool,
1127        visible_changed: bool,
1128        revision: u64,
1129    },
1130    DeferredCatalogDelta {
1131        added_hidden_count: usize,
1132        removed_hidden_count: usize,
1133        pending_source_count: usize,
1134    },
1135    WarningFailedClosed {
1136        error: String,
1137    },
1138    ExternalToolDelta {
1139        phase: ExternalToolDeltaPhase,
1140        #[serde(default, skip_serializing_if = "Option::is_none")]
1141        detail: Option<String>,
1142    },
1143    LegacyStatus {
1144        status: String,
1145    },
1146}
1147
1148impl ToolConfigChangeStatus {
1149    #[must_use]
1150    pub fn boundary_applied(base_changed: bool, visible_changed: bool, revision: u64) -> Self {
1151        Self::BoundaryApplied {
1152            base_changed,
1153            visible_changed,
1154            revision,
1155        }
1156    }
1157
1158    #[must_use]
1159    pub fn deferred_catalog_delta(
1160        added_hidden_count: usize,
1161        removed_hidden_count: usize,
1162        pending_source_count: usize,
1163    ) -> Self {
1164        Self::DeferredCatalogDelta {
1165            added_hidden_count,
1166            removed_hidden_count,
1167            pending_source_count,
1168        }
1169    }
1170
1171    #[must_use]
1172    pub fn warning_failed_closed(error: impl Into<String>) -> Self {
1173        Self::WarningFailedClosed {
1174            error: error.into(),
1175        }
1176    }
1177
1178    #[must_use]
1179    pub fn external_tool_delta(phase: ExternalToolDeltaPhase, detail: Option<String>) -> Self {
1180        Self::ExternalToolDelta { phase, detail }
1181    }
1182
1183    #[must_use]
1184    pub fn legacy_status(status: impl Into<String>) -> Self {
1185        Self::LegacyStatus {
1186            status: status.into(),
1187        }
1188    }
1189
1190    #[must_use]
1191    pub fn status_text(&self) -> String {
1192        match self {
1193            Self::BoundaryApplied {
1194                base_changed,
1195                visible_changed,
1196                revision,
1197            } => format!(
1198                "boundary_applied(base_changed={base_changed},visible_changed={visible_changed},revision={revision})"
1199            ),
1200            Self::DeferredCatalogDelta {
1201                added_hidden_count,
1202                removed_hidden_count,
1203                pending_source_count,
1204            } => format!(
1205                "deferred_catalog_delta(added_hidden={added_hidden_count},removed_hidden={removed_hidden_count},pending_sources={pending_source_count})"
1206            ),
1207            Self::WarningFailedClosed { error } => {
1208                format!("warning_failed_closed({error})")
1209            }
1210            Self::ExternalToolDelta { phase, detail } => {
1211                let mut status = phase.as_status().to_string();
1212                if *phase == ExternalToolDeltaPhase::Failed
1213                    && let Some(detail) = detail
1214                {
1215                    status = format!("{status}: {detail}");
1216                }
1217                status
1218            }
1219            Self::LegacyStatus { status } => status.clone(),
1220        }
1221    }
1222}
1223
1224/// Canonical outward lifecycle delta for external-tool surface changes.
1225#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1226pub struct ExternalToolDelta {
1227    pub target: String,
1228    pub operation: ToolConfigChangeOperation,
1229    pub phase: ExternalToolDeltaPhase,
1230    pub persisted: bool,
1231    #[serde(skip_serializing_if = "Option::is_none")]
1232    pub applied_at_turn: Option<u32>,
1233    #[serde(default, skip_serializing_if = "Option::is_none")]
1234    pub tool_count: Option<usize>,
1235    #[serde(default, skip_serializing_if = "Option::is_none")]
1236    pub detail: Option<String>,
1237}
1238
1239impl ExternalToolDelta {
1240    #[must_use]
1241    pub fn new(
1242        target: impl Into<String>,
1243        operation: ToolConfigChangeOperation,
1244        phase: ExternalToolDeltaPhase,
1245    ) -> Self {
1246        Self {
1247            target: target.into(),
1248            operation,
1249            phase,
1250            persisted: !matches!(
1251                phase,
1252                ExternalToolDeltaPhase::Pending | ExternalToolDeltaPhase::Draining
1253            ),
1254            applied_at_turn: None,
1255            tool_count: None,
1256            detail: None,
1257        }
1258    }
1259
1260    #[must_use]
1261    pub fn with_tool_count(mut self, tool_count: Option<usize>) -> Self {
1262        self.tool_count = tool_count;
1263        self
1264    }
1265
1266    #[must_use]
1267    pub fn with_detail(mut self, detail: Option<String>) -> Self {
1268        self.detail = detail;
1269        self
1270    }
1271
1272    #[must_use]
1273    pub fn status_text(&self) -> String {
1274        ToolConfigChangeStatus::external_tool_delta(self.phase, self.detail.clone()).status_text()
1275    }
1276
1277    #[must_use]
1278    pub fn to_tool_config_changed_payload(&self) -> ToolConfigChangedPayload {
1279        let status_info =
1280            ToolConfigChangeStatus::external_tool_delta(self.phase, self.detail.clone());
1281        ToolConfigChangedPayload::new(
1282            self.operation.clone(),
1283            self.target.clone(),
1284            status_info,
1285            self.persisted,
1286        )
1287        .with_applied_at_turn(self.applied_at_turn)
1288    }
1289}
1290
1291/// Events emitted during agent execution
1292///
1293/// These events form the streaming API for consumers.
1294#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1295#[derive(Debug, Clone, Serialize, Deserialize)]
1296#[serde(tag = "type", rename_all = "snake_case")]
1297#[non_exhaustive]
1298pub enum AgentEvent {
1299    // === Session Lifecycle ===
1300    /// Agent run started
1301    RunStarted {
1302        session_id: SessionId,
1303        prompt: ContentInput,
1304    },
1305
1306    /// Agent run completed successfully
1307    RunCompleted {
1308        session_id: SessionId,
1309        result: String,
1310        /// Structured output from the completed run, when schema extraction
1311        /// produced a typed value.
1312        #[serde(default, skip_serializing_if = "Option::is_none")]
1313        structured_output: Option<Value>,
1314        /// Whether a separate extraction terminal event is expected after this
1315        /// completed main run.
1316        #[serde(default)]
1317        extraction_required: bool,
1318        usage: Usage,
1319        #[serde(default, skip_serializing_if = "Option::is_none")]
1320        terminal_cause_kind: Option<TurnTerminalCauseKind>,
1321    },
1322
1323    /// Structured-output extraction succeeded after a completed main run.
1324    ExtractionSucceeded {
1325        session_id: SessionId,
1326        structured_output: Value,
1327        #[serde(default, skip_serializing_if = "Option::is_none")]
1328        schema_warnings: Option<Vec<crate::schema::SchemaWarning>>,
1329    },
1330
1331    /// Structured-output extraction failed after a completed main run.
1332    ExtractionFailed {
1333        session_id: SessionId,
1334        last_output: String,
1335        attempts: u32,
1336        reason: String,
1337    },
1338
1339    /// Agent run failed
1340    RunFailed {
1341        session_id: SessionId,
1342        error_class: AgentErrorClass,
1343        /// Display projection of `error_report.message`.
1344        error: String,
1345        #[serde(default, skip_serializing_if = "Option::is_none")]
1346        terminal_cause_kind: Option<TurnTerminalCauseKind>,
1347        #[serde(default, skip_serializing_if = "Option::is_none")]
1348        error_report: Option<AgentErrorReport>,
1349    },
1350
1351    // === Hook Lifecycle ===
1352    /// Hook invocation started.
1353    HookStarted { hook_id: HookId, point: HookPoint },
1354
1355    /// Hook invocation completed.
1356    HookCompleted {
1357        hook_id: HookId,
1358        point: HookPoint,
1359        duration_ms: u64,
1360    },
1361
1362    /// Hook invocation failed.
1363    HookFailed {
1364        hook_id: HookId,
1365        point: HookPoint,
1366        error: String,
1367    },
1368
1369    /// Hook denied an action.
1370    HookDenied {
1371        hook_id: HookId,
1372        point: HookPoint,
1373        reason_code: HookReasonCode,
1374        message: String,
1375        #[serde(default, skip_serializing_if = "Option::is_none")]
1376        payload: Option<Value>,
1377    },
1378
1379    // === LLM Interaction ===
1380    /// New turn started (calling LLM)
1381    TurnStarted { turn_number: u32 },
1382
1383    /// Streaming reasoning/thinking from the model
1384    ReasoningDelta { delta: String },
1385
1386    /// Reasoning/thinking complete for this block
1387    ReasoningComplete { content: String },
1388
1389    /// Streaming text from the model
1390    TextDelta { delta: String },
1391
1392    /// Text generation complete for this turn
1393    TextComplete { content: String },
1394
1395    /// Model requested a tool call
1396    ToolCallRequested {
1397        id: String,
1398        name: String,
1399        args: ToolCallArguments,
1400    },
1401
1402    /// Tool result received (injected into conversation)
1403    ToolResultReceived {
1404        id: String,
1405        name: String,
1406        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1407        content: Vec<ContentBlock>,
1408        is_error: bool,
1409    },
1410
1411    /// Turn completed
1412    TurnCompleted {
1413        stop_reason: StopReason,
1414        usage: Usage,
1415    },
1416
1417    // === Tool Execution ===
1418    /// Starting tool execution
1419    ToolExecutionStarted { id: String, name: String },
1420
1421    /// Tool execution completed
1422    ToolExecutionCompleted {
1423        id: String,
1424        name: String,
1425        /// Legacy text projection retained for existing event consumers.
1426        result: String,
1427        /// Canonical typed tool-result content.
1428        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1429        content: Vec<ContentBlock>,
1430        is_error: bool,
1431        duration_ms: u64,
1432    },
1433
1434    /// Tool execution timed out
1435    ToolExecutionTimedOut {
1436        id: String,
1437        name: String,
1438        timeout_ms: u64,
1439    },
1440
1441    // === Compaction ===
1442    /// Context compaction started.
1443    CompactionStarted {
1444        /// Input tokens from the last LLM call that triggered compaction.
1445        input_tokens: u64,
1446        /// Estimated total history tokens before compaction.
1447        estimated_history_tokens: u64,
1448        /// Number of messages before compaction.
1449        message_count: usize,
1450    },
1451
1452    /// Context compaction completed successfully.
1453    CompactionCompleted {
1454        /// Tokens consumed by the summary.
1455        summary_tokens: u64,
1456        /// Messages before compaction.
1457        messages_before: usize,
1458        /// Messages after compaction.
1459        messages_after: usize,
1460    },
1461
1462    /// Context compaction failed (non-fatal — agent continues with uncompacted history).
1463    CompactionFailed { error: String },
1464
1465    // === Budget ===
1466    /// Budget warning (approaching limits)
1467    BudgetWarning {
1468        budget_type: BudgetType,
1469        used: u64,
1470        limit: u64,
1471        percent: f32,
1472    },
1473
1474    // === Retry Events ===
1475    /// Retrying after error
1476    Retrying {
1477        attempt: u32,
1478        max_attempts: u32,
1479        error: String,
1480        delay_ms: u64,
1481        #[serde(default, skip_serializing_if = "Option::is_none")]
1482        retry: Option<LlmRetrySchedule>,
1483    },
1484
1485    // === Skill Events ===
1486    /// Skills resolved for this turn.
1487    SkillsResolved {
1488        skills: Vec<SkillKey>,
1489        injection_bytes: usize,
1490    },
1491
1492    /// A skill reference could not be resolved.
1493    SkillResolutionFailed {
1494        /// Canonical structured skill identity/reference, when resolution had one.
1495        #[serde(default, skip_serializing_if = "Option::is_none")]
1496        skill_key: Option<SkillKey>,
1497        /// Structured reason for the failure. Legacy payloads deserialize as `unknown`.
1498        #[serde(default)]
1499        reason: SkillResolutionFailureReason,
1500        /// Legacy display mirror for consumers still reading string references.
1501        #[serde(default)]
1502        reference: String,
1503        /// Legacy display mirror for consumers still reading string errors.
1504        #[serde(default)]
1505        error: String,
1506    },
1507
1508    // === Interaction-Scoped Streaming ===
1509    /// An interaction completed successfully (terminal event for tap subscribers).
1510    InteractionComplete {
1511        interaction_id: crate::interaction::InteractionId,
1512        result: String,
1513        /// Structured output from the completed interaction, when schema
1514        /// extraction produced a typed value.
1515        #[serde(default, skip_serializing_if = "Option::is_none")]
1516        structured_output: Option<Value>,
1517    },
1518
1519    /// An interaction reached an external callback boundary and is waiting for
1520    /// tool results before the session can continue.
1521    InteractionCallbackPending {
1522        interaction_id: crate::interaction::InteractionId,
1523        tool_name: String,
1524        args: Value,
1525    },
1526
1527    /// An interaction failed (terminal event for tap subscribers).
1528    InteractionFailed {
1529        interaction_id: crate::interaction::InteractionId,
1530        error: String,
1531    },
1532
1533    /// Some streaming events were dropped due to channel backpressure.
1534    /// Best-effort marker — the terminal event is authoritative.
1535    StreamTruncated { reason: String },
1536
1537    /// Live tool configuration changed for this session.
1538    ToolConfigChanged { payload: ToolConfigChangedPayload },
1539
1540    /// A background shell job completed (or failed/cancelled/timed out).
1541    BackgroundJobCompleted {
1542        job_id: String,
1543        display_name: String,
1544        /// Legacy display mirror for consumers still rendering string status.
1545        #[serde(rename = "status")]
1546        #[serde(
1547            default,
1548            skip_serializing_if = "Option::is_none",
1549            deserialize_with = "deserialize_legacy_background_job_status"
1550        )]
1551        legacy_status: Option<String>,
1552        terminal_status: BackgroundJobTerminalStatus,
1553        detail: String,
1554    },
1555}
1556
1557impl AgentEvent {
1558    pub fn background_job_completed(
1559        job_id: impl Into<String>,
1560        display_name: impl Into<String>,
1561        terminal_status: BackgroundJobTerminalStatus,
1562        detail: impl Into<String>,
1563    ) -> Self {
1564        Self::BackgroundJobCompleted {
1565            job_id: job_id.into(),
1566            display_name: display_name.into(),
1567            legacy_status: Some(terminal_status.as_str().to_string()),
1568            terminal_status,
1569            detail: detail.into(),
1570        }
1571    }
1572}
1573
1574/// Scope attribution frame for multi-agent streaming.
1575#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1576#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1577#[serde(tag = "scope", rename_all = "snake_case")]
1578#[non_exhaustive]
1579pub enum StreamScopeFrame {
1580    /// Top-level primary session scope.
1581    Primary { session_id: String },
1582    /// Mob member scope for flow dispatch turns.
1583    MobMember {
1584        flow_run_id: String,
1585        agent_identity: String,
1586        #[cfg_attr(feature = "schema", schemars(skip))]
1587        #[serde(default, skip_serializing)]
1588        agent_runtime_id: Option<String>,
1589        #[cfg_attr(feature = "schema", schemars(skip))]
1590        #[serde(default, skip_serializing)]
1591        fence_token: Option<u64>,
1592        #[cfg_attr(feature = "schema", schemars(skip))]
1593        #[serde(default, skip_serializing)]
1594        generation: Option<u64>,
1595    },
1596}
1597
1598/// Attributed stream event wrapper for multi-agent streaming.
1599#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1600#[derive(Debug, Clone, Serialize, Deserialize)]
1601pub struct ScopedAgentEvent {
1602    pub scope_id: String,
1603    pub scope_path: Vec<StreamScopeFrame>,
1604    pub event: AgentEvent,
1605}
1606
1607impl ScopedAgentEvent {
1608    /// Build a scoped event from a scope path and payload event.
1609    pub fn new(scope_path: Vec<StreamScopeFrame>, event: AgentEvent) -> Self {
1610        let scope_id = Self::scope_id_from_path(&scope_path);
1611        Self {
1612            scope_id,
1613            scope_path,
1614            event,
1615        }
1616    }
1617
1618    /// Build a primary-scoped event for a top-level session event.
1619    pub fn primary(session_id: impl Into<String>, event: AgentEvent) -> Self {
1620        Self::new(
1621            vec![StreamScopeFrame::Primary {
1622                session_id: session_id.into(),
1623            }],
1624            event,
1625        )
1626    }
1627
1628    /// Convenience alias for converting a legacy event into primary scope.
1629    pub fn from_agent_event_primary(session_id: impl Into<String>, event: AgentEvent) -> Self {
1630        Self::primary(session_id, event)
1631    }
1632
1633    /// Append one scope frame and recompute scope_id deterministically.
1634    pub fn append_scope(mut self, frame: StreamScopeFrame) -> Self {
1635        self.scope_path.push(frame);
1636        self.scope_id = Self::scope_id_from_path(&self.scope_path);
1637        self
1638    }
1639
1640    /// Deterministic canonical selector from scope path.
1641    ///
1642    /// Formats:
1643    /// - `primary`
1644    /// - `mob:<agent_identity>`
1645    pub fn scope_id_from_path(path: &[StreamScopeFrame]) -> String {
1646        if path.is_empty() {
1647            return "primary".to_string();
1648        }
1649        let mut segments: Vec<String> = Vec::with_capacity(path.len());
1650        for frame in path {
1651            match frame {
1652                StreamScopeFrame::Primary { .. } => segments.push("primary".to_string()),
1653                StreamScopeFrame::MobMember { agent_identity, .. } => {
1654                    segments.push(format!("mob:{agent_identity}"));
1655                }
1656            }
1657        }
1658        segments.join("/")
1659    }
1660}
1661
1662/// Type of budget being tracked
1663#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1664#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1665#[serde(rename_all = "snake_case")]
1666pub enum BudgetType {
1667    Tokens,
1668    Time,
1669    ToolCalls,
1670}
1671
1672/// Configuration for formatting verbose event output.
1673#[derive(Debug, Clone, Copy)]
1674pub struct VerboseEventConfig {
1675    pub max_tool_args_bytes: usize,
1676    pub max_tool_result_bytes: usize,
1677    pub max_text_bytes: usize,
1678}
1679
1680impl Default for VerboseEventConfig {
1681    fn default() -> Self {
1682        Self {
1683            max_tool_args_bytes: 100,
1684            max_tool_result_bytes: 200,
1685            max_text_bytes: 500,
1686        }
1687    }
1688}
1689
1690/// Format an agent event using default verbose formatting rules.
1691pub fn format_verbose_event(event: &AgentEvent) -> Option<String> {
1692    format_verbose_event_with_config(event, &VerboseEventConfig::default())
1693}
1694
1695/// Format an agent event using custom verbose formatting rules.
1696pub fn format_verbose_event_with_config(
1697    event: &AgentEvent,
1698    config: &VerboseEventConfig,
1699) -> Option<String> {
1700    match event {
1701        AgentEvent::TurnStarted { turn_number } => {
1702            Some(format!("\n━━━ Turn {} ━━━", turn_number + 1))
1703        }
1704        AgentEvent::ToolCallRequested { name, args, .. } => {
1705            let args_str = serde_json::to_string(args).unwrap_or_default();
1706            let args_preview = truncate_preview(&args_str, config.max_tool_args_bytes);
1707            Some(format!("  → Calling tool: {name} {args_preview}"))
1708        }
1709        AgentEvent::ToolExecutionCompleted {
1710            name,
1711            result,
1712            is_error,
1713            duration_ms,
1714            ..
1715        } => {
1716            let status = if *is_error { "✗" } else { "✓" };
1717            let result_preview = truncate_preview(result, config.max_tool_result_bytes);
1718            Some(format!(
1719                "  {status} {name} ({duration_ms}ms): {result_preview}"
1720            ))
1721        }
1722        AgentEvent::TurnCompleted { stop_reason, usage } => Some(format!(
1723            "  ── Turn complete: {:?} ({} in / {} out tokens)",
1724            stop_reason, usage.input_tokens, usage.output_tokens
1725        )),
1726        AgentEvent::TextComplete { content } => {
1727            if content.is_empty() {
1728                None
1729            } else {
1730                let preview = truncate_preview(content, config.max_text_bytes);
1731                Some(format!("  💬 Response: {preview}"))
1732            }
1733        }
1734        AgentEvent::ReasoningComplete { content } => {
1735            if content.is_empty() {
1736                None
1737            } else {
1738                let preview = truncate_preview(content, config.max_text_bytes);
1739                Some(format!("  💭 Thinking: {preview}"))
1740            }
1741        }
1742        AgentEvent::Retrying {
1743            attempt,
1744            max_attempts,
1745            error,
1746            delay_ms,
1747            ..
1748        } => Some(format!(
1749            "  ⟳ Retry {attempt}/{max_attempts}: {error} (waiting {delay_ms}ms)"
1750        )),
1751        AgentEvent::BudgetWarning {
1752            budget_type,
1753            used,
1754            limit,
1755            percent,
1756        } => Some(format!(
1757            "  ⚠ Budget warning: {:?} at {:.0}% ({}/{})",
1758            budget_type,
1759            percent * 100.0,
1760            used,
1761            limit
1762        )),
1763        AgentEvent::CompactionStarted {
1764            input_tokens,
1765            estimated_history_tokens,
1766            message_count,
1767        } => Some(format!(
1768            "  ⟳ Compaction started: {input_tokens} input tokens, ~{estimated_history_tokens} history tokens, {message_count} messages"
1769        )),
1770        AgentEvent::CompactionCompleted {
1771            summary_tokens,
1772            messages_before,
1773            messages_after,
1774        } => Some(format!(
1775            "  ✓ Compaction complete: {messages_before} → {messages_after} messages, {summary_tokens} summary tokens"
1776        )),
1777        AgentEvent::CompactionFailed { error } => {
1778            Some(format!("  ✗ Compaction failed (continuing): {error}"))
1779        }
1780        AgentEvent::BackgroundJobCompleted {
1781            job_id,
1782            display_name,
1783            terminal_status,
1784            detail,
1785            ..
1786        } => {
1787            let status = terminal_status.as_str();
1788            Some(format!(
1789                "  BG job {job_id} ({display_name}) {status}: {detail}"
1790            ))
1791        }
1792        AgentEvent::InteractionCallbackPending {
1793            tool_name, args, ..
1794        } => Some(format!(
1795            "  ⧖ Callback pending: {tool_name} {}",
1796            truncate_preview(&args.to_string(), config.max_tool_args_bytes)
1797        )),
1798        _ => None,
1799    }
1800}
1801
1802fn truncate_preview(input: &str, max_bytes: usize) -> String {
1803    if input.len() <= max_bytes {
1804        return input.to_string();
1805    }
1806    format!("{}...", truncate_str(input, max_bytes))
1807}
1808
1809fn truncate_str(s: &str, max_bytes: usize) -> &str {
1810    if s.len() <= max_bytes {
1811        return s;
1812    }
1813    let truncate_at = s
1814        .char_indices()
1815        .take_while(|(i, _)| *i < max_bytes)
1816        .last()
1817        .map_or(0, |(i, c)| i + c.len_utf8());
1818    &s[..truncate_at]
1819}
1820
1821#[cfg(test)]
1822#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
1823mod tests {
1824    use super::*;
1825    use crate::retry::{LlmRetryFailure, LlmRetryFailureKind, LlmRetryPlan, LlmRetrySchedule};
1826    use crate::skills::SkillName;
1827    use crate::types::ContentBlock;
1828
1829    fn text_block(text: &str) -> ContentBlock {
1830        ContentBlock::Text {
1831            text: text.to_string(),
1832        }
1833    }
1834
1835    fn image_block(media_type: &str, data: &str) -> ContentBlock {
1836        ContentBlock::Image {
1837            media_type: media_type.to_string(),
1838            data: data.into(),
1839        }
1840    }
1841
1842    fn tool_args(value: Value) -> ToolCallArguments {
1843        ToolCallArguments::from_value(value).expect("test tool args must be an object")
1844    }
1845
1846    #[test]
1847    fn tool_call_arguments_reject_string_projection() {
1848        let err = ToolCallArguments::from_value(serde_json::json!("{\"path\":"))
1849            .expect_err("provider argument strings must not become semantic tool-call args");
1850
1851        assert!(
1852            err.to_string().contains("JSON object, got string"),
1853            "unexpected error: {err}"
1854        );
1855    }
1856
1857    #[test]
1858    fn tool_call_requested_rejects_string_args_on_deserialize() {
1859        let value = serde_json::json!({
1860            "type": "tool_call_requested",
1861            "id": "tc_1",
1862            "name": "search",
1863            "args": "{\"query\":"
1864        });
1865
1866        let err = serde_json::from_value::<AgentEvent>(value)
1867            .expect_err("event surface must reject string-success tool args");
1868        assert!(
1869            err.to_string().contains("JSON object, got string"),
1870            "unexpected error: {err}"
1871        );
1872    }
1873
1874    #[test]
1875    fn tool_config_change_status_mirrors_legacy_status_text() {
1876        assert_eq!(
1877            ToolConfigChangeStatus::boundary_applied(true, false, 7).status_text(),
1878            "boundary_applied(base_changed=true,visible_changed=false,revision=7)"
1879        );
1880        assert_eq!(
1881            ToolConfigChangeStatus::deferred_catalog_delta(2, 1, 3).status_text(),
1882            "deferred_catalog_delta(added_hidden=2,removed_hidden=1,pending_sources=3)"
1883        );
1884        assert_eq!(
1885            ToolConfigChangeStatus::warning_failed_closed("injected failure").status_text(),
1886            "warning_failed_closed(injected failure)"
1887        );
1888        assert_eq!(
1889            ToolConfigChangeStatus::external_tool_delta(
1890                ExternalToolDeltaPhase::Failed,
1891                Some("exit 1".to_string()),
1892            )
1893            .status_text(),
1894            "failed: exit 1"
1895        );
1896    }
1897
1898    #[test]
1899    fn tool_result_events_carry_text_only_content_blocks() {
1900        let content = vec![text_block("plain output")];
1901        let completed = AgentEvent::ToolExecutionCompleted {
1902            id: "tc_text".to_string(),
1903            name: "text_tool".to_string(),
1904            result: "plain output".to_string(),
1905            content: content.clone(),
1906            is_error: false,
1907            duration_ms: 12,
1908        };
1909        let received = AgentEvent::ToolResultReceived {
1910            id: "tc_text".to_string(),
1911            name: "text_tool".to_string(),
1912            content,
1913            is_error: false,
1914        };
1915
1916        let completed_json = serde_json::to_value(&completed).expect("serialize completed event");
1917        assert_eq!(
1918            completed_json["content"],
1919            serde_json::json!([{"type": "text", "text": "plain output"}])
1920        );
1921        assert!(
1922            completed_json.get("has_images").is_none(),
1923            "typed content blocks should replace image side flags on event surfaces"
1924        );
1925
1926        let received_json = serde_json::to_value(&received).expect("serialize received event");
1927        assert_eq!(
1928            received_json["content"],
1929            serde_json::json!([{"type": "text", "text": "plain output"}])
1930        );
1931    }
1932
1933    #[test]
1934    fn tool_result_events_carry_image_only_content_blocks() {
1935        let content = vec![image_block("image/png", "AAAA")];
1936        let completed = AgentEvent::ToolExecutionCompleted {
1937            id: "tc_image".to_string(),
1938            name: "view_image".to_string(),
1939            result: "[image: image/png]".to_string(),
1940            content: content.clone(),
1941            is_error: false,
1942            duration_ms: 12,
1943        };
1944        let received = AgentEvent::ToolResultReceived {
1945            id: "tc_image".to_string(),
1946            name: "view_image".to_string(),
1947            content,
1948            is_error: false,
1949        };
1950
1951        let completed_json = serde_json::to_value(&completed).expect("serialize completed event");
1952        assert_eq!(
1953            completed_json["content"],
1954            serde_json::json!([{
1955                "type": "image",
1956                "media_type": "image/png",
1957                "source": "inline",
1958                "data": "AAAA"
1959            }])
1960        );
1961        assert!(
1962            completed_json.get("has_images").is_none(),
1963            "typed content blocks should replace image side flags on event surfaces"
1964        );
1965
1966        let received_json = serde_json::to_value(&received).expect("serialize received event");
1967        assert_eq!(received_json["content"], completed_json["content"]);
1968    }
1969
1970    #[test]
1971    fn tool_result_events_carry_mixed_content_blocks_in_order() {
1972        let content = vec![
1973            text_block("before"),
1974            image_block("image/png", "AAAA"),
1975            text_block("after"),
1976        ];
1977        let completed = AgentEvent::ToolExecutionCompleted {
1978            id: "tc_mixed".to_string(),
1979            name: "mixed_tool".to_string(),
1980            result: "before\n[image: image/png]\nafter".to_string(),
1981            content: content.clone(),
1982            is_error: false,
1983            duration_ms: 12,
1984        };
1985        let received = AgentEvent::ToolResultReceived {
1986            id: "tc_mixed".to_string(),
1987            name: "mixed_tool".to_string(),
1988            content: content.clone(),
1989            is_error: false,
1990        };
1991
1992        let completed_json = serde_json::to_value(&completed).expect("serialize completed event");
1993        assert_eq!(
1994            completed_json["content"],
1995            serde_json::json!([
1996                {"type": "text", "text": "before"},
1997                {
1998                    "type": "image",
1999                    "media_type": "image/png",
2000                    "source": "inline",
2001                    "data": "AAAA"
2002                },
2003                {"type": "text", "text": "after"}
2004            ])
2005        );
2006        assert!(
2007            completed_json.get("has_images").is_none(),
2008            "typed content blocks should replace image side flags on event surfaces"
2009        );
2010
2011        let roundtrip: AgentEvent = serde_json::from_value(completed_json).unwrap();
2012        match roundtrip {
2013            AgentEvent::ToolExecutionCompleted {
2014                content: roundtrip_content,
2015                ..
2016            } => assert_eq!(roundtrip_content, content),
2017            other => unreachable!("unexpected event: {other:?}"),
2018        }
2019
2020        let received_json = serde_json::to_value(&received).expect("serialize received event");
2021        assert_eq!(received_json["content"][0]["text"], "before");
2022        assert_eq!(received_json["content"][1]["media_type"], "image/png");
2023        assert_eq!(received_json["content"][2]["text"], "after");
2024    }
2025
2026    #[test]
2027    fn legacy_tool_result_event_payloads_deserialize_without_typed_content() {
2028        let completed: AgentEvent = serde_json::from_value(serde_json::json!({
2029            "type": "tool_execution_completed",
2030            "id": "tc_legacy",
2031            "name": "legacy_tool",
2032            "result": "legacy output",
2033            "is_error": false,
2034            "duration_ms": 3,
2035            "has_images": true
2036        }))
2037        .expect("legacy tool_execution_completed payload should deserialize");
2038        match completed {
2039            AgentEvent::ToolExecutionCompleted {
2040                result,
2041                content,
2042                is_error,
2043                ..
2044            } => {
2045                assert_eq!(result, "legacy output");
2046                assert!(content.is_empty());
2047                assert!(!is_error);
2048            }
2049            other => unreachable!("unexpected event: {other:?}"),
2050        }
2051
2052        let received: AgentEvent = serde_json::from_value(serde_json::json!({
2053            "type": "tool_result_received",
2054            "id": "tc_legacy",
2055            "name": "legacy_tool",
2056            "is_error": false
2057        }))
2058        .expect("legacy tool_result_received payload should deserialize");
2059        match received {
2060            AgentEvent::ToolResultReceived {
2061                content, is_error, ..
2062            } => {
2063                assert!(content.is_empty());
2064                assert!(!is_error);
2065            }
2066            other => unreachable!("unexpected event: {other:?}"),
2067        }
2068    }
2069
2070    #[test]
2071    fn tool_config_changed_payload_carries_structured_status_with_legacy_mirror() {
2072        let status_info = ToolConfigChangeStatus::boundary_applied(true, true, 42);
2073        let event = AgentEvent::ToolConfigChanged {
2074            payload: ToolConfigChangedPayload::new(
2075                ToolConfigChangeOperation::Reload,
2076                "tool_scope",
2077                status_info,
2078                false,
2079            )
2080            .with_applied_at_turn(Some(3))
2081            .with_domain(Some(ToolConfigChangeDomain::ToolScope)),
2082        };
2083
2084        let json = serde_json::to_value(event).unwrap();
2085        assert_eq!(
2086            json["payload"]["status"],
2087            "boundary_applied(base_changed=true,visible_changed=true,revision=42)"
2088        );
2089        assert_eq!(json["payload"]["status_info"]["kind"], "boundary_applied");
2090        assert_eq!(json["payload"]["status_info"]["base_changed"], true);
2091        assert_eq!(json["payload"]["status_info"]["visible_changed"], true);
2092        assert_eq!(json["payload"]["status_info"]["revision"], 42);
2093    }
2094
2095    #[test]
2096    fn tool_config_changed_payload_derives_legacy_status_from_typed_status() {
2097        let status = ToolConfigChangeStatus::boundary_applied(true, false, 9);
2098        let event = AgentEvent::ToolConfigChanged {
2099            payload: ToolConfigChangedPayload::new(
2100                ToolConfigChangeOperation::Reload,
2101                "tool_scope",
2102                status.clone(),
2103                false,
2104            )
2105            .with_applied_at_turn(Some(4))
2106            .with_domain(Some(ToolConfigChangeDomain::ToolScope)),
2107        };
2108
2109        let json = serde_json::to_value(event).unwrap();
2110        assert_eq!(
2111            json["payload"]["status"],
2112            "boundary_applied(base_changed=true,visible_changed=false,revision=9)"
2113        );
2114        assert_eq!(json["payload"]["status_info"]["kind"], "boundary_applied");
2115
2116        let event: AgentEvent = serde_json::from_value(json).unwrap();
2117        if let AgentEvent::ToolConfigChanged { payload } = event {
2118            assert_eq!(payload.status_info(), &status);
2119            assert_eq!(
2120                payload.status_text(),
2121                "boundary_applied(base_changed=true,visible_changed=false,revision=9)"
2122            );
2123        } else {
2124            panic!("expected tool_config_changed event");
2125        }
2126    }
2127
2128    #[test]
2129    fn tool_config_changed_payload_deserializes_legacy_status_without_typed_data() {
2130        let event: AgentEvent = serde_json::from_value(serde_json::json!({
2131            "type": "tool_config_changed",
2132            "payload": {
2133                "operation": "reload",
2134                "target": "tool_scope",
2135                "status": "boundary_applied(base_changed=true,visible_changed=true,revision=42)",
2136                "persisted": false,
2137                "applied_at_turn": 3,
2138                "domain": "tool_scope"
2139            }
2140        }))
2141        .unwrap();
2142
2143        assert!(
2144            matches!(event, AgentEvent::ToolConfigChanged { .. }),
2145            "expected tool_config_changed, got {event:?}"
2146        );
2147        if let AgentEvent::ToolConfigChanged { payload } = event {
2148            assert_eq!(
2149                payload.status_text(),
2150                "boundary_applied(base_changed=true,visible_changed=true,revision=42)"
2151            );
2152            assert_eq!(
2153                payload.status_info(),
2154                &ToolConfigChangeStatus::legacy_status(
2155                    "boundary_applied(base_changed=true,visible_changed=true,revision=42)"
2156                )
2157            );
2158        }
2159    }
2160
2161    #[test]
2162    fn tool_config_changed_payload_prefers_typed_status_over_legacy_mirror() {
2163        let event: AgentEvent = serde_json::from_value(serde_json::json!({
2164            "type": "tool_config_changed",
2165            "payload": {
2166                "operation": "reload",
2167                "target": "tool_scope",
2168                "status": "legacy stale status",
2169                "status_info": {
2170                    "kind": "boundary_applied",
2171                    "base_changed": true,
2172                    "visible_changed": false,
2173                    "revision": 9
2174                },
2175                "persisted": false,
2176                "domain": "tool_scope"
2177            }
2178        }))
2179        .unwrap();
2180
2181        if let AgentEvent::ToolConfigChanged { payload } = event {
2182            assert_eq!(
2183                payload.status_info(),
2184                &ToolConfigChangeStatus::boundary_applied(true, false, 9)
2185            );
2186            assert_eq!(
2187                payload.status_text(),
2188                "boundary_applied(base_changed=true,visible_changed=false,revision=9)"
2189            );
2190        } else {
2191            panic!("expected tool_config_changed event");
2192        }
2193    }
2194
2195    #[cfg(feature = "schema")]
2196    #[test]
2197    fn tool_config_changed_payload_schema_allows_legacy_status_only_replays() {
2198        let schema = serde_json::to_value(schemars::schema_for!(ToolConfigChangedPayload)).unwrap();
2199        let required = schema["required"].as_array().expect("required array");
2200
2201        assert!(
2202            required.iter().any(|field| field == "status"),
2203            "legacy status mirror remains required while it is emitted publicly"
2204        );
2205        assert!(
2206            !required.iter().any(|field| field == "status_info"),
2207            "legacy status-only event replays must remain schema-compatible"
2208        );
2209        assert!(
2210            schema["properties"]["status_info"].is_object(),
2211            "typed status_info remains part of the schema when present"
2212        );
2213    }
2214
2215    #[test]
2216    fn test_agent_event_json_schema() {
2217        // Test all event variants serialize correctly
2218        let events = vec![
2219            AgentEvent::RunStarted {
2220                session_id: SessionId::new(),
2221                prompt: ContentInput::Text("Hello".to_string()),
2222            },
2223            AgentEvent::TextDelta {
2224                delta: "chunk".to_string(),
2225            },
2226            AgentEvent::TurnStarted { turn_number: 1 },
2227            AgentEvent::TurnCompleted {
2228                stop_reason: StopReason::EndTurn,
2229                usage: Usage::default(),
2230            },
2231            AgentEvent::ToolCallRequested {
2232                id: "tc_1".to_string(),
2233                name: "read_file".to_string(),
2234                args: tool_args(serde_json::json!({"path": "/tmp/test"})),
2235            },
2236            AgentEvent::ToolResultReceived {
2237                id: "tc_1".to_string(),
2238                name: "read_file".to_string(),
2239                content: ContentBlock::text_vec("ok".to_string()),
2240                is_error: false,
2241            },
2242            AgentEvent::BudgetWarning {
2243                budget_type: BudgetType::Tokens,
2244                used: 8000,
2245                limit: 10000,
2246                percent: 0.8,
2247            },
2248            AgentEvent::Retrying {
2249                attempt: 1,
2250                max_attempts: 3,
2251                error: "Rate limited".to_string(),
2252                delay_ms: 1000,
2253                retry: None,
2254            },
2255            AgentEvent::RunCompleted {
2256                session_id: SessionId::new(),
2257                result: "Done".to_string(),
2258                structured_output: None,
2259                extraction_required: false,
2260                usage: Usage {
2261                    input_tokens: 100,
2262                    output_tokens: 50,
2263                    cache_creation_tokens: None,
2264                    cache_read_tokens: None,
2265                },
2266                terminal_cause_kind: None,
2267            },
2268            AgentEvent::RunFailed {
2269                session_id: SessionId::new(),
2270                error_class: AgentErrorClass::Budget,
2271                error: "Budget exceeded".to_string(),
2272                terminal_cause_kind: None,
2273                error_report: Some(AgentErrorReport {
2274                    class: AgentErrorClass::Budget,
2275                    reason: None,
2276                    message: "Budget exceeded".to_string(),
2277                }),
2278            },
2279            AgentEvent::CompactionStarted {
2280                input_tokens: 120_000,
2281                estimated_history_tokens: 150_000,
2282                message_count: 42,
2283            },
2284            AgentEvent::CompactionCompleted {
2285                summary_tokens: 2048,
2286                messages_before: 42,
2287                messages_after: 8,
2288            },
2289            AgentEvent::CompactionFailed {
2290                error: "LLM request failed".to_string(),
2291            },
2292            AgentEvent::InteractionComplete {
2293                interaction_id: crate::interaction::InteractionId(uuid::Uuid::new_v4()),
2294                result: "agent response".to_string(),
2295                structured_output: None,
2296            },
2297            AgentEvent::InteractionCallbackPending {
2298                interaction_id: crate::interaction::InteractionId(uuid::Uuid::new_v4()),
2299                tool_name: "external_mock".to_string(),
2300                args: serde_json::json!({"value": "browser"}),
2301            },
2302            AgentEvent::InteractionFailed {
2303                interaction_id: crate::interaction::InteractionId(uuid::Uuid::new_v4()),
2304                error: "LLM failure".to_string(),
2305            },
2306            AgentEvent::StreamTruncated {
2307                reason: "channel full".to_string(),
2308            },
2309            AgentEvent::ToolConfigChanged {
2310                payload: ToolConfigChangedPayload::new(
2311                    ToolConfigChangeOperation::Remove,
2312                    "filesystem",
2313                    ToolConfigChangeStatus::legacy_status("staged"),
2314                    false,
2315                )
2316                .with_applied_at_turn(Some(12)),
2317            },
2318            AgentEvent::background_job_completed(
2319                "j_123",
2320                "sleep 2",
2321                BackgroundJobTerminalStatus::Completed,
2322                "exit_code: 0",
2323            ),
2324        ];
2325
2326        for event in events {
2327            let json = serde_json::to_value(&event).unwrap();
2328
2329            // All events should have a "type" field
2330            assert!(
2331                json.get("type").is_some(),
2332                "Event missing type field: {event:?}"
2333            );
2334
2335            // Should roundtrip
2336            let roundtrip: AgentEvent = serde_json::from_value(json.clone()).unwrap();
2337            let json2 = serde_json::to_value(&roundtrip).unwrap();
2338            assert_eq!(json, json2);
2339        }
2340    }
2341
2342    #[test]
2343    fn background_job_completed_carries_typed_terminal_status() {
2344        let event = AgentEvent::background_job_completed(
2345            "j_123",
2346            "sleep 2",
2347            BackgroundJobTerminalStatus::Failed,
2348            "exit_code: 1",
2349        );
2350
2351        let json = serde_json::to_value(&event).unwrap();
2352        assert_eq!(json["type"], "background_job_completed");
2353        assert_eq!(json["status"], "failed");
2354        assert_eq!(json["terminal_status"], "failed");
2355
2356        let roundtrip: AgentEvent = serde_json::from_value(json).unwrap();
2357        match roundtrip {
2358            AgentEvent::BackgroundJobCompleted {
2359                legacy_status,
2360                terminal_status,
2361                ..
2362            } => {
2363                assert_eq!(legacy_status.as_deref(), Some("failed"));
2364                assert_eq!(terminal_status, BackgroundJobTerminalStatus::Failed);
2365            }
2366            other => unreachable!("unexpected event: {other:?}"),
2367        }
2368    }
2369
2370    #[test]
2371    fn background_job_completed_requires_typed_terminal_status() {
2372        let string_only_json = serde_json::json!({
2373            "type": "background_job_completed",
2374            "job_id": "j_123",
2375            "display_name": "sleep 2",
2376            "status": "completed",
2377            "detail": "exit_code: 1"
2378        });
2379        assert!(
2380            serde_json::from_value::<AgentEvent>(string_only_json).is_err(),
2381            "legacy status-only payload must not decode as completed"
2382        );
2383
2384        let malformed_status_only_json = serde_json::json!({
2385            "type": "background_job_completed",
2386            "job_id": "j_123",
2387            "display_name": "sleep 2",
2388            "status": "success",
2389            "detail": "exit_code: 0"
2390        });
2391        assert!(
2392            serde_json::from_value::<AgentEvent>(malformed_status_only_json).is_err(),
2393            "unknown legacy status string must not become success"
2394        );
2395
2396        let unknown_typed_json = serde_json::json!({
2397            "type": "background_job_completed",
2398            "job_id": "j_123",
2399            "display_name": "sleep 2",
2400            "status": "completed",
2401            "terminal_status": "success",
2402            "detail": "exit_code: 0"
2403        });
2404        assert!(
2405            serde_json::from_value::<AgentEvent>(unknown_typed_json).is_err(),
2406            "unknown typed terminal status must fail closed"
2407        );
2408
2409        let typed_without_legacy_json = serde_json::json!({
2410            "type": "background_job_completed",
2411            "job_id": "j_123",
2412            "display_name": "sleep 2",
2413            "terminal_status": "failed",
2414            "detail": "exit_code: 1"
2415        });
2416        let event: AgentEvent = serde_json::from_value(typed_without_legacy_json).unwrap();
2417        match event {
2418            AgentEvent::BackgroundJobCompleted {
2419                job_id,
2420                display_name,
2421                legacy_status,
2422                terminal_status,
2423                detail,
2424            } => {
2425                assert_eq!(job_id, "j_123");
2426                assert_eq!(display_name, "sleep 2");
2427                assert_eq!(legacy_status, None);
2428                assert_eq!(terminal_status, BackgroundJobTerminalStatus::Failed);
2429                assert_eq!(detail, "exit_code: 1");
2430            }
2431            other => unreachable!("unexpected event: {other:?}"),
2432        }
2433
2434        let stale_legacy_json = serde_json::json!({
2435            "type": "background_job_completed",
2436            "job_id": "j_123",
2437            "display_name": "sleep 2",
2438            "status": "completed",
2439            "terminal_status": "failed",
2440            "detail": "exit_code: 1"
2441        });
2442        let event: AgentEvent = serde_json::from_value(stale_legacy_json).unwrap();
2443        match event {
2444            AgentEvent::BackgroundJobCompleted {
2445                job_id,
2446                display_name,
2447                legacy_status,
2448                terminal_status,
2449                detail,
2450            } => {
2451                assert_eq!(job_id, "j_123");
2452                assert_eq!(display_name, "sleep 2");
2453                assert_eq!(legacy_status.as_deref(), Some("completed"));
2454                assert_eq!(terminal_status, BackgroundJobTerminalStatus::Failed);
2455                assert_eq!(detail, "exit_code: 1");
2456            }
2457            other => unreachable!("unexpected event: {other:?}"),
2458        }
2459
2460        let malformed_legacy_json = serde_json::json!({
2461            "type": "background_job_completed",
2462            "job_id": "j_123",
2463            "display_name": "sleep 2",
2464            "status": 0,
2465            "terminal_status": "failed",
2466            "detail": "exit_code: 1"
2467        });
2468        let event: AgentEvent = serde_json::from_value(malformed_legacy_json).unwrap();
2469        match event {
2470            AgentEvent::BackgroundJobCompleted {
2471                legacy_status,
2472                terminal_status,
2473                detail,
2474                ..
2475            } => {
2476                assert_eq!(legacy_status, None);
2477                assert_eq!(terminal_status, BackgroundJobTerminalStatus::Failed);
2478                assert_eq!(detail, "exit_code: 1");
2479            }
2480            other => unreachable!("unexpected event: {other:?}"),
2481        }
2482    }
2483
2484    #[test]
2485    fn background_job_terminal_status_maps_operation_truth() {
2486        use crate::ops::{OperationId, OperationResult};
2487        use crate::ops_lifecycle::{OperationStatus, OperationTerminalOutcome};
2488
2489        let result = OperationResult {
2490            id: OperationId(uuid::Uuid::new_v4()),
2491            content: String::new(),
2492            is_error: false,
2493            duration_ms: 0,
2494            tokens_used: 0,
2495        };
2496
2497        assert_eq!(
2498            BackgroundJobTerminalStatus::from_terminal_outcome(
2499                &OperationTerminalOutcome::Completed(result)
2500            ),
2501            BackgroundJobTerminalStatus::Completed
2502        );
2503        assert_eq!(
2504            BackgroundJobTerminalStatus::from_terminal_outcome(&OperationTerminalOutcome::Failed {
2505                error: "boom".to_string(),
2506            }),
2507            BackgroundJobTerminalStatus::Failed
2508        );
2509        assert_eq!(
2510            BackgroundJobTerminalStatus::from_terminal_outcome(
2511                &OperationTerminalOutcome::Aborted { reason: None }
2512            ),
2513            BackgroundJobTerminalStatus::Aborted
2514        );
2515        assert_eq!(
2516            BackgroundJobTerminalStatus::from_terminal_outcome(
2517                &OperationTerminalOutcome::Cancelled {
2518                    reason: Some("user".to_string()),
2519                }
2520            ),
2521            BackgroundJobTerminalStatus::Cancelled
2522        );
2523        assert_eq!(
2524            BackgroundJobTerminalStatus::from_terminal_outcome(&OperationTerminalOutcome::Retired),
2525            BackgroundJobTerminalStatus::Retired
2526        );
2527        assert_eq!(
2528            BackgroundJobTerminalStatus::from_terminal_outcome(
2529                &OperationTerminalOutcome::Terminated {
2530                    reason: "channel closed".to_string(),
2531                }
2532            ),
2533            BackgroundJobTerminalStatus::Terminated
2534        );
2535
2536        assert_eq!(
2537            BackgroundJobTerminalStatus::from_operation_status(OperationStatus::Completed),
2538            Some(BackgroundJobTerminalStatus::Completed)
2539        );
2540        assert_eq!(
2541            BackgroundJobTerminalStatus::from_operation_status(OperationStatus::Failed),
2542            Some(BackgroundJobTerminalStatus::Failed)
2543        );
2544        assert_eq!(
2545            BackgroundJobTerminalStatus::from_operation_status(OperationStatus::Aborted),
2546            Some(BackgroundJobTerminalStatus::Aborted)
2547        );
2548        assert_eq!(
2549            BackgroundJobTerminalStatus::from_operation_status(OperationStatus::Cancelled),
2550            Some(BackgroundJobTerminalStatus::Cancelled)
2551        );
2552        assert_eq!(
2553            BackgroundJobTerminalStatus::from_operation_status(OperationStatus::Retired),
2554            Some(BackgroundJobTerminalStatus::Retired)
2555        );
2556        assert_eq!(
2557            BackgroundJobTerminalStatus::from_operation_status(OperationStatus::Terminated),
2558            Some(BackgroundJobTerminalStatus::Terminated)
2559        );
2560        assert_eq!(
2561            BackgroundJobTerminalStatus::from_operation_status(OperationStatus::Running),
2562            None
2563        );
2564    }
2565
2566    #[test]
2567    fn retry_event_carries_typed_schedule() {
2568        let schedule = LlmRetrySchedule {
2569            failure: LlmRetryFailure {
2570                provider: "test".to_string(),
2571                kind: LlmRetryFailureKind::RateLimited,
2572                retry_after_ms: Some(30_000),
2573                duration_ms: None,
2574                message: "rate limited".to_string(),
2575            },
2576            plan: LlmRetryPlan {
2577                attempt: 1,
2578                max_retries: 3,
2579                computed_delay_ms: 500,
2580                selected_delay_ms: 30_000,
2581                retry_after_hint_ms: Some(30_000),
2582                rate_limit_floor_applied: true,
2583                budget_capped: false,
2584            },
2585        };
2586        let event = AgentEvent::Retrying {
2587            attempt: schedule.plan.attempt,
2588            max_attempts: schedule.plan.max_retries,
2589            error: schedule.failure.message.clone(),
2590            delay_ms: schedule.plan.selected_delay_ms,
2591            retry: Some(schedule),
2592        };
2593
2594        let value = serde_json::to_value(&event).unwrap();
2595        assert_eq!(value["retry"]["failure"]["kind"], "rate_limited");
2596        assert_eq!(value["retry"]["plan"]["attempt"], 1);
2597        assert_eq!(value["retry"]["plan"]["selected_delay_ms"], 30_000);
2598    }
2599
2600    #[test]
2601    fn skill_resolution_failed_carries_typed_key_and_reason_with_legacy_mirrors() {
2602        let key = SkillKey::builtin(SkillName::parse("test-skill").unwrap());
2603        let error = SkillError::NotFound { key: key.clone() };
2604        let reason = SkillResolutionFailureReason::from_skill_error(&error);
2605        let event = AgentEvent::SkillResolutionFailed {
2606            skill_key: Some(key.clone()),
2607            reason,
2608            reference: key.to_string(),
2609            error: error.to_string(),
2610        };
2611
2612        let value = serde_json::to_value(&event).unwrap();
2613        assert_eq!(
2614            value["skill_key"]["source_uuid"],
2615            key.source_uuid.to_string()
2616        );
2617        assert_eq!(value["skill_key"]["skill_name"], key.skill_name.as_str());
2618        assert_eq!(value["reason"]["reason_type"], "not_found");
2619        assert_eq!(
2620            value["reason"]["key"]["source_uuid"],
2621            key.source_uuid.to_string()
2622        );
2623        assert_eq!(
2624            value["reason"]["key"]["skill_name"],
2625            key.skill_name.as_str()
2626        );
2627        assert_eq!(value["reference"], key.to_string());
2628        assert_eq!(value["error"], error.to_string());
2629
2630        let roundtrip: AgentEvent = serde_json::from_value(value).unwrap();
2631        match roundtrip {
2632            AgentEvent::SkillResolutionFailed {
2633                skill_key,
2634                reason,
2635                reference,
2636                error: error_message,
2637            } => {
2638                assert_eq!(skill_key, Some(key.clone()));
2639                assert_eq!(
2640                    reason,
2641                    SkillResolutionFailureReason::NotFound { key: key.clone() }
2642                );
2643                assert_eq!(reference, key.to_string());
2644                assert_eq!(error_message, error.to_string());
2645            }
2646            other => unreachable!("unexpected event: {other:?}"),
2647        }
2648    }
2649
2650    #[test]
2651    fn legacy_skill_resolution_failed_payload_deserializes() {
2652        let value = serde_json::json!({
2653            "type": "skill_resolution_failed",
2654            "reference": "legacy/ref",
2655            "error": "missing",
2656        });
2657
2658        let event: AgentEvent = serde_json::from_value(value).unwrap();
2659        match event {
2660            AgentEvent::SkillResolutionFailed {
2661                skill_key,
2662                reason,
2663                reference,
2664                error,
2665            } => {
2666                assert_eq!(skill_key, None);
2667                assert_eq!(
2668                    reason,
2669                    SkillResolutionFailureReason::Unknown {
2670                        message: String::new()
2671                    }
2672                );
2673                assert_eq!(reference, "legacy/ref");
2674                assert_eq!(error, "missing");
2675            }
2676            other => unreachable!("unexpected event: {other:?}"),
2677        }
2678    }
2679
2680    #[test]
2681    fn unknown_skill_resolution_failed_reason_type_deserializes_as_unknown() {
2682        let value = serde_json::json!({
2683            "type": "skill_resolution_failed",
2684            "reason": {
2685                "reason_type": "future_reason",
2686                "message": "future reason details"
2687            },
2688        });
2689
2690        let event: AgentEvent = serde_json::from_value(value).unwrap();
2691        match event {
2692            AgentEvent::SkillResolutionFailed { reason, .. } => {
2693                assert_eq!(
2694                    reason,
2695                    SkillResolutionFailureReason::Unknown {
2696                        message: "future reason details".to_string()
2697                    }
2698                );
2699                assert_eq!(reason.to_string(), "future reason details");
2700            }
2701            other => unreachable!("unexpected event: {other:?}"),
2702        }
2703    }
2704
2705    #[test]
2706    fn agent_error_report_carries_typed_hook_reason() {
2707        let hook_id = HookId::new("guard-pre-tool");
2708        let error = crate::error::AgentError::HookDenied {
2709            hook_id: hook_id.clone(),
2710            point: HookPoint::RunStarted,
2711            reason_code: HookReasonCode::PolicyViolation,
2712            message: "blocked".to_string(),
2713            payload: None,
2714        };
2715        let report = AgentErrorReport::from_agent_error(&error);
2716        assert_eq!(report.class, AgentErrorClass::Hook);
2717        assert_eq!(
2718            report.reason,
2719            Some(AgentErrorReason::HookDenied {
2720                hook_id: Some(hook_id),
2721                point: HookPoint::RunStarted,
2722                reason_code: HookReasonCode::PolicyViolation,
2723            })
2724        );
2725        assert_eq!(report.message, error.to_string());
2726    }
2727
2728    #[test]
2729    fn agent_error_report_carries_typed_provider_error_reason() {
2730        let error = crate::error::AgentError::llm(
2731            "anthropic",
2732            LlmFailureReason::ProviderError(crate::error::LlmProviderError::retryable(
2733                LlmProviderErrorKind::ServerOverloaded,
2734                serde_json::json!({
2735                    "message": "provider overloaded"
2736                }),
2737            )),
2738            "provider overloaded",
2739        );
2740
2741        let report = AgentErrorReport::from_agent_error(&error);
2742
2743        assert_eq!(report.class, AgentErrorClass::Llm);
2744        assert_eq!(
2745            report.reason,
2746            Some(AgentErrorReason::LlmProviderError {
2747                provider_error_kind: LlmProviderErrorKind::ServerOverloaded,
2748                provider_error_retryability: LlmProviderErrorRetryability::Retryable,
2749                provider_error: serde_json::json!({
2750                    "message": "provider overloaded"
2751                }),
2752            })
2753        );
2754    }
2755
2756    #[test]
2757    fn agent_error_report_fails_closed_for_unknown_terminal_cause() {
2758        let error = crate::error::AgentError::TerminalFailure {
2759            outcome: TurnTerminalOutcome::Failed,
2760            cause_kind: TurnTerminalCauseKind::Unknown,
2761            message: "display text must not publish terminal cause".to_string(),
2762        };
2763
2764        let report = AgentErrorReport::from_agent_error(&error);
2765
2766        assert_eq!(report.class, AgentErrorClass::Internal);
2767        assert_eq!(report.reason, None);
2768        assert_eq!(report.message, error.to_string());
2769    }
2770
2771    #[test]
2772    fn test_agent_event_type_mapping_is_total_for_all_variants() {
2773        let events = vec![
2774            AgentEvent::RunStarted {
2775                session_id: SessionId::new(),
2776                prompt: ContentInput::Text("Hello".to_string()),
2777            },
2778            AgentEvent::RunCompleted {
2779                session_id: SessionId::new(),
2780                result: "Done".to_string(),
2781                structured_output: None,
2782                extraction_required: false,
2783                usage: Usage::default(),
2784                terminal_cause_kind: None,
2785            },
2786            AgentEvent::RunFailed {
2787                session_id: SessionId::new(),
2788                error_class: AgentErrorClass::Internal,
2789                error: "failed".to_string(),
2790                terminal_cause_kind: None,
2791                error_report: Some(AgentErrorReport {
2792                    class: AgentErrorClass::Internal,
2793                    reason: None,
2794                    message: "failed".to_string(),
2795                }),
2796            },
2797            AgentEvent::HookStarted {
2798                hook_id: HookId::new("hook-1"),
2799                point: HookPoint::RunStarted,
2800            },
2801            AgentEvent::HookCompleted {
2802                hook_id: HookId::new("hook-1"),
2803                point: HookPoint::RunStarted,
2804                duration_ms: 1,
2805            },
2806            AgentEvent::HookFailed {
2807                hook_id: HookId::new("hook-1"),
2808                point: HookPoint::RunStarted,
2809                error: "failed".to_string(),
2810            },
2811            AgentEvent::HookDenied {
2812                hook_id: HookId::new("hook-1"),
2813                point: HookPoint::RunStarted,
2814                reason_code: HookReasonCode::PolicyViolation,
2815                message: "nope".to_string(),
2816                payload: None,
2817            },
2818            AgentEvent::TurnStarted { turn_number: 1 },
2819            AgentEvent::ReasoningDelta {
2820                delta: "think".to_string(),
2821            },
2822            AgentEvent::ReasoningComplete {
2823                content: "done".to_string(),
2824            },
2825            AgentEvent::TextDelta {
2826                delta: "chunk".to_string(),
2827            },
2828            AgentEvent::TextComplete {
2829                content: "done".to_string(),
2830            },
2831            AgentEvent::ToolCallRequested {
2832                id: "tool-1".to_string(),
2833                name: "search".to_string(),
2834                args: tool_args(serde_json::json!({})),
2835            },
2836            AgentEvent::ToolResultReceived {
2837                id: "tool-1".to_string(),
2838                name: "search".to_string(),
2839                content: ContentBlock::text_vec("ok".to_string()),
2840                is_error: false,
2841            },
2842            AgentEvent::TurnCompleted {
2843                stop_reason: StopReason::EndTurn,
2844                usage: Usage::default(),
2845            },
2846            AgentEvent::ToolExecutionStarted {
2847                id: "tool-1".to_string(),
2848                name: "search".to_string(),
2849            },
2850            AgentEvent::ToolExecutionCompleted {
2851                id: "tool-1".to_string(),
2852                name: "search".to_string(),
2853                result: "ok".to_string(),
2854                content: ContentBlock::text_vec("ok".to_string()),
2855                is_error: false,
2856                duration_ms: 1,
2857            },
2858            AgentEvent::ToolExecutionTimedOut {
2859                id: "tool-1".to_string(),
2860                name: "search".to_string(),
2861                timeout_ms: 1000,
2862            },
2863            AgentEvent::CompactionStarted {
2864                input_tokens: 1,
2865                estimated_history_tokens: 2,
2866                message_count: 3,
2867            },
2868            AgentEvent::CompactionCompleted {
2869                summary_tokens: 1,
2870                messages_before: 3,
2871                messages_after: 1,
2872            },
2873            AgentEvent::CompactionFailed {
2874                error: "failed".to_string(),
2875            },
2876            AgentEvent::BudgetWarning {
2877                budget_type: BudgetType::Time,
2878                used: 1,
2879                limit: 2,
2880                percent: 50.0,
2881            },
2882            AgentEvent::Retrying {
2883                attempt: 1,
2884                max_attempts: 2,
2885                error: "retry".to_string(),
2886                delay_ms: 100,
2887                retry: None,
2888            },
2889            AgentEvent::SkillsResolved {
2890                skills: vec![],
2891                injection_bytes: 0,
2892            },
2893            AgentEvent::SkillResolutionFailed {
2894                skill_key: None,
2895                reason: SkillResolutionFailureReason::Unknown {
2896                    message: "missing".to_string(),
2897                },
2898                reference: "skill".to_string(),
2899                error: "missing".to_string(),
2900            },
2901            AgentEvent::InteractionComplete {
2902                interaction_id: crate::interaction::InteractionId(uuid::Uuid::new_v4()),
2903                result: "ok".to_string(),
2904                structured_output: None,
2905            },
2906            AgentEvent::InteractionCallbackPending {
2907                interaction_id: crate::interaction::InteractionId(uuid::Uuid::new_v4()),
2908                tool_name: "external_mock".to_string(),
2909                args: serde_json::json!({"value": "browser"}),
2910            },
2911            AgentEvent::InteractionFailed {
2912                interaction_id: crate::interaction::InteractionId(uuid::Uuid::new_v4()),
2913                error: "failed".to_string(),
2914            },
2915            AgentEvent::StreamTruncated {
2916                reason: "lag".to_string(),
2917            },
2918            AgentEvent::ToolConfigChanged {
2919                payload: ToolConfigChangedPayload::new(
2920                    ToolConfigChangeOperation::Reload,
2921                    "external",
2922                    ToolConfigChangeStatus::external_tool_delta(
2923                        ExternalToolDeltaPhase::Applied,
2924                        None,
2925                    ),
2926                    true,
2927                )
2928                .with_applied_at_turn(Some(1)),
2929            },
2930            AgentEvent::background_job_completed(
2931                "j_123",
2932                "sleep 2",
2933                BackgroundJobTerminalStatus::Completed,
2934                "exit_code: 0",
2935            ),
2936        ];
2937
2938        let expected_event_count = events.len();
2939        let mut kinds = std::collections::BTreeSet::new();
2940        for event in events {
2941            let kind = agent_event_type(&event);
2942            assert!(
2943                !kind.is_empty(),
2944                "event type mapping returned empty discriminator"
2945            );
2946            kinds.insert(kind);
2947        }
2948        assert_eq!(
2949            kinds.len(),
2950            expected_event_count,
2951            "expected one distinct discriminator per covered event variant"
2952        );
2953    }
2954
2955    #[test]
2956    fn test_budget_type_serialization() {
2957        assert_eq!(serde_json::to_value(BudgetType::Tokens).unwrap(), "tokens");
2958        assert_eq!(serde_json::to_value(BudgetType::Time).unwrap(), "time");
2959        assert_eq!(
2960            serde_json::to_value(BudgetType::ToolCalls).unwrap(),
2961            "tool_calls"
2962        );
2963    }
2964
2965    #[test]
2966    fn test_scoped_agent_event_roundtrip() {
2967        let event = ScopedAgentEvent::new(
2968            vec![StreamScopeFrame::MobMember {
2969                flow_run_id: "run_123".to_string(),
2970                agent_identity: "writer".to_string(),
2971                agent_runtime_id: Some("writer:0".to_string()),
2972                fence_token: Some(1),
2973                generation: Some(0),
2974            }],
2975            AgentEvent::TextDelta {
2976                delta: "hello".to_string(),
2977            },
2978        );
2979
2980        assert_eq!(event.scope_id, "mob:writer");
2981
2982        let json = serde_json::to_value(&event).unwrap();
2983        let frame = &json["scope_path"][0];
2984        assert_eq!(frame["flow_run_id"], "run_123");
2985        assert_eq!(frame["agent_identity"], "writer");
2986        assert!(
2987            frame.get("agent_runtime_id").is_none(),
2988            "scoped stream frames must not serialize runtime incarnation ids"
2989        );
2990        assert!(
2991            frame.get("fence_token").is_none(),
2992            "scoped stream frames must not serialize fence tokens"
2993        );
2994        assert!(
2995            frame.get("generation").is_none(),
2996            "scoped stream frames must not serialize runtime generations"
2997        );
2998        let roundtrip: ScopedAgentEvent = serde_json::from_value(json).unwrap();
2999        assert_eq!(roundtrip.scope_id, "mob:writer");
3000        assert!(matches!(
3001            roundtrip.event,
3002            AgentEvent::TextDelta { ref delta } if delta == "hello"
3003        ));
3004    }
3005
3006    #[test]
3007    fn test_scope_id_from_path_formats() {
3008        let primary = vec![StreamScopeFrame::Primary {
3009            session_id: "sid_x".to_string(),
3010        }];
3011        assert_eq!(ScopedAgentEvent::scope_id_from_path(&primary), "primary");
3012
3013        let mob = vec![StreamScopeFrame::MobMember {
3014            flow_run_id: "run_1".to_string(),
3015            agent_identity: "planner".to_string(),
3016            agent_runtime_id: Some("planner:2".to_string()),
3017            fence_token: Some(3),
3018            generation: Some(2),
3019        }];
3020        assert_eq!(ScopedAgentEvent::scope_id_from_path(&mob), "mob:planner");
3021    }
3022
3023    #[test]
3024    fn test_event_envelope_roundtrip() {
3025        let session_id = SessionId::new();
3026        let envelope = EventEnvelope::new_session(
3027            session_id.clone(),
3028            7,
3029            Some("mob_1".to_string()),
3030            AgentEvent::TextDelta {
3031                delta: "hello".to_string(),
3032            },
3033        );
3034        let value = serde_json::to_value(&envelope).expect("serialize envelope");
3035        let parsed: EventEnvelope<AgentEvent> =
3036            serde_json::from_value(value).expect("deserialize envelope");
3037        assert_eq!(parsed.source_session_id(), Some(&session_id));
3038        assert_eq!(parsed.source_id, format!("session:{session_id}"));
3039        assert_eq!(parsed.seq, 7);
3040        assert_eq!(parsed.mob_id.as_deref(), Some("mob_1"));
3041        assert!(parsed.timestamp_ms > 0);
3042        assert!(matches!(
3043            parsed.payload,
3044            AgentEvent::TextDelta { delta } if delta == "hello"
3045        ));
3046    }
3047
3048    #[test]
3049    fn event_envelope_requires_typed_source_identity() {
3050        let value = serde_json::json!({
3051            "event_id": uuid::Uuid::now_v7(),
3052            "source_id": "session:00000000-0000-4000-8000-000000000001",
3053            "seq": 7,
3054            "timestamp_ms": 1,
3055            "payload": {
3056                "type": "text_delta",
3057                "delta": "hello",
3058            },
3059        });
3060
3061        let result = serde_json::from_value::<EventEnvelope<AgentEvent>>(value);
3062
3063        assert!(
3064            result.is_err(),
3065            "source_id alone must not deserialize as canonical source identity"
3066        );
3067    }
3068
3069    #[test]
3070    fn malformed_legacy_source_id_does_not_override_typed_source() {
3071        let session_id = SessionId::new();
3072        let value = serde_json::json!({
3073            "event_id": uuid::Uuid::now_v7(),
3074            "source": {
3075                "type": "session",
3076                "session_id": session_id,
3077            },
3078            "source_id": "session:not-a-uuid",
3079            "seq": 7,
3080            "timestamp_ms": 1,
3081            "payload": {
3082                "type": "text_delta",
3083                "delta": "hello",
3084            },
3085        });
3086
3087        let parsed: EventEnvelope<AgentEvent> =
3088            serde_json::from_value(value).expect("typed source should deserialize");
3089
3090        assert_eq!(parsed.source_session_id(), Some(&session_id));
3091        assert_eq!(parsed.source_id, "session:not-a-uuid");
3092    }
3093
3094    #[test]
3095    fn legacy_session_source_id_string_does_not_classify_envelope() {
3096        let session_id = SessionId::new();
3097        let envelope = EventEnvelope::new(
3098            format!("session:{session_id}"),
3099            1,
3100            None,
3101            AgentEvent::TurnStarted { turn_number: 1 },
3102        );
3103
3104        assert_eq!(envelope.source_session_id(), None);
3105        assert_eq!(envelope.source_id, format!("session:{session_id}"));
3106    }
3107
3108    #[test]
3109    fn test_compare_event_envelopes_total_order() {
3110        let mut a = EventEnvelope::new("a", 1, None, AgentEvent::TurnStarted { turn_number: 1 });
3111        let mut b = EventEnvelope::new("a", 2, None, AgentEvent::TurnStarted { turn_number: 2 });
3112        a.timestamp_ms = 10;
3113        b.timestamp_ms = 10;
3114        assert_eq!(compare_event_envelopes(&a, &b), Ordering::Less);
3115        assert_eq!(compare_event_envelopes(&b, &a), Ordering::Greater);
3116    }
3117}