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