Skip to main content

a3s_code_core/
event_protocol.rs

1//! Versioned wire protocol for [`AgentEvent`].
2//!
3//! `AgentEvent` is the runtime enum. [`EventEnvelopeV1`] is its stable,
4//! language-neutral representation. SDKs consume the envelope instead of
5//! maintaining their own event-name matches, so a new runtime event cannot be
6//! silently projected as `unknown`.
7
8use crate::agent::AgentEvent;
9use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
10use serde_json::Value;
11use thiserror::Error;
12
13/// Wire version carried by [`EventEnvelopeV1`].
14pub const EVENT_ENVELOPE_V1_VERSION: u16 = 1;
15
16macro_rules! define_agent_event_types_v1 {
17    ($( $variant:ident => $constant:ident = $wire_name:literal ),+ $(,)?) => {
18        /// Canonical event type names for event envelope version 1.
19        ///
20        /// SDK constants and parity checks are generated from this catalog.
21        #[derive(Debug, Clone, Copy)]
22        pub struct AgentEventTypeV1;
23
24        impl AgentEventTypeV1 {
25            $(
26                pub const $constant: &'static str = $wire_name;
27            )+
28        }
29
30        /// Complete, ordered set of event type names supported by envelope v1.
31        pub const AGENT_EVENT_TYPES_V1: &[&str] = &[
32            $(AgentEventTypeV1::$constant),+
33        ];
34
35        impl AgentEvent {
36            /// Return the canonical version-1 wire type for this event.
37            ///
38            /// This match is deliberately exhaustive. Adding an `AgentEvent`
39            /// variant without assigning it a stable wire name is a compile
40            /// error rather than an SDK event named `unknown`.
41            pub const fn event_type_v1(&self) -> &'static str {
42                match self {
43                    $(Self::$variant { .. } => AgentEventTypeV1::$constant),+
44                }
45            }
46        }
47    };
48}
49
50define_agent_event_types_v1! {
51    Start => AGENT_START = "agent_start",
52    AgentModeChanged => AGENT_MODE_CHANGED = "agent_mode_changed",
53    TurnStart => TURN_START = "turn_start",
54    TextDelta => TEXT_DELTA = "text_delta",
55    ReasoningDelta => REASONING_DELTA = "reasoning_delta",
56    ToolStart => TOOL_START = "tool_start",
57    ToolInputDelta => TOOL_INPUT_DELTA = "tool_input_delta",
58    ToolRequestBound => TOOL_REQUEST_BOUND = "tool_request_bound",
59    ToolExecutionStart => TOOL_EXECUTION_START = "tool_execution_start",
60    ToolEnd => TOOL_END = "tool_end",
61    ToolOutputDelta => TOOL_OUTPUT_DELTA = "tool_output_delta",
62    TurnEnd => TURN_END = "turn_end",
63    End => AGENT_END = "agent_end",
64    Error => ERROR = "error",
65    ConfirmationRequired => CONFIRMATION_REQUIRED = "confirmation_required",
66    ConfirmationReceived => CONFIRMATION_RECEIVED = "confirmation_received",
67    ConfirmationTimeout => CONFIRMATION_TIMEOUT = "confirmation_timeout",
68    UserQuestion => USER_QUESTION = "user_question",
69    ExternalTaskPending => EXTERNAL_TASK_PENDING = "external_task_pending",
70    ExternalTaskCompleted => EXTERNAL_TASK_COMPLETED = "external_task_completed",
71    PermissionDenied => PERMISSION_DENIED = "permission_denied",
72    ContextResolving => CONTEXT_RESOLVING = "context_resolving",
73    ContextResolved => CONTEXT_RESOLVED = "context_resolved",
74    RunCapabilityBound => RUN_CAPABILITY_BOUND = "run_capability_bound",
75    ModelPresentationBound => MODEL_PRESENTATION_BOUND = "model_presentation_bound",
76    ModelInputBound => MODEL_INPUT_BOUND = "model_input_bound",
77    ModelUsageBound => MODEL_USAGE_BOUND = "model_usage_bound",
78    CognitiveContextBound => COGNITIVE_CONTEXT_BOUND = "cognitive_context_bound",
79    CommandDeadLettered => COMMAND_DEAD_LETTERED = "command_dead_lettered",
80    CommandRetry => COMMAND_RETRY = "command_retry",
81    QueueAlert => QUEUE_ALERT = "queue_alert",
82    TaskUpdated => TASK_UPDATED = "task_updated",
83    MemoryStored => MEMORY_STORED = "memory_stored",
84    MemoryRecalled => MEMORY_RECALLED = "memory_recalled",
85    MemoriesSearched => MEMORIES_SEARCHED = "memories_searched",
86    MemoryCleared => MEMORY_CLEARED = "memory_cleared",
87    SubagentStart => SUBAGENT_START = "subagent_start",
88    SubagentProgress => SUBAGENT_PROGRESS = "subagent_progress",
89    SubagentEnd => SUBAGENT_END = "subagent_end",
90    PlanningStart => PLANNING_START = "planning_start",
91    PlanningEnd => PLANNING_END = "planning_end",
92    StepStart => STEP_START = "step_start",
93    StepEnd => STEP_END = "step_end",
94    GoalExtracted => GOAL_EXTRACTED = "goal_extracted",
95    GoalProgress => GOAL_PROGRESS = "goal_progress",
96    GoalAchieved => GOAL_ACHIEVED = "goal_achieved",
97    ContextCompacted => CONTEXT_COMPACTED = "context_compacted",
98    RunControlApplied => RUN_CONTROL_APPLIED = "run_control_applied",
99    PersistenceFailed => PERSISTENCE_FAILED = "persistence_failed",
100    BudgetThresholdHit => BUDGET_THRESHOLD_HIT = "budget_threshold_hit",
101    PassivationRequested => PASSIVATION_REQUESTED = "passivation_requested",
102    PeerInvocation => PEER_INVOCATION = "peer_invocation",
103}
104
105/// Errors produced while converting a runtime event to the stable wire shape.
106#[derive(Debug, Error)]
107pub enum EventProtocolError {
108    #[error("failed to serialize agent event: {0}")]
109    Serialization(#[from] serde_json::Error),
110
111    #[error("serialized AgentEvent must be a JSON object with a string `type` field")]
112    InvalidRuntimeShape,
113
114    #[error(
115        "AgentEvent wire type drifted: canonical type is `{canonical}`, serde emitted `{serialized}`"
116    )]
117    TypeMismatch {
118        canonical: &'static str,
119        serialized: String,
120    },
121}
122
123/// Stable, versioned event representation shared by every SDK.
124///
125/// `event_type` is intentionally an open string. Deserializers therefore keep
126/// future event types and their complete payload instead of collapsing them to
127/// an `unknown` sentinel.
128#[derive(Debug, Clone, PartialEq, Serialize)]
129pub struct EventEnvelopeV1 {
130    pub version: u16,
131    #[serde(rename = "type")]
132    pub event_type: String,
133    pub payload: Value,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub metadata: Option<Value>,
136}
137
138impl<'de> Deserialize<'de> for EventEnvelopeV1 {
139    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
140    where
141        D: Deserializer<'de>,
142    {
143        #[derive(Deserialize)]
144        struct WireEnvelope {
145            version: u16,
146            #[serde(rename = "type")]
147            event_type: String,
148            payload: Value,
149            #[serde(default)]
150            metadata: Option<Value>,
151        }
152
153        let wire = WireEnvelope::deserialize(deserializer)?;
154        if wire.version != EVENT_ENVELOPE_V1_VERSION {
155            return Err(D::Error::custom(format_args!(
156                "unsupported event envelope version {}; expected {}",
157                wire.version, EVENT_ENVELOPE_V1_VERSION
158            )));
159        }
160
161        Ok(Self {
162            version: wire.version,
163            event_type: wire.event_type,
164            payload: wire.payload,
165            metadata: wire.metadata,
166        })
167    }
168}
169
170impl EventEnvelopeV1 {
171    /// Construct an envelope for a known or future event type.
172    pub fn new(event_type: impl Into<String>, payload: Value) -> Self {
173        Self {
174            version: EVENT_ENVELOPE_V1_VERSION,
175            event_type: event_type.into(),
176            payload,
177            metadata: None,
178        }
179    }
180
181    /// Attach optional protocol metadata such as run or correlation context.
182    pub fn with_metadata(mut self, metadata: Value) -> Self {
183        self.metadata = Some(metadata);
184        self
185    }
186}
187
188impl TryFrom<&AgentEvent> for EventEnvelopeV1 {
189    type Error = EventProtocolError;
190
191    fn try_from(event: &AgentEvent) -> Result<Self, Self::Error> {
192        let canonical = event.event_type_v1();
193        let Value::Object(mut serialized) = serde_json::to_value(event)? else {
194            return Err(EventProtocolError::InvalidRuntimeShape);
195        };
196        let Some(Value::String(serialized_type)) = serialized.remove("type") else {
197            return Err(EventProtocolError::InvalidRuntimeShape);
198        };
199        if serialized_type != canonical {
200            return Err(EventProtocolError::TypeMismatch {
201                canonical,
202                serialized: serialized_type,
203            });
204        }
205
206        Ok(Self::new(canonical, Value::Object(serialized)))
207    }
208}
209
210impl TryFrom<AgentEvent> for EventEnvelopeV1 {
211    type Error = EventProtocolError;
212
213    fn try_from(event: AgentEvent) -> Result<Self, Self::Error> {
214        Self::try_from(&event)
215    }
216}
217
218/// Convert a persisted run event into the same v1 envelope used by live SDK
219/// streams, attaching replay position and correlation metadata.
220pub fn run_event_envelope_v1(
221    record: &crate::run::RunEventRecord,
222    run_id: &str,
223    session_id: &str,
224) -> Result<EventEnvelopeV1, EventProtocolError> {
225    Ok(
226        EventEnvelopeV1::try_from(&record.event)?.with_metadata(serde_json::json!({
227            "run_id": run_id,
228            "session_id": session_id,
229            "sequence": record.sequence,
230            "timestamp_ms": record.timestamp_ms,
231        })),
232    )
233}
234
235/// SDK-facing projection of an envelope.
236///
237/// The canonical fields (`version`, `event_type`, `payload`, `metadata`) are
238/// lossless. Remaining fields retain the pre-v1 SDK conveniences and are
239/// derived centrally so Node and Python cannot disagree about them.
240#[derive(Debug, Clone, PartialEq)]
241pub struct AgentEventProjectionV1 {
242    pub version: u16,
243    pub event_type: String,
244    pub payload: Value,
245    pub metadata: Option<Value>,
246    pub payload_json: String,
247    pub metadata_json: Option<String>,
248    /// Legacy SDK `data` view. Events whose payload is not completely
249    /// represented by convenience fields keep the full payload here.
250    /// Unknown future event types always retain their payload.
251    pub data_json: Option<String>,
252    pub text: Option<String>,
253    pub tool_name: Option<String>,
254    pub tool_id: Option<String>,
255    pub tool_output: Option<String>,
256    pub exit_code: Option<i32>,
257    pub turn: Option<usize>,
258    pub prompt: Option<String>,
259    pub error: Option<String>,
260    pub total_tokens: Option<usize>,
261    pub verification_summary_json: Option<String>,
262    pub verification_summary_text: Option<String>,
263    pub error_kind_json: Option<String>,
264}
265
266impl AgentEventProjectionV1 {
267    fn string(payload: &Value, key: &str) -> Option<String> {
268        payload.get(key)?.as_str().map(ToOwned::to_owned)
269    }
270
271    fn usize(payload: &Value, key: &str) -> Option<usize> {
272        usize::try_from(payload.get(key)?.as_u64()?).ok()
273    }
274
275    fn i32(payload: &Value, key: &str) -> Option<i32> {
276        i32::try_from(payload.get(key)?.as_i64()?).ok()
277    }
278}
279
280impl From<EventEnvelopeV1> for AgentEventProjectionV1 {
281    fn from(envelope: EventEnvelopeV1) -> Self {
282        let payload_json = envelope.payload.to_string();
283        let metadata_json = envelope.metadata.as_ref().map(Value::to_string);
284        let data_json = match envelope.event_type.as_str() {
285            AgentEventTypeV1::AGENT_START
286            | AgentEventTypeV1::TURN_START
287            | AgentEventTypeV1::TEXT_DELTA
288            | AgentEventTypeV1::REASONING_DELTA
289            | AgentEventTypeV1::TOOL_START
290            | AgentEventTypeV1::TOOL_INPUT_DELTA
291            | AgentEventTypeV1::TOOL_OUTPUT_DELTA
292            | AgentEventTypeV1::TURN_END
293            | AgentEventTypeV1::AGENT_END
294            | AgentEventTypeV1::ERROR
295            | AgentEventTypeV1::PLANNING_START => None,
296            _ => Some(payload_json.clone()),
297        };
298        let mut projection = Self {
299            version: envelope.version,
300            event_type: envelope.event_type,
301            payload: envelope.payload,
302            metadata: envelope.metadata,
303            payload_json,
304            metadata_json,
305            data_json,
306            text: None,
307            tool_name: None,
308            tool_id: None,
309            tool_output: None,
310            exit_code: None,
311            turn: None,
312            prompt: None,
313            error: None,
314            total_tokens: None,
315            verification_summary_json: None,
316            verification_summary_text: None,
317            error_kind_json: None,
318        };
319
320        match projection.event_type.as_str() {
321            AgentEventTypeV1::AGENT_START | AgentEventTypeV1::PLANNING_START => {
322                projection.prompt = Self::string(&projection.payload, "prompt");
323            }
324            AgentEventTypeV1::TURN_START => {
325                projection.turn = Self::usize(&projection.payload, "turn");
326            }
327            AgentEventTypeV1::TEXT_DELTA | AgentEventTypeV1::REASONING_DELTA => {
328                projection.text = Self::string(&projection.payload, "text");
329            }
330            AgentEventTypeV1::TOOL_START => {
331                projection.tool_id = Self::string(&projection.payload, "id");
332                projection.tool_name = Self::string(&projection.payload, "name");
333            }
334            AgentEventTypeV1::TOOL_INPUT_DELTA => {
335                projection.tool_id = Self::string(&projection.payload, "id");
336                projection.text = Self::string(&projection.payload, "delta");
337            }
338            AgentEventTypeV1::TOOL_REQUEST_BOUND => {
339                projection.tool_id = Self::string(&projection.payload, "tool_id");
340                projection.tool_name = Self::string(&projection.payload, "tool_name");
341            }
342            AgentEventTypeV1::TOOL_EXECUTION_START => {
343                projection.tool_id = Self::string(&projection.payload, "id");
344                projection.tool_name = Self::string(&projection.payload, "name");
345            }
346            AgentEventTypeV1::TOOL_END => {
347                projection.tool_id = Self::string(&projection.payload, "id");
348                projection.tool_name = Self::string(&projection.payload, "name");
349                projection.tool_output = Self::string(&projection.payload, "output");
350                projection.exit_code = Self::i32(&projection.payload, "exit_code");
351                projection.error_kind_json = projection
352                    .payload
353                    .get("error_kind")
354                    .filter(|value| !value.is_null())
355                    .map(Value::to_string);
356            }
357            AgentEventTypeV1::TOOL_OUTPUT_DELTA => {
358                projection.tool_id = Self::string(&projection.payload, "id");
359                projection.tool_name = Self::string(&projection.payload, "name");
360                projection.text = Self::string(&projection.payload, "delta");
361            }
362            AgentEventTypeV1::TURN_END => {
363                projection.turn = Self::usize(&projection.payload, "turn");
364                projection.total_tokens = projection
365                    .payload
366                    .get("usage")
367                    .and_then(|usage| Self::usize(usage, "total_tokens"));
368            }
369            AgentEventTypeV1::AGENT_END => {
370                projection.text = Self::string(&projection.payload, "text");
371                projection.total_tokens = projection
372                    .payload
373                    .get("usage")
374                    .and_then(|usage| Self::usize(usage, "total_tokens"));
375                if let Some(summary) = projection.payload.get("verification_summary") {
376                    projection.verification_summary_json = Some(summary.to_string());
377                    projection.verification_summary_text = serde_json::from_value(summary.clone())
378                        .ok()
379                        .map(|summary| crate::verification::format_verification_summary(&summary));
380                }
381            }
382            AgentEventTypeV1::ERROR => {
383                projection.error = Self::string(&projection.payload, "message");
384            }
385            AgentEventTypeV1::CONFIRMATION_REQUIRED | AgentEventTypeV1::PERMISSION_DENIED => {
386                projection.tool_id = Self::string(&projection.payload, "tool_id");
387                projection.tool_name = Self::string(&projection.payload, "tool_name");
388            }
389            AgentEventTypeV1::CONFIRMATION_RECEIVED | AgentEventTypeV1::CONFIRMATION_TIMEOUT => {
390                projection.tool_id = Self::string(&projection.payload, "tool_id");
391            }
392            AgentEventTypeV1::SUBAGENT_START => {
393                projection.tool_id = Self::string(&projection.payload, "task_id");
394                projection.tool_name = Self::string(&projection.payload, "agent");
395                projection.text = Self::string(&projection.payload, "session_id");
396                projection.prompt = Self::string(&projection.payload, "description");
397            }
398            AgentEventTypeV1::SUBAGENT_PROGRESS => {
399                projection.tool_id = Self::string(&projection.payload, "task_id");
400                if let (Some(session_id), Some(status)) = (
401                    Self::string(&projection.payload, "session_id"),
402                    Self::string(&projection.payload, "status"),
403                ) {
404                    projection.text = Some(format!("{session_id}: {status}"));
405                }
406            }
407            AgentEventTypeV1::SUBAGENT_END => {
408                projection.tool_id = Self::string(&projection.payload, "task_id");
409                projection.tool_name = Self::string(&projection.payload, "agent");
410                projection.text = Self::string(&projection.payload, "session_id");
411                projection.tool_output = Self::string(&projection.payload, "output");
412                projection.exit_code = projection
413                    .payload
414                    .get("success")
415                    .and_then(Value::as_bool)
416                    .map(|success| if success { 0 } else { 1 });
417            }
418            _ => {}
419        }
420
421        projection
422    }
423}
424
425impl TryFrom<&AgentEvent> for AgentEventProjectionV1 {
426    type Error = EventProtocolError;
427
428    fn try_from(event: &AgentEvent) -> Result<Self, Self::Error> {
429        EventEnvelopeV1::try_from(event).map(Self::from)
430    }
431}
432
433impl TryFrom<AgentEvent> for AgentEventProjectionV1 {
434    type Error = EventProtocolError;
435
436    fn try_from(event: AgentEvent) -> Result<Self, Self::Error> {
437        Self::try_from(&event)
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use crate::llm::TokenUsage;
445    use crate::run::RunEventRecord;
446    use crate::verification::VerificationSummary;
447    use serde_json::json;
448    use std::collections::HashSet;
449
450    #[test]
451    fn envelope_rejects_unsupported_version_and_projects_confirmation_arms() {
452        let err = serde_json::from_value::<EventEnvelopeV1>(json!({
453            "version": 99,
454            "type": "text_delta",
455            "payload": { "text": "x" }
456        }))
457        .expect_err("unsupported version");
458        assert!(err
459            .to_string()
460            .contains("unsupported event envelope version"));
461
462        let required = AgentEventProjectionV1::try_from(&AgentEvent::ConfirmationRequired {
463            tool_id: "t1".into(),
464            tool_name: "bash".into(),
465            args: json!({}),
466            timeout_ms: 30_000,
467        })
468        .expect("project");
469        assert_eq!(required.tool_id.as_deref(), Some("t1"));
470        assert_eq!(required.tool_name.as_deref(), Some("bash"));
471        assert!(required.data_json.is_some());
472
473        let received = AgentEventProjectionV1::try_from(&AgentEvent::ConfirmationReceived {
474            tool_id: "t1".into(),
475            approved: true,
476            reason: Some("ok".into()),
477        })
478        .expect("project");
479        assert_eq!(received.tool_id.as_deref(), Some("t1"));
480
481        let timeout = AgentEventProjectionV1::try_from(&AgentEvent::ConfirmationTimeout {
482            tool_id: "t1".into(),
483            action_taken: "rejected".into(),
484        })
485        .expect("project");
486        assert_eq!(timeout.tool_id.as_deref(), Some("t1"));
487    }
488
489    #[test]
490    fn projection_covers_subagent_and_agent_end_verification_summary() {
491        let start = AgentEventProjectionV1::try_from(&AgentEvent::SubagentStart {
492            task_id: "task-1".into(),
493            agent: "worker".into(),
494            session_id: "child-sess".into(),
495            parent_session_id: "parent".into(),
496            description: "fix it".into(),
497            started_ms: 1,
498        })
499        .expect("start");
500        assert_eq!(start.tool_id.as_deref(), Some("task-1"));
501        assert_eq!(start.tool_name.as_deref(), Some("worker"));
502        assert_eq!(start.text.as_deref(), Some("child-sess"));
503        assert_eq!(start.prompt.as_deref(), Some("fix it"));
504
505        let progress = AgentEventProjectionV1::try_from(&AgentEvent::SubagentProgress {
506            task_id: "task-1".into(),
507            session_id: "child-sess".into(),
508            status: "running".into(),
509            metadata: json!({ "percent": 10 }),
510        })
511        .expect("progress");
512        assert_eq!(progress.text.as_deref(), Some("child-sess: running"));
513
514        let end = AgentEventProjectionV1::try_from(&AgentEvent::SubagentEnd {
515            task_id: "task-1".into(),
516            agent: "worker".into(),
517            session_id: "child-sess".into(),
518            success: false,
519            output: "failed".into(),
520            finished_ms: 2,
521        })
522        .expect("end");
523        assert_eq!(end.exit_code, Some(1));
524        assert_eq!(end.tool_output.as_deref(), Some("failed"));
525
526        let agent_end = AgentEventProjectionV1::try_from(&AgentEvent::End {
527            text: "done".into(),
528            usage: TokenUsage {
529                prompt_tokens: 1,
530                completion_tokens: 1,
531                total_tokens: 2,
532                cache_read_tokens: None,
533                cache_write_tokens: None,
534            },
535            verification_summary: Box::new(VerificationSummary::from_reports(&[])),
536            meta: None,
537        })
538        .expect("agent end");
539        assert_eq!(agent_end.text.as_deref(), Some("done"));
540        assert_eq!(agent_end.total_tokens, Some(2));
541        assert!(agent_end.verification_summary_json.is_some());
542        assert!(agent_end.verification_summary_text.is_some());
543    }
544
545    #[test]
546    fn run_event_envelope_attaches_replay_metadata() {
547        let record = RunEventRecord {
548            sequence: 3,
549            timestamp_ms: 42,
550            event: AgentEvent::TextDelta {
551                text: "hello".into(),
552            },
553        };
554        let envelope = run_event_envelope_v1(&record, "run-1", "session-1").expect("envelope");
555        assert_eq!(envelope.event_type, AgentEventTypeV1::TEXT_DELTA);
556        let metadata = envelope.metadata.expect("metadata");
557        assert_eq!(metadata["run_id"], "run-1");
558        assert_eq!(metadata["session_id"], "session-1");
559        assert_eq!(metadata["sequence"], 3);
560        assert_eq!(metadata["timestamp_ms"], 42);
561    }
562
563    #[test]
564    fn event_type_catalog_is_non_empty_and_stable() {
565        assert!(!AGENT_EVENT_TYPES_V1.is_empty());
566        let unique: HashSet<_> = AGENT_EVENT_TYPES_V1.iter().copied().collect();
567        assert_eq!(unique.len(), AGENT_EVENT_TYPES_V1.len());
568        assert!(AGENT_EVENT_TYPES_V1.contains(&AgentEventTypeV1::TEXT_DELTA));
569        assert_eq!(
570            AgentEvent::TextDelta { text: "x".into() }.event_type_v1(),
571            AgentEventTypeV1::TEXT_DELTA
572        );
573    }
574
575    #[test]
576    fn envelope_round_trips_and_projects_streaming_tool_and_turn_events() {
577        let envelope = EventEnvelopeV1::new("future.event", json!({"k": 1}))
578            .with_metadata(json!({"run_id": "r1"}));
579        let wire = serde_json::to_value(&envelope).unwrap();
580        let decoded: EventEnvelopeV1 = serde_json::from_value(wire).unwrap();
581        assert_eq!(decoded, envelope);
582
583        let future = AgentEventProjectionV1::from(EventEnvelopeV1::new(
584            "future.custom",
585            json!({"keep": true}),
586        ));
587        assert!(future.data_json.is_some());
588
589        let start = AgentEventProjectionV1::try_from(&AgentEvent::Start {
590            prompt: "build".into(),
591        })
592        .unwrap();
593        assert_eq!(start.prompt.as_deref(), Some("build"));
594        assert!(start.data_json.is_none());
595
596        let turn = AgentEventProjectionV1::try_from(&AgentEvent::TurnStart { turn: 4 }).unwrap();
597        assert_eq!(turn.turn, Some(4));
598
599        let text =
600            AgentEventProjectionV1::try_from(&AgentEvent::TextDelta { text: "hi".into() }).unwrap();
601        assert_eq!(text.text.as_deref(), Some("hi"));
602
603        let reasoning = AgentEventProjectionV1::try_from(&AgentEvent::ReasoningDelta {
604            text: "think".into(),
605        })
606        .unwrap();
607        assert_eq!(reasoning.text.as_deref(), Some("think"));
608
609        let tool_start = AgentEventProjectionV1::try_from(&AgentEvent::ToolStart {
610            id: "c1".into(),
611            name: "bash".into(),
612        })
613        .unwrap();
614        assert_eq!(tool_start.tool_id.as_deref(), Some("c1"));
615        assert_eq!(tool_start.tool_name.as_deref(), Some("bash"));
616
617        let input = AgentEventProjectionV1::try_from(&AgentEvent::ToolInputDelta {
618            id: Some("c1".into()),
619            delta: "{\"a\":1}".into(),
620        })
621        .unwrap();
622        assert_eq!(input.text.as_deref(), Some("{\"a\":1}"));
623
624        let exec = AgentEventProjectionV1::try_from(&AgentEvent::ToolExecutionStart {
625            id: "c1".into(),
626            name: "bash".into(),
627            args: json!({"command": "true"}),
628        })
629        .unwrap();
630        assert!(exec.data_json.is_some());
631
632        let tool_end = AgentEventProjectionV1::try_from(&AgentEvent::ToolEnd {
633            id: "c1".into(),
634            name: "bash".into(),
635            args: None,
636            output: "ok".into(),
637            exit_code: 0,
638            metadata: None,
639            error_kind: Some(crate::tools::ToolErrorKind::Timeout {
640                op: "bash".into(),
641                duration_ms: 10,
642            }),
643        })
644        .unwrap();
645        assert_eq!(tool_end.tool_output.as_deref(), Some("ok"));
646        assert!(tool_end.error_kind_json.is_some());
647
648        let out = AgentEventProjectionV1::try_from(&AgentEvent::ToolOutputDelta {
649            id: "c1".into(),
650            name: "bash".into(),
651            delta: "line".into(),
652        })
653        .unwrap();
654        assert_eq!(out.text.as_deref(), Some("line"));
655
656        let usage = TokenUsage {
657            prompt_tokens: 1,
658            completion_tokens: 1,
659            total_tokens: 9,
660            cache_read_tokens: None,
661            cache_write_tokens: None,
662        };
663        let turn_end = AgentEventProjectionV1::try_from(&AgentEvent::TurnEnd {
664            turn: 2,
665            usage: usage.clone(),
666        })
667        .unwrap();
668        assert_eq!(turn_end.total_tokens, Some(9));
669
670        let error = AgentEventProjectionV1::try_from(&AgentEvent::Error {
671            message: "boom".into(),
672        })
673        .unwrap();
674        assert_eq!(error.error.as_deref(), Some("boom"));
675
676        let denied = AgentEventProjectionV1::try_from(&AgentEvent::PermissionDenied {
677            tool_id: "t2".into(),
678            tool_name: "write".into(),
679            args: json!({}),
680            reason: "policy".into(),
681        })
682        .unwrap();
683        assert_eq!(denied.tool_name.as_deref(), Some("write"));
684
685        let planning = AgentEventProjectionV1::try_from(&AgentEvent::PlanningStart {
686            prompt: "plan".into(),
687        })
688        .unwrap();
689        assert_eq!(planning.prompt.as_deref(), Some("plan"));
690        assert!(planning.data_json.is_none());
691
692        let alert = AgentEvent::QueueAlert {
693            level: "warn".into(),
694            alert_type: "depth".into(),
695            message: "deep".into(),
696        };
697        let owned = EventEnvelopeV1::try_from(alert.clone()).unwrap();
698        let borrowed = EventEnvelopeV1::try_from(&alert).unwrap();
699        assert_eq!(owned, borrowed);
700        assert!(AgentEventProjectionV1::try_from(alert)
701            .unwrap()
702            .data_json
703            .is_some());
704    }
705
706    #[test]
707    fn tool_request_bound_projection_keeps_ids() {
708        let envelope = EventEnvelopeV1::new(
709            AgentEventTypeV1::TOOL_REQUEST_BOUND,
710            json!({
711                "tool_id": "c9",
712                "tool_name": "bash"
713            }),
714        );
715        let projection = AgentEventProjectionV1::from(envelope);
716        assert_eq!(projection.tool_id.as_deref(), Some("c9"));
717        assert_eq!(projection.tool_name.as_deref(), Some("bash"));
718        assert!(projection.data_json.is_some());
719    }
720}