Skip to main content

agent_tui_adapters/adapters/rpc/
mod.rs

1//! RPC boundary types and conversions between transport and use cases.
2
3pub mod params;
4pub mod types;
5
6pub use crate::common::RpcId;
7pub use types::RpcRequest;
8pub use types::RpcResponse;
9pub use types::request_id_from_json_str;
10pub use types::request_id_from_json_value;
11
12use base64::Engine;
13use base64::engine::general_purpose::STANDARD;
14use serde::Serialize;
15use serde::de::DeserializeOwned;
16use serde_json::json;
17
18use super::snapshot_adapters::session_info_to_json;
19use crate::adapters::daemon::DomainError;
20use crate::domain::AssertInput;
21use crate::domain::AssertOutput;
22use crate::domain::AttachInput;
23use crate::domain::AttachOutput;
24use crate::domain::CleanupInput;
25use crate::domain::CleanupOutput;
26use crate::domain::KeydownInput;
27use crate::domain::KeystrokeInput;
28use crate::domain::KeyupInput;
29use crate::domain::KillOutput;
30use crate::domain::ResizeInput;
31use crate::domain::ResizeOutput;
32use crate::domain::RestartOutput;
33use crate::domain::SessionId;
34use crate::domain::SessionInput;
35use crate::domain::SessionsOutput;
36use crate::domain::ShutdownOutput;
37use crate::domain::SnapshotInput;
38use crate::domain::SnapshotOutput;
39use crate::domain::SpawnInput;
40use crate::domain::SpawnOutput;
41use crate::domain::TerminalWriteInput;
42use crate::domain::TerminalWriteOutput;
43use crate::domain::TypeInput;
44use crate::domain::WaitInput;
45use crate::domain::WaitOutput;
46use crate::usecases::ports::SessionError;
47
48pub fn to_value<T: Serialize>(value: T) -> Result<serde_json::Value, serde_json::Error> {
49    serde_json::to_value(value)
50}
51
52pub fn to_value_opt<T: Serialize>(
53    value: Option<T>,
54) -> Result<Option<serde_json::Value>, serde_json::Error> {
55    value.map(serde_json::to_value).transpose()
56}
57
58fn invalid_session_response(id: &RpcId, message: &str) -> RpcResponse {
59    RpcResponse::error(id.clone(), -32602, &format!("Invalid session: {message}"))
60}
61
62#[allow(clippy::result_large_err)]
63fn deserialize_required_params<T: DeserializeOwned>(
64    request: &RpcRequest,
65) -> Result<T, RpcResponse> {
66    request
67        .params
68        .as_ref()
69        .ok_or_else(|| RpcResponse::error(request.id.clone(), -32602, "Missing params"))
70        .and_then(|params| {
71            T::deserialize(params).map_err(|err| {
72                RpcResponse::error(
73                    request.id.clone(),
74                    -32602,
75                    &format!("Invalid params: {err}"),
76                )
77            })
78        })
79}
80
81#[allow(clippy::result_large_err)]
82fn deserialize_optional_params<T>(request: &RpcRequest) -> Result<T, RpcResponse>
83where
84    T: DeserializeOwned + Default,
85{
86    request.params.as_ref().map_or_else(
87        || Ok(T::default()),
88        |params| {
89            T::deserialize(params).map_err(|err| {
90                RpcResponse::error(
91                    request.id.clone(),
92                    -32602,
93                    &format!("Invalid params: {err}"),
94                )
95            })
96        },
97    )
98}
99
100#[allow(clippy::result_large_err)]
101pub fn parse_session_id(
102    id: impl Into<RpcId>,
103    session: Option<String>,
104) -> Result<Option<SessionId>, RpcResponse> {
105    let id = id.into();
106    match session {
107        None => Ok(None),
108        Some(raw) => {
109            let trimmed = raw.trim();
110            if trimmed.is_empty() {
111                return Err(invalid_session_response(
112                    &id,
113                    "Session ID cannot be empty or whitespace-only",
114                ));
115            }
116            SessionId::try_new(trimmed)
117                .map(Some)
118                .map_err(|err| invalid_session_response(&id, &err.to_string()))
119        }
120    }
121}
122
123#[allow(clippy::result_large_err)]
124pub fn parse_session_selector(
125    id: impl Into<RpcId>,
126    session: Option<String>,
127) -> Result<Option<SessionId>, RpcResponse> {
128    let id = id.into();
129    match session {
130        None => Ok(None),
131        Some(raw) => {
132            let trimmed = raw.trim();
133            if trimmed == "active" {
134                return Ok(None);
135            }
136            if trimmed.is_empty() {
137                return Err(invalid_session_response(
138                    &id,
139                    "Session ID cannot be empty or whitespace-only",
140                ));
141            }
142            SessionId::try_new(trimmed)
143                .map(Some)
144                .map_err(|err| invalid_session_response(&id, &err.to_string()))
145        }
146    }
147}
148
149#[allow(clippy::result_large_err)]
150pub fn parse_session_input(request: &RpcRequest) -> Result<SessionInput, RpcResponse> {
151    let session_id =
152        parse_session_selector(&request.id, request.param_str("session").map(String::from))?;
153    Ok(SessionInput { session_id })
154}
155
156pub fn domain_error_response(id: impl Into<RpcId>, err: &DomainError) -> RpcResponse {
157    RpcResponse::domain_error(
158        id,
159        err.code(),
160        &err.to_string(),
161        err.category().as_str(),
162        Some(err.context()),
163        Some(err.suggestion()),
164    )
165}
166
167pub fn session_error_response(id: impl Into<RpcId>, err: SessionError) -> RpcResponse {
168    domain_error_response(id, &DomainError::from(err))
169}
170
171pub fn lock_timeout_response(id: impl Into<RpcId>, session_id: Option<&str>) -> RpcResponse {
172    let err = DomainError::LockTimeout {
173        session_id: session_id.map(String::from),
174    };
175    domain_error_response(id, &err)
176}
177
178#[allow(clippy::result_large_err)]
179pub fn parse_spawn_input(request: &RpcRequest) -> Result<SpawnInput, RpcResponse> {
180    let rpc_params: params::SpawnParams = deserialize_required_params(request)?;
181
182    let command = if rpc_params.command.is_empty() {
183        "bash".to_string()
184    } else {
185        rpc_params.command
186    };
187
188    Ok(SpawnInput {
189        command,
190        args: rpc_params.args,
191        cwd: rpc_params.cwd,
192        env: rpc_params.env,
193        session_id: parse_session_id(&request.id, rpc_params.session)?,
194        size: rpc_params.size,
195    })
196}
197
198pub fn spawn_output_to_response(id: impl Into<RpcId>, output: SpawnOutput) -> RpcResponse {
199    RpcResponse::success(
200        id,
201        json!({
202            "session_id": output.session_id.as_str(),
203            "pid": output.pid
204        }),
205    )
206}
207
208#[allow(clippy::result_large_err)]
209pub fn parse_snapshot_input(request: &RpcRequest) -> Result<SnapshotInput, RpcResponse> {
210    let rpc_params: params::SnapshotParams = deserialize_optional_params(request)?;
211
212    Ok(SnapshotInput {
213        session_id: parse_session_selector(&request.id, rpc_params.session)?,
214        region: rpc_params.region,
215        strip_ansi: rpc_params.strip_ansi,
216        retain_ansi: rpc_params.retain_ansi,
217        include_cursor: rpc_params.include_cursor,
218        include_render: rpc_params.include_render || rpc_params.retain_ansi,
219    })
220}
221
222pub fn snapshot_output_to_response(
223    id: impl Into<RpcId>,
224    output: SnapshotOutput,
225    strip_ansi: bool,
226    retain_ansi: bool,
227) -> RpcResponse {
228    use crate::common::strip_ansi_codes;
229
230    let rendered = output.rendered;
231    let compact_rendered = output.compact_rendered;
232    let screenshot = if retain_ansi {
233        compact_rendered
234            .clone()
235            .or(rendered.clone())
236            .unwrap_or_else(|| output.screenshot.clone())
237    } else if strip_ansi {
238        strip_ansi_codes(&output.screenshot)
239    } else {
240        output.screenshot
241    };
242
243    let mut result = json!({
244        "session_id": output.session_id.as_str(),
245        "screenshot": screenshot
246    });
247
248    if let Some(cursor) = output.cursor {
249        result["cursor"] = json!({
250            "row": cursor.row,
251            "col": cursor.col,
252            "visible": cursor.visible
253        });
254    }
255
256    if let Some(rendered) = rendered {
257        result["rendered"] = json!(rendered);
258    }
259    if let Some(compact_rendered) = compact_rendered {
260        result["compact_rendered"] = json!(compact_rendered);
261    }
262
263    RpcResponse::success(id, result)
264}
265
266#[allow(clippy::result_large_err)]
267pub fn parse_keystroke_input(request: &RpcRequest) -> Result<KeystrokeInput, RpcResponse> {
268    let key = request.require_str("key")?.to_string();
269
270    Ok(KeystrokeInput {
271        session_id: parse_session_selector(
272            &request.id,
273            request.param_str("session").map(String::from),
274        )?,
275        key,
276    })
277}
278
279#[allow(clippy::result_large_err)]
280pub fn parse_type_input(request: &RpcRequest) -> Result<TypeInput, RpcResponse> {
281    let text = request.require_str("text")?.to_string();
282
283    Ok(TypeInput {
284        session_id: parse_session_selector(
285            &request.id,
286            request.param_str("session").map(String::from),
287        )?,
288        text,
289    })
290}
291
292#[allow(clippy::result_large_err)]
293pub fn parse_keydown_input(request: &RpcRequest) -> Result<KeydownInput, RpcResponse> {
294    let key = request.require_str("key")?.to_string();
295
296    Ok(KeydownInput {
297        session_id: parse_session_selector(
298            &request.id,
299            request.param_str("session").map(String::from),
300        )?,
301        key,
302    })
303}
304
305#[allow(clippy::result_large_err)]
306pub fn parse_keyup_input(request: &RpcRequest) -> Result<KeyupInput, RpcResponse> {
307    let key = request.require_str("key")?.to_string();
308
309    Ok(KeyupInput {
310        session_id: parse_session_selector(
311            &request.id,
312            request.param_str("session").map(String::from),
313        )?,
314        key,
315    })
316}
317
318#[allow(clippy::result_large_err)]
319pub fn parse_wait_input(request: &RpcRequest) -> Result<WaitInput, RpcResponse> {
320    let rpc_params: params::WaitParams = deserialize_optional_params(request)?;
321
322    let condition = match rpc_params.condition.as_deref() {
323        Some(raw) => Some(crate::domain::WaitConditionType::parse(raw).map_err(|e| {
324            RpcResponse::error(
325                request.id.clone(),
326                -32602,
327                &format!("Invalid condition: {e}"),
328            )
329        })?),
330        None => None,
331    };
332
333    if condition
334        .as_ref()
335        .is_some_and(agent_tui_domain::WaitConditionType::requires_text)
336        && rpc_params.text.as_deref().is_none()
337    {
338        return Err(RpcResponse::error(
339            request.id.clone(),
340            -32602,
341            "Invalid condition: text is required",
342        ));
343    }
344
345    Ok(WaitInput {
346        session_id: parse_session_selector(&request.id, rpc_params.session)?,
347        text: rpc_params.text,
348        timeout_ms: rpc_params.timeout_ms,
349        condition,
350    })
351}
352
353pub fn wait_output_to_response(id: impl Into<RpcId>, output: WaitOutput) -> RpcResponse {
354    RpcResponse::success(
355        id,
356        json!({
357            "found": output.found,
358            "elapsed_ms": output.elapsed_ms
359        }),
360    )
361}
362
363pub fn kill_output_to_response(id: impl Into<RpcId>, output: KillOutput) -> RpcResponse {
364    RpcResponse::success(
365        id,
366        json!({
367            "success": output.success,
368            "session_id": output.session_id.as_str()
369        }),
370    )
371}
372
373pub fn sessions_output_to_response(id: impl Into<RpcId>, output: SessionsOutput) -> RpcResponse {
374    RpcResponse::success(
375        id,
376        json!({
377            "sessions": output.sessions.iter().map(session_info_to_json).collect::<Vec<_>>(),
378            "active_session": output.active_session.as_ref().map(agent_tui_domain::SessionId::as_str)
379        }),
380    )
381}
382
383#[allow(clippy::result_large_err)]
384pub fn parse_resize_input(request: &RpcRequest) -> Result<ResizeInput, RpcResponse> {
385    let rpc_params: params::ResizeParams = deserialize_required_params(request)?;
386
387    Ok(ResizeInput {
388        session_id: parse_session_selector(&request.id, rpc_params.session)?,
389        size: rpc_params.size,
390    })
391}
392
393pub fn resize_output_to_response(id: impl Into<RpcId>, output: ResizeOutput) -> RpcResponse {
394    RpcResponse::success(
395        id,
396        json!({
397            "success": output.success,
398            "session_id": output.session_id.as_str(),
399            "cols": output.size.cols(),
400            "rows": output.size.rows()
401        }),
402    )
403}
404
405pub fn restart_output_to_response(id: impl Into<RpcId>, output: RestartOutput) -> RpcResponse {
406    RpcResponse::success(
407        id,
408        json!({
409            "old_session_id": output.old_session_id.as_str(),
410            "new_session_id": output.new_session_id.as_str(),
411            "command": output.command,
412            "pid": output.pid
413        }),
414    )
415}
416
417#[allow(clippy::result_large_err)]
418pub fn parse_attach_input(request: &RpcRequest) -> Result<AttachInput, RpcResponse> {
419    let session_id = request.require_str("session")?;
420    Ok(AttachInput {
421        session_id: SessionId::try_new(session_id.trim()).map_err(|err| {
422            RpcResponse::error(
423                request.id.clone(),
424                -32602,
425                &format!("Invalid session: {err}"),
426            )
427        })?,
428    })
429}
430
431pub fn attach_output_to_response(id: impl Into<RpcId>, output: &AttachOutput) -> RpcResponse {
432    let session_id = output.session_id.as_str();
433    let message = format!("Now attached to session {session_id}");
434    RpcResponse::success(
435        id,
436        json!({
437            "session_id": session_id,
438            "success": output.success,
439            "message": message
440        }),
441    )
442}
443
444pub fn parse_cleanup_input(request: &RpcRequest) -> CleanupInput {
445    let all = request.param_bool("all", false);
446    CleanupInput { all }
447}
448
449pub fn cleanup_output_to_response(id: impl Into<RpcId>, output: CleanupOutput) -> RpcResponse {
450    RpcResponse::success(
451        id,
452        json!({
453            "cleaned": output.cleaned,
454            "failures": output.failures.iter().map(|f| json!({
455                "session_id": f.session_id.as_str(),
456                "error": f.error
457            })).collect::<Vec<_>>()
458        }),
459    )
460}
461
462#[allow(clippy::result_large_err)]
463pub fn parse_assert_input(request: &RpcRequest) -> Result<AssertInput, RpcResponse> {
464    let condition_type = request.require_str("type")?;
465    let value = request.require_str("value")?.to_string();
466
467    let condition_type =
468        crate::domain::AssertConditionType::parse(condition_type).map_err(|e| {
469            RpcResponse::error(request.id.clone(), -32602, &format!("Invalid type: {e}"))
470        })?;
471
472    Ok(AssertInput {
473        session_id: parse_session_selector(
474            &request.id,
475            request.param_str("session").map(String::from),
476        )?,
477        condition_type,
478        value,
479    })
480}
481
482pub fn assert_output_to_response(id: impl Into<RpcId>, output: AssertOutput) -> RpcResponse {
483    RpcResponse::success(
484        id,
485        json!({
486            "passed": output.passed,
487            "condition": output.condition
488        }),
489    )
490}
491
492pub fn shutdown_output_to_response(id: impl Into<RpcId>, output: ShutdownOutput) -> RpcResponse {
493    RpcResponse::success(id, json!({ "acknowledged": output.acknowledged }))
494}
495
496pub fn terminal_write_output_to_response(
497    id: impl Into<RpcId>,
498    output: TerminalWriteOutput,
499) -> RpcResponse {
500    RpcResponse::success(
501        id,
502        json!({
503            "session_id": output.session_id.as_str(),
504            "bytes_written": output.bytes_written,
505            "success": output.success
506        }),
507    )
508}
509
510#[allow(clippy::result_large_err)]
511pub fn parse_terminal_write_input(request: &RpcRequest) -> Result<TerminalWriteInput, RpcResponse> {
512    let rpc_params: params::PtyWriteParams = deserialize_required_params(request)?;
513
514    let data = STANDARD.decode(&rpc_params.data).map_err(|e| {
515        RpcResponse::error(request.id.clone(), -32602, &format!("Invalid base64: {e}"))
516    })?;
517
518    Ok(TerminalWriteInput {
519        session_id: parse_session_selector(&request.id, rpc_params.session)?,
520        data,
521    })
522}
523
524#[cfg(test)]
525#[path = "mod_tests.rs"]
526mod tests;