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