mail4agent_messenger_shell/provider/
codex.rs1use std::io::{Read, Write};
18use std::path::PathBuf;
19use std::time::{Duration, Instant};
20
21use serde_json::{json, Value};
22use tungstenite::{Message, WebSocket};
23
24use super::{
25 wake_prompt, ProviderKind, ProviderSession, SessionKind, WakeAdapter, WakeError, WakeLetter,
26 WakeOutcome,
27};
28
29pub const CODEX_APP_SERVER_ENV: &str = "M4A_CODEX_APP_SERVER";
31
32const RPC_TIMEOUT: Duration = Duration::from_secs(20);
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum CodexEndpoint {
37 Unix(PathBuf),
39 Ws(String),
41}
42
43impl CodexEndpoint {
44 pub fn parse(value: &str) -> Option<Self> {
47 let value = value.trim();
48 if let Some(path) = value.strip_prefix("unix://") {
49 return (!path.is_empty()).then(|| CodexEndpoint::Unix(PathBuf::from(path)));
50 }
51 let rest = value.strip_prefix("ws://")?;
52 let host = rest.split(['/', '?']).next().unwrap_or("");
53 let host = host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host);
54 matches!(host, "127.0.0.1" | "localhost" | "[::1]")
55 .then(|| CodexEndpoint::Ws(value.to_string()))
56 }
57
58 pub fn from_env() -> Option<Self> {
60 std::env::var(CODEX_APP_SERVER_ENV)
61 .ok()
62 .and_then(|value| Self::parse(&value))
63 }
64}
65
66pub struct CodexAppServerAdapter {
68 endpoint: Option<CodexEndpoint>,
69}
70
71impl CodexAppServerAdapter {
72 pub fn new(endpoint: Option<CodexEndpoint>) -> Self {
74 Self { endpoint }
75 }
76}
77
78impl WakeAdapter for CodexAppServerAdapter {
79 fn kind(&self) -> SessionKind {
80 SessionKind::local(ProviderKind::Codex)
81 }
82
83 fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
84 match &self.endpoint {
85 None => Err(WakeError::Unavailable(
86 "codex app-server endpoint unset".into(),
87 )),
88 Some(CodexEndpoint::Unix(path)) if !path.exists() => Err(WakeError::Unavailable(
89 "codex app-server socket missing".into(),
90 )),
91 Some(_) => Ok(()),
92 }
93 }
94
95 fn wake(
96 &mut self,
97 session: &ProviderSession,
98 letter: &WakeLetter<'_>,
99 ) -> Result<WakeOutcome, WakeError> {
100 self.probe(session)?;
101 let text = wake_prompt(session, letter);
102 match self.endpoint.clone() {
103 Some(CodexEndpoint::Ws(url)) => {
104 let (mut ws, _) = tungstenite::connect(url.as_str())
105 .map_err(|err| WakeError::Unavailable(format!("codex ws: {err}")))?;
106 start_turn(&mut ws, &session.session_id, &text)
107 }
108 #[cfg(unix)]
109 Some(CodexEndpoint::Unix(path)) => {
110 let stream = std::os::unix::net::UnixStream::connect(&path)
111 .map_err(|err| WakeError::Unavailable(format!("codex unix: {}", err.kind())))?;
112 stream
113 .set_read_timeout(Some(RPC_TIMEOUT))
114 .map_err(|err| WakeError::Transport(err.to_string()))?;
115 let (mut ws, _) = tungstenite::client("ws://localhost/", stream)
116 .map_err(|err| WakeError::Unavailable(format!("codex handshake: {err}")))?;
117 start_turn(&mut ws, &session.session_id, &text)
118 }
119 #[cfg(not(unix))]
120 Some(CodexEndpoint::Unix(_)) => Err(WakeError::Unavailable(
121 "unix:// app-server is unix-only here; use ws://127.0.0.1:PORT".into(),
122 )),
123 None => Err(WakeError::Unavailable(
124 "codex app-server endpoint unset".into(),
125 )),
126 }
127 }
128}
129
130pub fn initialize_request(id: i64) -> Value {
132 json!({
133 "id": id,
134 "method": "initialize",
135 "params": {"clientInfo": {"name": "mail4agent", "version": env!("CARGO_PKG_VERSION")}},
136 })
137}
138
139pub fn turn_start_request(id: i64, thread_id: &str, text: &str) -> Value {
141 json!({
142 "id": id,
143 "method": "turn/start",
144 "params": {"threadId": thread_id, "input": [{"type": "text", "text": text}]},
145 })
146}
147
148pub fn thread_resume_request(id: i64, thread_id: &str) -> Value {
150 json!({"id": id, "method": "thread/resume", "params": {"threadId": thread_id}})
151}
152
153pub fn start_turn<S: Read + Write>(
156 ws: &mut WebSocket<S>,
157 thread_id: &str,
158 text: &str,
159) -> Result<WakeOutcome, WakeError> {
160 call(ws, initialize_request(0))?
161 .map_err(|msg| WakeError::Transport(format!("initialize: {msg}")))?;
162 send(ws, json!({"method": "initialized"}))?;
163 match call(ws, turn_start_request(1, thread_id, text))? {
164 Ok(_) => Ok(WakeOutcome::Delivered),
165 Err(msg) if msg.contains("not found") || msg.contains("not loaded") => {
166 call(ws, thread_resume_request(2, thread_id))?
167 .map_err(|msg| WakeError::Transport(format!("thread/resume: {msg}")))?;
168 call(ws, turn_start_request(3, thread_id, text))?
169 .map(|_| WakeOutcome::Delivered)
170 .map_err(|msg| WakeError::Transport(format!("turn/start: {msg}")))
171 }
172 Err(msg) => Err(WakeError::Transport(format!("turn/start: {msg}"))),
173 }
174}
175
176fn send<S: Read + Write>(ws: &mut WebSocket<S>, value: Value) -> Result<(), WakeError> {
177 ws.send(Message::text(value.to_string()))
178 .map_err(|err| WakeError::Transport(format!("codex send: {err}")))
179}
180
181fn call<S: Read + Write>(
185 ws: &mut WebSocket<S>,
186 request: Value,
187) -> Result<Result<Value, String>, WakeError> {
188 let id = request["id"].clone();
189 send(ws, request)?;
190 let deadline = Instant::now() + RPC_TIMEOUT;
191 while Instant::now() < deadline {
192 let message = ws
193 .read()
194 .map_err(|err| WakeError::Transport(format!("codex read: {err}")))?;
195 let Message::Text(text) = message else {
196 continue;
197 };
198 let Ok(value) = serde_json::from_str::<Value>(text.as_str()) else {
199 continue;
200 };
201 if value.get("method").is_some() || value.get("id") != Some(&id) {
202 continue;
203 }
204 if let Some(error) = value.get("error") {
205 let msg = error["message"].as_str().unwrap_or("error").to_string();
206 return Ok(Err(msg));
207 }
208 return Ok(Ok(value.get("result").cloned().unwrap_or(Value::Null)));
209 }
210 Err(WakeError::Transport("codex rpc timed out".into()))
211}
212
213#[cfg(all(test, unix))]
214mod tests {
215 use super::*;
216 use crate::provider::tests::{letter, session};
217 use std::os::unix::net::UnixListener;
218
219 fn fake_server(path: PathBuf, loaded: bool) -> std::thread::JoinHandle<Vec<String>> {
220 let listener = UnixListener::bind(&path).unwrap();
221 std::thread::spawn(move || {
222 let (stream, _) = listener.accept().unwrap();
223 let mut ws = tungstenite::accept(stream).unwrap();
224 let mut seen = Vec::new();
225 let mut loaded = loaded;
226 loop {
227 let Ok(Message::Text(text)) = ws.read() else {
228 break;
229 };
230 let v: Value = serde_json::from_str(text.as_str()).unwrap();
231 let method = v["method"].as_str().unwrap_or("").to_string();
232 seen.push(method.clone());
233 assert!(v.get("jsonrpc").is_none());
234 let reply = match method.as_str() {
235 "initialize" => json!({"id": v["id"], "result": {"userAgent": "fake"}}),
236 "initialized" => continue,
237 "thread/resume" => {
238 loaded = true;
239 json!({"id": v["id"], "result": {"thread": {"id": "t"}}})
240 }
241 "turn/start" if loaded => {
242 assert_eq!(v["params"]["input"][0]["type"], "text");
243 ws.send(Message::text(
244 json!({"method": "turn/started", "params": {}}).to_string(),
245 ))
246 .unwrap();
247 json!({"id": v["id"], "result": {"turn": {"id": "u", "status": "inProgress"}}})
248 }
249 "turn/start" => {
250 json!({"id": v["id"], "error": {"code": -32600, "message": "thread not found: t"}})
251 }
252 _ => json!({"id": v["id"], "error": {"code": -32601, "message": "nope"}}),
253 };
254 ws.send(Message::text(reply.to_string())).unwrap();
255 if seen.iter().filter(|m| *m == "turn/start").count() >= 1
256 && loaded
257 && method == "turn/start"
258 {
259 break;
260 }
261 }
262 seen
263 })
264 }
265
266 fn sock(tag: &str) -> PathBuf {
267 let path =
268 std::env::temp_dir().join(format!("m4a-codex-{tag}-{}.sock", std::process::id()));
269 let _ = std::fs::remove_file(&path);
270 path
271 }
272
273 #[test]
274 fn loaded_thread_gets_one_turn_start() {
275 let path = sock("loaded");
276 let server = fake_server(path.clone(), true);
277 let mut adapter = CodexAppServerAdapter::new(Some(CodexEndpoint::Unix(path.clone())));
278 let s = session(SessionKind::local(ProviderKind::Codex));
279 assert_eq!(
280 adapter.wake(&s, &letter("hi")).unwrap(),
281 WakeOutcome::Delivered
282 );
283 assert_eq!(
284 server.join().unwrap(),
285 ["initialize", "initialized", "turn/start"]
286 );
287 let _ = std::fs::remove_file(&path);
288 }
289
290 #[test]
291 fn unloaded_thread_is_resumed_then_started() {
292 let path = sock("resume");
293 let server = fake_server(path.clone(), false);
294 let mut adapter = CodexAppServerAdapter::new(Some(CodexEndpoint::Unix(path.clone())));
295 let s = session(SessionKind::local(ProviderKind::Codex));
296 assert_eq!(
297 adapter.wake(&s, &letter("hi")).unwrap(),
298 WakeOutcome::Delivered
299 );
300 assert_eq!(
301 server.join().unwrap(),
302 [
303 "initialize",
304 "initialized",
305 "turn/start",
306 "thread/resume",
307 "turn/start"
308 ]
309 );
310 let _ = std::fs::remove_file(&path);
311 }
312
313 #[test]
314 fn endpoint_parse_is_loopback_only() {
315 assert_eq!(
316 CodexEndpoint::parse("unix:///tmp/a.sock"),
317 Some(CodexEndpoint::Unix("/tmp/a.sock".into()))
318 );
319 assert!(CodexEndpoint::parse("ws://127.0.0.1:4500").is_some());
320 assert!(CodexEndpoint::parse("ws://10.0.0.5:4500").is_none());
321 assert!(CodexEndpoint::parse("unix://").is_none());
322 }
323
324 #[test]
326 #[ignore]
327 fn live_turn_start() {
328 let endpoint = std::env::var("M4A_LIVE_CODEX_APP_SERVER").unwrap();
329 let thread = std::env::var("M4A_LIVE_CODEX_THREAD").unwrap();
330 let mut adapter = CodexAppServerAdapter::new(CodexEndpoint::parse(&endpoint));
331 let mut s = session(SessionKind::local(ProviderKind::Codex));
332 s.session_id = thread;
333 assert_eq!(
334 adapter.wake(&s, &letter("live probe")).unwrap(),
335 WakeOutcome::Delivered
336 );
337 }
338}