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    // Proxy (server↔skill submission relay)
33    ProxySubmit = 200,
34    ProxyCancel = 201,
35    ProxyStdoutChunk = 202,
36    ProxyStderrChunk = 203,
37    ProxyCompleted = 204,
38    ProxyRejected = 205,
39}
40
41impl SkillMessageType {
42    pub fn from_u32(v: u32) -> Option<Self> {
43        match v {
44            1 => Some(Self::Execute),
45            2 => Some(Self::Cancel),
46            3 => Some(Self::StdinData),
47            4 => Some(Self::Resize),
48            5 => Some(Self::Signal),
49            6 => Some(Self::StartSession),
50            7 => Some(Self::Shutdown),
51            128 => Some(Self::Ack),
52            129 => Some(Self::StdoutChunk),
53            130 => Some(Self::StderrChunk),
54            131 => Some(Self::Progress),
55            132 => Some(Self::Completed),
56            133 => Some(Self::Error),
57            134 => Some(Self::SessionStarted),
58            200 => Some(Self::ProxySubmit),
59            201 => Some(Self::ProxyCancel),
60            202 => Some(Self::ProxyStdoutChunk),
61            203 => Some(Self::ProxyStderrChunk),
62            204 => Some(Self::ProxyCompleted),
63            205 => Some(Self::ProxyRejected),
64            _ => None,
65        }
66    }
67}
68
69/// A raw skill message with type and payload bytes.
70#[derive(Debug, Clone)]
71pub struct SkillMessage {
72    pub msg_type: SkillMessageType,
73    pub payload: Vec<u8>,
74}
75
76impl SkillMessage {
77    pub fn new(msg_type: SkillMessageType, payload: Vec<u8>) -> Self {
78        Self { msg_type, payload }
79    }
80
81    /// Deserialize the JSON payload into a typed struct.
82    pub fn deserialize_payload<T: for<'de> Deserialize<'de>>(&self) -> Option<T> {
83        serde_json::from_slice(&self.payload).ok()
84    }
85}
86
87// ─── Payload types ──────────────────────────────────────────────────────────
88
89/// Server→Skill: execute a command.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(rename_all = "PascalCase")]
92pub struct ExecutePayload {
93    pub execution_id: String,
94    pub command_name: String,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub command: Option<String>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub args: Option<Vec<String>>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub working_directory: Option<String>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub environment: Option<HashMap<String, String>>,
103    #[serde(default)]
104    pub timeout_ms: i32,
105}
106
107/// Server→Skill: start an interactive session (PTY).
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(rename_all = "PascalCase")]
110pub struct StartSessionPayload {
111    pub session_id: String,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub shell: Option<String>,
114    #[serde(default = "default_rows")]
115    pub rows: i32,
116    #[serde(default = "default_cols")]
117    pub cols: i32,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub working_directory: Option<String>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub environment: Option<HashMap<String, String>>,
122}
123
124fn default_rows() -> i32 {
125    24
126}
127fn default_cols() -> i32 {
128    80
129}
130
131/// Server→Skill: stdin data for an active execution or session.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(rename_all = "PascalCase")]
134pub struct StdinDataPayload {
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub id: Option<String>,
137    #[serde(with = "base64_bytes")]
138    pub data: Vec<u8>,
139}
140
141/// Server→Skill: resize terminal.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(rename_all = "PascalCase")]
144pub struct ResizePayload {
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub session_id: Option<String>,
147    pub rows: i32,
148    pub cols: i32,
149}
150
151/// Server→Skill: send signal to process.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(rename_all = "PascalCase")]
154pub struct SignalPayload {
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub id: Option<String>,
157    pub signal_number: i32,
158}
159
160/// Skill→Server: acknowledge an Execute/StartSession request.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[serde(rename_all = "PascalCase")]
163pub struct AckPayload {
164    pub id: String,
165    pub accepted: bool,
166}
167
168/// Skill→Server: a chunk of stdout or stderr data.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(rename_all = "PascalCase")]
171pub struct DataChunkPayload {
172    pub id: String,
173    pub seq: u64,
174    #[serde(with = "base64_bytes")]
175    pub data: Vec<u8>,
176}
177
178/// Skill→Server: progress update.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(rename_all = "PascalCase")]
181pub struct ProgressPayload {
182    pub id: String,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub message: Option<String>,
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub percent: Option<i32>,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub stage: Option<String>,
189}
190
191/// Skill→Server: execution completed.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(rename_all = "PascalCase")]
194pub struct CompletedPayload {
195    pub id: String,
196    pub exit_code: i32,
197    pub status: String,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub error: Option<String>,
200    pub finished_at_unix: i64,
201}
202
203/// Skill→Server: error response.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(rename_all = "PascalCase")]
206pub struct ErrorPayload {
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub id: Option<String>,
209    pub code: String,
210    pub message: String,
211}
212
213/// Skill→Server: interactive session started.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215#[serde(rename_all = "PascalCase")]
216pub struct SessionStartedPayload {
217    pub id: String,
218    pub status: String,
219}
220
221// ─── Proxy payload types ────────────────────────────────────────────────────
222
223/// Target of a proxied submission.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(rename_all = "PascalCase")]
226pub struct ProxyTarget {
227    pub kind: String,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub agent_id: Option<String>,
230}
231
232/// Server↔Skill: submit a command via the proxy relay.
233#[derive(Debug, Clone, Serialize, Deserialize)]
234#[serde(rename_all = "PascalCase")]
235pub struct ProxySubmitPayload {
236    pub proxy_id: String,
237    pub external_id: String,
238    pub command_name: String,
239    pub args: Vec<String>,
240    pub target: ProxyTarget,
241}
242
243/// Server↔Skill: a proxied submission was rejected.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245#[serde(rename_all = "PascalCase")]
246pub struct ProxyRejectedPayload {
247    pub proxy_id: String,
248    pub reason_code: String,
249    pub message: String,
250}
251
252/// Host→Skill: a chunk of proxied stdout (202) or stderr (203) output.
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(rename_all = "PascalCase")]
255pub struct ProxyChunkPayload {
256    pub proxy_id: String,
257    pub seq: u64,
258    #[serde(with = "base64_bytes")]
259    pub data: Vec<u8>,
260}
261
262/// Host→Skill: a proxied submission completed (204).
263#[derive(Debug, Clone, Serialize, Deserialize)]
264#[serde(rename_all = "PascalCase")]
265pub struct ProxyCompletedPayload {
266    pub proxy_id: String,
267    pub exit_code: i32,
268    pub status: String,
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub error: Option<String>,
271    pub finished_at_unix: i64,
272}
273
274// ─── Base64 byte serde helper ───────────────────────────────────────────────
275mod base64_bytes {
276    use base64::prelude::*;
277    use serde::{self, Deserialize, Deserializer, Serializer};
278
279    pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
280    where
281        S: Serializer,
282    {
283        serializer.serialize_str(&BASE64_STANDARD.encode(bytes))
284    }
285
286    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
287    where
288        D: Deserializer<'de>,
289    {
290        let s = String::deserialize(deserializer)?;
291        BASE64_STANDARD.decode(&s).map_err(serde::de::Error::custom)
292    }
293}
294
295#[cfg(test)]
296#[path = "message_tests.rs"]
297mod tests;