Adds POST /api/sessions/:id/input and a CLI command on top of it, so a script (or another agent) can hand an instruction to a running session without attaching to it. The text goes in as one write and the Enter follows 150ms later: agents that run a full-screen prompt coalesce a burst of input into a paste, and a newline inside that burst becomes a line break instead of sending the message. `--no-enter` skips the Enter and leaves the text in the prompt. The session argument takes an ID, a unique ID prefix, or a session name; names win over prefixes. A session whose pane has exited is refused (409) rather than silently accepted — unlike a stray keystroke on an attach socket, a send that goes nowhere is worth hearing about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
218 lines
6.6 KiB
Rust
218 lines
6.6 KiB
Rust
//! The CLI against a daemon. It had no such tests: `maestro list`'s formatting
|
|
//! 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_testing::{session, MockDaemon};
|
|
use uuid::Uuid;
|
|
|
|
#[tokio::test]
|
|
async fn list_reads_the_daemon_catalog() {
|
|
let mock = MockDaemon::start().await;
|
|
mock.add_session(session(Uuid::from_u128(1), "/home/mock/work", "claude"));
|
|
|
|
let sessions = mock.client().list_sessions().await.expect("list");
|
|
|
|
assert_eq!(sessions.len(), 1);
|
|
assert_eq!(sessions[0].command, "claude");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn info_carries_version_and_presets() {
|
|
let mock = MockDaemon::start().await;
|
|
mock.set_info(DaemonInfo {
|
|
hostname: "konishi".into(),
|
|
version: "9.9.9".into(),
|
|
home_dir: "/home/naps".into(),
|
|
presets: vec![maestro_protocol::SpawnPreset {
|
|
id: "claude".into(),
|
|
label: "Claude".into(),
|
|
command: "claude".into(),
|
|
}],
|
|
});
|
|
|
|
let info = mock.client().daemon_info().await.expect("info");
|
|
|
|
// The CLI's own DaemonInfo used to drop both of these fields.
|
|
assert_eq!(info.version, "9.9.9");
|
|
assert_eq!(info.presets.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_structured_failure_surfaces_the_daemon_message() {
|
|
let mock = MockDaemon::start().await;
|
|
mock.force_error(502, "tmux_failed", "no server running");
|
|
|
|
let err = mock
|
|
.client()
|
|
.list_sessions()
|
|
.await
|
|
.expect_err("should fail");
|
|
|
|
assert_eq!(
|
|
err.daemon_message(),
|
|
Some("no server running"),
|
|
"the daemon's own message must survive: {err}"
|
|
);
|
|
assert!(!err.is_unreachable());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_unreachable_daemon_is_distinguishable_from_a_rejection() {
|
|
// Port 1 has nothing listening on it.
|
|
let client = DaemonClient::new("http://127.0.0.1:1");
|
|
|
|
let err = client.list_sessions().await.expect_err("should fail");
|
|
|
|
assert!(
|
|
err.is_unreachable(),
|
|
"connection refused must not read as an HTTP rejection: {err}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn killing_an_unknown_session_reports_the_daemon_reason() {
|
|
let mock = MockDaemon::start().await;
|
|
|
|
let err = mock
|
|
.client()
|
|
.kill_session(Uuid::new_v4(), false)
|
|
.await
|
|
.expect_err("should fail");
|
|
|
|
assert_eq!(err.daemon_message(), Some("no such session on this daemon"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn spawn_round_trips_through_the_daemon() {
|
|
let mock = MockDaemon::start().await;
|
|
|
|
let created = mock
|
|
.client()
|
|
.create_session(&maestro_protocol::CreateSessionRequest {
|
|
cwd: "/home/mock".into(),
|
|
command: "bash".into(),
|
|
env: Default::default(),
|
|
metadata: Default::default(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
assert_eq!(created.command, "bash");
|
|
assert_eq!(mock.session_count(), 1);
|
|
|
|
mock.client()
|
|
.kill_session(created.id, false)
|
|
.await
|
|
.expect("kill");
|
|
assert_eq!(mock.session_count(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_types_text_and_submits_by_default() {
|
|
let mock = MockDaemon::start().await;
|
|
let id = Uuid::from_u128(7);
|
|
mock.add_session(session(id, "/home/mock/work", "claude"));
|
|
|
|
let args = maestro_cli::cli::SendArgs {
|
|
session: id.to_string(),
|
|
text: vec!["run".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);
|
|
let inputs = mock.inputs();
|
|
assert_eq!(inputs.len(), 1);
|
|
assert_eq!(inputs[0].0, id);
|
|
assert_eq!(inputs[0].1.text, "run the tests");
|
|
assert!(inputs[0].1.submit, "Enter is the default");
|
|
}
|
|
|
|
/// `--no-enter` leaves the text in the prompt; the flag has to survive the
|
|
/// whole way to the daemon or the message is sent anyway.
|
|
#[tokio::test]
|
|
async fn send_without_enter_leaves_the_text_unsubmitted() {
|
|
let mock = MockDaemon::start().await;
|
|
let id = Uuid::from_u128(8);
|
|
mock.add_session(session(id, "/tmp", "bash"));
|
|
|
|
let args = maestro_cli::cli::SendArgs {
|
|
session: id.to_string(),
|
|
text: vec!["ls".into()],
|
|
no_enter: true,
|
|
};
|
|
maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
|
.await
|
|
.expect("send");
|
|
|
|
assert!(!mock.inputs()[0].1.submit);
|
|
}
|
|
|
|
/// No text arguments → the message comes from stdin, minus the trailing
|
|
/// newline a pipe or heredoc adds.
|
|
#[tokio::test]
|
|
async fn send_reads_stdin_and_drops_its_trailing_newline() {
|
|
let mock = MockDaemon::start().await;
|
|
let id = Uuid::from_u128(9);
|
|
mock.add_session(session(id, "/tmp", "bash"));
|
|
|
|
let args = maestro_cli::cli::SendArgs {
|
|
session: id.to_string(),
|
|
text: Vec::new(),
|
|
no_enter: false,
|
|
};
|
|
let mut stdin = std::io::Cursor::new(b"multi\nline\n".to_vec());
|
|
maestro_cli::sessions::run_send(&mock.client(), &args, &mut stdin)
|
|
.await
|
|
.expect("send");
|
|
|
|
assert_eq!(mock.inputs()[0].1.text, "multi\nline");
|
|
}
|
|
|
|
/// A session named through `PATCH /api/sessions/:id` is addressable by that
|
|
/// name, so scripts don't have to carry a UUID around.
|
|
#[tokio::test]
|
|
async fn send_resolves_a_session_by_name() {
|
|
let mock = MockDaemon::start().await;
|
|
let id = Uuid::from_u128(10);
|
|
let mut named = session(id, "/tmp", "claude");
|
|
named
|
|
.metadata
|
|
.insert("name".into(), serde_json::Value::String("planner".into()));
|
|
mock.add_session(named);
|
|
mock.add_session(session(Uuid::from_u128(11), "/tmp", "bash"));
|
|
|
|
let args = maestro_cli::cli::SendArgs {
|
|
session: "Planner".into(),
|
|
text: vec!["go".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);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
|
let mock = MockDaemon::start().await;
|
|
mock.add_session(session(Uuid::from_u128(12), "/tmp", "bash"));
|
|
|
|
let args = maestro_cli::cli::SendArgs {
|
|
session: "nope".into(),
|
|
text: vec!["hi".into()],
|
|
no_enter: false,
|
|
};
|
|
let err = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
|
.await
|
|
.expect_err("should not resolve");
|
|
|
|
assert!(err.to_string().contains("no session matches"), "{err}");
|
|
assert!(mock.inputs().is_empty());
|
|
}
|