Skip to main content

algocline_app/pool/
protocol.rs

1use std::path::PathBuf;
2
3use algocline_core::TokenUsage;
4use serde::{Deserialize, Serialize};
5
6// ─── Request ─────────────────────────────────────────────────────────────────
7
8/// A message sent from the MCP process (pool client) to a pool worker.
9///
10/// Serialised as a single JSON line with an `"op"` discriminant field.
11///
12/// ```json
13/// {"op":"handshake","version":"0.31.0"}
14/// {"op":"run","code":"return 1","ctx":null,"lib_paths":[]}
15/// {"op":"continue","sid":"abc","response":"yes","query_id":"q1","usage":null}
16/// {"op":"status"}
17/// {"op":"shutdown"}
18/// ```
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(tag = "op", rename_all = "snake_case")]
21pub enum PoolRequest {
22    /// Version negotiation; must be the first message sent.
23    Handshake {
24        /// Crate version of the client (e.g. `env!("CARGO_PKG_VERSION")`).
25        version: String,
26    },
27
28    /// Start a new Lua execution session.
29    Run {
30        /// Lua source code to execute.
31        code: String,
32        /// Optional JSON context passed as `alc.ctx`.
33        ctx: Option<serde_json::Value>,
34        /// Additional Lua library search paths.
35        lib_paths: Vec<PathBuf>,
36    },
37
38    /// Resume a paused session with the LLM response.
39    Continue {
40        /// Session ID originally returned in the `Run` response.
41        sid: String,
42        /// LLM response text.
43        response: String,
44        /// The specific query being answered (if multi-query).
45        query_id: Option<String>,
46        /// Token usage reported by the host alongside this response.
47        usage: Option<TokenUsage>,
48    },
49
50    /// Query the worker's current health/state (read-only).
51    ///
52    /// When `include_history` is `true`, the worker enriches the response with
53    /// the `conversation_history` of the active session (≤10 entries). Old
54    /// clients that send `{"op":"status"}` without the field deserialise as
55    /// `include_history: false` (`#[serde(default)]`), preserving wire
56    /// backward compatibility.
57    Status {
58        #[serde(default)]
59        include_history: bool,
60    },
61
62    /// Ask the worker to finish gracefully and exit.
63    Shutdown,
64}
65
66// ─── Response ────────────────────────────────────────────────────────────────
67
68/// Data payload carried by a successful [`PoolResponse`].
69///
70/// `FeedResult`-equivalent payloads are represented as `serde_json::Value` so
71/// that the `pool` protocol module does not introduce a hard dependency on the
72/// `algocline-engine` crate's concrete type.  Subtask 5 (AppService dispatch)
73/// will deserialize the value into the appropriate engine type at the call site.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(tag = "kind", rename_all = "snake_case")]
76pub enum PoolResponseData {
77    /// Reply to a `Handshake` request.
78    Handshake {
79        /// Crate version of the worker process.
80        version: String,
81    },
82
83    /// Reply to a `Run` or `Continue` request.
84    Feed {
85        /// The session ID assigned or continued by the worker.
86        session_id: String,
87        /// JSON-serialised `FeedResult` (Accepted / Paused / Finished).
88        feed_result: serde_json::Value,
89    },
90
91    /// Reply to a `Status` request.
92    Status {
93        /// Whether the worker currently has an active session.
94        has_session: bool,
95        /// Session ID of the active session, if any.
96        session_id: Option<String>,
97        /// `conversation_history` for the active session when the request set
98        /// `include_history: true`. Capped at 10 entries by the engine.
99        /// Absent when `include_history` was false or no active session exists.
100        #[serde(default, skip_serializing_if = "Option::is_none")]
101        conversation_history: Option<serde_json::Value>,
102    },
103
104    /// Reply to a `Shutdown` request (worker will exit after sending this).
105    Shutdown,
106}
107
108/// A message sent from a pool worker back to the pool client.
109///
110/// Serialised as a single JSON line:
111///
112/// ```json
113/// {"ok":true,"data":{"kind":"handshake","version":"0.31.0"}}
114/// {"ok":false,"error":"worker handshake failed: version mismatch"}
115/// ```
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct PoolResponse {
118    /// `true` if the operation succeeded.
119    pub ok: bool,
120    /// Present when `ok` is `true`.
121    pub data: Option<PoolResponseData>,
122    /// Present when `ok` is `false`.
123    pub error: Option<String>,
124}
125
126impl PoolResponse {
127    /// Construct a successful response.
128    pub fn success(data: PoolResponseData) -> Self {
129        Self {
130            ok: true,
131            data: Some(data),
132            error: None,
133        }
134    }
135
136    /// Construct an error response.
137    pub fn failure(error: impl Into<String>) -> Self {
138        Self {
139            ok: false,
140            data: None,
141            error: Some(error.into()),
142        }
143    }
144}
145
146// ─── Tests ───────────────────────────────────────────────────────────────────
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    /// Verify that a `Handshake` request round-trips through JSON without loss.
153    ///
154    /// The JSON line format must preserve the `"op"` discriminant so the worker
155    /// can dispatch on it without additional framing.
156    #[test]
157    fn handshake_request_roundtrip() {
158        let req = PoolRequest::Handshake {
159            version: "0.31.0".to_string(),
160        };
161
162        let json = serde_json::to_string(&req).expect("serialize");
163        // Must be a single line (no embedded newlines).
164        assert!(!json.contains('\n'), "JSON line must not contain newlines");
165        // Must carry the op discriminant.
166        assert!(json.contains("\"op\":\"handshake\""), "op field missing");
167        // Must carry the version field.
168        assert!(
169            json.contains("\"version\":\"0.31.0\""),
170            "version field missing"
171        );
172
173        let decoded: PoolRequest = serde_json::from_str(&json).expect("deserialize");
174        match decoded {
175            PoolRequest::Handshake { version } => {
176                assert_eq!(version, "0.31.0");
177            }
178            other => panic!("unexpected variant: {other:?}"),
179        }
180    }
181
182    /// Verify that a successful `PoolResponse` round-trips through JSON.
183    #[test]
184    fn response_success_roundtrip() {
185        let resp = PoolResponse::success(PoolResponseData::Handshake {
186            version: "0.31.0".to_string(),
187        });
188
189        let json = serde_json::to_string(&resp).expect("serialize");
190        assert!(json.contains("\"ok\":true"), "ok flag missing");
191
192        let decoded: PoolResponse = serde_json::from_str(&json).expect("deserialize");
193        assert!(decoded.ok);
194        assert!(decoded.error.is_none());
195        match decoded.data {
196            Some(PoolResponseData::Handshake { version }) => {
197                assert_eq!(version, "0.31.0");
198            }
199            other => panic!("unexpected data: {other:?}"),
200        }
201    }
202
203    /// Backward compat: legacy clients send `{"op":"status"}` without the
204    /// `include_history` field. `#[serde(default)]` must accept that and
205    /// deserialise as `include_history: false`.
206    #[test]
207    fn status_request_legacy_wire_default_false() {
208        let legacy_json = r#"{"op":"status"}"#;
209        let decoded: PoolRequest = serde_json::from_str(legacy_json).expect("deserialize legacy");
210        match decoded {
211            PoolRequest::Status { include_history } => {
212                assert!(
213                    !include_history,
214                    "legacy {{op:status}} must default include_history=false"
215                );
216            }
217            other => panic!("unexpected variant: {other:?}"),
218        }
219    }
220
221    /// Status request with `include_history: true` round-trips correctly.
222    #[test]
223    fn status_request_with_history_roundtrip() {
224        let req = PoolRequest::Status {
225            include_history: true,
226        };
227        let json = serde_json::to_string(&req).expect("serialize");
228        assert!(json.contains("\"op\":\"status\""), "op missing");
229        assert!(
230            json.contains("\"include_history\":true"),
231            "include_history missing"
232        );
233        let decoded: PoolRequest = serde_json::from_str(&json).expect("deserialize");
234        match decoded {
235            PoolRequest::Status { include_history } => {
236                assert!(include_history, "round-trip must preserve true");
237            }
238            other => panic!("unexpected variant: {other:?}"),
239        }
240    }
241
242    /// Status response with `conversation_history` round-trips and
243    /// `skip_serializing_if = "Option::is_none"` omits the field when absent.
244    #[test]
245    fn status_response_history_roundtrip_and_skip() {
246        // Present case
247        let with_history = PoolResponse::success(PoolResponseData::Status {
248            has_session: true,
249            session_id: Some("sid-1".to_string()),
250            conversation_history: Some(serde_json::json!([{"role":"user","content":"hi"}])),
251        });
252        let json = serde_json::to_string(&with_history).expect("serialize");
253        assert!(
254            json.contains("\"conversation_history\""),
255            "conversation_history must be present when Some"
256        );
257        let decoded: PoolResponse = serde_json::from_str(&json).expect("deserialize");
258        match decoded.data {
259            Some(PoolResponseData::Status {
260                conversation_history: Some(_),
261                ..
262            }) => {}
263            other => panic!("expected Status with history, got {other:?}"),
264        }
265
266        // Absent case: skip_serializing_if must omit the field on the wire
267        let without_history = PoolResponse::success(PoolResponseData::Status {
268            has_session: false,
269            session_id: None,
270            conversation_history: None,
271        });
272        let json = serde_json::to_string(&without_history).expect("serialize");
273        assert!(
274            !json.contains("\"conversation_history\""),
275            "conversation_history must be omitted when None"
276        );
277        let decoded: PoolResponse = serde_json::from_str(&json).expect("deserialize");
278        match decoded.data {
279            Some(PoolResponseData::Status {
280                conversation_history: None,
281                ..
282            }) => {}
283            other => panic!("expected Status with no history, got {other:?}"),
284        }
285    }
286
287    /// Verify that an error `PoolResponse` round-trips through JSON.
288    #[test]
289    fn response_failure_roundtrip() {
290        let resp = PoolResponse::failure("version mismatch");
291
292        let json = serde_json::to_string(&resp).expect("serialize");
293        assert!(json.contains("\"ok\":false"), "ok flag missing");
294
295        let decoded: PoolResponse = serde_json::from_str(&json).expect("deserialize");
296        assert!(!decoded.ok);
297        assert!(decoded.data.is_none());
298        assert_eq!(decoded.error.as_deref(), Some("version mismatch"));
299    }
300}