Skip to main content

ralph_proto/
json_rpc.rs

1//! JSON-RPC protocol types for Ralph's stdin/stdout communication.
2//!
3//! This module defines the wire format for Ralph's JSON-lines protocol,
4//! enabling IPC between the orchestration loop and frontends (TUI, IDE
5//! integrations, custom UIs). The protocol follows pi's `--mode rpc`
6//! conventions but is tailored for Ralph's multi-hat, iteration-based model.
7//!
8//! ## Protocol Overview
9//!
10//! - **Transport**: Newline-delimited JSON over stdin (commands) and stdout (events)
11//! - **Commands**: Sent to Ralph via stdin to control the loop
12//! - **Events**: Emitted by Ralph via stdout to report state changes
13//! - **Responses**: Command replies with success/failure and optional data
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18// ============================================================================
19// Commands (stdin → Ralph)
20// ============================================================================
21
22/// Commands sent to Ralph via stdin.
23///
24/// Each command is a single JSON line. Commands with an `id` field receive
25/// correlated responses.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
27#[serde(tag = "type", rename_all = "snake_case")]
28pub enum RpcCommand {
29    /// Start the loop with a prompt (must be sent before loop starts).
30    Prompt {
31        #[serde(default)]
32        id: Option<String>,
33        /// The prompt text to execute.
34        prompt: String,
35        /// Optional backend override.
36        #[serde(default)]
37        backend: Option<String>,
38        /// Optional max iterations override.
39        #[serde(default)]
40        max_iterations: Option<u32>,
41    },
42
43    /// Inject guidance that affects the current or next iteration.
44    /// Equivalent to the TUI's guidance input.
45    Guidance {
46        #[serde(default)]
47        id: Option<String>,
48        /// The guidance message to inject.
49        message: String,
50    },
51
52    /// Steer the agent immediately during the current iteration.
53    /// The guidance is injected into the running context.
54    Steer {
55        #[serde(default)]
56        id: Option<String>,
57        /// The steering message.
58        message: String,
59    },
60
61    /// Queue a follow-up message for the next iteration.
62    FollowUp {
63        #[serde(default)]
64        id: Option<String>,
65        /// The follow-up message.
66        message: String,
67    },
68
69    /// Request immediate termination of the loop.
70    Abort {
71        #[serde(default)]
72        id: Option<String>,
73        /// Optional reason for the abort.
74        #[serde(default)]
75        reason: Option<String>,
76    },
77
78    /// Request the current loop state snapshot.
79    GetState {
80        #[serde(default)]
81        id: Option<String>,
82    },
83
84    /// Request iteration history and metadata.
85    GetIterations {
86        #[serde(default)]
87        id: Option<String>,
88        /// If true, include full iteration content. Default: false (metadata only).
89        #[serde(default)]
90        include_content: bool,
91    },
92
93    /// Force a hat change for the next iteration.
94    SetHat {
95        #[serde(default)]
96        id: Option<String>,
97        /// The hat ID to switch to.
98        hat: String,
99    },
100
101    /// Response to an extension UI prompt (future support).
102    ExtensionUiResponse {
103        #[serde(default)]
104        id: Option<String>,
105        /// The extension request ID being responded to.
106        request_id: String,
107        /// The user's response data.
108        response: Value,
109    },
110}
111
112impl RpcCommand {
113    /// Returns the command's correlation ID if present.
114    pub fn id(&self) -> Option<&str> {
115        match self {
116            RpcCommand::Prompt { id, .. } => id.as_deref(),
117            RpcCommand::Guidance { id, .. } => id.as_deref(),
118            RpcCommand::Steer { id, .. } => id.as_deref(),
119            RpcCommand::FollowUp { id, .. } => id.as_deref(),
120            RpcCommand::Abort { id, .. } => id.as_deref(),
121            RpcCommand::GetState { id } => id.as_deref(),
122            RpcCommand::GetIterations { id, .. } => id.as_deref(),
123            RpcCommand::SetHat { id, .. } => id.as_deref(),
124            RpcCommand::ExtensionUiResponse { id, .. } => id.as_deref(),
125        }
126    }
127
128    /// Returns the command type name (for response correlation).
129    pub fn command_type(&self) -> &'static str {
130        match self {
131            RpcCommand::Prompt { .. } => "prompt",
132            RpcCommand::Guidance { .. } => "guidance",
133            RpcCommand::Steer { .. } => "steer",
134            RpcCommand::FollowUp { .. } => "follow_up",
135            RpcCommand::Abort { .. } => "abort",
136            RpcCommand::GetState { .. } => "get_state",
137            RpcCommand::GetIterations { .. } => "get_iterations",
138            RpcCommand::SetHat { .. } => "set_hat",
139            RpcCommand::ExtensionUiResponse { .. } => "extension_ui_response",
140        }
141    }
142}
143
144// ============================================================================
145// Events (Ralph → stdout)
146// ============================================================================
147
148/// Events emitted by Ralph via stdout.
149///
150/// Each event is a single JSON line. Events are emitted in real-time as the
151/// orchestration loop progresses.
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
153#[serde(tag = "type", rename_all = "snake_case")]
154pub enum RpcEvent {
155    /// The orchestration loop has started.
156    LoopStarted {
157        /// The prompt being executed.
158        prompt: String,
159        /// Maximum iterations configured.
160        max_iterations: Option<u32>,
161        /// Backend being used.
162        backend: String,
163        /// Unix timestamp (milliseconds) when the loop started.
164        started_at: u64,
165    },
166
167    /// A new iteration is beginning.
168    IterationStart {
169        /// Iteration number (1-indexed).
170        iteration: u32,
171        /// Maximum iterations configured.
172        max_iterations: Option<u32>,
173        /// The hat being worn for this iteration.
174        hat: String,
175        /// Hat display name (with emoji).
176        hat_display: String,
177        /// Backend being used.
178        backend: String,
179        /// Unix timestamp (milliseconds).
180        started_at: u64,
181    },
182
183    /// An iteration has completed.
184    IterationEnd {
185        /// Iteration number.
186        iteration: u32,
187        /// Duration in milliseconds.
188        duration_ms: u64,
189        /// Estimated cost in USD.
190        cost_usd: f64,
191        /// Input tokens used.
192        input_tokens: u64,
193        /// Output tokens used.
194        output_tokens: u64,
195        /// Cache read tokens.
196        cache_read_tokens: u64,
197        /// Cache write tokens.
198        cache_write_tokens: u64,
199        /// Whether this iteration triggered LOOP_COMPLETE.
200        loop_complete_triggered: bool,
201    },
202
203    /// Streaming text delta from the agent.
204    TextDelta {
205        /// Iteration number.
206        iteration: u32,
207        /// The text chunk.
208        delta: String,
209    },
210
211    /// A tool invocation is starting.
212    ToolCallStart {
213        /// Iteration number.
214        iteration: u32,
215        /// Tool name (e.g., "Bash", "Read", "Grep").
216        tool_name: String,
217        /// Unique tool call ID.
218        tool_call_id: String,
219        /// Tool input parameters.
220        input: Value,
221    },
222
223    /// A tool invocation has completed.
224    ToolCallEnd {
225        /// Iteration number.
226        iteration: u32,
227        /// Tool call ID (matches ToolCallStart).
228        tool_call_id: String,
229        /// Tool output (may be truncated for large outputs).
230        output: String,
231        /// Whether this was an error result.
232        is_error: bool,
233        /// Duration in milliseconds.
234        duration_ms: u64,
235    },
236
237    /// An error occurred during execution.
238    Error {
239        /// Iteration number (0 if loop-level error).
240        iteration: u32,
241        /// Error code (e.g., "TIMEOUT", "API_ERROR", "PARSE_ERROR").
242        code: String,
243        /// Human-readable error message.
244        message: String,
245        /// Whether the error is recoverable.
246        recoverable: bool,
247    },
248
249    /// The hat has changed.
250    HatChanged {
251        /// Iteration number where the change takes effect.
252        iteration: u32,
253        /// Previous hat ID.
254        from_hat: String,
255        /// New hat ID.
256        to_hat: String,
257        /// New hat display name.
258        to_hat_display: String,
259        /// Reason for the change.
260        reason: String,
261    },
262
263    /// Task status has changed.
264    TaskStatusChanged {
265        /// Task ID.
266        task_id: String,
267        /// Previous status.
268        from_status: String,
269        /// New status.
270        to_status: String,
271        /// Task title.
272        title: String,
273    },
274
275    /// Current task counts have been updated.
276    TaskCountsUpdated {
277        /// Total number of tasks.
278        total: usize,
279        /// Number of open tasks.
280        open: usize,
281        /// Number of closed tasks.
282        closed: usize,
283        /// Number of ready (unblocked) tasks.
284        ready: usize,
285    },
286
287    /// Acknowledgment that guidance was received.
288    GuidanceAck {
289        /// The guidance message that was received.
290        message: String,
291        /// Whether the guidance will be applied to the current or next iteration.
292        applies_to: GuidanceTarget,
293    },
294
295    /// The orchestration loop has terminated.
296    LoopTerminated {
297        /// Reason for termination.
298        reason: TerminationReason,
299        /// Total iterations completed.
300        total_iterations: u32,
301        /// Total duration in milliseconds.
302        duration_ms: u64,
303        /// Total estimated cost in USD.
304        total_cost_usd: f64,
305        /// Unix timestamp (milliseconds).
306        terminated_at: u64,
307    },
308
309    /// Response to a command.
310    Response {
311        /// The command type this responds to.
312        command: String,
313        /// Correlation ID from the command (if provided).
314        #[serde(skip_serializing_if = "Option::is_none")]
315        id: Option<String>,
316        /// Whether the command succeeded.
317        success: bool,
318        /// Response data (command-specific).
319        #[serde(skip_serializing_if = "Option::is_none")]
320        data: Option<Value>,
321        /// Error message if success is false.
322        #[serde(skip_serializing_if = "Option::is_none")]
323        error: Option<String>,
324    },
325
326    /// A generic orchestration event from the EventBus.
327    /// Maps ralph_proto::Event topics to RPC for observability.
328    OrchestrationEvent {
329        /// Event topic (e.g., "build.task", "build.done", "loop.terminate").
330        topic: String,
331        /// Event payload.
332        payload: String,
333        /// Source hat ID (if any).
334        #[serde(skip_serializing_if = "Option::is_none")]
335        source: Option<String>,
336        /// Target hat ID (if any).
337        #[serde(skip_serializing_if = "Option::is_none")]
338        target: Option<String>,
339    },
340
341    /// A wave of parallel workers has started.
342    WaveStarted {
343        /// Hat display name.
344        hat_name: String,
345        /// Number of parallel workers.
346        worker_count: u32,
347        /// Timeout per worker in seconds.
348        timeout_secs: u64,
349    },
350
351    /// A wave worker has completed.
352    WaveWorkerDone {
353        /// Zero-based worker index.
354        index: u32,
355        /// Total workers in this wave.
356        total: u32,
357        /// Duration in milliseconds.
358        duration_ms: u64,
359        /// Whether the worker succeeded.
360        success: bool,
361        /// Preview of the worker's payload/role.
362        payload_preview: String,
363    },
364
365    /// Streaming text delta from a wave worker.
366    WaveWorkerTextDelta {
367        /// Zero-based worker index.
368        worker_index: u32,
369        /// The text chunk.
370        delta: String,
371    },
372
373    /// All wave workers have completed.
374    WaveCompleted {
375        /// Number of successful workers.
376        succeeded: usize,
377        /// Number of failed workers.
378        failed: usize,
379        /// Total duration in milliseconds.
380        duration_ms: u64,
381    },
382}
383
384impl RpcEvent {
385    /// Creates a successful response event.
386    pub fn success_response(command: &str, id: Option<String>, data: Option<Value>) -> Self {
387        RpcEvent::Response {
388            command: command.to_string(),
389            id,
390            success: true,
391            data,
392            error: None,
393        }
394    }
395
396    /// Creates a failed response event.
397    pub fn error_response(command: &str, id: Option<String>, error: impl Into<String>) -> Self {
398        RpcEvent::Response {
399            command: command.to_string(),
400            id,
401            success: false,
402            data: None,
403            error: Some(error.into()),
404        }
405    }
406}
407
408// ============================================================================
409// Supporting types
410// ============================================================================
411
412/// Target for guidance application.
413#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
414#[serde(rename_all = "snake_case")]
415pub enum GuidanceTarget {
416    /// Guidance will be applied to the current iteration (steer).
417    Current,
418    /// Guidance will be applied to the next iteration (follow-up).
419    Next,
420}
421
422/// Reason for loop termination.
423#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
424#[serde(rename_all = "snake_case")]
425pub enum TerminationReason {
426    /// Loop completed successfully (LOOP_COMPLETE detected).
427    Completed,
428    /// Maximum iterations reached.
429    MaxIterations,
430    /// User requested abort.
431    Interrupted,
432    /// An unrecoverable error occurred.
433    Error,
434    /// All tasks completed.
435    AllTasksClosed,
436    /// Backpressure blocked too many times.
437    BackpressureLimit,
438}
439
440/// Snapshot of loop state (returned by get_state command).
441#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
442pub struct RpcState {
443    /// Current iteration number.
444    pub iteration: u32,
445    /// Maximum iterations configured.
446    pub max_iterations: Option<u32>,
447    /// Current hat ID.
448    pub hat: String,
449    /// Current hat display name.
450    pub hat_display: String,
451    /// Backend being used.
452    pub backend: String,
453    /// Whether the loop has completed.
454    pub completed: bool,
455    /// Loop start time (Unix ms).
456    pub started_at: u64,
457    /// Current iteration start time (Unix ms).
458    pub iteration_started_at: Option<u64>,
459    /// Task counts.
460    pub task_counts: RpcTaskCounts,
461    /// Active task (if any).
462    pub active_task: Option<RpcTaskSummary>,
463    /// Total cost so far.
464    pub total_cost_usd: f64,
465}
466
467/// Task counts for RPC state.
468#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
469pub struct RpcTaskCounts {
470    pub total: usize,
471    pub open: usize,
472    pub closed: usize,
473    pub ready: usize,
474}
475
476/// Task summary for RPC state.
477#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
478pub struct RpcTaskSummary {
479    pub id: String,
480    pub title: String,
481    pub status: String,
482}
483
484/// Iteration metadata (returned by get_iterations command).
485#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
486pub struct RpcIterationInfo {
487    /// Iteration number.
488    pub iteration: u32,
489    /// Hat used for this iteration.
490    pub hat: String,
491    /// Backend used.
492    pub backend: String,
493    /// Duration in milliseconds.
494    pub duration_ms: u64,
495    /// Cost in USD.
496    pub cost_usd: f64,
497    /// Whether LOOP_COMPLETE was triggered.
498    pub loop_complete_triggered: bool,
499    /// Full content (only if requested).
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub content: Option<String>,
502}
503
504// ============================================================================
505// Parsing and serialization helpers
506// ============================================================================
507
508/// Parses a JSON line into an RpcCommand.
509///
510/// Returns an error with a descriptive message if parsing fails.
511pub fn parse_command(line: &str) -> Result<RpcCommand, String> {
512    let trimmed = line.trim();
513    if trimmed.is_empty() {
514        return Err("empty line".to_string());
515    }
516    serde_json::from_str(trimmed).map_err(|e| format!("JSON parse error: {e}"))
517}
518
519/// Serializes an RpcEvent to a JSON line (no trailing newline).
520pub fn emit_event(event: &RpcEvent) -> String {
521    // Unwrap is safe: RpcEvent is always serializable
522    serde_json::to_string(event).expect("RpcEvent serialization failed")
523}
524
525/// Serializes an RpcEvent to a JSON line with trailing newline.
526pub fn emit_event_line(event: &RpcEvent) -> String {
527    let mut line = emit_event(event);
528    line.push('\n');
529    line
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use serde_json::json;
536
537    // =========================================================================
538    // Command round-trip tests
539    // =========================================================================
540
541    #[test]
542    fn test_prompt_command_roundtrip() {
543        let cmd = RpcCommand::Prompt {
544            id: Some("req-1".to_string()),
545            prompt: "implement feature X".to_string(),
546            backend: Some("claude".to_string()),
547            max_iterations: Some(5),
548        };
549        let json = serde_json::to_string(&cmd).unwrap();
550        let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
551        assert_eq!(cmd, parsed);
552    }
553
554    #[test]
555    fn test_guidance_command_roundtrip() {
556        let cmd = RpcCommand::Guidance {
557            id: None,
558            message: "focus on tests".to_string(),
559        };
560        let json = serde_json::to_string(&cmd).unwrap();
561        let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
562        assert_eq!(cmd, parsed);
563    }
564
565    #[test]
566    fn test_steer_command_roundtrip() {
567        let cmd = RpcCommand::Steer {
568            id: Some("steer-1".to_string()),
569            message: "use async instead".to_string(),
570        };
571        let json = serde_json::to_string(&cmd).unwrap();
572        let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
573        assert_eq!(cmd, parsed);
574    }
575
576    #[test]
577    fn test_follow_up_command_roundtrip() {
578        let cmd = RpcCommand::FollowUp {
579            id: None,
580            message: "now run the tests".to_string(),
581        };
582        let json = serde_json::to_string(&cmd).unwrap();
583        let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
584        assert_eq!(cmd, parsed);
585    }
586
587    #[test]
588    fn test_abort_command_roundtrip() {
589        let cmd = RpcCommand::Abort {
590            id: Some("abort-1".to_string()),
591            reason: Some("user cancelled".to_string()),
592        };
593        let json = serde_json::to_string(&cmd).unwrap();
594        let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
595        assert_eq!(cmd, parsed);
596    }
597
598    #[test]
599    fn test_get_state_command_roundtrip() {
600        let cmd = RpcCommand::GetState {
601            id: Some("state-1".to_string()),
602        };
603        let json = serde_json::to_string(&cmd).unwrap();
604        let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
605        assert_eq!(cmd, parsed);
606    }
607
608    #[test]
609    fn test_get_iterations_command_roundtrip() {
610        let cmd = RpcCommand::GetIterations {
611            id: Some("iters-1".to_string()),
612            include_content: true,
613        };
614        let json = serde_json::to_string(&cmd).unwrap();
615        let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
616        assert_eq!(cmd, parsed);
617    }
618
619    #[test]
620    fn test_set_hat_command_roundtrip() {
621        let cmd = RpcCommand::SetHat {
622            id: None,
623            hat: "confessor".to_string(),
624        };
625        let json = serde_json::to_string(&cmd).unwrap();
626        let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
627        assert_eq!(cmd, parsed);
628    }
629
630    #[test]
631    fn test_extension_ui_response_command_roundtrip() {
632        let cmd = RpcCommand::ExtensionUiResponse {
633            id: Some("ext-1".to_string()),
634            request_id: "ui-req-123".to_string(),
635            response: json!({"selected": "option-a"}),
636        };
637        let json = serde_json::to_string(&cmd).unwrap();
638        let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
639        assert_eq!(cmd, parsed);
640    }
641
642    // =========================================================================
643    // Event round-trip tests
644    // =========================================================================
645
646    #[test]
647    fn test_loop_started_event_roundtrip() {
648        let event = RpcEvent::LoopStarted {
649            prompt: "test prompt".to_string(),
650            max_iterations: Some(10),
651            backend: "claude".to_string(),
652            started_at: 1_700_000_000_000,
653        };
654        let json = serde_json::to_string(&event).unwrap();
655        let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
656        assert_eq!(event, parsed);
657    }
658
659    #[test]
660    fn test_iteration_start_event_roundtrip() {
661        let event = RpcEvent::IterationStart {
662            iteration: 3,
663            max_iterations: Some(10),
664            hat: "builder".to_string(),
665            hat_display: "🔨Builder".to_string(),
666            backend: "claude".to_string(),
667            started_at: 1_700_000_001_000,
668        };
669        let json = serde_json::to_string(&event).unwrap();
670        let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
671        assert_eq!(event, parsed);
672    }
673
674    #[test]
675    fn test_iteration_end_event_roundtrip() {
676        let event = RpcEvent::IterationEnd {
677            iteration: 3,
678            duration_ms: 5432,
679            cost_usd: 0.0054,
680            input_tokens: 8000,
681            output_tokens: 500,
682            cache_read_tokens: 7500,
683            cache_write_tokens: 100,
684            loop_complete_triggered: false,
685        };
686        let json = serde_json::to_string(&event).unwrap();
687        let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
688        assert_eq!(event, parsed);
689    }
690
691    #[test]
692    fn test_text_delta_event_roundtrip() {
693        let event = RpcEvent::TextDelta {
694            iteration: 2,
695            delta: "Hello, world!".to_string(),
696        };
697        let json = serde_json::to_string(&event).unwrap();
698        let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
699        assert_eq!(event, parsed);
700    }
701
702    #[test]
703    fn test_tool_call_start_event_roundtrip() {
704        let event = RpcEvent::ToolCallStart {
705            iteration: 1,
706            tool_name: "Bash".to_string(),
707            tool_call_id: "toolu_123".to_string(),
708            input: json!({"command": "ls -la"}),
709        };
710        let json = serde_json::to_string(&event).unwrap();
711        let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
712        assert_eq!(event, parsed);
713    }
714
715    #[test]
716    fn test_tool_call_end_event_roundtrip() {
717        let event = RpcEvent::ToolCallEnd {
718            iteration: 1,
719            tool_call_id: "toolu_123".to_string(),
720            output: "file1.rs\nfile2.rs".to_string(),
721            is_error: false,
722            duration_ms: 150,
723        };
724        let json = serde_json::to_string(&event).unwrap();
725        let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
726        assert_eq!(event, parsed);
727    }
728
729    #[test]
730    fn test_error_event_roundtrip() {
731        let event = RpcEvent::Error {
732            iteration: 2,
733            code: "TIMEOUT".to_string(),
734            message: "API request timed out".to_string(),
735            recoverable: true,
736        };
737        let json = serde_json::to_string(&event).unwrap();
738        let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
739        assert_eq!(event, parsed);
740    }
741
742    #[test]
743    fn test_hat_changed_event_roundtrip() {
744        let event = RpcEvent::HatChanged {
745            iteration: 4,
746            from_hat: "builder".to_string(),
747            to_hat: "confessor".to_string(),
748            to_hat_display: "🙏Confessor".to_string(),
749            reason: "build.done received".to_string(),
750        };
751        let json = serde_json::to_string(&event).unwrap();
752        let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
753        assert_eq!(event, parsed);
754    }
755
756    #[test]
757    fn test_task_status_changed_event_roundtrip() {
758        let event = RpcEvent::TaskStatusChanged {
759            task_id: "task-123".to_string(),
760            from_status: "open".to_string(),
761            to_status: "closed".to_string(),
762            title: "Implement feature X".to_string(),
763        };
764        let json = serde_json::to_string(&event).unwrap();
765        let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
766        assert_eq!(event, parsed);
767    }
768
769    #[test]
770    fn test_task_counts_updated_event_roundtrip() {
771        let event = RpcEvent::TaskCountsUpdated {
772            total: 10,
773            open: 3,
774            closed: 7,
775            ready: 2,
776        };
777        let json = serde_json::to_string(&event).unwrap();
778        let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
779        assert_eq!(event, parsed);
780    }
781
782    #[test]
783    fn test_guidance_ack_event_roundtrip() {
784        let event = RpcEvent::GuidanceAck {
785            message: "focus on tests".to_string(),
786            applies_to: GuidanceTarget::Next,
787        };
788        let json = serde_json::to_string(&event).unwrap();
789        let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
790        assert_eq!(event, parsed);
791    }
792
793    #[test]
794    fn test_loop_terminated_event_roundtrip() {
795        let event = RpcEvent::LoopTerminated {
796            reason: TerminationReason::Completed,
797            total_iterations: 5,
798            duration_ms: 120_000,
799            total_cost_usd: 0.25,
800            terminated_at: 1_700_000_120_000,
801        };
802        let json = serde_json::to_string(&event).unwrap();
803        let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
804        assert_eq!(event, parsed);
805    }
806
807    #[test]
808    fn test_response_event_success_roundtrip() {
809        let event = RpcEvent::Response {
810            command: "get_state".to_string(),
811            id: Some("req-42".to_string()),
812            success: true,
813            data: Some(json!({"iteration": 3})),
814            error: None,
815        };
816        let json = serde_json::to_string(&event).unwrap();
817        let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
818        assert_eq!(event, parsed);
819    }
820
821    #[test]
822    fn test_response_event_error_roundtrip() {
823        let event = RpcEvent::Response {
824            command: "prompt".to_string(),
825            id: Some("req-43".to_string()),
826            success: false,
827            data: None,
828            error: Some("loop already running".to_string()),
829        };
830        let json = serde_json::to_string(&event).unwrap();
831        let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
832        assert_eq!(event, parsed);
833    }
834
835    // =========================================================================
836    // Termination reason tests
837    // =========================================================================
838
839    #[test]
840    fn test_termination_reason_variants() {
841        let reasons = [
842            TerminationReason::Completed,
843            TerminationReason::MaxIterations,
844            TerminationReason::Interrupted,
845            TerminationReason::Error,
846            TerminationReason::AllTasksClosed,
847            TerminationReason::BackpressureLimit,
848        ];
849        for reason in reasons {
850            let json = serde_json::to_string(&reason).unwrap();
851            let parsed: TerminationReason = serde_json::from_str(&json).unwrap();
852            assert_eq!(reason, parsed);
853        }
854    }
855
856    // =========================================================================
857    // Parsing helper tests
858    // =========================================================================
859
860    #[test]
861    fn test_parse_command_valid() {
862        let line = r#"{"type": "get_state", "id": "test-1"}"#;
863        let cmd = parse_command(line).unwrap();
864        assert!(matches!(cmd, RpcCommand::GetState { id: Some(ref i) } if i == "test-1"));
865    }
866
867    #[test]
868    fn test_parse_command_empty() {
869        assert!(parse_command("").is_err());
870        assert!(parse_command("   ").is_err());
871    }
872
873    #[test]
874    fn test_parse_command_invalid_json() {
875        assert!(parse_command("{not valid}").is_err());
876    }
877
878    #[test]
879    fn test_parse_command_unknown_type() {
880        let line = r#"{"type": "unknown_command"}"#;
881        assert!(parse_command(line).is_err());
882    }
883
884    #[test]
885    fn test_emit_event() {
886        let event = RpcEvent::TextDelta {
887            iteration: 1,
888            delta: "hello".to_string(),
889        };
890        let json = emit_event(&event);
891        assert!(!json.ends_with('\n'));
892        assert!(json.contains(r#""type":"text_delta""#));
893    }
894
895    #[test]
896    fn test_emit_event_line() {
897        let event = RpcEvent::TextDelta {
898            iteration: 1,
899            delta: "hello".to_string(),
900        };
901        let line = emit_event_line(&event);
902        assert!(line.ends_with('\n'));
903        assert_eq!(line.matches('\n').count(), 1);
904    }
905
906    // =========================================================================
907    // Helper method tests
908    // =========================================================================
909
910    #[test]
911    fn test_command_id() {
912        let cmd = RpcCommand::GetState {
913            id: Some("req-1".to_string()),
914        };
915        assert_eq!(cmd.id(), Some("req-1"));
916
917        let cmd = RpcCommand::Abort {
918            id: None,
919            reason: None,
920        };
921        assert_eq!(cmd.id(), None);
922    }
923
924    #[test]
925    fn test_command_type() {
926        assert_eq!(
927            RpcCommand::Prompt {
928                id: None,
929                prompt: "test".into(),
930                backend: None,
931                max_iterations: None
932            }
933            .command_type(),
934            "prompt"
935        );
936        assert_eq!(
937            RpcCommand::GetState { id: None }.command_type(),
938            "get_state"
939        );
940        assert_eq!(
941            RpcCommand::Abort {
942                id: None,
943                reason: None
944            }
945            .command_type(),
946            "abort"
947        );
948    }
949
950    #[test]
951    fn test_success_response() {
952        let event = RpcEvent::success_response(
953            "get_state",
954            Some("req-1".into()),
955            Some(json!({"ok": true})),
956        );
957        match event {
958            RpcEvent::Response {
959                command,
960                id,
961                success,
962                data,
963                error,
964            } => {
965                assert_eq!(command, "get_state");
966                assert_eq!(id, Some("req-1".to_string()));
967                assert!(success);
968                assert!(data.is_some());
969                assert!(error.is_none());
970            }
971            _ => panic!("Expected Response event"),
972        }
973    }
974
975    #[test]
976    fn test_error_response() {
977        let event = RpcEvent::error_response("prompt", None, "loop already running");
978        match event {
979            RpcEvent::Response {
980                command,
981                id,
982                success,
983                data,
984                error,
985            } => {
986                assert_eq!(command, "prompt");
987                assert!(id.is_none());
988                assert!(!success);
989                assert!(data.is_none());
990                assert_eq!(error, Some("loop already running".to_string()));
991            }
992            _ => panic!("Expected Response event"),
993        }
994    }
995
996    // =========================================================================
997    // State types tests
998    // =========================================================================
999
1000    #[test]
1001    fn test_rpc_state_roundtrip() {
1002        let state = RpcState {
1003            iteration: 3,
1004            max_iterations: Some(10),
1005            hat: "builder".to_string(),
1006            hat_display: "🔨Builder".to_string(),
1007            backend: "claude".to_string(),
1008            completed: false,
1009            started_at: 1_700_000_000_000,
1010            iteration_started_at: Some(1_700_000_005_000),
1011            task_counts: RpcTaskCounts {
1012                total: 5,
1013                open: 2,
1014                closed: 3,
1015                ready: 1,
1016            },
1017            active_task: Some(RpcTaskSummary {
1018                id: "task-123".to_string(),
1019                title: "Fix bug".to_string(),
1020                status: "running".to_string(),
1021            }),
1022            total_cost_usd: 0.15,
1023        };
1024        let json = serde_json::to_string(&state).unwrap();
1025        let parsed: RpcState = serde_json::from_str(&json).unwrap();
1026        assert_eq!(state, parsed);
1027    }
1028
1029    #[test]
1030    fn test_rpc_iteration_info_roundtrip() {
1031        let info = RpcIterationInfo {
1032            iteration: 2,
1033            hat: "builder".to_string(),
1034            backend: "claude".to_string(),
1035            duration_ms: 5000,
1036            cost_usd: 0.05,
1037            loop_complete_triggered: false,
1038            content: Some("iteration content here".to_string()),
1039        };
1040        let json = serde_json::to_string(&info).unwrap();
1041        let parsed: RpcIterationInfo = serde_json::from_str(&json).unwrap();
1042        assert_eq!(info, parsed);
1043    }
1044
1045    // =========================================================================
1046    // Pi protocol alignment tests (naming conventions)
1047    // =========================================================================
1048
1049    #[test]
1050    fn test_pi_aligned_naming() {
1051        // Verify our event types follow pi conventions
1052        let text_delta = RpcEvent::TextDelta {
1053            iteration: 1,
1054            delta: "test".to_string(),
1055        };
1056        let json = serde_json::to_string(&text_delta).unwrap();
1057        assert!(json.contains(r#""type":"text_delta""#));
1058
1059        let tool_start = RpcEvent::ToolCallStart {
1060            iteration: 1,
1061            tool_name: "Bash".to_string(),
1062            tool_call_id: "id".to_string(),
1063            input: json!({}),
1064        };
1065        let json = serde_json::to_string(&tool_start).unwrap();
1066        assert!(json.contains(r#""type":"tool_call_start""#));
1067
1068        let tool_end = RpcEvent::ToolCallEnd {
1069            iteration: 1,
1070            tool_call_id: "id".to_string(),
1071            output: String::new(),
1072            is_error: false,
1073            duration_ms: 0,
1074        };
1075        let json = serde_json::to_string(&tool_end).unwrap();
1076        assert!(json.contains(r#""type":"tool_call_end""#));
1077    }
1078}