From b339fecad3ccfb307f3300e1256cf4567ae2bb59 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 8 Sep 2026 13:43:01 +0100 Subject: [PATCH] feat(maestro): route `maestro send` through ACP for structured sessions Structured sessions were rejected by `send_input` with NotTerminal. Now `run_send` checks the session view and routes to `send_structured_prompt` for structured sessions. Co-Authored-By: Claude Opus 4.6 --- crates/maestro-cli/src/cli.rs | 4 +- crates/maestro-cli/src/sessions.rs | 42 ++++++++++++------- .../maestro-cli/tests/against_mock_daemon.rs | 31 +++++++++++++- crates/maestro-testing/src/lib.rs | 39 ++++++++++++++++- 4 files changed, 97 insertions(+), 19 deletions(-) diff --git a/crates/maestro-cli/src/cli.rs b/crates/maestro-cli/src/cli.rs index 882f5e4..250b502 100644 --- a/crates/maestro-cli/src/cli.rs +++ b/crates/maestro-cli/src/cli.rs @@ -41,8 +41,8 @@ pub enum Command { Attach(AttachArgs), /// Kill a session by ID. Kill(KillArgs), - /// Type text into a running session, as if it had been typed on an - /// attached terminal. + /// Send text to a running session. Terminal sessions receive raw + /// keystrokes; structured sessions receive an ACP prompt. Send(SendArgs), /// Install or update the agent activity hook (#106). Hooks(HooksArgs), diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index 88e8beb..57ea9ef 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -130,9 +130,12 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result id, - Err(_) => { - let sessions = client.list_sessions().await?; - resolve_session(&sessions, &args.session)? - } - }; + // We always need the session list: even a bare UUID needs the view field + // to decide which endpoint to hit. + let sessions = client.list_sessions().await?; + let id = resolve_session(&sessions, &args.session)?; + let view = sessions + .iter() + .find(|s| s.id == id) + .map(|s| s.view) + .unwrap_or(SessionView::Terminal); - let req = SendInputRequest { - text, - submit: !args.no_enter, - }; - client.send_input(id, &req).await?; + match view { + SessionView::Structured => { + client.send_structured_prompt(id, &text).await?; + } + SessionView::Terminal => { + let req = SendInputRequest { + text, + submit: !args.no_enter, + }; + client.send_input(id, &req).await?; + } + } Ok(id) } diff --git a/crates/maestro-cli/tests/against_mock_daemon.rs b/crates/maestro-cli/tests/against_mock_daemon.rs index 016eceb..0dfe0ed 100644 --- a/crates/maestro-cli/tests/against_mock_daemon.rs +++ b/crates/maestro-cli/tests/against_mock_daemon.rs @@ -2,7 +2,7 @@ //! was unit-tested, but nothing exercised the client, the error decoding, or //! the unreachable-daemon path without compiling and forking a real daemon. -use maestro_protocol::{DaemonClient, DaemonInfo}; +use maestro_protocol::{DaemonClient, DaemonInfo, SessionView}; use maestro_testing::{session, MockDaemon}; use uuid::Uuid; @@ -202,6 +202,35 @@ async fn send_resolves_a_session_by_name() { assert_eq!(sent, id); } +/// A structured session routes through the ACP prompt endpoint, not the +/// terminal input one — `--no-enter` is irrelevant (ACP prompts are +/// complete messages). +#[tokio::test] +async fn send_to_structured_session_uses_acp_prompt() { + let mock = MockDaemon::start().await; + let id = Uuid::from_u128(13); + let mut s = session(id, "/tmp", "claude"); + s.view = SessionView::Structured; + mock.add_session(s); + + let args = maestro_cli::cli::SendArgs { + session: id.to_string(), + text: vec!["fix".into(), "the".into(), "tests".into()], + no_enter: false, + }; + let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty()) + .await + .expect("send"); + + assert_eq!(sent, id); + // Terminal input must stay empty — the message went through ACP. + assert!(mock.inputs().is_empty()); + let prompts = mock.structured_prompts(); + assert_eq!(prompts.len(), 1); + assert_eq!(prompts[0].0, id); + assert_eq!(prompts[0].1.prompt, "fix the tests"); +} + #[tokio::test] async fn send_to_an_unknown_session_fails_without_typing_anything() { let mock = MockDaemon::start().await; diff --git a/crates/maestro-testing/src/lib.rs b/crates/maestro-testing/src/lib.rs index 30c8535..eefdbaa 100644 --- a/crates/maestro-testing/src/lib.rs +++ b/crates/maestro-testing/src/lib.rs @@ -22,7 +22,8 @@ use chrono::Utc; use futures_util::{SinkExt, StreamExt}; use maestro_protocol::{ routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing, - GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION, + GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, StructuredPromptRequest, + PROTOCOL_VERSION, }; use tokio::net::TcpListener; use tokio::sync::{broadcast, oneshot}; @@ -60,6 +61,8 @@ struct Inner { forced_error: Option<(u16, ApiError)>, /// Everything `POST /api/sessions/:id/input` was asked to type, in order. inputs: Vec<(Uuid, SendInputRequest)>, + /// Every ACP prompt sent to a structured session, in order. + structured_prompts: Vec<(Uuid, StructuredPromptRequest)>, } #[derive(Clone)] @@ -92,6 +95,10 @@ impl MockDaemon { .route(routes::SESSIONS, get(list_sessions).post(create_session)) .route(routes::patterns::SESSION, delete(kill_session)) .route(routes::patterns::SESSION_INPUT, post(send_input)) + .route( + routes::patterns::STRUCTURED_PROMPT, + post(structured_prompt), + ) .route(routes::INFO, get(daemon_info)) .route(routes::DIRS, get(list_dir)) .route(routes::RECENT_DIRS, get(recent_dirs)) @@ -178,6 +185,16 @@ impl MockDaemon { self.state.inner.lock().expect("poisoned").inputs.clone() } + /// Every ACP prompt this daemon received, oldest first. + pub fn structured_prompts(&self) -> Vec<(Uuid, StructuredPromptRequest)> { + self.state + .inner + .lock() + .expect("poisoned") + .structured_prompts + .clone() + } + pub fn session_count(&self) -> usize { self.state.inner.lock().expect("poisoned").sessions.len() } @@ -266,6 +283,26 @@ async fn send_input( StatusCode::NO_CONTENT.into_response() } +async fn structured_prompt( + State(s): State, + Path(id): Path, + Json(req): Json, +) -> Response { + if let Some(r) = forced(&s) { + return r; + } + let mut guard = s.inner.lock().expect("poisoned"); + if !guard.sessions.iter().any(|x| x.id == id) { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("not_found", "no such session on this daemon")), + ) + .into_response(); + } + guard.structured_prompts.push((id, req)); + StatusCode::NO_CONTENT.into_response() +} + async fn daemon_info(State(s): State) -> Response { if let Some(r) = forced(&s) { return r;