aboutsummaryrefslogtreecommitdiff
path: root/tests/cli_wrapper.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-17 23:55:08 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-17 23:59:56 +0300
commite0758c32118f2aa006db3d8fa4b41833fe6e7ead (patch)
tree2b29dd61ebf870db8d0ac95cc0b4515178c834c4 /tests/cli_wrapper.rs
parent040956531f673c8fb7720037494ed4e61b123c6c (diff)
feat: harden named agent cli workflows
refs #1525
Diffstat (limited to 'tests/cli_wrapper.rs')
-rw-r--r--tests/cli_wrapper.rs870
1 files changed, 820 insertions, 50 deletions
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index 8412e2d4..1b2c6407 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -325,6 +325,23 @@ fn run_cli_in_dir(socket_path: &Path, args: &[&str], current_dir: &Path) -> std:
command.output().unwrap()
}
+fn pane_topology_snapshot(list_response: &serde_json::Value) -> Vec<serde_json::Value> {
+ list_response["result"]["panes"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|pane| {
+ serde_json::json!({
+ "pane_id": pane["pane_id"],
+ "terminal_id": pane["terminal_id"],
+ "workspace_id": pane["workspace_id"],
+ "tab_id": pane["tab_id"],
+ "focused": pane["focused"],
+ })
+ })
+ .collect()
+}
+
fn run_cli_json(socket_path: &Path, args: &[&str]) -> serde_json::Value {
let output = run_cli(socket_path, args);
parse_cli_json_output(args, output)
@@ -1702,7 +1719,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {full_stdout}"
);
assert!(
- full_stdout.contains(" protocol: 16"),
+ full_stdout.contains(" protocol: 17"),
"stdout: {full_stdout}"
);
assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}");
@@ -1735,7 +1752,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {server_stdout}"
);
assert!(
- server_stdout.contains("protocol: 16"),
+ server_stdout.contains("protocol: 17"),
"stdout: {server_stdout}"
);
@@ -1747,7 +1764,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {client_stdout}"
);
assert!(
- client_stdout.contains("protocol: 16"),
+ client_stdout.contains("protocol: 17"),
"stdout: {client_stdout}"
);
assert!(
@@ -1757,7 +1774,7 @@ fn status_commands_report_client_and_server_versions() {
let full_json = run_cli_json(&socket_path, &["status", "--json"]);
assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION"));
- assert_eq!(full_json["client"]["protocol"], 16);
+ assert_eq!(full_json["client"]["protocol"], 17);
assert_eq!(full_json["server"]["status"], "running");
assert_eq!(full_json["server"]["running"], true);
assert_eq!(full_json["server"]["compatible"], true);
@@ -1771,12 +1788,12 @@ fn status_commands_report_client_and_server_versions() {
let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]);
assert_eq!(server_json["status"], "running");
assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION"));
- assert_eq!(server_json["protocol"], 16);
+ assert_eq!(server_json["protocol"], 17);
assert_eq!(server_json["compatible"], true);
let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]);
assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION"));
- assert_eq!(client_json["protocol"], 16);
+ assert_eq!(client_json["protocol"], 17);
assert!(client_json["binary"]
.as_str()
.is_some_and(|path| !path.is_empty()));
@@ -2656,13 +2673,95 @@ fn tab_management_commands_work() {
#[test]
fn agent_start_command_works() {
+ use std::os::unix::fs::PermissionsExt;
+
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
+ let bin = base.join("bin");
+ let captured_args = base.join("pi-args");
+ fs::create_dir_all(&bin).unwrap();
+ let fake_pi = bin.join("pi");
+ fs::write(
+ &fake_pi,
+ format!(
+ "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nexport HERDR_AGENT=pi\n'{}' pane report-agent \"$HERDR_PANE_ID\" --source custom:fake-pi --agent pi --state idle >/dev/null\nwhile IFS= read -r prompt; do\n [ \"$prompt\" = \"do not transition\" ] && continue\n '{}' pane report-agent \"$HERDR_PANE_ID\" --source custom:fake-pi --agent pi --state working >/dev/null\n '{}' pane report-agent \"$HERDR_PANE_ID\" --source custom:fake-pi --agent pi --state idle >/dev/null\ndone\n",
+ captured_args.display(),
+ env!("CARGO_BIN_EXE_herdr"),
+ env!("CARGO_BIN_EXE_herdr"),
+ env!("CARGO_BIN_EXE_herdr"),
+ ),
+ )
+ .unwrap();
+ fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap();
- let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ let herdr = spawn_herdr_with_path(&config_home, &runtime_dir, &socket_path, Some(&bin));
wait_for_socket(&socket_path, Duration::from_secs(5));
+ let created = run_cli_json(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let before = run_cli_json(&socket_path, &["pane", "list"]);
+ let before_topology = pane_topology_snapshot(&before);
+
+ let missing = run_cli(
+ &socket_path,
+ &[
+ "agent", "start", "missing", "--kind", "pi", "--pane", "w999:p1",
+ ],
+ );
+ assert_eq!(missing.status.code(), Some(1));
+ let missing: serde_json::Value = serde_json::from_slice(&missing.stderr).unwrap();
+ assert_eq!(missing["error"]["code"], "agent_pane_not_found");
+ assert_eq!(
+ pane_topology_snapshot(&run_cli_json(&socket_path, &["pane", "list"])),
+ before_topology
+ );
+
+ for unsafe_arg in ["tab\tcompletion", "escape\x1b[201~"] {
+ let rejected = run_cli(
+ &socket_path,
+ &[
+ "agent",
+ "start",
+ "invalid-argument",
+ "--kind",
+ "pi",
+ "--pane",
+ &pane_id,
+ "--",
+ unsafe_arg,
+ ],
+ );
+ assert_eq!(rejected.status.code(), Some(1));
+ let error: serde_json::Value = serde_json::from_slice(&rejected.stderr).unwrap();
+ assert_eq!(error["error"]["code"], "invalid_agent_argument");
+ }
+
+ for invalid_timeout in ["3000", "300001"] {
+ let rejected = run_cli(
+ &socket_path,
+ &[
+ "agent",
+ "start",
+ "invalid-timeout",
+ "--kind",
+ "pi",
+ "--pane",
+ &pane_id,
+ "--timeout",
+ invalid_timeout,
+ ],
+ );
+ assert_eq!(rejected.status.code(), Some(1));
+ let error: serde_json::Value = serde_json::from_slice(&rejected.stderr).unwrap();
+ assert_eq!(error["error"]["code"], "invalid_agent_timeout");
+ }
let started = run_cli_json(
&socket_path,
@@ -2670,48 +2769,254 @@ fn agent_start_command_works() {
"agent",
"start",
"main",
- "--cwd",
- base.to_str().unwrap(),
+ "--kind",
+ "pi",
+ "--pane",
+ &pane_id,
+ "--timeout",
+ "8000",
"--",
- "/bin/sh",
- "-c",
- "printf cli-agent-start-ok; sleep 2",
- "--session",
- "child-session",
+ "--name",
+ "scratch",
+ "--no-session",
],
);
assert_eq!(started["result"]["type"], "agent_started");
assert_eq!(started["result"]["agent"]["name"], "main");
- assert_eq!(started["result"]["argv"][0], "/bin/sh");
- assert_eq!(started["result"]["argv"][3], "--session");
- assert_eq!(started["result"]["argv"][4], "child-session");
- let terminal_id = started["result"]["agent"]["terminal_id"]
- .as_str()
- .unwrap()
- .to_string();
+ assert_eq!(started["result"]["agent"]["agent"], "pi");
+ assert_eq!(started["result"]["agent"]["pane_id"], pane_id);
+ assert_eq!(started["result"]["argv"][0], "pi");
+ assert_eq!(started["result"]["argv"][1], "--name");
+ assert_eq!(started["result"]["argv"][2], "scratch");
+ assert_eq!(started["result"]["argv"][3], "--no-session");
+ assert_eq!(
+ fs::read_to_string(&captured_args).unwrap(),
+ "--name\nscratch\n--no-session\n"
+ );
- let listed = run_cli_json(&socket_path, &["agent", "list"]);
- assert_eq!(listed["result"]["agents"][0]["terminal_id"], terminal_id);
- assert_eq!(listed["result"]["agents"][0]["name"], "main");
+ let literal_flag_prompt = run_cli(&socket_path, &["agent", "prompt", "main", "--wait"]);
+ assert!(
+ literal_flag_prompt.status.success(),
+ "flag-shaped prompt was not treated literally: {}",
+ String::from_utf8_lossy(&literal_flag_prompt.stderr)
+ );
- let duplicate = run_cli(
+ let after = run_cli_json(&socket_path, &["pane", "list"]);
+ assert_eq!(pane_topology_snapshot(&after), before_topology);
+
+ let stale_idle = run_cli(
&socket_path,
&[
"agent",
- "start",
+ "prompt",
"main",
- "--cwd",
- base.to_str().unwrap(),
- "--",
- "/bin/sh",
- "-c",
- "true",
+ "do not transition",
+ "--wait",
+ "--timeout",
+ "200",
+ ],
+ );
+ assert_eq!(stale_idle.status.code(), Some(1));
+ let stale_idle: serde_json::Value = serde_json::from_slice(&stale_idle.stderr).unwrap();
+ assert_eq!(stale_idle["error"]["code"], "timeout");
+
+ let prompted = run_cli(
+ &socket_path,
+ &[
+ "agent",
+ "prompt",
+ "main",
+ "Review this diff",
+ "--wait",
+ "--timeout",
+ "2000",
],
);
+ assert!(
+ prompted.status.success(),
+ "prompt failed: {}",
+ String::from_utf8_lossy(&prompted.stderr)
+ );
+ let prompted: serde_json::Value = serde_json::from_slice(&prompted.stdout).unwrap();
+ assert_eq!(prompted["result"]["type"], "agent_prompted");
+
+ let duplicate = run_cli(
+ &socket_path,
+ &["agent", "start", "main", "--kind", "pi", "--pane", &pane_id],
+ );
assert!(!duplicate.status.success());
let duplicate_json: serde_json::Value = serde_json::from_slice(&duplicate.stderr).unwrap();
assert_eq!(duplicate_json["error"]["code"], "agent_name_taken");
+ let busy = run_cli(
+ &socket_path,
+ &[
+ "agent", "start", "second", "--kind", "pi", "--pane", &pane_id,
+ ],
+ );
+ assert!(!busy.status.success());
+ let busy_json: serde_json::Value = serde_json::from_slice(&busy.stderr).unwrap();
+ assert_eq!(busy_json["error"]["code"], "agent_pane_busy");
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn agent_start_rejects_a_shell_replaced_by_a_foreground_program() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("herdr.sock");
+ let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+ let created = run_cli_json(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let topology = pane_topology_snapshot(&run_cli_json(&socket_path, &["pane", "list"]));
+ assert!(
+ run_cli(&socket_path, &["pane", "run", &pane_id, "exec sleep 5"])
+ .status
+ .success()
+ );
+ thread::sleep(Duration::from_millis(150));
+
+ let started = run_cli(
+ &socket_path,
+ &[
+ "agent",
+ "start",
+ "worker",
+ "--kind",
+ "pi",
+ "--pane",
+ &pane_id,
+ "--timeout",
+ "1000",
+ ],
+ );
+ assert_eq!(started.status.code(), Some(1));
+ let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap();
+ assert_eq!(error["error"]["code"], "agent_pane_busy");
+ assert_eq!(
+ pane_topology_snapshot(&run_cli_json(&socket_path, &["pane", "list"])),
+ topology
+ );
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn agent_start_timeout_releases_the_name_for_reuse() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("herdr.sock");
+ let bin = base.join("bin");
+ fs::create_dir_all(&bin).unwrap();
+ let fake_pi = bin.join("pi");
+ fs::write(
+ &fake_pi,
+ "#!/bin/sh\nunset HERDR_AGENT\nexec /bin/sleep 20\n",
+ )
+ .unwrap();
+ fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap();
+
+ let herdr = spawn_herdr_with_path(&config_home, &runtime_dir, &socket_path, Some(&bin));
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+ let created = run_cli_json(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let started = run_cli(
+ &socket_path,
+ &[
+ "agent",
+ "start",
+ "worker",
+ "--kind",
+ "pi",
+ "--pane",
+ &pane_id,
+ "--timeout",
+ "3100",
+ ],
+ );
+ assert_eq!(started.status.code(), Some(1));
+ let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap();
+ assert_eq!(error["error"]["code"], "timeout");
+
+ let reused = run_cli(&socket_path, &["agent", "rename", &pane_id, "worker"]);
+ assert!(
+ reused.status.success(),
+ "name was not released: {}",
+ String::from_utf8_lossy(&reused.stderr)
+ );
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn agent_start_reports_detected_kind_mismatch_before_released_name() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("herdr.sock");
+ let bin = base.join("bin");
+ fs::create_dir_all(&bin).unwrap();
+ let fake_pi = bin.join("pi");
+ fs::write(
+ &fake_pi,
+ "#!/bin/sh\nHERDR_AGENT=codex exec /bin/sleep 10\n",
+ )
+ .unwrap();
+ fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap();
+
+ let herdr = spawn_herdr_with_path(&config_home, &runtime_dir, &socket_path, Some(&bin));
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+ let created = run_cli_json(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let started = run_cli(
+ &socket_path,
+ &[
+ "agent",
+ "start",
+ "worker",
+ "--kind",
+ "pi",
+ "--pane",
+ &pane_id,
+ "--timeout",
+ "5000",
+ ],
+ );
+ assert_eq!(started.status.code(), Some(1));
+ let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap();
+ assert_eq!(error["error"]["code"], "agent_kind_mismatch");
+
+ let reused = run_cli(&socket_path, &["agent", "rename", &pane_id, "worker"]);
+ assert!(reused.status.success());
+
cleanup_spawned_herdr(herdr, base);
}
@@ -2751,19 +3056,178 @@ fn agent_commands_work() {
let fetched = run_cli_json(&socket_path, &["agent", "get", "worker"]);
assert_eq!(fetched["result"]["agent"]["pane_id"], root_pane_id);
+ let reported = run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &root_pane_id,
+ "--source",
+ "custom:test",
+ "--agent",
+ "pi",
+ "--state",
+ "idle",
+ ],
+ );
+ assert!(reported.status.success());
let waited = run_cli_json(
&socket_path,
+ &["agent", "wait", "worker", "--timeout", "100"],
+ );
+ assert_eq!(waited["result"]["agent"]["pane_id"], root_pane_id);
+
+ // A stale semantic report must not allow prompt text into the resumed shell.
+ let prompted = run_cli(
+ &socket_path,
+ &["agent", "prompt", "worker", "echo prompt-must-not-run"],
+ );
+ assert_eq!(prompted.status.code(), Some(1));
+ let prompted_json: serde_json::Value = serde_json::from_slice(&prompted.stderr).unwrap();
+ assert_eq!(prompted_json["error"]["code"], "agent_not_ready");
+
+ let working = run_cli(
+ &socket_path,
&[
- "agent",
- "wait",
- "worker",
- "--status",
+ "pane",
+ "report-agent",
+ &root_pane_id,
+ "--source",
+ "custom:wait",
+ "--agent",
+ "pi",
+ "--state",
+ "working",
+ ],
+ );
+ assert!(working.status.success());
+ let blocked_socket = socket_path.clone();
+ let blocked_pane = root_pane_id.clone();
+ let blocked_transition = thread::spawn(move || {
+ thread::sleep(Duration::from_millis(100));
+ let blocked = run_cli(
+ &blocked_socket,
+ &[
+ "pane",
+ "report-agent",
+ &blocked_pane,
+ "--source",
+ "custom:wait",
+ "--agent",
+ "pi",
+ "--state",
+ "blocked",
+ ],
+ );
+ assert!(blocked.status.success());
+ });
+ let waited = run_cli_json(
+ &socket_path,
+ &["agent", "wait", "worker", "--timeout", "2000"],
+ );
+ blocked_transition.join().unwrap();
+ assert_eq!(waited["result"]["agent"]["agent_status"], "blocked");
+ let immediate_blocked =
+ run_cli_json(&socket_path, &["agent", "wait", "worker", "--timeout", "1"]);
+ assert_eq!(
+ immediate_blocked["result"]["agent"]["agent_status"],
+ "blocked"
+ );
+
+ assert!(run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &root_pane_id,
+ "--source",
+ "custom:wait",
+ "--agent",
+ "pi",
+ "--state",
+ "working",
+ ],
+ )
+ .status
+ .success());
+ let idle_socket = socket_path.clone();
+ let idle_pane = root_pane_id.clone();
+ let idle_transition = thread::spawn(move || {
+ thread::sleep(Duration::from_millis(100));
+ assert!(run_cli(
+ &idle_socket,
+ &[
+ "pane",
+ "report-agent",
+ &idle_pane,
+ "--source",
+ "custom:wait",
+ "--agent",
+ "pi",
+ "--state",
+ "idle",
+ ],
+ )
+ .status
+ .success());
+ });
+ let idle_wait = run_cli_json(
+ &socket_path,
+ &["agent", "wait", "worker", "--timeout", "2000"],
+ );
+ idle_transition.join().unwrap();
+ assert!(matches!(
+ idle_wait["result"]["agent"]["agent_status"].as_str(),
+ Some("idle" | "done")
+ ));
+
+ assert!(run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &root_pane_id,
+ "--source",
+ "custom:wait",
+ "--agent",
+ "pi",
+ "--state",
+ "working",
+ ],
+ )
+ .status
+ .success());
+ let timed_out = run_cli(
+ &socket_path,
+ &["agent", "wait", "worker", "--timeout", "100"],
+ );
+ assert_eq!(timed_out.status.code(), Some(1));
+ let timeout: serde_json::Value = serde_json::from_slice(&timed_out.stderr).unwrap();
+ assert_eq!(timeout["error"]["code"], "timeout");
+
+ assert!(run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &root_pane_id,
+ "--source",
+ "custom:wait",
+ "--agent",
+ "pi",
+ "--state",
"unknown",
- "--timeout",
- "100",
],
+ )
+ .status
+ .success());
+ let unknown = run_cli(
+ &socket_path,
+ &["agent", "wait", "worker", "--timeout", "1000"],
);
- assert_eq!(waited["result"]["agent"]["pane_id"], root_pane_id);
+ assert_eq!(unknown.status.code(), Some(1));
+ let unknown: serde_json::Value = serde_json::from_slice(&unknown.stderr).unwrap();
+ assert_eq!(unknown["error"]["code"], "agent_not_running");
let read = run_cli_json(
&socket_path,
@@ -2787,6 +3251,261 @@ fn agent_commands_work() {
}
#[test]
+fn agent_wait_returns_immediately_for_unseen_done_agent() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("herdr.sock");
+ let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+
+ let created = run_cli_json(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ let first = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let workspace_id = created["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .unwrap();
+ let second_tab = run_cli_json(
+ &socket_path,
+ &["tab", "create", "--workspace", workspace_id],
+ );
+ let second_tab_id = second_tab["result"]["tab"]["tab_id"].as_str().unwrap();
+ assert_ne!(second_tab_id, "w1:t1");
+ assert!(run_cli(&socket_path, &["tab", "focus", second_tab_id])
+ .status
+ .success());
+ assert!(run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &first,
+ "--source",
+ "custom:done",
+ "--agent",
+ "pi",
+ "--state",
+ "working",
+ ],
+ )
+ .status
+ .success());
+ assert!(
+ run_cli(&socket_path, &["agent", "rename", &first, "worker"])
+ .status
+ .success()
+ );
+ assert!(run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &first,
+ "--source",
+ "custom:done",
+ "--agent",
+ "pi",
+ "--state",
+ "idle",
+ ],
+ )
+ .status
+ .success());
+
+ let waited = run_cli_json(&socket_path, &["agent", "wait", "worker", "--timeout", "1"]);
+ assert_eq!(waited["result"]["agent"]["agent_status"], "done");
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn agent_wait_pins_the_original_terminal_when_name_is_reused() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("herdr.sock");
+ let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+
+ let created = run_cli_json(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ let first = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let split = run_cli_json(
+ &socket_path,
+ &["pane", "split", &first, "--direction", "right"],
+ );
+ let second = split["result"]["pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ assert!(run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &first,
+ "--source",
+ "custom:race",
+ "--agent",
+ "pi",
+ "--state",
+ "working",
+ ],
+ )
+ .status
+ .success());
+ assert!(
+ run_cli(&socket_path, &["agent", "rename", &first, "worker"])
+ .status
+ .success()
+ );
+
+ let wait_socket = socket_path.clone();
+ let waiter = thread::spawn(move || {
+ run_cli(
+ &wait_socket,
+ &["agent", "wait", "worker", "--timeout", "2000"],
+ )
+ });
+ thread::sleep(Duration::from_millis(250));
+ assert!(
+ run_cli(&socket_path, &["agent", "rename", "worker", "--clear"])
+ .status
+ .success()
+ );
+ assert!(run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &second,
+ "--source",
+ "custom:race",
+ "--agent",
+ "pi",
+ "--state",
+ "idle",
+ ],
+ )
+ .status
+ .success());
+ assert!(
+ run_cli(&socket_path, &["agent", "rename", &second, "worker"])
+ .status
+ .success()
+ );
+
+ let waited = waiter.join().unwrap();
+ assert_eq!(waited.status.code(), Some(1));
+ let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap();
+ assert_eq!(error["error"]["code"], "agent_name_not_found");
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn agent_wait_ignores_other_panes_and_errors_when_its_pane_closes() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("herdr.sock");
+ let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+
+ let created = run_cli_json(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ let first = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let split = run_cli_json(
+ &socket_path,
+ &["pane", "split", &first, "--direction", "right"],
+ );
+ let second = split["result"]["pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ assert!(run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &first,
+ "--source",
+ "custom:close",
+ "--agent",
+ "pi",
+ "--state",
+ "working",
+ ],
+ )
+ .status
+ .success());
+ assert!(
+ run_cli(&socket_path, &["agent", "rename", &first, "worker"])
+ .status
+ .success()
+ );
+
+ let wait_socket = socket_path.clone();
+ let (done_tx, done_rx) = std::sync::mpsc::channel();
+ thread::spawn(move || {
+ let _ = done_tx.send(run_cli(
+ &wait_socket,
+ &["agent", "wait", "worker", "--timeout", "3000"],
+ ));
+ });
+ thread::sleep(Duration::from_millis(150));
+ assert!(run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &second,
+ "--source",
+ "custom:close",
+ "--agent",
+ "pi",
+ "--state",
+ "idle",
+ ],
+ )
+ .status
+ .success());
+ thread::sleep(Duration::from_millis(150));
+ assert!(matches!(
+ done_rx.try_recv(),
+ Err(std::sync::mpsc::TryRecvError::Empty)
+ ));
+
+ assert!(run_cli(&socket_path, &["pane", "close", &first])
+ .status
+ .success());
+ let waited = done_rx.recv_timeout(Duration::from_secs(2)).unwrap();
+ assert_eq!(waited.status.code(), Some(1));
+ let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap();
+ assert!(matches!(
+ error["error"]["code"].as_str(),
+ Some("agent_not_found" | "pane_not_found")
+ ));
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
fn pane_close_only_removes_the_target_tab_when_other_tabs_exist() {
let base = unique_test_dir();
let config_home = base.join("config");
@@ -4503,6 +5222,65 @@ fn cli_allows_same_protocol_different_version_and_preserves_server_error() {
}
#[test]
+fn agent_wait_reports_poll_transport_failure_as_structured_error() {
+ let base = unique_test_dir();
+ fs::create_dir_all(&base).unwrap();
+ let socket_path = base.join("herdr.sock");
+ let listener = UnixListener::bind(&socket_path).unwrap();
+
+ let server = thread::spawn(move || {
+ for request_number in 0..2 {
+ let (mut ping_stream, _) = listener.accept().unwrap();
+ let mut ping_line = String::new();
+ BufReader::new(ping_stream.try_clone().unwrap())
+ .read_line(&mut ping_line)
+ .unwrap();
+ let ping: serde_json::Value = serde_json::from_str(&ping_line).unwrap();
+ assert_eq!(ping["method"], "ping");
+ write_fake_pong(&mut ping_stream, &ping, "current", CURRENT_PROTOCOL);
+
+ let (mut get_stream, _) = listener.accept().unwrap();
+ let mut get_line = String::new();
+ BufReader::new(get_stream.try_clone().unwrap())
+ .read_line(&mut get_line)
+ .unwrap();
+ let get: serde_json::Value = serde_json::from_str(&get_line).unwrap();
+ assert_eq!(get["method"], "agent.get");
+ if request_number == 0 {
+ writeln!(
+ get_stream,
+ "{}",
+ serde_json::json!({
+ "id": get["id"],
+ "result": {
+ "type": "agent_info",
+ "agent": {
+ "name": "worker",
+ "pane_id": "w1:p1",
+ "terminal_id": "term_1",
+ "agent_status": "working"
+ }
+ }
+ })
+ )
+ .unwrap();
+ get_stream.flush().unwrap();
+ }
+ }
+ });
+
+ let waited = run_cli(
+ &socket_path,
+ &["agent", "wait", "worker", "--timeout", "5000"],
+ );
+ assert_eq!(waited.status.code(), Some(1));
+ let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap();
+ assert_eq!(error["error"]["code"], "agent_wait_transport_failed");
+ server.join().unwrap();
+ cleanup_test_base(&base);
+}
+
+#[test]
fn agent_wait_rechecks_protocol_before_subscription() {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();
@@ -4533,7 +5311,7 @@ fn agent_wait_rechecks_protocol_before_subscription() {
assert_eq!(get_request["method"], "agent.get");
get_stream
.write_all(
- br#"{"id":"cli:agent:wait:resolve","result":{"type":"agent_info","agent":{"pane_id":"w1:p1","agent_status":"working"}}}"#,
+ br#"{"id":"cli:agent:wait:resolve","result":{"type":"agent_info","agent":{"name":"worker","pane_id":"w1:p1","terminal_id":"t1","agent_status":"working"}}}"#,
)
.unwrap();
get_stream.write_all(b"\n").unwrap();
@@ -4552,7 +5330,7 @@ fn agent_wait_rechecks_protocol_before_subscription() {
let deadline = Instant::now() + Duration::from_millis(250);
while Instant::now() < deadline {
match listener.accept() {
- Ok(_) => panic!("agent wait dispatched events.subscribe after mismatch"),
+ Ok(_) => panic!("agent wait polled agent state after protocol mismatch"),
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
@@ -4563,15 +5341,7 @@ fn agent_wait_rechecks_protocol_before_subscription() {
let waited = run_cli(
&socket_path,
- &[
- "agent",
- "wait",
- "worker",
- "--status",
- "blocked",
- "--timeout",
- "5000",
- ],
+ &["agent", "wait", "worker", "--timeout", "5000"],
);
assert_eq!(waited.status.code(), Some(1));