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