Skip to main content

scud/commands/spawn/headless/
events.rs

1//! Streaming event types for headless execution
2//!
3//! Defines the event types used to capture and transmit streaming
4//! output from headless agent execution.
5
6use serde::{Deserialize, Serialize};
7
8/// A streaming event from an agent
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct StreamEvent {
11    /// Timestamp in milliseconds since session start
12    pub timestamp_ms: u64,
13    /// The event kind
14    pub kind: StreamEventKind,
15}
16
17/// Types of streaming events
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19#[serde(tag = "type", rename_all = "snake_case")]
20pub enum StreamEventKind {
21    /// Text output delta
22    TextDelta { text: String },
23
24    /// Tool execution started
25    ToolStart {
26        tool_name: String,
27        tool_id: String,
28        input_summary: String,
29    },
30
31    /// Tool execution completed
32    ToolResult {
33        tool_name: String,
34        tool_id: String,
35        success: bool,
36    },
37
38    /// Agent completed successfully
39    Complete { success: bool },
40
41    /// Agent encountered an error
42    Error { message: String },
43
44    /// Session ID assigned (for continuation)
45    SessionAssigned { session_id: String },
46}
47
48impl StreamEvent {
49    /// Create a new event with the given kind
50    ///
51    /// The timestamp will be set to 0 and should be updated by the store
52    /// when the event is added.
53    pub fn new(kind: StreamEventKind) -> Self {
54        Self {
55            timestamp_ms: 0,
56            kind,
57        }
58    }
59
60    /// Create a new event with an explicit timestamp
61    pub fn with_timestamp(kind: StreamEventKind, timestamp_ms: u64) -> Self {
62        Self { timestamp_ms, kind }
63    }
64
65    /// Create a text delta event
66    pub fn text_delta(text: impl Into<String>) -> Self {
67        Self::new(StreamEventKind::TextDelta { text: text.into() })
68    }
69
70    /// Create a tool start event
71    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    /// Create a tool result event
80    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    /// Create a completion event
89    pub fn complete(success: bool) -> Self {
90        Self::new(StreamEventKind::Complete { success })
91    }
92
93    /// Create an error event
94    pub fn error(message: impl Into<String>) -> Self {
95        Self::new(StreamEventKind::Error {
96            message: message.into(),
97        })
98    }
99
100    /// Create a session assigned event
101    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    /// Check if this event indicates completion (success or failure)
108    pub fn is_terminal(&self) -> bool {
109        matches!(
110            self.kind,
111            StreamEventKind::Complete { .. } | StreamEventKind::Error { .. }
112        )
113    }
114
115    /// Check if this event indicates successful completion
116    pub fn is_success(&self) -> bool {
117        matches!(self.kind, StreamEventKind::Complete { success: true })
118    }
119
120    /// Get the text content if this is a text delta event
121    pub fn text(&self) -> Option<&str> {
122        match &self.kind {
123            StreamEventKind::TextDelta { text } => Some(text),
124            _ => None,
125        }
126    }
127
128    /// Get the session ID if this is a session assigned event
129    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        // Verify the JSON structure matches expected format
291        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}