scud/commands/spawn/headless/
events.rs1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct StreamEvent {
11 pub timestamp_ms: u64,
13 pub kind: StreamEventKind,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19#[serde(tag = "type", rename_all = "snake_case")]
20pub enum StreamEventKind {
21 TextDelta { text: String },
23
24 ToolStart {
26 tool_name: String,
27 tool_id: String,
28 input_summary: String,
29 },
30
31 ToolResult {
33 tool_name: String,
34 tool_id: String,
35 success: bool,
36 },
37
38 Complete { success: bool },
40
41 Error { message: String },
43
44 SessionAssigned { session_id: String },
46}
47
48impl StreamEvent {
49 pub fn new(kind: StreamEventKind) -> Self {
54 Self {
55 timestamp_ms: 0,
56 kind,
57 }
58 }
59
60 pub fn with_timestamp(kind: StreamEventKind, timestamp_ms: u64) -> Self {
62 Self { timestamp_ms, kind }
63 }
64
65 pub fn text_delta(text: impl Into<String>) -> Self {
67 Self::new(StreamEventKind::TextDelta { text: text.into() })
68 }
69
70 pub fn tool_start(name: &str, id: &str, input: &str) -> Self {
72 Self::new(StreamEventKind::ToolStart {
73 tool_name: name.to_string(),
74 tool_id: id.to_string(),
75 input_summary: input.to_string(),
76 })
77 }
78
79 pub fn tool_result(name: &str, id: &str, success: bool) -> Self {
81 Self::new(StreamEventKind::ToolResult {
82 tool_name: name.to_string(),
83 tool_id: id.to_string(),
84 success,
85 })
86 }
87
88 pub fn complete(success: bool) -> Self {
90 Self::new(StreamEventKind::Complete { success })
91 }
92
93 pub fn error(message: impl Into<String>) -> Self {
95 Self::new(StreamEventKind::Error {
96 message: message.into(),
97 })
98 }
99
100 pub fn session_assigned(session_id: impl Into<String>) -> Self {
102 Self::new(StreamEventKind::SessionAssigned {
103 session_id: session_id.into(),
104 })
105 }
106
107 pub fn is_terminal(&self) -> bool {
109 matches!(
110 self.kind,
111 StreamEventKind::Complete { .. } | StreamEventKind::Error { .. }
112 )
113 }
114
115 pub fn is_success(&self) -> bool {
117 matches!(self.kind, StreamEventKind::Complete { success: true })
118 }
119
120 pub fn text(&self) -> Option<&str> {
122 match &self.kind {
123 StreamEventKind::TextDelta { text } => Some(text),
124 _ => None,
125 }
126 }
127
128 pub fn session_id(&self) -> Option<&str> {
130 match &self.kind {
131 StreamEventKind::SessionAssigned { session_id } => Some(session_id),
132 _ => None,
133 }
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn test_text_delta_creation() {
143 let event = StreamEvent::text_delta("Hello, world!");
144 assert_eq!(event.timestamp_ms, 0);
145 assert!(matches!(
146 event.kind,
147 StreamEventKind::TextDelta { ref text } if text == "Hello, world!"
148 ));
149 assert_eq!(event.text(), Some("Hello, world!"));
150 }
151
152 #[test]
153 fn test_tool_start_creation() {
154 let event = StreamEvent::tool_start("Read", "tool_123", "{path: src/main.rs}");
155 match &event.kind {
156 StreamEventKind::ToolStart {
157 tool_name,
158 tool_id,
159 input_summary,
160 } => {
161 assert_eq!(tool_name, "Read");
162 assert_eq!(tool_id, "tool_123");
163 assert_eq!(input_summary, "{path: src/main.rs}");
164 }
165 _ => panic!("Expected ToolStart"),
166 }
167 }
168
169 #[test]
170 fn test_tool_result_creation() {
171 let event = StreamEvent::tool_result("Edit", "tool_456", true);
172 match &event.kind {
173 StreamEventKind::ToolResult {
174 tool_name,
175 tool_id,
176 success,
177 } => {
178 assert_eq!(tool_name, "Edit");
179 assert_eq!(tool_id, "tool_456");
180 assert!(*success);
181 }
182 _ => panic!("Expected ToolResult"),
183 }
184 }
185
186 #[test]
187 fn test_tool_result_failure() {
188 let event = StreamEvent::tool_result("Bash", "tool_789", false);
189 match &event.kind {
190 StreamEventKind::ToolResult { success, .. } => {
191 assert!(!*success);
192 }
193 _ => panic!("Expected ToolResult"),
194 }
195 }
196
197 #[test]
198 fn test_complete_event_success() {
199 let event = StreamEvent::complete(true);
200 assert!(event.is_terminal());
201 assert!(event.is_success());
202 assert!(matches!(
203 event.kind,
204 StreamEventKind::Complete { success: true }
205 ));
206 }
207
208 #[test]
209 fn test_complete_event_failure() {
210 let event = StreamEvent::complete(false);
211 assert!(event.is_terminal());
212 assert!(!event.is_success());
213 assert!(matches!(
214 event.kind,
215 StreamEventKind::Complete { success: false }
216 ));
217 }
218
219 #[test]
220 fn test_error_event() {
221 let event = StreamEvent::error("Something went wrong");
222 assert!(event.is_terminal());
223 assert!(!event.is_success());
224 match &event.kind {
225 StreamEventKind::Error { message } => {
226 assert_eq!(message, "Something went wrong");
227 }
228 _ => panic!("Expected Error"),
229 }
230 }
231
232 #[test]
233 fn test_session_assigned() {
234 let event = StreamEvent::session_assigned("sess-abc123");
235 assert!(!event.is_terminal());
236 assert_eq!(event.session_id(), Some("sess-abc123"));
237 }
238
239 #[test]
240 fn test_with_timestamp() {
241 let event = StreamEvent::with_timestamp(
242 StreamEventKind::TextDelta {
243 text: "test".to_string(),
244 },
245 1234,
246 );
247 assert_eq!(event.timestamp_ms, 1234);
248 }
249
250 #[test]
251 fn test_text_accessor_none_for_non_text() {
252 let event = StreamEvent::complete(true);
253 assert_eq!(event.text(), None);
254 }
255
256 #[test]
257 fn test_session_id_accessor_none_for_non_session() {
258 let event = StreamEvent::text_delta("hello");
259 assert_eq!(event.session_id(), None);
260 }
261
262 #[test]
263 fn test_serialization_roundtrip() {
264 let events = vec![
265 StreamEvent::text_delta("Hello"),
266 StreamEvent::tool_start("Bash", "t1", "echo test"),
267 StreamEvent::tool_result("Bash", "t1", true),
268 StreamEvent::session_assigned("sess-123"),
269 StreamEvent::complete(true),
270 StreamEvent::error("failed"),
271 ];
272
273 for event in events {
274 let json = serde_json::to_string(&event).expect("serialize");
275 let parsed: StreamEvent = serde_json::from_str(&json).expect("deserialize");
276 assert_eq!(event, parsed);
277 }
278 }
279
280 #[test]
281 fn test_serde_json_format() {
282 let event = StreamEvent::with_timestamp(
283 StreamEventKind::TextDelta {
284 text: "Hello".to_string(),
285 },
286 100,
287 );
288 let json = serde_json::to_string(&event).unwrap();
289
290 assert!(json.contains("\"timestamp_ms\":100"));
292 assert!(json.contains("\"type\":\"text_delta\""));
293 assert!(json.contains("\"text\":\"Hello\""));
294 }
295
296 #[test]
297 fn test_non_terminal_events() {
298 let events = vec![
299 StreamEvent::text_delta("text"),
300 StreamEvent::tool_start("Read", "t1", "{}"),
301 StreamEvent::tool_result("Read", "t1", true),
302 StreamEvent::session_assigned("sess-1"),
303 ];
304
305 for event in events {
306 assert!(
307 !event.is_terminal(),
308 "Event should not be terminal: {:?}",
309 event
310 );
311 }
312 }
313}