1use crate::agent::AgentEvent;
9use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
10use serde_json::Value;
11use thiserror::Error;
12
13pub const EVENT_ENVELOPE_V1_VERSION: u16 = 1;
15
16macro_rules! define_agent_event_types_v1 {
17 ($( $variant:ident => $constant:ident = $wire_name:literal ),+ $(,)?) => {
18 #[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 pub const AGENT_EVENT_TYPES_V1: &[&str] = &[
32 $(AgentEventTypeV1::$constant),+
33 ];
34
35 impl AgentEvent {
36 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#[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#[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 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 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
218pub 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#[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 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}