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    Status,
52
53    /// Ask the worker to finish gracefully and exit.
54    Shutdown,
55}
56
57// ─── Response ────────────────────────────────────────────────────────────────
58
59/// Data payload carried by a successful [`PoolResponse`].
60///
61/// `FeedResult`-equivalent payloads are represented as `serde_json::Value` so
62/// that the `pool` protocol module does not introduce a hard dependency on the
63/// `algocline-engine` crate's concrete type.  Subtask 5 (AppService dispatch)
64/// will deserialize the value into the appropriate engine type at the call site.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(tag = "kind", rename_all = "snake_case")]
67pub enum PoolResponseData {
68    /// Reply to a `Handshake` request.
69    Handshake {
70        /// Crate version of the worker process.
71        version: String,
72    },
73
74    /// Reply to a `Run` or `Continue` request.
75    Feed {
76        /// The session ID assigned or continued by the worker.
77        session_id: String,
78        /// JSON-serialised `FeedResult` (Accepted / Paused / Finished).
79        feed_result: serde_json::Value,
80    },
81
82    /// Reply to a `Status` request.
83    Status {
84        /// Whether the worker currently has an active session.
85        has_session: bool,
86        /// Session ID of the active session, if any.
87        session_id: Option<String>,
88    },
89
90    /// Reply to a `Shutdown` request (worker will exit after sending this).
91    Shutdown,
92}
93
94/// A message sent from a pool worker back to the pool client.
95///
96/// Serialised as a single JSON line:
97///
98/// ```json
99/// {"ok":true,"data":{"kind":"handshake","version":"0.31.0"}}
100/// {"ok":false,"error":"worker handshake failed: version mismatch"}
101/// ```
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct PoolResponse {
104    /// `true` if the operation succeeded.
105    pub ok: bool,
106    /// Present when `ok` is `true`.
107    pub data: Option<PoolResponseData>,
108    /// Present when `ok` is `false`.
109    pub error: Option<String>,
110}
111
112impl PoolResponse {
113    /// Construct a successful response.
114    pub fn success(data: PoolResponseData) -> Self {
115        Self {
116            ok: true,
117            data: Some(data),
118            error: None,
119        }
120    }
121
122    /// Construct an error response.
123    pub fn failure(error: impl Into<String>) -> Self {
124        Self {
125            ok: false,
126            data: None,
127            error: Some(error.into()),
128        }
129    }
130}
131
132// ─── Tests ───────────────────────────────────────────────────────────────────
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    /// Verify that a `Handshake` request round-trips through JSON without loss.
139    ///
140    /// The JSON line format must preserve the `"op"` discriminant so the worker
141    /// can dispatch on it without additional framing.
142    #[test]
143    fn handshake_request_roundtrip() {
144        let req = PoolRequest::Handshake {
145            version: "0.31.0".to_string(),
146        };
147
148        let json = serde_json::to_string(&req).expect("serialize");
149        // Must be a single line (no embedded newlines).
150        assert!(!json.contains('\n'), "JSON line must not contain newlines");
151        // Must carry the op discriminant.
152        assert!(json.contains("\"op\":\"handshake\""), "op field missing");
153        // Must carry the version field.
154        assert!(
155            json.contains("\"version\":\"0.31.0\""),
156            "version field missing"
157        );
158
159        let decoded: PoolRequest = serde_json::from_str(&json).expect("deserialize");
160        match decoded {
161            PoolRequest::Handshake { version } => {
162                assert_eq!(version, "0.31.0");
163            }
164            other => panic!("unexpected variant: {other:?}"),
165        }
166    }
167
168    /// Verify that a successful `PoolResponse` round-trips through JSON.
169    #[test]
170    fn response_success_roundtrip() {
171        let resp = PoolResponse::success(PoolResponseData::Handshake {
172            version: "0.31.0".to_string(),
173        });
174
175        let json = serde_json::to_string(&resp).expect("serialize");
176        assert!(json.contains("\"ok\":true"), "ok flag missing");
177
178        let decoded: PoolResponse = serde_json::from_str(&json).expect("deserialize");
179        assert!(decoded.ok);
180        assert!(decoded.error.is_none());
181        match decoded.data {
182            Some(PoolResponseData::Handshake { version }) => {
183                assert_eq!(version, "0.31.0");
184            }
185            other => panic!("unexpected data: {other:?}"),
186        }
187    }
188
189    /// Verify that an error `PoolResponse` round-trips through JSON.
190    #[test]
191    fn response_failure_roundtrip() {
192        let resp = PoolResponse::failure("version mismatch");
193
194        let json = serde_json::to_string(&resp).expect("serialize");
195        assert!(json.contains("\"ok\":false"), "ok flag missing");
196
197        let decoded: PoolResponse = serde_json::from_str(&json).expect("deserialize");
198        assert!(!decoded.ok);
199        assert!(decoded.data.is_none());
200        assert_eq!(decoded.error.as_deref(), Some("version mismatch"));
201    }
202}