Skip to main content

agecli_skill_protocol/
message.rs

1//! Skill message types and payload structures for binary UDS protocol.
2//!
3//! Wire format: `[type: u32 BE][length: u32 BE][payload: JSON bytes]`
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Message types for skill ↔ server communication.
9///
10/// Values 1–127 are server→skill (requests), 128+ are skill→server (responses).
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12#[repr(u32)]
13pub enum SkillMessageType {
14    // Server → Skill
15    Execute = 1,
16    Cancel = 2,
17    StdinData = 3,
18    Resize = 4,
19    Signal = 5,
20    StartSession = 6,
21    Shutdown = 7,
22
23    // Skill → Server
24    Ack = 128,
25    StdoutChunk = 129,
26    StderrChunk = 130,
27    Progress = 131,
28    Completed = 132,
29    Error = 133,
30    SessionStarted = 134,
31}
32
33impl SkillMessageType {
34    pub fn from_u32(v: u32) -> Option<Self> {
35        match v {
36            1 => Some(Self::Execute),
37            2 => Some(Self::Cancel),
38            3 => Some(Self::StdinData),
39            4 => Some(Self::Resize),
40            5 => Some(Self::Signal),
41            6 => Some(Self::StartSession),
42            7 => Some(Self::Shutdown),
43            128 => Some(Self::Ack),
44            129 => Some(Self::StdoutChunk),
45            130 => Some(Self::StderrChunk),
46            131 => Some(Self::Progress),
47            132 => Some(Self::Completed),
48            133 => Some(Self::Error),
49            134 => Some(Self::SessionStarted),
50            _ => None,
51        }
52    }
53}
54
55/// A raw skill message with type and payload bytes.
56#[derive(Debug, Clone)]
57pub struct SkillMessage {
58    pub msg_type: SkillMessageType,
59    pub payload: Vec<u8>,
60}
61
62impl SkillMessage {
63    pub fn new(msg_type: SkillMessageType, payload: Vec<u8>) -> Self {
64        Self { msg_type, payload }
65    }
66
67    /// Deserialize the JSON payload into a typed struct.
68    pub fn deserialize_payload<T: for<'de> Deserialize<'de>>(&self) -> Option<T> {
69        serde_json::from_slice(&self.payload).ok()
70    }
71}
72
73// ─── Payload types ──────────────────────────────────────────────────────────
74
75/// Server→Skill: execute a command.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "PascalCase")]
78pub struct ExecutePayload {
79    pub execution_id: String,
80    pub command_name: String,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub command: Option<String>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub args: Option<Vec<String>>,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub working_directory: Option<String>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub environment: Option<HashMap<String, String>>,
89    #[serde(default)]
90    pub timeout_ms: i32,
91}
92
93/// Server→Skill: start an interactive session (PTY).
94#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(rename_all = "PascalCase")]
96pub struct StartSessionPayload {
97    pub session_id: String,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub shell: Option<String>,
100    #[serde(default = "default_rows")]
101    pub rows: i32,
102    #[serde(default = "default_cols")]
103    pub cols: i32,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub working_directory: Option<String>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub environment: Option<HashMap<String, String>>,
108}
109
110fn default_rows() -> i32 {
111    24
112}
113fn default_cols() -> i32 {
114    80
115}
116
117/// Server→Skill: stdin data for an active execution or session.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(rename_all = "PascalCase")]
120pub struct StdinDataPayload {
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub id: Option<String>,
123    #[serde(with = "base64_bytes")]
124    pub data: Vec<u8>,
125}
126
127/// Server→Skill: resize terminal.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(rename_all = "PascalCase")]
130pub struct ResizePayload {
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub session_id: Option<String>,
133    pub rows: i32,
134    pub cols: i32,
135}
136
137/// Server→Skill: send signal to process.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139#[serde(rename_all = "PascalCase")]
140pub struct SignalPayload {
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub id: Option<String>,
143    pub signal_number: i32,
144}
145
146/// Skill→Server: acknowledge an Execute/StartSession request.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(rename_all = "PascalCase")]
149pub struct AckPayload {
150    pub id: String,
151    pub accepted: bool,
152}
153
154/// Skill→Server: a chunk of stdout or stderr data.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(rename_all = "PascalCase")]
157pub struct DataChunkPayload {
158    pub id: String,
159    pub seq: u64,
160    #[serde(with = "base64_bytes")]
161    pub data: Vec<u8>,
162}
163
164/// Skill→Server: progress update.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(rename_all = "PascalCase")]
167pub struct ProgressPayload {
168    pub id: String,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub message: Option<String>,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub percent: Option<i32>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub stage: Option<String>,
175}
176
177/// Skill→Server: execution completed.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179#[serde(rename_all = "PascalCase")]
180pub struct CompletedPayload {
181    pub id: String,
182    pub exit_code: i32,
183    pub status: String,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub error: Option<String>,
186    pub finished_at_unix: i64,
187}
188
189/// Skill→Server: error response.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191#[serde(rename_all = "PascalCase")]
192pub struct ErrorPayload {
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub id: Option<String>,
195    pub code: String,
196    pub message: String,
197}
198
199/// Skill→Server: interactive session started.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[serde(rename_all = "PascalCase")]
202pub struct SessionStartedPayload {
203    pub id: String,
204    pub status: String,
205}
206
207// ─── Base64 byte serde helper ───────────────────────────────────────────────
208
209mod base64_bytes {
210    use base64::prelude::*;
211    use serde::{self, Deserialize, Deserializer, Serializer};
212
213    pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
214    where
215        S: Serializer,
216    {
217        serializer.serialize_str(&BASE64_STANDARD.encode(bytes))
218    }
219
220    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
221    where
222        D: Deserializer<'de>,
223    {
224        let s = String::deserialize(deserializer)?;
225        BASE64_STANDARD.decode(&s).map_err(serde::de::Error::custom)
226    }
227}
228
229#[cfg(test)]
230#[path = "message_tests.rs"]
231mod tests;