Skip to main content

uira_core/protocol/
events.rs

1//! Event types for streaming and JSONL output
2
3use crate::TokenUsage;
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6
7/// Top-level event for JSONL streaming
8#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(tag = "type", rename_all = "snake_case")]
10#[non_exhaustive]
11pub enum ThreadEvent {
12    /// Thread has started
13    ThreadStarted { thread_id: String },
14
15    /// A new turn has started
16    TurnStarted { turn_number: usize },
17
18    /// A turn has completed
19    TurnCompleted {
20        turn_number: usize,
21        usage: TokenUsage,
22    },
23
24    /// An item has started processing
25    ItemStarted { item: Item },
26
27    /// An item has completed
28    ItemCompleted { item: Item },
29
30    /// Content is being streamed
31    ContentDelta { delta: String },
32
33    /// Thinking/reasoning content is being streamed
34    ThinkingDelta { thinking: String },
35
36    /// Waiting for user input
37    WaitingForInput { prompt: String },
38
39    /// Error occurred
40    Error { message: String, recoverable: bool },
41
42    /// Thread has completed
43    ThreadCompleted { usage: TokenUsage },
44
45    /// Thread was cancelled
46    ThreadCancelled,
47
48    // Goal Verification Events
49    /// Goal verification has started
50    GoalVerificationStarted { goals: Vec<String>, method: String },
51    /// Result of a single goal verification
52    GoalVerificationResult {
53        goal: String,
54        score: f64,
55        target: f64,
56        passed: bool,
57        duration_ms: u64,
58    },
59    /// All goal verifications completed
60    GoalVerificationCompleted {
61        all_passed: bool,
62        passed_count: usize,
63        total_count: usize,
64    },
65
66    // Ralph Mode Events
67    /// Ralph iteration has started
68    RalphIterationStarted {
69        iteration: u32,
70        max_iterations: u32,
71        prompt: String,
72    },
73    /// Ralph is continuing (verification failed)
74    RalphContinuation {
75        reason: String,
76        confidence: u32,
77        details: String,
78    },
79    /// Ralph circuit breaker tripped
80    RalphCircuitBreak { reason: String, iteration: u32 },
81
82    // Background Task Events
83    /// Background task has been spawned
84    BackgroundTaskSpawned {
85        task_id: String,
86        description: String,
87        agent: String,
88    },
89    /// Background task progress update
90    BackgroundTaskProgress {
91        task_id: String,
92        status: String,
93        message: Option<String>,
94    },
95    /// Background task has completed
96    BackgroundTaskCompleted {
97        task_id: String,
98        success: bool,
99        result_preview: Option<String>,
100        duration_secs: f64,
101    },
102
103    /// Subagent has been started
104    SubagentStarted {
105        task_id: String,
106        agent_name: String,
107        model: String,
108        session_id: String,
109    },
110    /// Subagent has completed
111    SubagentCompleted {
112        task_id: String,
113        session_id: String,
114        success: bool,
115        duration_secs: f64,
116    },
117
118    /// Model was switched at runtime
119    ModelSwitched { model: String, provider: String },
120
121    // Permission/Approval/Compaction Events
122    /// Permission was evaluated for a tool
123    PermissionEvaluated {
124        permission: String,
125        path: String,
126        action: String,
127        rule_matched: Option<String>,
128    },
129    /// Approval decision was cached
130    ApprovalCached {
131        tool_name: String,
132        pattern: String,
133        decision: String,
134    },
135    /// Context compaction started
136    CompactionStarted {
137        strategy: String,
138        token_count_before: usize,
139    },
140    /// Context compaction completed
141    CompactionCompleted {
142        token_count_before: usize,
143        token_count_after: usize,
144        messages_removed: usize,
145    },
146
147    /// Todo list was updated
148    TodoUpdated { todos: Vec<crate::TodoItem> },
149}
150
151/// Item types that can be processed
152#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(tag = "type", rename_all = "snake_case")]
154pub enum Item {
155    /// Agent is thinking/reasoning
156    Thinking {
157        #[serde(skip_serializing_if = "Option::is_none")]
158        content: Option<String>,
159    },
160
161    /// Agent message (text response)
162    AgentMessage {
163        content: String,
164        #[serde(skip_serializing_if = "Option::is_none")]
165        name: Option<String>,
166    },
167
168    /// Tool is being called
169    ToolCall {
170        id: String,
171        name: String,
172        input: serde_json::Value,
173    },
174
175    /// Tool has returned a result
176    ToolResult {
177        tool_call_id: String,
178        output: String,
179        is_error: bool,
180    },
181
182    /// Command execution (bash, etc.)
183    CommandExecution {
184        command: String,
185        exit_code: i32,
186        stdout: String,
187        stderr: String,
188    },
189
190    /// File change
191    FileChange {
192        path: PathBuf,
193        change_type: FileChangeType,
194        #[serde(skip_serializing_if = "Option::is_none")]
195        patch: Option<String>,
196    },
197
198    /// Approval request
199    ApprovalRequest {
200        id: String,
201        tool_name: String,
202        input: serde_json::Value,
203        reason: String,
204    },
205
206    /// Approval decision
207    ApprovalDecision { request_id: String, approved: bool },
208
209    /// MCP tool call
210    McpToolCall {
211        server: String,
212        tool: String,
213        result: serde_json::Value,
214    },
215}
216
217/// Type of file change
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum FileChangeType {
221    Create,
222    Modify,
223    Delete,
224    Rename,
225}
226
227/// Agent state for status reporting
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(rename_all = "snake_case")]
230pub enum AgentState {
231    Idle,
232    WaitingForUser,
233    Thinking,
234    ExecutingTool,
235    WaitingForApproval,
236    Complete,
237    Failed,
238    Cancelled,
239}
240
241impl std::fmt::Display for AgentState {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        match self {
244            Self::Idle => write!(f, "idle"),
245            Self::WaitingForUser => write!(f, "waiting for user"),
246            Self::Thinking => write!(f, "thinking"),
247            Self::ExecutingTool => write!(f, "executing tool"),
248            Self::WaitingForApproval => write!(f, "waiting for approval"),
249            Self::Complete => write!(f, "complete"),
250            Self::Failed => write!(f, "failed"),
251            Self::Cancelled => write!(f, "cancelled"),
252        }
253    }
254}
255
256/// Error types that can occur during agent execution
257#[derive(Debug, Clone, thiserror::Error, Serialize, Deserialize)]
258#[serde(tag = "type", rename_all = "snake_case")]
259pub enum AgentError {
260    #[error("tool error: {tool} - {message}")]
261    ToolError { tool: String, message: String },
262
263    #[error("cancelled by user")]
264    Cancelled,
265
266    #[error("max turns exceeded: {turns}")]
267    MaxTurnsExceeded { turns: usize },
268}
269
270impl AgentError {
271    pub fn is_recoverable(&self) -> bool {
272        matches!(self, Self::ToolError { .. })
273    }
274}
275
276/// Progress update during agent execution
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct Progress {
279    pub state: AgentState,
280    pub turn: usize,
281    pub message: Option<String>,
282    pub tool_name: Option<String>,
283    pub usage: TokenUsage,
284}
285
286/// Execution result
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct ExecutionResult {
289    pub success: bool,
290    pub output: String,
291    pub turns: usize,
292    pub usage: TokenUsage,
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub error: Option<AgentError>,
295}
296
297impl ExecutionResult {
298    pub fn success(output: impl Into<String>, turns: usize, usage: TokenUsage) -> Self {
299        Self {
300            success: true,
301            output: output.into(),
302            turns,
303            usage,
304            error: None,
305        }
306    }
307
308    pub fn failure(error: AgentError, turns: usize, usage: TokenUsage) -> Self {
309        Self {
310            success: false,
311            output: String::new(),
312            turns,
313            usage,
314            error: Some(error),
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_thread_event_serialization() {
325        let event = ThreadEvent::ThreadStarted {
326            thread_id: "thread_123".to_string(),
327        };
328        let json = serde_json::to_string(&event).unwrap();
329        assert!(json.contains("\"type\":\"thread_started\""));
330    }
331
332    #[test]
333    fn test_item_serialization() {
334        let item = Item::AgentMessage {
335            content: "Hello!".to_string(),
336            name: None,
337        };
338        let json = serde_json::to_string(&item).unwrap();
339        assert!(json.contains("\"type\":\"agent_message\""));
340    }
341
342    #[test]
343    fn test_agent_error_recoverable() {
344        assert!(AgentError::ToolError {
345            tool: "bash".to_string(),
346            message: "command failed".to_string(),
347        }
348        .is_recoverable());
349
350        assert!(!AgentError::Cancelled.is_recoverable());
351    }
352
353    #[test]
354    fn test_execution_result() {
355        let result = ExecutionResult::success("Done!", 5, TokenUsage::default());
356        assert!(result.success);
357        assert!(result.error.is_none());
358
359        let failure = ExecutionResult::failure(AgentError::Cancelled, 3, TokenUsage::default());
360        assert!(!failure.success);
361        assert!(failure.error.is_some());
362    }
363
364    #[test]
365    fn test_new_event_serialization() {
366        // Goal verification events
367        let event = ThreadEvent::GoalVerificationStarted {
368            goals: vec!["test-coverage".to_string(), "lint".to_string()],
369            method: "auto".to_string(),
370        };
371        let json = serde_json::to_string(&event).unwrap();
372        assert!(json.contains("\"type\":\"goal_verification_started\""));
373        let parsed: ThreadEvent = serde_json::from_str(&json).unwrap();
374        match parsed {
375            ThreadEvent::GoalVerificationStarted { goals, method } => {
376                assert_eq!(goals.len(), 2);
377                assert_eq!(method, "auto");
378            }
379            _ => panic!("Wrong variant"),
380        }
381
382        let event = ThreadEvent::GoalVerificationResult {
383            goal: "test-coverage".to_string(),
384            score: 85.5,
385            target: 80.0,
386            passed: true,
387            duration_ms: 1234,
388        };
389        let json = serde_json::to_string(&event).unwrap();
390        assert!(json.contains("\"type\":\"goal_verification_result\""));
391
392        let event = ThreadEvent::GoalVerificationCompleted {
393            all_passed: true,
394            passed_count: 3,
395            total_count: 3,
396        };
397        let json = serde_json::to_string(&event).unwrap();
398        assert!(json.contains("\"type\":\"goal_verification_completed\""));
399
400        // Ralph mode events
401        let event = ThreadEvent::RalphIterationStarted {
402            iteration: 1,
403            max_iterations: 10,
404            prompt: "Fix all tests".to_string(),
405        };
406        let json = serde_json::to_string(&event).unwrap();
407        assert!(json.contains("\"type\":\"ralph_iteration_started\""));
408
409        let event = ThreadEvent::RalphContinuation {
410            reason: "verification_failed".to_string(),
411            confidence: 45,
412            details: "2 tests still failing".to_string(),
413        };
414        let json = serde_json::to_string(&event).unwrap();
415        assert!(json.contains("\"type\":\"ralph_continuation\""));
416
417        let event = ThreadEvent::RalphCircuitBreak {
418            reason: "stagnation".to_string(),
419            iteration: 5,
420        };
421        let json = serde_json::to_string(&event).unwrap();
422        assert!(json.contains("\"type\":\"ralph_circuit_break\""));
423
424        // Background task events
425        let event = ThreadEvent::BackgroundTaskSpawned {
426            task_id: "bg_123".to_string(),
427            description: "Running tests".to_string(),
428            agent: "executor".to_string(),
429        };
430        let json = serde_json::to_string(&event).unwrap();
431        assert!(json.contains("\"type\":\"background_task_spawned\""));
432
433        let event = ThreadEvent::BackgroundTaskProgress {
434            task_id: "bg_123".to_string(),
435            status: "running".to_string(),
436            message: Some("50% complete".to_string()),
437        };
438        let json = serde_json::to_string(&event).unwrap();
439        assert!(json.contains("\"type\":\"background_task_progress\""));
440
441        let event = ThreadEvent::BackgroundTaskCompleted {
442            task_id: "bg_123".to_string(),
443            success: true,
444            result_preview: Some("All tests passed".to_string()),
445            duration_secs: 12.5,
446        };
447        let json = serde_json::to_string(&event).unwrap();
448        assert!(json.contains("\"type\":\"background_task_completed\""));
449    }
450}