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