use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RpcCommand {
Prompt {
#[serde(default)]
id: Option<String>,
prompt: String,
#[serde(default)]
backend: Option<String>,
#[serde(default)]
max_iterations: Option<u32>,
},
Guidance {
#[serde(default)]
id: Option<String>,
message: String,
},
Steer {
#[serde(default)]
id: Option<String>,
message: String,
},
FollowUp {
#[serde(default)]
id: Option<String>,
message: String,
},
Abort {
#[serde(default)]
id: Option<String>,
#[serde(default)]
reason: Option<String>,
},
GetState {
#[serde(default)]
id: Option<String>,
},
GetIterations {
#[serde(default)]
id: Option<String>,
#[serde(default)]
include_content: bool,
},
SetHat {
#[serde(default)]
id: Option<String>,
hat: String,
},
ExtensionUiResponse {
#[serde(default)]
id: Option<String>,
request_id: String,
response: Value,
},
}
impl RpcCommand {
pub fn id(&self) -> Option<&str> {
match self {
RpcCommand::Prompt { id, .. } => id.as_deref(),
RpcCommand::Guidance { id, .. } => id.as_deref(),
RpcCommand::Steer { id, .. } => id.as_deref(),
RpcCommand::FollowUp { id, .. } => id.as_deref(),
RpcCommand::Abort { id, .. } => id.as_deref(),
RpcCommand::GetState { id } => id.as_deref(),
RpcCommand::GetIterations { id, .. } => id.as_deref(),
RpcCommand::SetHat { id, .. } => id.as_deref(),
RpcCommand::ExtensionUiResponse { id, .. } => id.as_deref(),
}
}
pub fn command_type(&self) -> &'static str {
match self {
RpcCommand::Prompt { .. } => "prompt",
RpcCommand::Guidance { .. } => "guidance",
RpcCommand::Steer { .. } => "steer",
RpcCommand::FollowUp { .. } => "follow_up",
RpcCommand::Abort { .. } => "abort",
RpcCommand::GetState { .. } => "get_state",
RpcCommand::GetIterations { .. } => "get_iterations",
RpcCommand::SetHat { .. } => "set_hat",
RpcCommand::ExtensionUiResponse { .. } => "extension_ui_response",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RpcEvent {
LoopStarted {
prompt: String,
max_iterations: Option<u32>,
backend: String,
started_at: u64,
},
IterationStart {
iteration: u32,
max_iterations: Option<u32>,
hat: String,
hat_display: String,
backend: String,
started_at: u64,
},
IterationEnd {
iteration: u32,
duration_ms: u64,
cost_usd: f64,
input_tokens: u64,
output_tokens: u64,
cache_read_tokens: u64,
cache_write_tokens: u64,
loop_complete_triggered: bool,
},
TextDelta {
iteration: u32,
delta: String,
},
ToolCallStart {
iteration: u32,
tool_name: String,
tool_call_id: String,
input: Value,
},
ToolCallEnd {
iteration: u32,
tool_call_id: String,
output: String,
is_error: bool,
duration_ms: u64,
},
Error {
iteration: u32,
code: String,
message: String,
recoverable: bool,
},
HatChanged {
iteration: u32,
from_hat: String,
to_hat: String,
to_hat_display: String,
reason: String,
},
TaskStatusChanged {
task_id: String,
from_status: String,
to_status: String,
title: String,
},
TaskCountsUpdated {
total: usize,
open: usize,
closed: usize,
ready: usize,
},
GuidanceAck {
message: String,
applies_to: GuidanceTarget,
},
LoopTerminated {
reason: TerminationReason,
total_iterations: u32,
duration_ms: u64,
total_cost_usd: f64,
terminated_at: u64,
},
Response {
command: String,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
OrchestrationEvent {
topic: String,
payload: String,
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
target: Option<String>,
},
}
impl RpcEvent {
pub fn success_response(command: &str, id: Option<String>, data: Option<Value>) -> Self {
RpcEvent::Response {
command: command.to_string(),
id,
success: true,
data,
error: None,
}
}
pub fn error_response(command: &str, id: Option<String>, error: impl Into<String>) -> Self {
RpcEvent::Response {
command: command.to_string(),
id,
success: false,
data: None,
error: Some(error.into()),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum GuidanceTarget {
Current,
Next,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TerminationReason {
Completed,
MaxIterations,
Interrupted,
Error,
AllTasksClosed,
BackpressureLimit,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RpcState {
pub iteration: u32,
pub max_iterations: Option<u32>,
pub hat: String,
pub hat_display: String,
pub backend: String,
pub completed: bool,
pub started_at: u64,
pub iteration_started_at: Option<u64>,
pub task_counts: RpcTaskCounts,
pub active_task: Option<RpcTaskSummary>,
pub total_cost_usd: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct RpcTaskCounts {
pub total: usize,
pub open: usize,
pub closed: usize,
pub ready: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RpcTaskSummary {
pub id: String,
pub title: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RpcIterationInfo {
pub iteration: u32,
pub hat: String,
pub backend: String,
pub duration_ms: u64,
pub cost_usd: f64,
pub loop_complete_triggered: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
}
pub fn parse_command(line: &str) -> Result<RpcCommand, String> {
let trimmed = line.trim();
if trimmed.is_empty() {
return Err("empty line".to_string());
}
serde_json::from_str(trimmed).map_err(|e| format!("JSON parse error: {e}"))
}
pub fn emit_event(event: &RpcEvent) -> String {
serde_json::to_string(event).expect("RpcEvent serialization failed")
}
pub fn emit_event_line(event: &RpcEvent) -> String {
let mut line = emit_event(event);
line.push('\n');
line
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_prompt_command_roundtrip() {
let cmd = RpcCommand::Prompt {
id: Some("req-1".to_string()),
prompt: "implement feature X".to_string(),
backend: Some("claude".to_string()),
max_iterations: Some(5),
};
let json = serde_json::to_string(&cmd).unwrap();
let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
assert_eq!(cmd, parsed);
}
#[test]
fn test_guidance_command_roundtrip() {
let cmd = RpcCommand::Guidance {
id: None,
message: "focus on tests".to_string(),
};
let json = serde_json::to_string(&cmd).unwrap();
let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
assert_eq!(cmd, parsed);
}
#[test]
fn test_steer_command_roundtrip() {
let cmd = RpcCommand::Steer {
id: Some("steer-1".to_string()),
message: "use async instead".to_string(),
};
let json = serde_json::to_string(&cmd).unwrap();
let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
assert_eq!(cmd, parsed);
}
#[test]
fn test_follow_up_command_roundtrip() {
let cmd = RpcCommand::FollowUp {
id: None,
message: "now run the tests".to_string(),
};
let json = serde_json::to_string(&cmd).unwrap();
let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
assert_eq!(cmd, parsed);
}
#[test]
fn test_abort_command_roundtrip() {
let cmd = RpcCommand::Abort {
id: Some("abort-1".to_string()),
reason: Some("user cancelled".to_string()),
};
let json = serde_json::to_string(&cmd).unwrap();
let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
assert_eq!(cmd, parsed);
}
#[test]
fn test_get_state_command_roundtrip() {
let cmd = RpcCommand::GetState {
id: Some("state-1".to_string()),
};
let json = serde_json::to_string(&cmd).unwrap();
let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
assert_eq!(cmd, parsed);
}
#[test]
fn test_get_iterations_command_roundtrip() {
let cmd = RpcCommand::GetIterations {
id: Some("iters-1".to_string()),
include_content: true,
};
let json = serde_json::to_string(&cmd).unwrap();
let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
assert_eq!(cmd, parsed);
}
#[test]
fn test_set_hat_command_roundtrip() {
let cmd = RpcCommand::SetHat {
id: None,
hat: "confessor".to_string(),
};
let json = serde_json::to_string(&cmd).unwrap();
let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
assert_eq!(cmd, parsed);
}
#[test]
fn test_extension_ui_response_command_roundtrip() {
let cmd = RpcCommand::ExtensionUiResponse {
id: Some("ext-1".to_string()),
request_id: "ui-req-123".to_string(),
response: json!({"selected": "option-a"}),
};
let json = serde_json::to_string(&cmd).unwrap();
let parsed: RpcCommand = serde_json::from_str(&json).unwrap();
assert_eq!(cmd, parsed);
}
#[test]
fn test_loop_started_event_roundtrip() {
let event = RpcEvent::LoopStarted {
prompt: "test prompt".to_string(),
max_iterations: Some(10),
backend: "claude".to_string(),
started_at: 1_700_000_000_000,
};
let json = serde_json::to_string(&event).unwrap();
let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, parsed);
}
#[test]
fn test_iteration_start_event_roundtrip() {
let event = RpcEvent::IterationStart {
iteration: 3,
max_iterations: Some(10),
hat: "builder".to_string(),
hat_display: "🔨Builder".to_string(),
backend: "claude".to_string(),
started_at: 1_700_000_001_000,
};
let json = serde_json::to_string(&event).unwrap();
let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, parsed);
}
#[test]
fn test_iteration_end_event_roundtrip() {
let event = RpcEvent::IterationEnd {
iteration: 3,
duration_ms: 5432,
cost_usd: 0.0054,
input_tokens: 8000,
output_tokens: 500,
cache_read_tokens: 7500,
cache_write_tokens: 100,
loop_complete_triggered: false,
};
let json = serde_json::to_string(&event).unwrap();
let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, parsed);
}
#[test]
fn test_text_delta_event_roundtrip() {
let event = RpcEvent::TextDelta {
iteration: 2,
delta: "Hello, world!".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, parsed);
}
#[test]
fn test_tool_call_start_event_roundtrip() {
let event = RpcEvent::ToolCallStart {
iteration: 1,
tool_name: "Bash".to_string(),
tool_call_id: "toolu_123".to_string(),
input: json!({"command": "ls -la"}),
};
let json = serde_json::to_string(&event).unwrap();
let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, parsed);
}
#[test]
fn test_tool_call_end_event_roundtrip() {
let event = RpcEvent::ToolCallEnd {
iteration: 1,
tool_call_id: "toolu_123".to_string(),
output: "file1.rs\nfile2.rs".to_string(),
is_error: false,
duration_ms: 150,
};
let json = serde_json::to_string(&event).unwrap();
let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, parsed);
}
#[test]
fn test_error_event_roundtrip() {
let event = RpcEvent::Error {
iteration: 2,
code: "TIMEOUT".to_string(),
message: "API request timed out".to_string(),
recoverable: true,
};
let json = serde_json::to_string(&event).unwrap();
let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, parsed);
}
#[test]
fn test_hat_changed_event_roundtrip() {
let event = RpcEvent::HatChanged {
iteration: 4,
from_hat: "builder".to_string(),
to_hat: "confessor".to_string(),
to_hat_display: "🙏Confessor".to_string(),
reason: "build.done received".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, parsed);
}
#[test]
fn test_task_status_changed_event_roundtrip() {
let event = RpcEvent::TaskStatusChanged {
task_id: "task-123".to_string(),
from_status: "open".to_string(),
to_status: "closed".to_string(),
title: "Implement feature X".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, parsed);
}
#[test]
fn test_task_counts_updated_event_roundtrip() {
let event = RpcEvent::TaskCountsUpdated {
total: 10,
open: 3,
closed: 7,
ready: 2,
};
let json = serde_json::to_string(&event).unwrap();
let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, parsed);
}
#[test]
fn test_guidance_ack_event_roundtrip() {
let event = RpcEvent::GuidanceAck {
message: "focus on tests".to_string(),
applies_to: GuidanceTarget::Next,
};
let json = serde_json::to_string(&event).unwrap();
let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, parsed);
}
#[test]
fn test_loop_terminated_event_roundtrip() {
let event = RpcEvent::LoopTerminated {
reason: TerminationReason::Completed,
total_iterations: 5,
duration_ms: 120_000,
total_cost_usd: 0.25,
terminated_at: 1_700_000_120_000,
};
let json = serde_json::to_string(&event).unwrap();
let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, parsed);
}
#[test]
fn test_response_event_success_roundtrip() {
let event = RpcEvent::Response {
command: "get_state".to_string(),
id: Some("req-42".to_string()),
success: true,
data: Some(json!({"iteration": 3})),
error: None,
};
let json = serde_json::to_string(&event).unwrap();
let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, parsed);
}
#[test]
fn test_response_event_error_roundtrip() {
let event = RpcEvent::Response {
command: "prompt".to_string(),
id: Some("req-43".to_string()),
success: false,
data: None,
error: Some("loop already running".to_string()),
};
let json = serde_json::to_string(&event).unwrap();
let parsed: RpcEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, parsed);
}
#[test]
fn test_termination_reason_variants() {
let reasons = [
TerminationReason::Completed,
TerminationReason::MaxIterations,
TerminationReason::Interrupted,
TerminationReason::Error,
TerminationReason::AllTasksClosed,
TerminationReason::BackpressureLimit,
];
for reason in reasons {
let json = serde_json::to_string(&reason).unwrap();
let parsed: TerminationReason = serde_json::from_str(&json).unwrap();
assert_eq!(reason, parsed);
}
}
#[test]
fn test_parse_command_valid() {
let line = r#"{"type": "get_state", "id": "test-1"}"#;
let cmd = parse_command(line).unwrap();
assert!(matches!(cmd, RpcCommand::GetState { id: Some(ref i) } if i == "test-1"));
}
#[test]
fn test_parse_command_empty() {
assert!(parse_command("").is_err());
assert!(parse_command(" ").is_err());
}
#[test]
fn test_parse_command_invalid_json() {
assert!(parse_command("{not valid}").is_err());
}
#[test]
fn test_parse_command_unknown_type() {
let line = r#"{"type": "unknown_command"}"#;
assert!(parse_command(line).is_err());
}
#[test]
fn test_emit_event() {
let event = RpcEvent::TextDelta {
iteration: 1,
delta: "hello".to_string(),
};
let json = emit_event(&event);
assert!(!json.ends_with('\n'));
assert!(json.contains(r#""type":"text_delta""#));
}
#[test]
fn test_emit_event_line() {
let event = RpcEvent::TextDelta {
iteration: 1,
delta: "hello".to_string(),
};
let line = emit_event_line(&event);
assert!(line.ends_with('\n'));
assert_eq!(line.matches('\n').count(), 1);
}
#[test]
fn test_command_id() {
let cmd = RpcCommand::GetState {
id: Some("req-1".to_string()),
};
assert_eq!(cmd.id(), Some("req-1"));
let cmd = RpcCommand::Abort {
id: None,
reason: None,
};
assert_eq!(cmd.id(), None);
}
#[test]
fn test_command_type() {
assert_eq!(
RpcCommand::Prompt {
id: None,
prompt: "test".into(),
backend: None,
max_iterations: None
}
.command_type(),
"prompt"
);
assert_eq!(
RpcCommand::GetState { id: None }.command_type(),
"get_state"
);
assert_eq!(
RpcCommand::Abort {
id: None,
reason: None
}
.command_type(),
"abort"
);
}
#[test]
fn test_success_response() {
let event = RpcEvent::success_response(
"get_state",
Some("req-1".into()),
Some(json!({"ok": true})),
);
match event {
RpcEvent::Response {
command,
id,
success,
data,
error,
} => {
assert_eq!(command, "get_state");
assert_eq!(id, Some("req-1".to_string()));
assert!(success);
assert!(data.is_some());
assert!(error.is_none());
}
_ => panic!("Expected Response event"),
}
}
#[test]
fn test_error_response() {
let event = RpcEvent::error_response("prompt", None, "loop already running");
match event {
RpcEvent::Response {
command,
id,
success,
data,
error,
} => {
assert_eq!(command, "prompt");
assert!(id.is_none());
assert!(!success);
assert!(data.is_none());
assert_eq!(error, Some("loop already running".to_string()));
}
_ => panic!("Expected Response event"),
}
}
#[test]
fn test_rpc_state_roundtrip() {
let state = RpcState {
iteration: 3,
max_iterations: Some(10),
hat: "builder".to_string(),
hat_display: "🔨Builder".to_string(),
backend: "claude".to_string(),
completed: false,
started_at: 1_700_000_000_000,
iteration_started_at: Some(1_700_000_005_000),
task_counts: RpcTaskCounts {
total: 5,
open: 2,
closed: 3,
ready: 1,
},
active_task: Some(RpcTaskSummary {
id: "task-123".to_string(),
title: "Fix bug".to_string(),
status: "running".to_string(),
}),
total_cost_usd: 0.15,
};
let json = serde_json::to_string(&state).unwrap();
let parsed: RpcState = serde_json::from_str(&json).unwrap();
assert_eq!(state, parsed);
}
#[test]
fn test_rpc_iteration_info_roundtrip() {
let info = RpcIterationInfo {
iteration: 2,
hat: "builder".to_string(),
backend: "claude".to_string(),
duration_ms: 5000,
cost_usd: 0.05,
loop_complete_triggered: false,
content: Some("iteration content here".to_string()),
};
let json = serde_json::to_string(&info).unwrap();
let parsed: RpcIterationInfo = serde_json::from_str(&json).unwrap();
assert_eq!(info, parsed);
}
#[test]
fn test_pi_aligned_naming() {
let text_delta = RpcEvent::TextDelta {
iteration: 1,
delta: "test".to_string(),
};
let json = serde_json::to_string(&text_delta).unwrap();
assert!(json.contains(r#""type":"text_delta""#));
let tool_start = RpcEvent::ToolCallStart {
iteration: 1,
tool_name: "Bash".to_string(),
tool_call_id: "id".to_string(),
input: json!({}),
};
let json = serde_json::to_string(&tool_start).unwrap();
assert!(json.contains(r#""type":"tool_call_start""#));
let tool_end = RpcEvent::ToolCallEnd {
iteration: 1,
tool_call_id: "id".to_string(),
output: String::new(),
is_error: false,
duration_ms: 0,
};
let json = serde_json::to_string(&tool_end).unwrap();
assert!(json.contains(r#""type":"tool_call_end""#));
}
}