agent_tui/daemon/adapters/
rpc.rs

1use crate::ipc::{RpcRequest, RpcResponse, params};
2use serde_json::{Value, json};
3
4use super::recording_adapters::{build_asciicast, build_raw_frames};
5use super::snapshot_adapters::session_info_to_json;
6use crate::daemon::domain::{
7    AccessibilitySnapshotInput, AttachInput, AttachOutput, CleanupInput, CleanupOutput, ClearInput,
8    ClickInput, ConsoleInput, ConsoleOutput, CountInput, CountOutput, DomainElement,
9    DoubleClickInput, ElementStateInput, ErrorsInput, ErrorsOutput, FillInput, FindInput,
10    FindOutput, FocusCheckOutput, FocusInput, GetFocusedOutput, GetTextOutput, GetTitleOutput,
11    GetValueOutput, HealthOutput, IsCheckedOutput, IsEnabledOutput, KeydownInput, KeystrokeInput,
12    KeyupInput, KillOutput, MetricsOutput, MultiselectInput, MultiselectOutput, PtyReadInput,
13    PtyReadOutput, PtyWriteInput, PtyWriteOutput, RecordStartInput, RecordStartOutput,
14    RecordStatusInput, RecordStatusOutput, RecordStopInput, RecordStopOutput, ResizeInput,
15    ResizeOutput, RestartOutput, ScrollInput, ScrollIntoViewInput, ScrollIntoViewOutput,
16    ScrollOutput, SelectAllInput, SelectInput, SessionId, SessionInput, SessionsOutput,
17    SnapshotInput, SnapshotOutput, SpawnInput, SpawnOutput, ToggleInput, ToggleOutput, TraceInput,
18    TraceOutput, TypeInput, VisibilityOutput, WaitInput, WaitOutput,
19};
20use crate::daemon::error::{DomainError, SessionError};
21
22/// Convert an optional string session ID to an optional SessionId.
23///
24/// This handles the conversion from IPC layer strings to domain SessionId:
25/// - None -> None (use active session)
26/// - Some("") or whitespace -> None (treat empty as unspecified)
27/// - Some(id) -> Some(SessionId::new(id))
28pub fn parse_session_id(session: Option<String>) -> Option<SessionId> {
29    session.and_then(|s| {
30        if s.trim().is_empty() {
31            None
32        } else {
33            Some(SessionId::new(s))
34        }
35    })
36}
37
38/// Parse SessionInput from RpcRequest.
39///
40/// Extracts the optional session parameter and wraps it in a SessionInput.
41pub fn parse_session_input(request: &RpcRequest) -> SessionInput {
42    let session_id = parse_session_id(request.param_str("session").map(String::from));
43    SessionInput { session_id }
44}
45
46const MAX_TERMINAL_COLS: u16 = 500;
47const MAX_TERMINAL_ROWS: u16 = 200;
48const MIN_TERMINAL_COLS: u16 = 10;
49const MIN_TERMINAL_ROWS: u16 = 5;
50
51/// Convert a DomainElement to JSON representation.
52pub fn element_to_json(el: &DomainElement) -> Value {
53    json!({
54        "ref": el.element_ref,
55        "type": el.element_type.as_str(),
56        "label": el.label,
57        "value": el.value,
58        "position": {
59            "row": el.position.row,
60            "col": el.position.col,
61            "width": el.position.width,
62            "height": el.position.height
63        },
64        "focused": el.focused,
65        "selected": el.selected,
66        "checked": el.checked,
67        "disabled": el.disabled,
68        "hint": el.hint
69    })
70}
71
72/// Convert a DomainError to an RpcResponse.
73pub fn domain_error_response(id: u64, err: &DomainError) -> RpcResponse {
74    RpcResponse::domain_error(
75        id,
76        err.code(),
77        &err.to_string(),
78        err.category().as_str(),
79        Some(err.context()),
80        Some(err.suggestion()),
81    )
82}
83
84/// Convert a SessionError to an RpcResponse.
85pub fn session_error_response(id: u64, err: SessionError) -> RpcResponse {
86    domain_error_response(id, &DomainError::from(err))
87}
88
89/// Create a lock timeout error response.
90pub fn lock_timeout_response(id: u64, session_id: Option<&str>) -> RpcResponse {
91    let err = DomainError::LockTimeout {
92        session_id: session_id.map(String::from),
93    };
94    domain_error_response(id, &err)
95}
96
97/// Parse SpawnInput from RpcRequest using shared params type.
98#[allow(clippy::result_large_err)]
99pub fn parse_spawn_input(request: &RpcRequest) -> Result<SpawnInput, RpcResponse> {
100    let rpc_params: params::SpawnParams = request
101        .params
102        .as_ref()
103        .ok_or_else(|| RpcResponse::error(request.id, -32602, "Missing params"))
104        .and_then(|p| {
105            serde_json::from_value(p.clone()).map_err(|e| {
106                RpcResponse::error(request.id, -32602, &format!("Invalid params: {}", e))
107            })
108        })?;
109
110    // Use "bash" as default if command is empty
111    let command = if rpc_params.command.is_empty() {
112        "bash".to_string()
113    } else {
114        rpc_params.command
115    };
116
117    Ok(SpawnInput {
118        command,
119        args: rpc_params.args,
120        cwd: rpc_params.cwd,
121        env: None,
122        session_id: parse_session_id(rpc_params.session),
123        cols: rpc_params.cols.clamp(MIN_TERMINAL_COLS, MAX_TERMINAL_COLS),
124        rows: rpc_params.rows.clamp(MIN_TERMINAL_ROWS, MAX_TERMINAL_ROWS),
125    })
126}
127
128/// Convert SpawnOutput to RpcResponse.
129pub fn spawn_output_to_response(id: u64, output: SpawnOutput) -> RpcResponse {
130    RpcResponse::success(
131        id,
132        json!({
133            "session_id": output.session_id.as_str(),
134            "pid": output.pid
135        }),
136    )
137}
138
139/// Parse SnapshotInput from RpcRequest using shared params type.
140pub fn parse_snapshot_input(request: &RpcRequest) -> SnapshotInput {
141    // Deserialize to shared params type, then convert to domain type
142    let rpc_params: params::SnapshotParams = request
143        .params
144        .as_ref()
145        .and_then(|p| serde_json::from_value(p.clone()).ok())
146        .unwrap_or_default();
147
148    SnapshotInput {
149        session_id: parse_session_id(rpc_params.session),
150        include_elements: rpc_params.include_elements,
151        region: rpc_params.region,
152        strip_ansi: rpc_params.strip_ansi,
153        include_cursor: rpc_params.include_cursor,
154    }
155}
156
157/// Convert SnapshotOutput to RpcResponse.
158///
159/// If `strip_ansi` is true, ANSI escape codes will be removed from the screen output.
160pub fn snapshot_output_to_response(
161    id: u64,
162    output: SnapshotOutput,
163    strip_ansi: bool,
164) -> RpcResponse {
165    use crate::common::strip_ansi_codes;
166
167    let screen = if strip_ansi {
168        strip_ansi_codes(&output.screen)
169    } else {
170        output.screen
171    };
172
173    let mut result = json!({
174        "session_id": output.session_id.as_str(),
175        "screen": screen
176    });
177
178    if let Some(elements) = output.elements {
179        result["elements"] = json!(elements.iter().map(element_to_json).collect::<Vec<_>>());
180    }
181
182    if let Some(cursor) = output.cursor {
183        result["cursor"] = json!({
184            "row": cursor.row,
185            "col": cursor.col,
186            "visible": cursor.visible
187        });
188    }
189
190    RpcResponse::success(id, result)
191}
192
193/// Parse ClickInput from RpcRequest.
194#[allow(clippy::result_large_err)]
195pub fn parse_click_input(request: &RpcRequest) -> Result<ClickInput, RpcResponse> {
196    let element_ref = request.require_str("ref")?.to_string();
197
198    Ok(ClickInput {
199        session_id: parse_session_id(request.param_str("session").map(String::from)),
200        element_ref,
201    })
202}
203
204/// Parse FillInput from RpcRequest.
205#[allow(clippy::result_large_err)]
206pub fn parse_fill_input(request: &RpcRequest) -> Result<FillInput, RpcResponse> {
207    let element_ref = request.require_str("ref")?.to_string();
208    let value = request.require_str("value")?.to_string();
209
210    Ok(FillInput {
211        session_id: parse_session_id(request.param_str("session").map(String::from)),
212        element_ref,
213        value,
214    })
215}
216
217/// Parse FindInput from RpcRequest using shared params type.
218pub fn parse_find_input(request: &RpcRequest) -> FindInput {
219    let rpc_params: params::FindParams = request
220        .params
221        .as_ref()
222        .and_then(|p| serde_json::from_value(p.clone()).ok())
223        .unwrap_or_default();
224
225    FindInput {
226        session_id: parse_session_id(rpc_params.session),
227        role: rpc_params.role,
228        name: rpc_params.name,
229        text: rpc_params.text,
230        placeholder: rpc_params.placeholder,
231        focused: rpc_params.focused,
232        nth: rpc_params.nth,
233        exact: rpc_params.exact,
234    }
235}
236
237/// Parse KeystrokeInput from RpcRequest.
238#[allow(clippy::result_large_err)]
239pub fn parse_keystroke_input(request: &RpcRequest) -> Result<KeystrokeInput, RpcResponse> {
240    let key = request.require_str("key")?.to_string();
241
242    Ok(KeystrokeInput {
243        session_id: parse_session_id(request.param_str("session").map(String::from)),
244        key,
245    })
246}
247
248/// Parse TypeInput from RpcRequest.
249#[allow(clippy::result_large_err)]
250pub fn parse_type_input(request: &RpcRequest) -> Result<TypeInput, RpcResponse> {
251    let text = request.require_str("text")?.to_string();
252
253    Ok(TypeInput {
254        session_id: parse_session_id(request.param_str("session").map(String::from)),
255        text,
256    })
257}
258
259/// Parse WaitInput from RpcRequest using shared params type.
260pub fn parse_wait_input(request: &RpcRequest) -> WaitInput {
261    let rpc_params: params::WaitParams = request
262        .params
263        .as_ref()
264        .and_then(|p| serde_json::from_value(p.clone()).ok())
265        .unwrap_or_default();
266
267    WaitInput {
268        session_id: parse_session_id(rpc_params.session),
269        text: rpc_params.text,
270        timeout_ms: rpc_params.timeout_ms,
271        condition: rpc_params.condition,
272        target: rpc_params.target,
273    }
274}
275
276/// Convert WaitOutput to RpcResponse.
277pub fn wait_output_to_response(id: u64, output: WaitOutput) -> RpcResponse {
278    RpcResponse::success(
279        id,
280        json!({
281            "found": output.found,
282            "elapsed_ms": output.elapsed_ms
283        }),
284    )
285}
286
287/// Parse ScrollInput from RpcRequest.
288#[allow(clippy::result_large_err)]
289pub fn parse_scroll_input(request: &RpcRequest) -> Result<ScrollInput, RpcResponse> {
290    let direction = request.require_str("direction")?.to_string();
291
292    Ok(ScrollInput {
293        session_id: parse_session_id(request.param_str("session").map(String::from)),
294        direction,
295        amount: request.param_u16("amount", 1),
296    })
297}
298
299/// Convert KillOutput to RpcResponse.
300pub fn kill_output_to_response(id: u64, output: KillOutput) -> RpcResponse {
301    RpcResponse::success(
302        id,
303        json!({
304            "success": output.success,
305            "session_id": output.session_id.as_str()
306        }),
307    )
308}
309
310/// Convert SessionsOutput to RpcResponse.
311pub fn sessions_output_to_response(id: u64, output: SessionsOutput) -> RpcResponse {
312    RpcResponse::success(
313        id,
314        json!({
315            "sessions": output.sessions.iter().map(session_info_to_json).collect::<Vec<_>>(),
316            "active_session": output.active_session.as_ref().map(|id| id.as_str())
317        }),
318    )
319}
320
321/// Create a simple success response.
322pub fn success_response(id: u64, message: &str) -> RpcResponse {
323    RpcResponse::success(
324        id,
325        json!({
326            "success": true,
327            "message": message
328        }),
329    )
330}
331
332/// Create a click success response.
333pub fn click_success_response(id: u64, element_ref: &str, warning: Option<&str>) -> RpcResponse {
334    let mut result = json!({
335        "success": true,
336        "message": format!("Clicked {}", element_ref)
337    });
338    if let Some(w) = warning {
339        result["warning"] = json!(w);
340    }
341    RpcResponse::success(id, result)
342}
343
344/// Create a fill success response.
345pub fn fill_success_response(id: u64, element_ref: &str) -> RpcResponse {
346    RpcResponse::success(
347        id,
348        json!({
349            "success": true,
350            "message": format!("Filled {} with value", element_ref)
351        }),
352    )
353}
354
355/// Parse ResizeInput from RpcRequest using shared params type.
356pub fn parse_resize_input(request: &RpcRequest) -> ResizeInput {
357    let rpc_params: params::ResizeParams = request
358        .params
359        .as_ref()
360        .and_then(|p| serde_json::from_value(p.clone()).ok())
361        .unwrap_or(params::ResizeParams {
362            cols: 80,
363            rows: 24,
364            session: None,
365        });
366
367    ResizeInput {
368        session_id: parse_session_id(rpc_params.session),
369        cols: rpc_params.cols.clamp(MIN_TERMINAL_COLS, MAX_TERMINAL_COLS),
370        rows: rpc_params.rows.clamp(MIN_TERMINAL_ROWS, MAX_TERMINAL_ROWS),
371    }
372}
373
374/// Convert ResizeOutput to RpcResponse.
375pub fn resize_output_to_response(id: u64, output: ResizeOutput) -> RpcResponse {
376    RpcResponse::success(
377        id,
378        json!({
379            "success": output.success,
380            "session_id": output.session_id.as_str(),
381            "size": { "cols": output.cols, "rows": output.rows }
382        }),
383    )
384}
385
386/// Convert RestartOutput to RpcResponse.
387pub fn restart_output_to_response(id: u64, output: RestartOutput) -> RpcResponse {
388    RpcResponse::success(
389        id,
390        json!({
391            "success": true,
392            "old_session_id": output.old_session_id.as_str(),
393            "new_session_id": output.new_session_id.as_str(),
394            "command": output.command,
395            "pid": output.pid
396        }),
397    )
398}
399
400/// Parse an attach input from an RPC request.
401#[allow(clippy::result_large_err)]
402pub fn parse_attach_input(request: &RpcRequest) -> Result<AttachInput, RpcResponse> {
403    let session_id = request.require_str("session")?;
404    Ok(AttachInput {
405        session_id: SessionId::new(session_id),
406    })
407}
408
409/// Convert AttachOutput to RpcResponse.
410pub fn attach_output_to_response(id: u64, output: AttachOutput) -> RpcResponse {
411    RpcResponse::success(
412        id,
413        json!({
414            "success": output.success,
415            "session_id": output.session_id.as_str(),
416            "message": output.message
417        }),
418    )
419}
420
421/// Parse CleanupInput from RpcRequest.
422pub fn parse_cleanup_input(request: &RpcRequest) -> CleanupInput {
423    let all = request.param_bool("all", false);
424    CleanupInput { all }
425}
426
427/// Convert CleanupOutput to RpcResponse.
428pub fn cleanup_output_to_response(id: u64, output: CleanupOutput) -> RpcResponse {
429    let failures_json: Vec<Value> = output
430        .failures
431        .iter()
432        .map(|f| {
433            json!({
434                "session": f.session_id.as_str(),
435                "error": f.error
436            })
437        })
438        .collect();
439
440    RpcResponse::success(
441        id,
442        json!({
443            "sessions_cleaned": output.cleaned,
444            "sessions_failed": output.failures.len(),
445            "failures": failures_json
446        }),
447    )
448}
449
450use crate::daemon::domain::{AssertConditionType, AssertInput, AssertOutput};
451
452/// Parse AssertInput from RpcRequest.
453///
454/// The assert endpoint expects a condition string in the format "type:value".
455#[allow(clippy::result_large_err)]
456pub fn parse_assert_input(request: &RpcRequest) -> Result<AssertInput, RpcResponse> {
457    let condition = request.param_str("condition").unwrap_or("");
458    let session = request.param_str("session").map(String::from);
459
460    let parts: Vec<&str> = condition.splitn(2, ':').collect();
461    if parts.len() != 2 {
462        return Err(RpcResponse::error(
463            request.id,
464            -32602,
465            "Invalid condition format. Use: text:pattern, element:ref, or session:id",
466        ));
467    }
468
469    let (cond_type_str, value) = (parts[0], parts[1]);
470
471    let condition_type = match AssertConditionType::parse(cond_type_str) {
472        Ok(ct) => ct,
473        Err(msg) => {
474            return Err(RpcResponse::error(request.id, -32602, &msg));
475        }
476    };
477
478    Ok(AssertInput {
479        session_id: parse_session_id(session),
480        condition_type,
481        value: value.to_string(),
482    })
483}
484
485/// Convert AssertOutput to RpcResponse.
486pub fn assert_output_to_response(id: u64, output: AssertOutput) -> RpcResponse {
487    RpcResponse::success(
488        id,
489        json!({
490            "condition": output.condition,
491            "passed": output.passed
492        }),
493    )
494}
495
496/// Convert ScrollOutput to RpcResponse.
497pub fn scroll_output_to_response(
498    id: u64,
499    output: ScrollOutput,
500    direction: &str,
501    amount: u16,
502) -> RpcResponse {
503    RpcResponse::success(
504        id,
505        json!({
506            "success": output.success,
507            "direction": direction,
508            "amount": amount
509        }),
510    )
511}
512
513/// Parse CountInput from RpcRequest using shared params type.
514pub fn parse_count_input(request: &RpcRequest) -> CountInput {
515    let rpc_params: params::CountParams = request
516        .params
517        .as_ref()
518        .and_then(|p| serde_json::from_value(p.clone()).ok())
519        .unwrap_or_default();
520
521    CountInput {
522        session_id: parse_session_id(rpc_params.session),
523        role: rpc_params.role,
524        name: rpc_params.name,
525        text: rpc_params.text,
526    }
527}
528
529/// Convert CountOutput to RpcResponse.
530pub fn count_output_to_response(id: u64, output: CountOutput) -> RpcResponse {
531    RpcResponse::success(id, json!({ "count": output.count }))
532}
533
534// ============================================================
535// Diagnostics output adapters
536// ============================================================
537
538/// Convert HealthOutput to RpcResponse.
539pub fn health_output_to_response(id: u64, output: HealthOutput) -> RpcResponse {
540    RpcResponse::success(
541        id,
542        json!({
543            "status": output.status,
544            "pid": output.pid,
545            "uptime_ms": output.uptime_ms,
546            "session_count": output.session_count,
547            "version": output.version,
548            "active_connections": output.active_connections,
549            "total_requests": output.total_requests,
550            "error_count": output.error_count
551        }),
552    )
553}
554
555/// Convert MetricsOutput to RpcResponse.
556pub fn metrics_output_to_response(id: u64, output: MetricsOutput) -> RpcResponse {
557    RpcResponse::success(
558        id,
559        json!({
560            "requests_total": output.requests_total,
561            "errors_total": output.errors_total,
562            "lock_timeouts": output.lock_timeouts,
563            "poison_recoveries": output.poison_recoveries,
564            "uptime_ms": output.uptime_ms,
565            "active_connections": output.active_connections,
566            "session_count": output.session_count
567        }),
568    )
569}
570
571/// Convert TraceOutput to RpcResponse.
572pub fn trace_output_to_response(id: u64, output: TraceOutput) -> RpcResponse {
573    let trace_json: Vec<_> = output
574        .entries
575        .iter()
576        .map(|t| {
577            json!({
578                "timestamp_ms": t.timestamp_ms,
579                "action": t.action,
580                "details": t.details
581            })
582        })
583        .collect();
584
585    RpcResponse::success(
586        id,
587        json!({
588            "trace": trace_json,
589            "count": trace_json.len()
590        }),
591    )
592}
593
594/// Convert ConsoleOutput to RpcResponse.
595pub fn console_output_to_response(id: u64, output: ConsoleOutput) -> RpcResponse {
596    RpcResponse::success(
597        id,
598        json!({
599            "output": output.lines,
600            "line_count": output.lines.len()
601        }),
602    )
603}
604
605/// Convert ErrorsOutput to RpcResponse.
606pub fn errors_output_to_response(id: u64, output: ErrorsOutput) -> RpcResponse {
607    let errors_json: Vec<_> = output
608        .errors
609        .iter()
610        .map(|e| {
611            json!({
612                "timestamp": e.timestamp,
613                "message": e.message,
614                "source": e.source
615            })
616        })
617        .collect();
618
619    RpcResponse::success(
620        id,
621        json!({
622            "errors": errors_json,
623            "count": errors_json.len()
624        }),
625    )
626}
627
628/// Convert PtyReadOutput to RpcResponse.
629pub fn pty_read_output_to_response(id: u64, output: PtyReadOutput) -> RpcResponse {
630    RpcResponse::success(
631        id,
632        json!({
633            "session_id": output.session_id.as_str(),
634            "data": output.data,
635            "bytes_read": output.bytes_read
636        }),
637    )
638}
639
640/// Convert PtyWriteOutput to RpcResponse.
641pub fn pty_write_output_to_response(id: u64, output: PtyWriteOutput) -> RpcResponse {
642    RpcResponse::success(
643        id,
644        json!({
645            "session_id": output.session_id.as_str(),
646            "bytes_written": output.bytes_written,
647            "success": output.success
648        }),
649    )
650}
651
652// ============================================================
653// Recording output adapters
654// ============================================================
655
656/// Convert RecordStartOutput to RpcResponse.
657pub fn record_start_output_to_response(id: u64, output: RecordStartOutput) -> RpcResponse {
658    RpcResponse::success(
659        id,
660        json!({
661            "success": output.success,
662            "session_id": output.session_id.as_str(),
663            "recording": true
664        }),
665    )
666}
667
668/// Convert RecordStatusOutput to RpcResponse.
669pub fn record_status_output_to_response(id: u64, output: RecordStatusOutput) -> RpcResponse {
670    RpcResponse::success(
671        id,
672        json!({
673            "recording": output.is_recording,
674            "frame_count": output.frame_count,
675            "duration_ms": output.duration_ms
676        }),
677    )
678}
679
680/// Convert RecordStopOutput to RpcResponse.
681pub fn record_stop_output_to_response(id: u64, output: RecordStopOutput) -> RpcResponse {
682    let recording_data = if output.format == "asciicast" {
683        build_asciicast(
684            output.session_id.as_ref(),
685            output.cols,
686            output.rows,
687            &output.frames,
688        )
689    } else {
690        build_raw_frames(&output.frames)
691    };
692
693    RpcResponse::success(
694        id,
695        json!({
696            "success": true,
697            "session_id": output.session_id.as_str(),
698            "frame_count": output.frame_count,
699            "recording": recording_data
700        }),
701    )
702}
703
704// ============================================================
705// Element query output adapters
706// ============================================================
707
708/// Convert FindOutput to RpcResponse.
709pub fn find_output_to_response(id: u64, output: FindOutput) -> RpcResponse {
710    let elements_json: Vec<Value> = output.elements.iter().map(element_to_json).collect();
711    RpcResponse::success(
712        id,
713        json!({
714            "elements": elements_json,
715            "count": output.count
716        }),
717    )
718}
719
720/// Convert GetTextOutput to RpcResponse with element_ref.
721pub fn get_text_output_to_response(
722    id: u64,
723    element_ref: &str,
724    output: GetTextOutput,
725) -> RpcResponse {
726    RpcResponse::success(
727        id,
728        json!({ "ref": element_ref, "text": output.text, "found": output.found }),
729    )
730}
731
732/// Convert GetValueOutput to RpcResponse with element_ref.
733pub fn get_value_output_to_response(
734    id: u64,
735    element_ref: &str,
736    output: GetValueOutput,
737) -> RpcResponse {
738    RpcResponse::success(
739        id,
740        json!({ "ref": element_ref, "value": output.value, "found": output.found }),
741    )
742}
743
744/// Convert VisibilityOutput to RpcResponse with element_ref.
745pub fn visibility_output_to_response(
746    id: u64,
747    element_ref: &str,
748    output: VisibilityOutput,
749) -> RpcResponse {
750    RpcResponse::success(id, json!({ "ref": element_ref, "visible": output.visible }))
751}
752
753/// Convert FocusCheckOutput to RpcResponse with element_ref.
754pub fn focus_check_output_to_response(
755    id: u64,
756    element_ref: &str,
757    output: FocusCheckOutput,
758) -> RpcResponse {
759    RpcResponse::success(
760        id,
761        json!({ "ref": element_ref, "focused": output.focused, "found": output.found }),
762    )
763}
764
765/// Convert IsEnabledOutput to RpcResponse with element_ref.
766pub fn is_enabled_output_to_response(
767    id: u64,
768    element_ref: &str,
769    output: IsEnabledOutput,
770) -> RpcResponse {
771    RpcResponse::success(
772        id,
773        json!({ "ref": element_ref, "enabled": output.enabled, "found": output.found }),
774    )
775}
776
777/// Convert IsCheckedOutput to RpcResponse with element_ref.
778pub fn is_checked_output_to_response(
779    id: u64,
780    element_ref: &str,
781    output: IsCheckedOutput,
782) -> RpcResponse {
783    let mut response =
784        json!({ "ref": element_ref, "checked": output.checked, "found": output.found });
785    if let Some(msg) = output.message {
786        response["message"] = json!(msg);
787    }
788    RpcResponse::success(id, response)
789}
790
791/// Convert GetFocusedOutput to RpcResponse.
792pub fn get_focused_output_to_response(id: u64, output: GetFocusedOutput) -> RpcResponse {
793    if let Some(el) = output.element {
794        RpcResponse::success(
795            id,
796            json!({
797                "ref": el.element_ref,
798                "type": el.element_type.as_str(),
799                "label": el.label,
800                "value": el.value,
801                "found": true
802            }),
803        )
804    } else {
805        RpcResponse::success(
806            id,
807            json!({
808                "found": false,
809                "message": "No focused element found."
810            }),
811        )
812    }
813}
814
815/// Convert GetTitleOutput to RpcResponse.
816pub fn get_title_output_to_response(id: u64, output: GetTitleOutput) -> RpcResponse {
817    RpcResponse::success(
818        id,
819        json!({
820            "session_id": output.session_id.as_str(),
821            "title": output.title
822        }),
823    )
824}
825
826/// Convert ToggleOutput to RpcResponse with element_ref.
827pub fn toggle_output_to_response(id: u64, element_ref: &str, output: ToggleOutput) -> RpcResponse {
828    RpcResponse::success(
829        id,
830        json!({ "success": true, "ref": element_ref, "checked": output.checked }),
831    )
832}
833
834/// Convert SelectOutput to RpcResponse with element_ref.
835pub fn select_output_to_response(id: u64, element_ref: &str, option: &str) -> RpcResponse {
836    RpcResponse::success(
837        id,
838        json!({ "success": true, "ref": element_ref, "option": option }),
839    )
840}
841
842/// Convert ScrollIntoViewOutput to RpcResponse with element_ref.
843pub fn scroll_into_view_output_to_response(
844    id: u64,
845    element_ref: &str,
846    output: ScrollIntoViewOutput,
847) -> RpcResponse {
848    if output.success {
849        RpcResponse::success(
850            id,
851            json!({
852                "success": true,
853                "ref": element_ref,
854                "scrolls_needed": output.scrolls_needed
855            }),
856        )
857    } else {
858        RpcResponse::success(
859            id,
860            json!({
861                "success": false,
862                "message": output.message.unwrap_or_else(|| "Element not found after scrolling".to_string())
863            }),
864        )
865    }
866}
867
868/// Convert MultiselectOutput to RpcResponse with element_ref.
869pub fn multiselect_output_to_response(
870    id: u64,
871    element_ref: &str,
872    output: MultiselectOutput,
873) -> RpcResponse {
874    RpcResponse::success(
875        id,
876        json!({ "success": true, "ref": element_ref, "selected_options": output.selected_options }),
877    )
878}
879
880// ============================================================
881// Element-ref based input parsers (require validation)
882// ============================================================
883
884/// Parse DoubleClickInput from RpcRequest.
885#[allow(clippy::result_large_err)]
886pub fn parse_double_click_input(request: &RpcRequest) -> Result<DoubleClickInput, RpcResponse> {
887    let element_ref = request.require_str("ref")?.to_string();
888
889    Ok(DoubleClickInput {
890        session_id: parse_session_id(request.param_str("session").map(String::from)),
891        element_ref,
892    })
893}
894
895/// Parse FocusInput from RpcRequest.
896#[allow(clippy::result_large_err)]
897pub fn parse_focus_input(request: &RpcRequest) -> Result<FocusInput, RpcResponse> {
898    let element_ref = request.require_str("ref")?.to_string();
899
900    Ok(FocusInput {
901        session_id: parse_session_id(request.param_str("session").map(String::from)),
902        element_ref,
903    })
904}
905
906/// Parse ClearInput from RpcRequest.
907#[allow(clippy::result_large_err)]
908pub fn parse_clear_input(request: &RpcRequest) -> Result<ClearInput, RpcResponse> {
909    let element_ref = request.require_str("ref")?.to_string();
910
911    Ok(ClearInput {
912        session_id: parse_session_id(request.param_str("session").map(String::from)),
913        element_ref,
914    })
915}
916
917/// Parse SelectAllInput from RpcRequest.
918#[allow(clippy::result_large_err)]
919pub fn parse_select_all_input(request: &RpcRequest) -> Result<SelectAllInput, RpcResponse> {
920    let element_ref = request.require_str("ref")?.to_string();
921
922    Ok(SelectAllInput {
923        session_id: parse_session_id(request.param_str("session").map(String::from)),
924        element_ref,
925    })
926}
927
928/// Parse ScrollIntoViewInput from RpcRequest.
929#[allow(clippy::result_large_err)]
930pub fn parse_scroll_into_view_input(
931    request: &RpcRequest,
932) -> Result<ScrollIntoViewInput, RpcResponse> {
933    let element_ref = request.require_str("ref")?.to_string();
934
935    Ok(ScrollIntoViewInput {
936        session_id: parse_session_id(request.param_str("session").map(String::from)),
937        element_ref,
938    })
939}
940
941/// Parse ElementStateInput from RpcRequest (for get_text, get_value, is_visible, etc.).
942#[allow(clippy::result_large_err)]
943pub fn parse_element_state_input(request: &RpcRequest) -> Result<ElementStateInput, RpcResponse> {
944    let element_ref = request.require_str("ref")?.to_string();
945
946    Ok(ElementStateInput {
947        session_id: parse_session_id(request.param_str("session").map(String::from)),
948        element_ref,
949    })
950}
951
952/// Parse ToggleInput from RpcRequest.
953#[allow(clippy::result_large_err)]
954pub fn parse_toggle_input(request: &RpcRequest) -> Result<ToggleInput, RpcResponse> {
955    let element_ref = request.require_str("ref")?.to_string();
956
957    Ok(ToggleInput {
958        session_id: parse_session_id(request.param_str("session").map(String::from)),
959        element_ref,
960        state: request.param_bool_opt("state"),
961    })
962}
963
964/// Parse SelectInput from RpcRequest.
965#[allow(clippy::result_large_err)]
966pub fn parse_select_input(request: &RpcRequest) -> Result<SelectInput, RpcResponse> {
967    let element_ref = request.require_str("ref")?.to_string();
968    let option = request.require_str("option")?.to_string();
969
970    Ok(SelectInput {
971        session_id: parse_session_id(request.param_str("session").map(String::from)),
972        element_ref,
973        option,
974    })
975}
976
977/// Parse MultiselectInput from RpcRequest.
978#[allow(clippy::result_large_err)]
979pub fn parse_multiselect_input(request: &RpcRequest) -> Result<MultiselectInput, RpcResponse> {
980    let options: Vec<String> = request
981        .require_array("options")?
982        .iter()
983        .filter_map(|v| v.as_str().map(str::to_owned))
984        .collect();
985
986    if options.is_empty() {
987        return Err(RpcResponse::error(
988            request.id,
989            -32602,
990            "Options array cannot be empty",
991        ));
992    }
993
994    let element_ref = request.require_str("ref")?.to_string();
995
996    Ok(MultiselectInput {
997        session_id: parse_session_id(request.param_str("session").map(String::from)),
998        element_ref,
999        options,
1000    })
1001}
1002
1003// ============================================================
1004// Key input parsers
1005// ============================================================
1006
1007/// Parse KeydownInput from RpcRequest.
1008#[allow(clippy::result_large_err)]
1009pub fn parse_keydown_input(request: &RpcRequest) -> Result<KeydownInput, RpcResponse> {
1010    let key = request.require_str("key")?.to_string();
1011
1012    Ok(KeydownInput {
1013        session_id: parse_session_id(request.param_str("session").map(String::from)),
1014        key,
1015    })
1016}
1017
1018/// Parse KeyupInput from RpcRequest.
1019#[allow(clippy::result_large_err)]
1020pub fn parse_keyup_input(request: &RpcRequest) -> Result<KeyupInput, RpcResponse> {
1021    let key = request.require_str("key")?.to_string();
1022
1023    Ok(KeyupInput {
1024        session_id: parse_session_id(request.param_str("session").map(String::from)),
1025        key,
1026    })
1027}
1028
1029// ============================================================
1030// Recording input parsers
1031// ============================================================
1032
1033/// Parse RecordStartInput from RpcRequest.
1034pub fn parse_record_start_input(request: &RpcRequest) -> RecordStartInput {
1035    RecordStartInput {
1036        session_id: parse_session_id(request.param_str("session").map(String::from)),
1037    }
1038}
1039
1040/// Parse RecordStopInput from RpcRequest.
1041pub fn parse_record_stop_input(request: &RpcRequest) -> RecordStopInput {
1042    RecordStopInput {
1043        session_id: parse_session_id(request.param_str("session").map(String::from)),
1044        format: request.param_str("format").map(String::from),
1045    }
1046}
1047
1048/// Parse RecordStatusInput from RpcRequest.
1049pub fn parse_record_status_input(request: &RpcRequest) -> RecordStatusInput {
1050    RecordStatusInput {
1051        session_id: parse_session_id(request.param_str("session").map(String::from)),
1052    }
1053}
1054
1055// ============================================================
1056// Diagnostics input parsers
1057// ============================================================
1058
1059/// Parse AccessibilitySnapshotInput from RpcRequest.
1060pub fn parse_accessibility_snapshot_input(request: &RpcRequest) -> AccessibilitySnapshotInput {
1061    AccessibilitySnapshotInput {
1062        session_id: parse_session_id(request.param_str("session").map(String::from)),
1063        interactive_only: request.param_bool("interactive", false),
1064    }
1065}
1066
1067/// Parse TraceInput from RpcRequest.
1068pub fn parse_trace_input(request: &RpcRequest) -> TraceInput {
1069    TraceInput {
1070        session_id: parse_session_id(request.param_str("session").map(String::from)),
1071        start: false,
1072        stop: false,
1073        count: request.param_u64("count", 1000) as usize,
1074    }
1075}
1076
1077/// Parse ConsoleInput from RpcRequest.
1078pub fn parse_console_input(request: &RpcRequest) -> ConsoleInput {
1079    ConsoleInput {
1080        session_id: parse_session_id(request.param_str("session").map(String::from)),
1081        count: 0,
1082        clear: false,
1083    }
1084}
1085
1086/// Parse ErrorsInput from RpcRequest.
1087pub fn parse_errors_input(request: &RpcRequest) -> ErrorsInput {
1088    ErrorsInput {
1089        session_id: parse_session_id(request.param_str("session").map(String::from)),
1090        count: request.param_u64("count", 1000) as usize,
1091        clear: false,
1092    }
1093}
1094
1095/// Parse PtyReadInput from RpcRequest.
1096pub fn parse_pty_read_input(request: &RpcRequest) -> PtyReadInput {
1097    PtyReadInput {
1098        session_id: parse_session_id(request.param_str("session").map(String::from)),
1099        max_bytes: request.param_u64("max_bytes", 4096) as usize,
1100    }
1101}
1102
1103/// Parse PtyWriteInput from RpcRequest.
1104#[allow(clippy::result_large_err)]
1105pub fn parse_pty_write_input(request: &RpcRequest) -> Result<PtyWriteInput, RpcResponse> {
1106    let data = request.require_str("data")?.to_string();
1107
1108    Ok(PtyWriteInput {
1109        session_id: parse_session_id(request.param_str("session").map(String::from)),
1110        data,
1111    })
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116    use super::*;
1117    use crate::daemon::domain::SessionId;
1118
1119    fn make_request(id: u64, method: &str, params: Option<serde_json::Value>) -> RpcRequest {
1120        RpcRequest::new(id, method.to_string(), params)
1121    }
1122
1123    #[test]
1124    fn test_parse_spawn_input_defaults() {
1125        let request = make_request(1, "spawn", Some(json!({})));
1126        let input = parse_spawn_input(&request).unwrap();
1127        assert_eq!(input.command, "bash");
1128        assert!(input.args.is_empty());
1129        assert_eq!(input.cols, 80);
1130        assert_eq!(input.rows, 24);
1131    }
1132
1133    #[test]
1134    fn test_parse_spawn_input_custom() {
1135        let request = make_request(
1136            1,
1137            "spawn",
1138            Some(json!({
1139                "command": "vim",
1140                "args": ["file.txt"],
1141                "cols": 120,
1142                "rows": 40,
1143                "cwd": "/home/user"
1144            })),
1145        );
1146        let input = parse_spawn_input(&request).unwrap();
1147        assert_eq!(input.command, "vim");
1148        assert_eq!(input.args, vec!["file.txt"]);
1149        assert_eq!(input.cols, 120);
1150        assert_eq!(input.rows, 40);
1151        assert_eq!(input.cwd, Some("/home/user".to_string()));
1152    }
1153
1154    #[test]
1155    fn test_parse_spawn_input_clamps_values() {
1156        let request = make_request(
1157            1,
1158            "spawn",
1159            Some(json!({
1160                "cols": 1000,
1161                "rows": 500
1162            })),
1163        );
1164        let input = parse_spawn_input(&request).unwrap();
1165        assert_eq!(input.cols, MAX_TERMINAL_COLS);
1166        assert_eq!(input.rows, MAX_TERMINAL_ROWS);
1167    }
1168
1169    #[test]
1170    fn test_parse_snapshot_input() {
1171        let request = make_request(
1172            1,
1173            "snapshot",
1174            Some(json!({
1175                "session": "abc123",
1176                "include_elements": true,
1177                "include_cursor": true
1178            })),
1179        );
1180        let input = parse_snapshot_input(&request);
1181        assert_eq!(input.session_id, Some(SessionId::new("abc123")));
1182        assert!(input.include_elements);
1183        assert!(input.include_cursor);
1184    }
1185
1186    #[test]
1187    fn test_parse_wait_input() {
1188        let request = make_request(
1189            1,
1190            "wait",
1191            Some(json!({
1192                "text": "Ready",
1193                "timeout_ms": 5000
1194            })),
1195        );
1196        let input = parse_wait_input(&request);
1197        assert_eq!(input.text, Some("Ready".to_string()));
1198        assert_eq!(input.timeout_ms, 5000);
1199    }
1200
1201    // ================================================================
1202    // Element-ref based input parser tests
1203    // ================================================================
1204
1205    #[test]
1206    fn test_parse_double_click_input() {
1207        let request = make_request(
1208            1,
1209            "dbl_click",
1210            Some(json!({ "ref": "@btn1", "session": "sess1" })),
1211        );
1212        let input = parse_double_click_input(&request).unwrap();
1213        assert_eq!(input.element_ref, "@btn1");
1214        assert_eq!(input.session_id, Some(SessionId::new("sess1")));
1215    }
1216
1217    #[test]
1218    fn test_parse_double_click_input_missing_ref() {
1219        let request = make_request(1, "dbl_click", Some(json!({})));
1220        let result = parse_double_click_input(&request);
1221        assert!(result.is_err());
1222    }
1223
1224    #[test]
1225    fn test_parse_focus_input() {
1226        let request = make_request(1, "focus", Some(json!({ "ref": "@input1" })));
1227        let input = parse_focus_input(&request).unwrap();
1228        assert_eq!(input.element_ref, "@input1");
1229        assert!(input.session_id.is_none());
1230    }
1231
1232    #[test]
1233    fn test_parse_clear_input() {
1234        let request = make_request(1, "clear", Some(json!({ "ref": "@field" })));
1235        let input = parse_clear_input(&request).unwrap();
1236        assert_eq!(input.element_ref, "@field");
1237    }
1238
1239    #[test]
1240    fn test_parse_select_all_input() {
1241        let request = make_request(1, "select_all", Some(json!({ "ref": "@textarea" })));
1242        let input = parse_select_all_input(&request).unwrap();
1243        assert_eq!(input.element_ref, "@textarea");
1244    }
1245
1246    #[test]
1247    fn test_parse_scroll_into_view_input() {
1248        let request = make_request(1, "scroll_into_view", Some(json!({ "ref": "@item" })));
1249        let input = parse_scroll_into_view_input(&request).unwrap();
1250        assert_eq!(input.element_ref, "@item");
1251    }
1252
1253    #[test]
1254    fn test_parse_element_state_input() {
1255        let request = make_request(1, "get_text", Some(json!({ "ref": "@label" })));
1256        let input = parse_element_state_input(&request).unwrap();
1257        assert_eq!(input.element_ref, "@label");
1258    }
1259
1260    #[test]
1261    fn test_parse_toggle_input_with_state() {
1262        let request = make_request(
1263            1,
1264            "toggle",
1265            Some(json!({ "ref": "@checkbox", "state": true })),
1266        );
1267        let input = parse_toggle_input(&request).unwrap();
1268        assert_eq!(input.element_ref, "@checkbox");
1269        assert_eq!(input.state, Some(true));
1270    }
1271
1272    #[test]
1273    fn test_parse_toggle_input_without_state() {
1274        let request = make_request(1, "toggle", Some(json!({ "ref": "@checkbox" })));
1275        let input = parse_toggle_input(&request).unwrap();
1276        assert!(input.state.is_none());
1277    }
1278
1279    #[test]
1280    fn test_parse_select_input() {
1281        let request = make_request(
1282            1,
1283            "select",
1284            Some(json!({ "ref": "@dropdown", "option": "choice1" })),
1285        );
1286        let input = parse_select_input(&request).unwrap();
1287        assert_eq!(input.element_ref, "@dropdown");
1288        assert_eq!(input.option, "choice1");
1289    }
1290
1291    #[test]
1292    fn test_parse_select_input_missing_option() {
1293        let request = make_request(1, "select", Some(json!({ "ref": "@dropdown" })));
1294        let result = parse_select_input(&request);
1295        assert!(result.is_err());
1296    }
1297
1298    #[test]
1299    fn test_parse_multiselect_input() {
1300        let request = make_request(
1301            1,
1302            "multiselect",
1303            Some(json!({ "ref": "@list", "options": ["a", "b", "c"] })),
1304        );
1305        let input = parse_multiselect_input(&request).unwrap();
1306        assert_eq!(input.element_ref, "@list");
1307        assert_eq!(input.options, vec!["a", "b", "c"]);
1308    }
1309
1310    #[test]
1311    fn test_parse_multiselect_input_empty_options() {
1312        let request = make_request(
1313            1,
1314            "multiselect",
1315            Some(json!({ "ref": "@list", "options": [] })),
1316        );
1317        let result = parse_multiselect_input(&request);
1318        assert!(result.is_err());
1319    }
1320
1321    // ================================================================
1322    // Key input parser tests
1323    // ================================================================
1324
1325    #[test]
1326    fn test_parse_keydown_input() {
1327        let request = make_request(1, "keydown", Some(json!({ "key": "Shift" })));
1328        let input = parse_keydown_input(&request).unwrap();
1329        assert_eq!(input.key, "Shift");
1330    }
1331
1332    #[test]
1333    fn test_parse_keyup_input() {
1334        let request = make_request(1, "keyup", Some(json!({ "key": "Control" })));
1335        let input = parse_keyup_input(&request).unwrap();
1336        assert_eq!(input.key, "Control");
1337    }
1338
1339    // ================================================================
1340    // Recording input parser tests
1341    // ================================================================
1342
1343    #[test]
1344    fn test_parse_record_start_input() {
1345        let request = make_request(1, "record_start", Some(json!({ "session": "rec-session" })));
1346        let input = parse_record_start_input(&request);
1347        assert_eq!(input.session_id, Some(SessionId::new("rec-session")));
1348    }
1349
1350    #[test]
1351    fn test_parse_record_stop_input() {
1352        let request = make_request(1, "record_stop", Some(json!({ "format": "asciicast" })));
1353        let input = parse_record_stop_input(&request);
1354        assert_eq!(input.format, Some("asciicast".to_string()));
1355    }
1356
1357    #[test]
1358    fn test_parse_record_status_input() {
1359        let request = make_request(1, "record_status", None);
1360        let input = parse_record_status_input(&request);
1361        assert!(input.session_id.is_none());
1362    }
1363
1364    // ================================================================
1365    // Diagnostics input parser tests
1366    // ================================================================
1367
1368    #[test]
1369    fn test_parse_accessibility_snapshot_input() {
1370        let request = make_request(
1371            1,
1372            "accessibility_snapshot",
1373            Some(json!({ "interactive": true })),
1374        );
1375        let input = parse_accessibility_snapshot_input(&request);
1376        assert!(input.interactive_only);
1377    }
1378
1379    #[test]
1380    fn test_parse_trace_input() {
1381        let request = make_request(1, "trace", Some(json!({ "count": 500 })));
1382        let input = parse_trace_input(&request);
1383        assert_eq!(input.count, 500);
1384    }
1385
1386    #[test]
1387    fn test_parse_trace_input_defaults() {
1388        let request = make_request(1, "trace", None);
1389        let input = parse_trace_input(&request);
1390        assert_eq!(input.count, 1000);
1391    }
1392
1393    #[test]
1394    fn test_parse_console_input() {
1395        let request = make_request(1, "console", None);
1396        let input = parse_console_input(&request);
1397        assert_eq!(input.count, 0);
1398        assert!(!input.clear);
1399    }
1400
1401    #[test]
1402    fn test_parse_errors_input() {
1403        let request = make_request(1, "errors", Some(json!({ "count": 50 })));
1404        let input = parse_errors_input(&request);
1405        assert_eq!(input.count, 50);
1406    }
1407
1408    #[test]
1409    fn test_parse_pty_read_input() {
1410        let request = make_request(1, "pty_read", Some(json!({ "max_bytes": 8192 })));
1411        let input = parse_pty_read_input(&request);
1412        assert_eq!(input.max_bytes, 8192);
1413    }
1414
1415    #[test]
1416    fn test_parse_pty_read_input_defaults() {
1417        let request = make_request(1, "pty_read", None);
1418        let input = parse_pty_read_input(&request);
1419        assert_eq!(input.max_bytes, 4096);
1420    }
1421
1422    #[test]
1423    fn test_parse_pty_write_input() {
1424        let request = make_request(1, "pty_write", Some(json!({ "data": "hello" })));
1425        let input = parse_pty_write_input(&request).unwrap();
1426        assert_eq!(input.data, "hello");
1427    }
1428
1429    #[test]
1430    fn test_parse_pty_write_input_missing_data() {
1431        let request = make_request(1, "pty_write", Some(json!({})));
1432        let result = parse_pty_write_input(&request);
1433        assert!(result.is_err());
1434    }
1435
1436    // ================================================================
1437    // Output to response adapter tests
1438    // ================================================================
1439
1440    fn extract_result(response: RpcResponse) -> serde_json::Value {
1441        let json_str = serde_json::to_string(&response).expect("serialize");
1442        let parsed: serde_json::Value = serde_json::from_str(&json_str).expect("parse");
1443        parsed["result"].clone()
1444    }
1445
1446    #[test]
1447    fn test_health_output_to_response() {
1448        let output = HealthOutput {
1449            status: "healthy".to_string(),
1450            pid: 1234,
1451            uptime_ms: 5000,
1452            session_count: 2,
1453            version: "0.1.0".to_string(),
1454            active_connections: 1,
1455            total_requests: 100,
1456            error_count: 5,
1457        };
1458        let response = health_output_to_response(1, output);
1459        let result = extract_result(response);
1460        assert_eq!(result["status"], "healthy");
1461        assert_eq!(result["pid"], 1234);
1462    }
1463
1464    #[test]
1465    fn test_metrics_output_to_response() {
1466        let output = MetricsOutput {
1467            requests_total: 100,
1468            errors_total: 5,
1469            lock_timeouts: 2,
1470            poison_recoveries: 0,
1471            uptime_ms: 10000,
1472            active_connections: 3,
1473            session_count: 2,
1474        };
1475        let response = metrics_output_to_response(1, output);
1476        let result = extract_result(response);
1477        assert_eq!(result["requests_total"], 100);
1478        assert_eq!(result["errors_total"], 5);
1479    }
1480
1481    #[test]
1482    fn test_console_output_to_response() {
1483        let output = ConsoleOutput {
1484            lines: vec!["line1".to_string(), "line2".to_string()],
1485        };
1486        let response = console_output_to_response(1, output);
1487        let result = extract_result(response);
1488        assert_eq!(result["line_count"], 2);
1489    }
1490
1491    #[test]
1492    fn test_pty_read_output_to_response() {
1493        let output = PtyReadOutput {
1494            session_id: SessionId::new("sess1"),
1495            data: "output".to_string(),
1496            bytes_read: 6,
1497        };
1498        let response = pty_read_output_to_response(1, output);
1499        let result = extract_result(response);
1500        assert_eq!(result["session_id"], "sess1");
1501        assert_eq!(result["bytes_read"], 6);
1502    }
1503
1504    #[test]
1505    fn test_pty_write_output_to_response() {
1506        let output = PtyWriteOutput {
1507            session_id: SessionId::new("sess1"),
1508            bytes_written: 5,
1509            success: true,
1510        };
1511        let response = pty_write_output_to_response(1, output);
1512        let result = extract_result(response);
1513        assert!(result["success"].as_bool().unwrap());
1514        assert_eq!(result["bytes_written"], 5);
1515    }
1516
1517    #[test]
1518    fn test_record_start_output_to_response() {
1519        let output = RecordStartOutput {
1520            session_id: SessionId::new("rec1"),
1521            success: true,
1522        };
1523        let response = record_start_output_to_response(1, output);
1524        let result = extract_result(response);
1525        assert!(result["recording"].as_bool().unwrap());
1526    }
1527
1528    #[test]
1529    fn test_get_text_output_to_response() {
1530        let output = GetTextOutput {
1531            found: true,
1532            text: "hello".to_string(),
1533        };
1534        let response = get_text_output_to_response(1, "@label", output);
1535        let result = extract_result(response);
1536        assert_eq!(result["ref"], "@label");
1537        assert_eq!(result["text"], "hello");
1538        assert!(result["found"].as_bool().unwrap());
1539    }
1540
1541    #[test]
1542    fn test_visibility_output_to_response() {
1543        let output = VisibilityOutput {
1544            found: true,
1545            visible: true,
1546        };
1547        let response = visibility_output_to_response(1, "@btn", output);
1548        let result = extract_result(response);
1549        assert_eq!(result["ref"], "@btn");
1550        assert!(result["visible"].as_bool().unwrap());
1551    }
1552
1553    #[test]
1554    fn test_toggle_output_to_response() {
1555        let output = ToggleOutput {
1556            success: true,
1557            checked: true,
1558            message: None,
1559        };
1560        let response = toggle_output_to_response(1, "@checkbox", output);
1561        let result = extract_result(response);
1562        assert!(result["checked"].as_bool().unwrap());
1563    }
1564
1565    #[test]
1566    fn test_scroll_into_view_output_to_response_success() {
1567        let output = ScrollIntoViewOutput {
1568            success: true,
1569            scrolls_needed: 3,
1570            message: None,
1571        };
1572        let response = scroll_into_view_output_to_response(1, "@item", output);
1573        let result = extract_result(response);
1574        assert!(result["success"].as_bool().unwrap());
1575        assert_eq!(result["scrolls_needed"], 3);
1576    }
1577
1578    #[test]
1579    fn test_scroll_into_view_output_to_response_failure() {
1580        let output = ScrollIntoViewOutput {
1581            success: false,
1582            scrolls_needed: 0,
1583            message: Some("Not found".to_string()),
1584        };
1585        let response = scroll_into_view_output_to_response(1, "@item", output);
1586        let result = extract_result(response);
1587        assert!(!result["success"].as_bool().unwrap());
1588        assert_eq!(result["message"], "Not found");
1589    }
1590
1591    #[test]
1592    fn test_multiselect_output_to_response() {
1593        let output = MultiselectOutput {
1594            success: true,
1595            selected_options: vec!["a".to_string(), "b".to_string()],
1596            message: None,
1597        };
1598        let response = multiselect_output_to_response(1, "@list", output);
1599        let result = extract_result(response);
1600        assert!(result["success"].as_bool().unwrap());
1601        assert_eq!(result["selected_options"].as_array().unwrap().len(), 2);
1602    }
1603
1604    #[test]
1605    fn test_get_title_output_to_response() {
1606        let output = GetTitleOutput {
1607            session_id: SessionId::new("sess1"),
1608            title: "My Terminal".to_string(),
1609        };
1610        let response = get_title_output_to_response(1, output);
1611        let result = extract_result(response);
1612        assert_eq!(result["session_id"], "sess1");
1613        assert_eq!(result["title"], "My Terminal");
1614    }
1615}