use crate::io::{Model, PiMessage};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ImageContent {
#[serde(rename = "type")]
pub content_type: String,
pub data: String,
#[serde(rename = "mimeType")]
pub mime_type: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StreamingBehavior {
#[serde(rename = "steer")]
Steer,
#[serde(rename = "followUp")]
FollowUp,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RpcCommand {
Prompt {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
images: Option<Vec<ImageContent>>,
#[serde(rename = "streamingBehavior", skip_serializing_if = "Option::is_none")]
streaming_behavior: Option<StreamingBehavior>,
},
Steer {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
images: Option<Vec<ImageContent>>,
},
FollowUp {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
images: Option<Vec<ImageContent>>,
},
Abort {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
ClearQueue {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
NewSession {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
GetState {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
GetMessages {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
SetModel {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
provider: String,
#[serde(rename = "modelId")]
model_id: String,
},
CycleModel {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
GetAvailableModels {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
SetThinkingLevel {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
level: String,
},
CycleThinkingLevel {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
GetAvailableThinkingLevels {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
SetSteeringMode {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
mode: String,
},
SetFollowUpMode {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
mode: String,
},
Compact {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
instructions: Option<String>,
},
SetAutoCompaction {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
enabled: bool,
},
SetAutoRetry {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
enabled: bool,
},
AbortRetry {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
Bash {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
command: String,
},
AbortBash {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
GetSessionStats {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
ExportHtml {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
},
SetSessionName {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
name: String,
},
#[serde(untagged)]
Raw(Value),
}
impl RpcCommand {
pub fn to_line(&self) -> Result<String, serde_json::Error> {
Ok(format!("{}\n", serde_json::to_string(self)?))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RpcResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub command: String,
#[serde(default)]
pub success: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl RpcResponse {
pub fn disposition(&self) -> Option<InputDisposition> {
self.data
.as_ref()?
.get("disposition")?
.as_str()
.map(InputDisposition::from)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum InputDisposition {
Handled,
Queued,
Started,
Unknown(String),
}
impl InputDisposition {
pub fn as_str(&self) -> &str {
match self {
Self::Handled => "handled",
Self::Queued => "queued",
Self::Started => "started",
Self::Unknown(s) => s.as_str(),
}
}
}
impl std::fmt::Display for InputDisposition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl From<&str> for InputDisposition {
fn from(s: &str) -> Self {
match s {
"handled" => Self::Handled,
"queued" => Self::Queued,
"started" => Self::Started,
other => Self::Unknown(other.to_string()),
}
}
}
impl Serialize for InputDisposition {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for InputDisposition {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(Self::from(s.as_str()))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct AgentState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<Model>,
#[serde(rename = "thinkingLevel", default)]
pub thinking_level: String,
#[serde(rename = "isStreaming", default)]
pub is_streaming: bool,
#[serde(rename = "isCompacting", default)]
pub is_compacting: bool,
#[serde(rename = "steeringMode", default)]
pub steering_mode: String,
#[serde(rename = "followUpMode", default)]
pub follow_up_mode: String,
#[serde(
rename = "sessionFile",
default,
skip_serializing_if = "Option::is_none"
)]
pub session_file: Option<String>,
#[serde(rename = "sessionId", default)]
pub session_id: String,
#[serde(
rename = "sessionName",
default,
skip_serializing_if = "Option::is_none"
)]
pub session_name: Option<String>,
#[serde(rename = "autoCompactionEnabled", default)]
pub auto_compaction_enabled: bool,
#[serde(rename = "messageCount", default)]
pub message_count: i64,
#[serde(rename = "pendingMessageCount", default)]
pub pending_message_count: i64,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct BashResult {
#[serde(default)]
pub output: String,
#[serde(rename = "exitCode", default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i64>,
#[serde(default)]
pub cancelled: bool,
#[serde(default)]
pub truncated: bool,
#[serde(
rename = "fullOutputPath",
default,
skip_serializing_if = "Option::is_none"
)]
pub full_output_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Messages {
#[serde(default)]
pub messages: Vec<PiMessage>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn commands_serialize_with_snake_case_type_tags() {
let c = RpcCommand::GetState {
id: Some("r1".into()),
};
let v = serde_json::to_value(&c).unwrap();
assert_eq!(v["type"], "get_state");
assert_eq!(v["id"], "r1");
let c = RpcCommand::Prompt {
id: None,
message: "hi".into(),
images: None,
streaming_behavior: Some(StreamingBehavior::Steer),
};
let v = serde_json::to_value(&c).unwrap();
assert_eq!(v["type"], "prompt");
assert_eq!(v["streamingBehavior"], "steer");
assert!(v.get("images").is_none());
}
#[test]
fn response_envelope_parses_success_and_error() {
let r: RpcResponse = serde_json::from_str(
r#"{"id":"r1","type":"response","command":"get_state","success":true,"data":{}}"#,
)
.unwrap();
assert!(r.success);
assert_eq!(r.command, "get_state");
let r: RpcResponse = serde_json::from_str(
r#"{"type":"response","command":"set_model","success":false,"error":"Model not found: invalid/model"}"#,
)
.unwrap();
assert!(!r.success);
assert!(r.error.unwrap().contains("Model not found"));
}
#[test]
fn input_disposition_reads_from_response_data() {
for (command, wire, expected) in [
("prompt", "started", InputDisposition::Started),
("prompt", "handled", InputDisposition::Handled),
("steer", "queued", InputDisposition::Queued),
(
"follow_up",
"deferred",
InputDisposition::Unknown("deferred".into()),
),
] {
let r: RpcResponse = serde_json::from_value(serde_json::json!({
"id": "r1", "type": "response", "command": command,
"success": true, "data": {"disposition": wire}
}))
.unwrap();
let got = r.disposition().expect("disposition present");
assert_eq!(got, expected);
assert_eq!(got.to_string(), wire);
assert_eq!(serde_json::to_value(&got).unwrap(), wire);
}
let legacy: RpcResponse = serde_json::from_str(
r#"{"id":"r1","type":"response","command":"prompt","success":true}"#,
)
.unwrap();
assert_eq!(legacy.disposition(), None);
}
#[test]
fn agent_state_parses_credential_free_placeholder() {
let s: AgentState = serde_json::from_value(serde_json::json!({
"model": {"id":"unknown","name":"unknown","api":"unknown","provider":"unknown",
"baseUrl":"","reasoning":false,"input":[],
"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},
"contextWindow":0,"maxTokens":0},
"thinkingLevel":"off","isStreaming":false,"isCompacting":false,
"steeringMode":"one-at-a-time","followUpMode":"one-at-a-time",
"sessionId":"01a05ec0-4e0e-7dd4-a225-2e78cccd0059",
"autoCompactionEnabled":true,"messageCount":0,"pendingMessageCount":0
}))
.unwrap();
assert_eq!(s.model.unwrap().context_window, 0);
assert_eq!(s.message_count, 0);
}
#[test]
fn raw_command_passes_through_unmodeled_types() {
let c = RpcCommand::Raw(serde_json::json!({"type": "get_tree", "id": "r9"}));
let line = c.to_line().unwrap();
assert!(line.ends_with('\n'));
assert!(line.contains("\"get_tree\""));
}
}