1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::{ContextEvent, MessageEvent, ModelEvent, StreamState, ToolEvent, TurnEvent, TurnOutcome};
5
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
8#[serde(tag = "category", content = "event", rename_all = "snake_case")]
9#[allow(clippy::large_enum_variant)]
10pub enum AgentEvent {
11 Message(MessageEvent),
12 Tool(ToolEvent),
13 Turn(TurnEvent),
14 Context(ContextEvent),
15 Model(ModelEvent),
16 SessionUsage(llm::SessionUsageEvent),
17}
18
19impl AgentEvent {
20 pub fn text(message_id: &str, chunk: &str, state: StreamState) -> Self {
21 Self::Message(MessageEvent::Text {
22 message_id: message_id.into(),
23 chunk: chunk.into(),
24 is_complete: state.is_complete(),
25 })
26 }
27
28 pub fn thought(message_id: &str, chunk: &str, state: StreamState) -> Self {
29 Self::Message(MessageEvent::Thought {
30 message_id: message_id.into(),
31 chunk: chunk.into(),
32 is_complete: state.is_complete(),
33 })
34 }
35
36 pub fn turn_ended(outcome: TurnOutcome) -> Self {
37 Self::Turn(TurnEvent::Ended { outcome })
38 }
39
40 pub fn content(&self) -> Option<String> {
42 match self {
43 Self::Message(MessageEvent::Text { chunk, .. } | MessageEvent::Thought { chunk, .. }) => {
44 Some(chunk.clone())
45 }
46 Self::Tool(ToolEvent::Result { result, .. } | ToolEvent::TaskCompleted { result, .. }) => {
47 Some(result.result.clone())
48 }
49 Self::Tool(ToolEvent::Error { error } | ToolEvent::TaskFailed { error, .. }) => Some(error.error.clone()),
50 Self::Tool(ToolEvent::TaskCreated { task_id, .. }) => Some(task_id.clone()),
51 Self::Tool(ToolEvent::TaskStatus { task_id, status, status_message, .. }) => {
52 Some(status_message.as_ref().map_or_else(
53 || format!("{task_id}: {status}"),
54 |message| format!("{task_id}: {status} - {message}"),
55 ))
56 }
57 Self::Tool(ToolEvent::TaskCancelled { task_id, .. }) => Some(format!("{task_id}: cancelled")),
58 Self::Context(ContextEvent::CompactionResult { summary, .. }) => Some(summary.clone()),
59 _ => None,
60 }
61 }
62
63 pub fn turn_outcome(&self) -> Option<&TurnOutcome> {
65 match self {
66 Self::Turn(TurnEvent::Ended { outcome }) => Some(outcome),
67 _ => None,
68 }
69 }
70
71 pub fn is_stream_delta(&self) -> bool {
72 matches!(
73 self,
74 Self::Message(
75 MessageEvent::Text { is_complete: false, .. } | MessageEvent::Thought { is_complete: false, .. }
76 ) | Self::Tool(ToolEvent::InputDelta { .. })
77 )
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84 use crate::events::{CompactionOutcome, LlmCallOutcome};
85 use llm::{ContextUsage, LlmCallPurpose};
86 use utils::display_meta::ToolDisplayMeta;
87
88 #[test]
89 fn serializes_nested_event_contract() {
90 let event = AgentEvent::text("m1", "hello", StreamState::Complete);
91 assert_eq!(
92 serde_json::to_value(event).unwrap(),
93 serde_json::json!({"category":"message","event":{"type":"text","message_id":"m1","chunk":"hello","is_complete":true}})
94 );
95 }
96
97 #[test]
98 fn nested_events_roundtrip() {
99 let events = [
100 AgentEvent::text("m", "text", StreamState::Complete),
101 AgentEvent::Tool(ToolEvent::DefinitionsUpdated { tools: vec![] }),
102 AgentEvent::Turn(TurnEvent::LlmCallEnded {
103 purpose: LlmCallPurpose::Chat,
104 outcome: LlmCallOutcome::Cancelled,
105 }),
106 AgentEvent::Context(ContextEvent::UsageUpdated { usage: ContextUsage::default() }),
107 AgentEvent::Context(ContextEvent::CompactionEnded {
108 compaction_id: "compaction".into(),
109 outcome: CompactionOutcome::Completed,
110 }),
111 AgentEvent::Model(ModelEvent::Switched { previous: "a".into(), new: "b".into() }),
112 AgentEvent::SessionUsage(llm::testing::session_usage_event(1, llm::TokenUsage::new(1, 2))),
113 AgentEvent::Tool(ToolEvent::DisplayUpdate {
114 request: llm::ToolCallRequest { id: "call".into(), name: "read".into(), arguments: "{}".into() },
115 meta: ToolDisplayMeta::new("Read file", "main.rs").into(),
116 }),
117 AgentEvent::Tool(ToolEvent::SubAgentProgress {
118 request: llm::ToolCallRequest { id: "call".into(), name: "spawn".into(), arguments: "{}".into() },
119 payload: Box::new(crate::events::SubAgentProgressPayload {
120 task_id: "task_0".into(),
121 agent_name: "explorer".into(),
122 event: AgentEvent::turn_ended(TurnOutcome::Completed),
123 }),
124 }),
125 ];
126 for event in events {
127 let json = serde_json::to_string(&event).unwrap();
128 assert_eq!(serde_json::from_str::<AgentEvent>(&json).unwrap(), event);
129 }
130 }
131
132 #[test]
133 fn failed_call_ended_serializes_diagnostics_and_omits_absent_fields() {
134 let event = AgentEvent::Turn(TurnEvent::LlmCallEnded {
135 purpose: LlmCallPurpose::Chat,
136 outcome: LlmCallOutcome::Failed {
137 error: "Server error: boom (status 200, code server_error, request_id req-1)".into(),
138 will_retry: true,
139 http_status: Some(200),
140 provider_request_id: Some("req-1".into()),
141 provider_error_code: Some("server_error".into()),
142 },
143 });
144 assert_eq!(
145 serde_json::to_value(&event).unwrap(),
146 serde_json::json!({
147 "category": "turn",
148 "event": {
149 "type": "llm_call_ended",
150 "purpose": "chat",
151 "outcome": {
152 "status": "failed",
153 "error": "Server error: boom (status 200, code server_error, request_id req-1)",
154 "will_retry": true,
155 "http_status": 200,
156 "provider_request_id": "req-1",
157 "provider_error_code": "server_error"
158 }
159 }
160 })
161 );
162
163 let minimal = AgentEvent::Turn(TurnEvent::LlmCallEnded {
164 purpose: LlmCallPurpose::Chat,
165 outcome: LlmCallOutcome::failed("boom", false),
166 });
167 let value = serde_json::to_value(&minimal).unwrap();
168 let outcome = &value["event"]["outcome"];
169 assert_eq!(outcome["status"], "failed");
170 assert!(outcome.get("http_status").is_none());
171 assert!(outcome.get("provider_request_id").is_none());
172 assert!(outcome.get("provider_error_code").is_none());
173 assert_eq!(serde_json::from_value::<AgentEvent>(value).unwrap(), minimal);
174 }
175
176 #[test]
177 fn from_llm_error_copies_provider_diagnostics() {
178 let provider = llm::ProviderError::server("boom")
179 .with_http_status(503)
180 .with_request_id(Some("req-9".into()))
181 .with_code(Some("server_error".into()));
182 let outcome = LlmCallOutcome::from_llm_error(&llm::LlmError::from(provider), true);
183 match outcome {
184 LlmCallOutcome::Failed { http_status, provider_request_id, provider_error_code, will_retry, .. } => {
185 assert!(will_retry);
186 assert_eq!(http_status, Some(503));
187 assert_eq!(provider_request_id.as_deref(), Some("req-9"));
188 assert_eq!(provider_error_code.as_deref(), Some("server_error"));
189 }
190 _ => panic!("expected failed outcome"),
191 }
192 let terminal = LlmCallOutcome::from_llm_error(&llm::LlmError::InvalidArgument("bad".into()), false);
193 match terminal {
194 LlmCallOutcome::Failed { http_status, provider_request_id, provider_error_code, .. } => {
195 assert_eq!(http_status, None);
196 assert_eq!(provider_request_id, None);
197 assert_eq!(provider_error_code, None);
198 }
199 _ => panic!("expected failed outcome"),
200 }
201 }
202
203 #[test]
204 fn turn_outcome_returns_outcome_only_for_turn_end() {
205 assert_eq!(AgentEvent::turn_ended(TurnOutcome::Completed).turn_outcome(), Some(&TurnOutcome::Completed));
206 assert_eq!(AgentEvent::text("m", "text", StreamState::Complete).turn_outcome(), None);
207 assert_eq!(AgentEvent::Turn(TurnEvent::Started { content: vec![] }).turn_outcome(), None);
208 }
209
210 #[test]
211 fn only_partial_chunks_and_tool_input_deltas_are_stream_deltas() {
212 let deltas = [
213 AgentEvent::text("m", "par", StreamState::Partial),
214 AgentEvent::thought("m", "par", StreamState::Partial),
215 AgentEvent::Tool(ToolEvent::InputDelta { id: "call".into(), chunk: "{".into() }),
216 ];
217 let complete = [
218 AgentEvent::text("m", "partial", StreamState::Complete),
219 AgentEvent::thought("m", "partial", StreamState::Complete),
220 AgentEvent::Tool(ToolEvent::InputStarted { id: "call".into(), name: "read".into() }),
221 AgentEvent::Tool(ToolEvent::Call {
222 request: llm::ToolCallRequest { id: "call".into(), name: "read".into(), arguments: "{}".into() },
223 }),
224 AgentEvent::turn_ended(TurnOutcome::Completed),
225 ];
226 assert!(deltas.iter().all(AgentEvent::is_stream_delta));
227 assert!(!complete.iter().any(AgentEvent::is_stream_delta));
228 }
229}