1pub 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;