From 96bc43965e287c5cb836cf262d756de3ff0778b8 Mon Sep 17 00:00:00 2001 From: Brent G Date: Sun, 21 Jun 2026 06:03:48 +0000 Subject: [PATCH] =?UTF-8?q?feat(remote-control):=20receive=20agentInput=20?= =?UTF-8?q?(text=20+=20control=20keys)=20=E2=86=92=20agent=20PTY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Atem side of remote agent control v1. Astation (merged) sends `agentInput` over the relay; Atem now receives it and drives the focused agent's terminal. - AstationMessage::AgentInput { agent_id, kind, text, key } matching the Astation wire shape exactly ({type:"agentInput", data:{agentId?, kind, text?, key?}}). - handle_agent_input: kind:"text" trims/skips-empty then types the line + submit sequence (\n\r, matching send_claude_prompt); kind:"key" writes raw PTY bytes via agent_key_to_bytes (enter/esc/ctrl-c/up/down/left/right/y/n). - Routes to the codex PTY when it's the focused chat, else claude; no-ops if no agent session is live (v1: drives an already-running agent only). - 10 tests: wire deserialization/roundtrip, keyβ†’bytes mapping, and handler routing asserting exact bytes reach the PTY (incl. empty-text + unknown-key). πŸ€– Built with SMT --- designs/remote-agent-control.md | 7 +- src/app.rs | 144 ++++++++++++++++++++++++++++++++ src/websocket_client.rs | 65 ++++++++++++++ 3 files changed, 214 insertions(+), 2 deletions(-) diff --git a/designs/remote-agent-control.md b/designs/remote-agent-control.md index 1270df7..a1bc5e5 100644 --- a/designs/remote-agent-control.md +++ b/designs/remote-agent-control.md @@ -153,8 +153,11 @@ atem β†’ Astation acks/feedback ride the existing `commandResponse` / ### Injection semantics (atem side) - atem owns the agent PTY (`claude_client.rs` / `codex_client.rs`). `kind:text` - β†’ write `text` + `\n` to the PTY master. `kind:key` β†’ write the raw byte(s) - (`\r`, `\x1b`, `\x03`, arrow CSI, `y`/`n`) to the PTY master. + β†’ trim, skip-if-empty, then write `text` + the submit sequence `\n\r` to the + PTY master (matches `send_claude_prompt`, which the Claude/Codex TUIs need to + reliably accept a line). `kind:key` β†’ write the raw byte(s) (`\r`, `\x1b`, + `\x03`, arrow CSI, `y`/`n`) to the PTY master. Implemented as + `handle_agent_input` + `agent_key_to_bytes` in `app.rs`. - **Busy handling**: input is written to the live TUI's stdin. If the agent is mid-task, a typed line queues at its prompt (same as a human typing early); `Ctrl-C` is how you interrupt. atem does not try to gate on agent state in v1. diff --git a/src/app.rs b/src/app.rs index 741520a..f99a4ad 100644 --- a/src/app.rs +++ b/src/app.rs @@ -182,6 +182,23 @@ pub struct PendingVisualize { pub last_output_at: Instant, } +/// Map an `agentInput` control-key name (from Astation) to the raw byte +/// sequence written to the agent PTY. Returns None for an unknown key. +pub fn agent_key_to_bytes(key: &str) -> Option<&'static str> { + match key { + "enter" => Some("\r"), + "esc" => Some("\x1b"), + "ctrl-c" => Some("\x03"), + "up" => Some("\x1b[A"), + "down" => Some("\x1b[B"), + "left" => Some("\x1b[D"), + "right" => Some("\x1b[C"), + "y" => Some("y"), + "n" => Some("n"), + _ => None, + } +} + /// Tracks a voice coding request waiting for Claude to finish. #[derive(Debug, Clone)] pub struct PendingVoiceRequest { @@ -1983,6 +2000,14 @@ impl App { } => { self.handle_voice_request(session_id, accumulated_text, relay_url).await; } + AstationMessage::AgentInput { + agent_id, + kind, + text, + key, + } => { + self.handle_agent_input(agent_id, kind, text, key); + } AstationMessage::VisualizeRequest { session_id, topic, @@ -2226,6 +2251,54 @@ impl App { } /// Handle a voice coding request from Astation: send to Claude and track for completion. + /// Remote agent control (Astation β†’ Atem): write text or a control key to + /// the focused agent's PTY. `agent_id` is ignored in v1 (the focused/only + /// agent = the current chat mode). `kind:"text"` types the line and submits + /// it with Enter; `kind:"key"` writes the raw control bytes (see + /// [`agent_key_to_bytes`]). Unknown kinds/keys are dropped with a status note. + pub fn handle_agent_input( + &mut self, + _agent_id: Option, + kind: String, + text: Option, + key: Option, + ) { + // `kind` selects the input; the non-matching field is ignored (Astation + // only ever populates one). + let data: Option = match kind.as_str() { + // Type the line and submit it. Trims + skips empty like + // send_claude_prompt, and matches its submit sequence (text, then + // "\n", then "\r") which the Claude/Codex TUIs need to accept a line. + "text" => text + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .map(|t| format!("{t}\n\r")), + "key" => key + .as_deref() + .and_then(agent_key_to_bytes) + .map(|s| s.to_string()), + _ => None, + }; + + let Some(data) = data else { + self.status_message = Some(format!( + "Ignored agentInput (kind={}, key={:?})", + kind, key + )); + return; + }; + + // Route to the focused agent's PTY. Codex when it's the active chat; + // Claude otherwise (matches the voice path's default target). The + // send_* helpers no-op if that agent has no live session. + if matches!(self.mode, AppMode::CodexChat) && self.codex_sender.is_some() { + self.send_codex_data(&data); + } else { + self.send_claude_data(&data); + } + self.status_message = Some(format!("\u{2328} agentInput: {}", kind)); + } + pub async fn handle_voice_request( &mut self, session_id: String, @@ -2562,6 +2635,77 @@ pub fn current_timestamp_ms() -> u64 { mod tests { use super::*; + #[test] + fn agent_key_maps_control_keys_to_pty_bytes() { + assert_eq!(agent_key_to_bytes("enter"), Some("\r")); + assert_eq!(agent_key_to_bytes("esc"), Some("\x1b")); + assert_eq!(agent_key_to_bytes("ctrl-c"), Some("\x03")); + assert_eq!(agent_key_to_bytes("up"), Some("\x1b[A")); + assert_eq!(agent_key_to_bytes("down"), Some("\x1b[B")); + assert_eq!(agent_key_to_bytes("y"), Some("y")); + assert_eq!(agent_key_to_bytes("n"), Some("n")); + } + + #[test] + fn agent_key_unknown_returns_none() { + assert_eq!(agent_key_to_bytes("f13"), None); + assert_eq!(agent_key_to_bytes(""), None); + assert_eq!(agent_key_to_bytes("Enter"), None); // case-sensitive by design + } + + #[test] + fn handle_agent_input_text_types_line_and_submits_to_claude() { + let mut app = App::new(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + app.claude_sender = Some(tx); + app.mode = AppMode::ClaudeChat; + app.handle_agent_input(None, "text".into(), Some("hello".into()), None); + let got: String = std::iter::from_fn(|| rx.try_recv().ok()).collect(); + assert_eq!(got, "hello\n\r"); + } + + #[test] + fn handle_agent_input_key_writes_raw_control_bytes() { + let mut app = App::new(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + app.claude_sender = Some(tx); + app.mode = AppMode::ClaudeChat; + app.handle_agent_input(None, "key".into(), None, Some("ctrl-c".into())); + let got: String = std::iter::from_fn(|| rx.try_recv().ok()).collect(); + assert_eq!(got, "\x03"); + } + + #[test] + fn handle_agent_input_empty_text_sends_nothing() { + let mut app = App::new(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + app.claude_sender = Some(tx); + app.mode = AppMode::ClaudeChat; + app.handle_agent_input(None, "text".into(), Some(" ".into()), None); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn handle_agent_input_unknown_key_sends_nothing() { + let mut app = App::new(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + app.claude_sender = Some(tx); + app.mode = AppMode::ClaudeChat; + app.handle_agent_input(None, "key".into(), None, Some("f13".into())); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn handle_agent_input_routes_to_codex_when_focused() { + let mut app = App::new(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + app.codex_sender = Some(tx); + app.mode = AppMode::CodexChat; + app.handle_agent_input(None, "key".into(), None, Some("esc".into())); + let got: String = std::iter::from_fn(|| rx.try_recv().ok()).collect(); + assert_eq!(got, "\x1b"); + } + #[test] fn test_active_cli_default_is_claude() { let app = App::new(); diff --git a/src/websocket_client.rs b/src/websocket_client.rs index 2110f0e..8840801 100644 --- a/src/websocket_client.rs +++ b/src/websocket_client.rs @@ -191,6 +191,20 @@ pub enum AstationMessage { relay_url: String, }, + /// Astation β†’ Atem (remote agent control v1): text or a control key to write + /// to the focused agent's PTY. `agent_id` is optional (v1 = focused/only + /// agent). Wire shape: `{type:"agentInput", data:{agentId?, kind, text?, key?}}`. + #[serde(rename = "agentInput")] + AgentInput { + #[serde(rename = "agentId", default, skip_serializing_if = "Option::is_none")] + agent_id: Option, + kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + key: Option, + }, + /// Atem β†’ Astation: voice coding response confirmation. #[serde(rename = "voiceResponse")] VoiceResponse { @@ -2163,6 +2177,57 @@ mod tests { matches!(parsed, AstationMessage::PairSavePreference { save_credentials: true }); } + // --- AgentInput (remote agent control) --- + + #[test] + fn agent_input_text_deserializes_from_astation_wire() { + // Exact shape sent by Astation's sendAgentText (key omitted). + let json = r#"{"type":"agentInput","data":{"agentId":"a1","kind":"text","text":"refactor auth"}}"#; + let msg: AstationMessage = serde_json::from_str(json).unwrap(); + match msg { + AstationMessage::AgentInput { agent_id, kind, text, key } => { + assert_eq!(agent_id.as_deref(), Some("a1")); + assert_eq!(kind, "text"); + assert_eq!(text.as_deref(), Some("refactor auth")); + assert_eq!(key, None); + } + _ => panic!("expected AgentInput"), + } + } + + #[test] + fn agent_input_key_deserializes_without_agent_id() { + // sendAgentKey with agentId omitted (v1 focused-agent default). + let json = r#"{"type":"agentInput","data":{"kind":"key","key":"ctrl-c"}}"#; + let msg: AstationMessage = serde_json::from_str(json).unwrap(); + match msg { + AstationMessage::AgentInput { agent_id, kind, text, key } => { + assert_eq!(agent_id, None); + assert_eq!(kind, "key"); + assert_eq!(text, None); + assert_eq!(key.as_deref(), Some("ctrl-c")); + } + _ => panic!("expected AgentInput"), + } + } + + #[test] + fn agent_input_roundtrips() { + let msg = AstationMessage::AgentInput { + agent_id: None, + kind: "text".into(), + text: Some("hi".into()), + key: None, + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains(r#""type":"agentInput""#)); + // nil fields are omitted on the wire + assert!(!json.contains("agentId")); + assert!(!json.contains("\"key\"")); + let parsed: AstationMessage = serde_json::from_str(&json).unwrap(); + matches!(parsed, AstationMessage::AgentInput { .. }); + } + #[test] fn pair_save_preference_roundtrip_false() { let msg = AstationMessage::PairSavePreference { save_credentials: false };