Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b339fecad3 |
@@ -41,8 +41,8 @@ pub enum Command {
|
|||||||
Attach(AttachArgs),
|
Attach(AttachArgs),
|
||||||
/// Kill a session by ID.
|
/// Kill a session by ID.
|
||||||
Kill(KillArgs),
|
Kill(KillArgs),
|
||||||
/// Type text into a running session, as if it had been typed on an
|
/// Send text to a running session. Terminal sessions receive raw
|
||||||
/// attached terminal.
|
/// keystrokes; structured sessions receive an ACP prompt.
|
||||||
Send(SendArgs),
|
Send(SendArgs),
|
||||||
/// Install or update the agent activity hook (#106).
|
/// Install or update the agent activity hook (#106).
|
||||||
Hooks(HooksArgs),
|
Hooks(HooksArgs),
|
||||||
|
|||||||
@@ -130,9 +130,12 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
|
|||||||
/// `maestro send`. Answers with the session that was typed into, so the
|
/// `maestro send`. Answers with the session that was typed into, so the
|
||||||
/// caller can name it once the daemon has accepted the text.
|
/// caller can name it once the daemon has accepted the text.
|
||||||
///
|
///
|
||||||
/// A bare ID skips the catalog fetch; anything else needs the list to match
|
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
|
||||||
/// against. Text comes from the arguments, or from stdin when there is none —
|
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
|
||||||
/// the shape a heredoc or a pipe wants.
|
/// there — ACP prompts are always complete messages).
|
||||||
|
///
|
||||||
|
/// Text comes from the arguments, or from stdin when there is none — the
|
||||||
|
/// shape a heredoc or a pipe wants.
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub async fn run_send(
|
pub async fn run_send(
|
||||||
client: &DaemonClient,
|
client: &DaemonClient,
|
||||||
@@ -155,19 +158,28 @@ pub async fn run_send(
|
|||||||
return Err(CliError::NothingToSend);
|
return Err(CliError::NothingToSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = match uuid::Uuid::parse_str(args.session.trim()) {
|
// We always need the session list: even a bare UUID needs the view field
|
||||||
Ok(id) => id,
|
// to decide which endpoint to hit.
|
||||||
Err(_) => {
|
let sessions = client.list_sessions().await?;
|
||||||
let sessions = client.list_sessions().await?;
|
let id = resolve_session(&sessions, &args.session)?;
|
||||||
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 {
|
match view {
|
||||||
text,
|
SessionView::Structured => {
|
||||||
submit: !args.no_enter,
|
client.send_structured_prompt(id, &text).await?;
|
||||||
};
|
}
|
||||||
client.send_input(id, &req).await?;
|
SessionView::Terminal => {
|
||||||
|
let req = SendInputRequest {
|
||||||
|
text,
|
||||||
|
submit: !args.no_enter,
|
||||||
|
};
|
||||||
|
client.send_input(id, &req).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
||||||
//! the unreachable-daemon path without compiling and forking a real daemon.
|
//! 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 maestro_testing::{session, MockDaemon};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -202,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
|
|||||||
assert_eq!(sent, id);
|
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]
|
#[tokio::test]
|
||||||
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
||||||
let mock = MockDaemon::start().await;
|
let mock = MockDaemon::start().await;
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ use chrono::Utc;
|
|||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
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::net::TcpListener;
|
||||||
use tokio::sync::{broadcast, oneshot};
|
use tokio::sync::{broadcast, oneshot};
|
||||||
@@ -60,6 +61,8 @@ struct Inner {
|
|||||||
forced_error: Option<(u16, ApiError)>,
|
forced_error: Option<(u16, ApiError)>,
|
||||||
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
||||||
inputs: Vec<(Uuid, SendInputRequest)>,
|
inputs: Vec<(Uuid, SendInputRequest)>,
|
||||||
|
/// Every ACP prompt sent to a structured session, in order.
|
||||||
|
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -92,6 +95,10 @@ impl MockDaemon {
|
|||||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||||
.route(routes::patterns::SESSION, delete(kill_session))
|
.route(routes::patterns::SESSION, delete(kill_session))
|
||||||
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||||
|
.route(
|
||||||
|
routes::patterns::STRUCTURED_PROMPT,
|
||||||
|
post(structured_prompt),
|
||||||
|
)
|
||||||
.route(routes::INFO, get(daemon_info))
|
.route(routes::INFO, get(daemon_info))
|
||||||
.route(routes::DIRS, get(list_dir))
|
.route(routes::DIRS, get(list_dir))
|
||||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||||
@@ -178,6 +185,16 @@ impl MockDaemon {
|
|||||||
self.state.inner.lock().expect("poisoned").inputs.clone()
|
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 {
|
pub fn session_count(&self) -> usize {
|
||||||
self.state.inner.lock().expect("poisoned").sessions.len()
|
self.state.inner.lock().expect("poisoned").sessions.len()
|
||||||
}
|
}
|
||||||
@@ -266,6 +283,26 @@ async fn send_input(
|
|||||||
StatusCode::NO_CONTENT.into_response()
|
StatusCode::NO_CONTENT.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn structured_prompt(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<StructuredPromptRequest>,
|
||||||
|
) -> 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<AppState>) -> Response {
|
async fn daemon_info(State(s): State<AppState>) -> Response {
|
||||||
if let Some(r) = forced(&s) {
|
if let Some(r) = forced(&s) {
|
||||||
return r;
|
return r;
|
||||||
|
|||||||
Reference in New Issue
Block a user