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