aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/api_ping.rs52
-rw-r--r--tests/cli.rs6
-rw-r--r--tests/cli/agent_options.rs83
-rw-r--r--tests/cli/agent_transport.rs146
-rw-r--r--tests/cli/agent_wait.rs200
-rw-r--r--tests/cli/agents.rs1189
-rw-r--r--tests/cli/harness.rs608
-rw-r--r--tests/cli/hooks.rs308
-rw-r--r--tests/cli/mod.rs13
-rw-r--r--tests/cli/panes.rs568
-rw-r--r--tests/cli/plugins.rs932
-rw-r--r--tests/cli/protocol.rs122
-rw-r--r--tests/cli/protocol_guard.rs87
-rw-r--r--tests/cli/sessions.rs671
-rw-r--r--tests/cli/surface.rs603
-rw-r--r--tests/cli/workspace.rs663
-rw-r--r--tests/cli_wrapper.rs5651
-rw-r--r--tests/live_handoff.rs88
18 files changed, 6322 insertions, 5668 deletions
diff --git a/tests/api_ping.rs b/tests/api_ping.rs
index 0e0b711e..a31a5866 100644
--- a/tests/api_ping.rs
+++ b/tests/api_ping.rs
@@ -509,6 +509,21 @@ fn workspace_list_and_create_round_trip() {
let recent_text = recent["result"]["read"]["text"].as_str().unwrap();
assert!(recent_text.contains("beta") || recent_text.contains("gamma"));
+ for source in ["visible", "detection"] {
+ let limited = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_11_{source}","method":"pane.read","params":{{"pane_id":"{}","source":"{source}","lines":2}}}}"#,
+ pane_id
+ ),
+ );
+ let text = limited["result"]["read"]["text"].as_str().unwrap();
+ assert!(
+ text.lines().count() <= 2,
+ "{source} ignored its two-line limit: {text:?}"
+ );
+ }
+
let waited = send_request(
&socket_path,
&format!(
@@ -1176,29 +1191,32 @@ fn agent_methods_round_trip_over_socket() {
assert_eq!(agents[0]["agent_status"], "working");
assert_eq!(agents[0]["pane_id"], pane_id);
- let fetched_by_detected_agent = send_request(
+ let fetched_by_pane = send_request(
&socket_path,
- r#"{"id":"agent_get_detected","method":"agent.get","params":{"target":"pi"}}"#,
+ &format!(
+ r#"{{"id":"agent_get_pane","method":"agent.get","params":{{"target":"{}"}}}}"#,
+ pane_id
+ ),
);
assert_eq!(
- fetched_by_detected_agent["result"]["agent"]["terminal_id"],
+ fetched_by_pane["result"]["agent"]["terminal_id"],
terminal_id
);
let renamed_first_agent = send_request(
&socket_path,
- r#"{"id":"agent_rename_first","method":"agent.rename","params":{"target":"pi","name":"worker"}}"#,
+ &format!(
+ r#"{{"id":"agent_rename_first","method":"agent.rename","params":{{"target":"{}","name":"worker"}}}}"#,
+ pane_id
+ ),
);
assert_eq!(renamed_first_agent["result"]["agent"]["name"], "worker");
- let fetched_by_terminal = send_request(
+ let fetched_by_name = send_request(
&socket_path,
- &format!(
- r#"{{"id":"agent_get_terminal","method":"agent.get","params":{{"target":"{}"}}}}"#,
- terminal_id
- ),
+ r#"{"id":"agent_get_name","method":"agent.get","params":{"target":"worker"}}"#,
);
- assert_eq!(fetched_by_terminal["result"]["agent"]["name"], "worker");
+ assert_eq!(fetched_by_name["result"]["agent"]["name"], "worker");
let read = send_request(
&socket_path,
@@ -1209,12 +1227,9 @@ fn agent_methods_round_trip_over_socket() {
let sent = send_request(
&socket_path,
- &format!(
- r#"{{"id":"agent_send","method":"agent.send","params":{{"target":"{}","text":"echo agent-send-ok\n"}}}}"#,
- terminal_id
- ),
+ r#"{"id":"agent_send_keys","method":"agent.send_keys","params":{"target":"worker","keys":["enter"]}}"#,
);
- assert_eq!(sent["result"]["type"], "ok");
+ assert_eq!(sent["error"]["code"], "agent_not_ready");
let tab_created = send_request(
&socket_path,
@@ -1244,7 +1259,7 @@ fn agent_methods_round_trip_over_socket() {
&socket_path,
&format!(
r#"{{"id":"agent_second_rename","method":"agent.rename","params":{{"target":"{}","name":"reviewer"}}}}"#,
- second_terminal_id
+ second_pane_id
),
);
assert_eq!(second_renamed["result"]["agent"]["name"], "reviewer");
@@ -1785,7 +1800,10 @@ fn pane_report_agent_updates_effective_state() {
let agent = send_request(
&socket_path,
- r#"{"id":"req_hook_metadata_agent","method":"agent.get","params":{"target":"pi"}}"#,
+ &format!(
+ r#"{{"id":"req_hook_metadata_agent","method":"agent.get","params":{{"target":"{}"}}}}"#,
+ pane_id
+ ),
);
assert_eq!(agent["result"]["agent"]["agent"], "pi");
assert_eq!(
diff --git a/tests/cli.rs b/tests/cli.rs
new file mode 100644
index 00000000..c7eb2834
--- /dev/null
+++ b/tests/cli.rs
@@ -0,0 +1,6 @@
+#![cfg(not(target_os = "macos"))]
+
+mod support;
+
+#[path = "cli/mod.rs"]
+mod cases;
diff --git a/tests/cli/agent_options.rs b/tests/cli/agent_options.rs
new file mode 100644
index 00000000..f8b6b7af
--- /dev/null
+++ b/tests/cli/agent_options.rs
@@ -0,0 +1,83 @@
+use super::harness::*;
+
+#[test]
+fn agent_wait_accepts_repeated_until_and_exits_when_one_status_matches() {
+ 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_dir = base.join("bin");
+
+ fs::create_dir_all(&bin_dir).unwrap();
+ let fake_pi = bin_dir.join("pi");
+ fs::write(
+ &fake_pi,
+ "#!/bin/sh\nprintf 'starting\\n'\nsleep 4\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n",
+ )
+ .unwrap();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mut perms = fs::metadata(&fake_pi).unwrap().permissions();
+ perms.set_mode(0o755);
+ fs::set_permissions(&fake_pi, perms).unwrap();
+ }
+
+ let inherited_path = std::env::var("PATH").unwrap_or_default();
+ let path_override = format!("{}:{}", bin_dir.display(), inherited_path);
+ let herdr = spawn_herdr_with_path(
+ &config_home,
+ &runtime_dir,
+ &socket_path,
+ Some(Path::new(&path_override)),
+ );
+
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+
+ let created = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_cli_2","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
+ base.display()
+ ),
+ );
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let start_pi = run_cli(&socket_path, &["pane", "run", &pane_id, "pi"]);
+ assert!(start_pi.status.success());
+ assert!(wait_until(
+ Duration::from_secs(3),
+ Duration::from_millis(25),
+ || run_cli(&socket_path, &["agent", "get", &pane_id])
+ .status
+ .success()
+ ));
+
+ let waited = run_cli(
+ &socket_path,
+ &[
+ "agent",
+ "wait",
+ &pane_id,
+ "--until",
+ "blocked",
+ "--until",
+ "idle",
+ "--timeout",
+ "10000",
+ ],
+ );
+ assert!(
+ waited.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&waited.stderr)
+ );
+ let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap();
+ assert_eq!(waited_json["result"]["agent"]["agent_status"], "idle");
+ assert_eq!(waited_json["result"]["agent"]["agent"], "pi");
+
+ cleanup_spawned_herdr(herdr, base);
+}
diff --git a/tests/cli/agent_transport.rs b/tests/cli/agent_transport.rs
new file mode 100644
index 00000000..42e67828
--- /dev/null
+++ b/tests/cli/agent_transport.rs
@@ -0,0 +1,146 @@
+use super::harness::*;
+
+#[test]
+fn agent_start_accepts_durable_readiness_during_detection_gap() {
+ 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 || {
+ let (mut start_stream, start_line) = accept_fake_cli_operation(&listener);
+ let start: serde_json::Value = serde_json::from_str(&start_line).unwrap();
+ assert_eq!(start["method"], "agent.start");
+ writeln!(
+ start_stream,
+ "{}",
+ serde_json::json!({
+ "id": start["id"],
+ "result": {
+ "type": "agent_started",
+ "agent": {
+ "pane_id": "w1:p1",
+ "terminal_id": "term_1",
+ "name": "reviewer"
+ },
+ "argv": ["opencode"]
+ }
+ })
+ )
+ .unwrap();
+ start_stream.flush().unwrap();
+
+ let (mut get_stream, get_line) = accept_fake_cli_operation(&listener);
+ let get: serde_json::Value = serde_json::from_str(&get_line).unwrap();
+ assert_eq!(get["method"], "agent.get");
+ assert_eq!(get["params"]["target"], "reviewer");
+ writeln!(
+ get_stream,
+ "{}",
+ serde_json::json!({
+ "id": get["id"],
+ "result": {
+ "type": "agent_info",
+ "agent": {
+ "agent": null,
+ "agent_status": "unknown",
+ "interactive_ready": true,
+ "launch_pending": false,
+ "name": "reviewer",
+ "pane_id": "w1:p1",
+ "terminal_id": "term_1"
+ }
+ }
+ })
+ )
+ .unwrap();
+ get_stream.flush().unwrap();
+ });
+
+ let started = run_cli(
+ &socket_path,
+ &[
+ "agent", "start", "reviewer", "--kind", "opencode", "--pane", "w1:p1",
+ ],
+ );
+ assert!(
+ started.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&started.stderr)
+ );
+ let started: serde_json::Value = serde_json::from_slice(&started.stdout).unwrap();
+ assert_eq!(started["result"]["type"], "agent_started");
+ assert_eq!(started["result"]["agent"]["name"], "reviewer");
+ assert!(started["result"]["agent"]["interactive_ready"]
+ .as_bool()
+ .unwrap());
+
+ server.join().unwrap();
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn prompt_wait_is_sent_as_one_agent_request() {
+ 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 || {
+ let (mut prompt_stream, prompt_line) = accept_fake_cli_operation(&listener);
+ let prompt: serde_json::Value = serde_json::from_str(&prompt_line).unwrap();
+ assert_eq!(prompt["method"], "agent.prompt");
+ assert_eq!(prompt["params"]["target"], "w1:p1");
+ assert_eq!(
+ prompt["params"]["wait"]["until"],
+ serde_json::json!(["idle"])
+ );
+ assert!(prompt["params"]["wait"].get("timeout_ms").is_none());
+ writeln!(
+ prompt_stream,
+ "{}",
+ serde_json::json!({
+ "id": prompt["id"],
+ "result": {
+ "type": "agent_prompted",
+ "agent": {
+ "pane_id": "w1:p1",
+ "terminal_id": "term_1",
+ "name": "reviewer",
+ "agent": "pi",
+ "agent_status": "idle",
+ "workspace_id": "w1",
+ "tab_id": "w1:t1",
+ "focused": true,
+ "revision": 0
+ }
+ }
+ })
+ )
+ .unwrap();
+ prompt_stream.flush().unwrap();
+ });
+
+ let prompted = run_cli(
+ &socket_path,
+ &[
+ "agent",
+ "prompt",
+ "w1:p1",
+ "review this",
+ "--wait",
+ "--until",
+ "idle",
+ ],
+ );
+ assert!(
+ prompted.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&prompted.stderr)
+ );
+ let prompted: serde_json::Value = serde_json::from_slice(&prompted.stdout).unwrap();
+ assert_eq!(prompted["result"]["agent"]["name"], "reviewer");
+
+ server.join().unwrap();
+ cleanup_test_base(&base);
+}
diff --git a/tests/cli/agent_wait.rs b/tests/cli/agent_wait.rs
new file mode 100644
index 00000000..11997d00
--- /dev/null
+++ b/tests/cli/agent_wait.rs
@@ -0,0 +1,200 @@
+use super::harness::*;
+
+#[test]
+fn agent_wait_exits_immediately_when_status_already_matches() {
+ 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 = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_cli_immediate_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
+ base.display()
+ ),
+ );
+ let workspace_id = created["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let pane_id = format!("{workspace_id}:p1");
+
+ let reported = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_cli_immediate_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"idle"}}}}"#,
+ pane_id
+ ),
+ );
+ assert_eq!(reported["result"]["type"], "ok");
+
+ let waited = run_cli(
+ &socket_path,
+ &[
+ "agent",
+ "wait",
+ &pane_id,
+ "--until",
+ "idle",
+ "--timeout",
+ "1000",
+ ],
+ );
+ assert!(
+ waited.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&waited.stderr)
+ );
+ let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap();
+ assert_eq!(waited_json["result"]["agent"]["agent_status"], "idle");
+ assert_eq!(waited_json["result"]["agent"]["agent"], "pi");
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn agent_wait_times_out_when_status_does_not_match() {
+ 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 = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_cli_timeout_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
+ base.display()
+ ),
+ );
+ assert_eq!(created["result"]["type"], "workspace_created");
+ let pane_id = created["result"]["root_pane"]["pane_id"].as_str().unwrap();
+ let reported = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_cli_timeout_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working"}}}}"#,
+ pane_id
+ ),
+ );
+ assert_eq!(reported["result"]["type"], "ok");
+
+ let waited = run_cli(
+ &socket_path,
+ &[
+ "agent",
+ "wait",
+ pane_id,
+ "--until",
+ "blocked",
+ "--timeout",
+ "100",
+ ],
+ );
+ assert!(!waited.status.success());
+ assert!(
+ String::from_utf8_lossy(&waited.stderr).contains("timed out waiting for agent status"),
+ "stderr: {}",
+ String::from_utf8_lossy(&waited.stderr)
+ );
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn agent_wait_exits_when_done_status_matches() {
+ 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_dir = base.join("bin");
+
+ fs::create_dir_all(&bin_dir).unwrap();
+ let fake_pi = bin_dir.join("pi");
+ fs::write(
+ &fake_pi,
+ "#!/bin/sh\nprintf 'starting\\n'\nsleep 4\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n",
+ )
+ .unwrap();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mut perms = fs::metadata(&fake_pi).unwrap().permissions();
+ perms.set_mode(0o755);
+ fs::set_permissions(&fake_pi, perms).unwrap();
+ }
+
+ let inherited_path = std::env::var("PATH").unwrap_or_default();
+ let path_override = format!("{}:{}", bin_dir.display(), inherited_path);
+ let herdr = spawn_herdr_with_path(
+ &config_home,
+ &runtime_dir,
+ &socket_path,
+ Some(Path::new(&path_override)),
+ );
+
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+
+ let created = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_cli_status_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
+ base.display()
+ ),
+ );
+ let workspace_id = created["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let tab_created = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_cli_status_2","method":"tab.create","params":{{"workspace_id":"{}","focus":true}}}}"#,
+ workspace_id
+ ),
+ );
+ assert_eq!(tab_created["result"]["type"], "tab_created");
+
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let start_pi = run_cli(&socket_path, &["pane", "run", &pane_id, "pi"]);
+ assert!(start_pi.status.success());
+ assert!(wait_until(
+ Duration::from_secs(3),
+ Duration::from_millis(25),
+ || run_cli(&socket_path, &["agent", "get", &pane_id])
+ .status
+ .success()
+ ));
+
+ let waited = run_cli(
+ &socket_path,
+ &[
+ "agent",
+ "wait",
+ &pane_id,
+ "--until",
+ "done",
+ "--timeout",
+ "10000",
+ ],
+ );
+ assert!(
+ waited.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&waited.stderr)
+ );
+ let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap();
+ assert_eq!(waited_json["result"]["agent"]["agent_status"], "done");
+ assert_eq!(waited_json["result"]["agent"]["agent"], "pi");
+
+ cleanup_spawned_herdr(herdr, base);
+}
diff --git a/tests/cli/agents.rs b/tests/cli/agents.rs
new file mode 100644
index 00000000..e06d0ee5
--- /dev/null
+++ b/tests/cli/agents.rs
@@ -0,0 +1,1189 @@
+use super::harness::*;
+
+#[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");
+ let captured_prompts = base.join("pi-prompts");
+ 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\n printf '%s\\n' \"$prompt\" >> '{}'\ndone\n",
+ captured_args.display(),
+ env!("CARGO_BIN_EXE_herdr"),
+ env!("CARGO_BIN_EXE_herdr"),
+ env!("CARGO_BIN_EXE_herdr"),
+ captured_prompts.display(),
+ ),
+ )
+ .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();
+ run_cli_json(&socket_path, &["pane", "rename", &pane_id, "shell-pane"]);
+ 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,
+ &[
+ "agent",
+ "start",
+ "main",
+ "--kind",
+ "pi",
+ "--pane",
+ &pane_id,
+ "--timeout",
+ "8000",
+ "--",
+ "--name",
+ "scratch",
+ "--no-session",
+ ],
+ );
+ assert_eq!(started["result"]["type"], "agent_started");
+ assert_eq!(started["result"]["agent"]["name"], "main");
+ assert_eq!(started["result"]["agent"]["agent"], "pi");
+ assert_eq!(started["result"]["agent"]["pane_id"], pane_id);
+ assert_eq!(
+ run_cli_json(&socket_path, &["pane", "get", &pane_id])["result"]["pane"]["label"],
+ "shell-pane"
+ );
+ 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 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 literal_flag_prompt: serde_json::Value =
+ serde_json::from_slice(&literal_flag_prompt.stdout).unwrap();
+ assert_eq!(literal_flag_prompt["result"]["type"], "agent_prompted");
+ assert!(wait_until(
+ Duration::from_secs(2),
+ Duration::from_millis(25),
+ || captured_prompts.exists()
+ ));
+
+ 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",
+ "prompt",
+ "main",
+ "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 split = run_cli_json(
+ &socket_path,
+ &["pane", "split", &pane_id, "--direction", "right"],
+ );
+ let reuse_pane_id = split["result"]["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");
+
+ assert!(run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &reuse_pane_id,
+ "--source",
+ "custom:reuse",
+ "--agent",
+ "pi",
+ "--state",
+ "idle",
+ ],
+ )
+ .status
+ .success());
+ let reused = run_cli(&socket_path, &["agent", "rename", &reuse_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 split = run_cli_json(
+ &socket_path,
+ &["pane", "split", &pane_id, "--direction", "right"],
+ );
+ let reuse_pane_id = split["result"]["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");
+
+ assert!(run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &reuse_pane_id,
+ "--source",
+ "custom:reuse",
+ "--agent",
+ "pi",
+ "--state",
+ "idle",
+ ],
+ )
+ .status
+ .success());
+ let reused = run_cli(&socket_path, &["agent", "rename", &reuse_pane_id, "worker"]);
+ assert!(reused.status.success());
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn agent_start_follows_its_named_terminal_when_the_pane_moves() {
+ 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=pi 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 first = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let start_socket = socket_path.clone();
+ let start_pane = first.clone();
+ let starter = thread::spawn(move || {
+ run_cli(
+ &start_socket,
+ &[
+ "agent",
+ "start",
+ "worker",
+ "--kind",
+ "pi",
+ "--pane",
+ &start_pane,
+ "--timeout",
+ "8000",
+ ],
+ )
+ });
+ assert!(wait_until(
+ Duration::from_secs(2),
+ Duration::from_millis(25),
+ || run_cli(&socket_path, &["agent", "get", "worker"])
+ .status
+ .success()
+ ));
+
+ let moved = run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "move",
+ &first,
+ "--new-workspace",
+ "--label",
+ "moved",
+ "--no-focus",
+ ],
+ );
+ assert!(
+ moved.status.success(),
+ "move failed: {}",
+ String::from_utf8_lossy(&moved.stderr)
+ );
+
+ let started = starter.join().unwrap();
+ assert!(
+ started.status.success(),
+ "start failed after swap: {}",
+ String::from_utf8_lossy(&started.stderr)
+ );
+ let started: serde_json::Value = serde_json::from_slice(&started.stdout).unwrap();
+ assert_ne!(started["result"]["agent"]["pane_id"], first);
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn agent_start_and_rename_reject_invalid_names() {
+ 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 expected_message = "agent name must start with a lowercase letter and contain only lowercase letters, digits, '-' or '_' (1-32 characters)";
+
+ let started = run_cli(
+ &socket_path,
+ &[
+ "agent",
+ "start",
+ "reviewer one",
+ "--kind",
+ "pi",
+ "--pane",
+ &pane_id,
+ ],
+ );
+ assert_eq!(started.status.code(), Some(1));
+ let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap();
+ assert_eq!(error["error"]["code"], "invalid_agent_name");
+ assert_eq!(error["error"]["message"], expected_message);
+
+ assert!(run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &pane_id,
+ "--source",
+ "custom:name",
+ "--agent",
+ "pi",
+ "--state",
+ "idle",
+ ],
+ )
+ .status
+ .success());
+ let renamed = run_cli(&socket_path, &["agent", "rename", &pane_id, "reviewer one"]);
+ assert_eq!(renamed.status.code(), Some(1));
+ let error: serde_json::Value = serde_json::from_slice(&renamed.stderr).unwrap();
+ assert_eq!(error["error"]["code"], "invalid_agent_name");
+ assert_eq!(error["error"]["message"], expected_message);
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn agent_commands_work() {
+ 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(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ assert!(created.status.success());
+ let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
+ let root_pane_id = created_json["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let terminal_id = created_json["result"]["root_pane"]["terminal_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let reported = run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &root_pane_id,
+ "--source",
+ "custom:test",
+ "--agent",
+ "pi",
+ "--state",
+ "idle",
+ ],
+ );
+ assert!(reported.status.success());
+ let renamed = run_cli(&socket_path, &["agent", "rename", &root_pane_id, "worker"]);
+ assert!(renamed.status.success());
+
+ let listed = run_cli_json(&socket_path, &["agent", "list"]);
+ assert_eq!(listed["result"]["type"], "agent_list");
+ assert_eq!(listed["result"]["agents"][0]["terminal_id"], terminal_id);
+ assert_eq!(listed["result"]["agents"][0]["name"], "worker");
+
+ let fetched = run_cli_json(&socket_path, &["agent", "get", "worker"]);
+ assert_eq!(fetched["result"]["agent"]["pane_id"], root_pane_id);
+ 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,
+ &[
+ "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",
+ ],
+ )
+ .status
+ .success());
+ let unknown = run_cli_json(
+ &socket_path,
+ &[
+ "agent",
+ "wait",
+ "worker",
+ "--until",
+ "unknown",
+ "--timeout",
+ "1000",
+ ],
+ );
+ assert_eq!(unknown["result"]["agent"]["agent_status"], "unknown");
+
+ let pane_read = run_cli(
+ &socket_path,
+ &["pane", "read", &root_pane_id, "--source", "visible"],
+ );
+ let agent_read = run_cli(
+ &socket_path,
+ &["agent", "read", &root_pane_id, "--source", "visible"],
+ );
+ assert!(pane_read.status.success());
+ assert!(agent_read.status.success());
+ assert_eq!(agent_read.stdout, pane_read.stdout);
+
+ let missing_read = run_cli(&socket_path, &["agent", "read", "missing"]);
+ assert_eq!(missing_read.status.code(), Some(1));
+ let missing_read: serde_json::Value = serde_json::from_slice(&missing_read.stderr).unwrap();
+ assert_eq!(missing_read["error"]["code"], "agent_not_found");
+
+ let sent = run_cli(&socket_path, &["agent", "send-keys", "worker", "enter"]);
+ assert_eq!(sent.status.code(), Some(1));
+ let sent: serde_json::Value = serde_json::from_slice(&sent.stderr).unwrap();
+ assert_eq!(sent["error"]["code"], "agent_not_ready");
+
+ let agent_renamed = run_cli_json(&socket_path, &["agent", "rename", "worker", "reviewer"]);
+ assert_eq!(agent_renamed["result"]["agent"]["name"], "reviewer");
+
+ let focused = run_cli_json(&socket_path, &["agent", "focus", "reviewer"]);
+ assert_eq!(focused["result"]["agent"]["focused"], true);
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[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_tolerates_detection_uncertainty_and_pane_target_rename() {
+ 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();
+ assert!(run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &pane_id,
+ "--source",
+ "custom:uncertain",
+ "--agent",
+ "pi",
+ "--state",
+ "working",
+ ],
+ )
+ .status
+ .success());
+ assert!(
+ run_cli(&socket_path, &["agent", "rename", &pane_id, "worker"])
+ .status
+ .success()
+ );
+
+ let wait_socket = socket_path.clone();
+ let waiter = thread::spawn(move || {
+ run_cli(
+ &wait_socket,
+ &[
+ "agent",
+ "wait",
+ "worker",
+ "--until",
+ "unknown",
+ "--timeout",
+ "2000",
+ ],
+ )
+ });
+ thread::sleep(Duration::from_millis(150));
+ let cleared = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"agent_wait_uncertain","method":"pane.clear_agent_authority","params":{{"pane_id":"{}","source":"custom:uncertain"}}}}"#,
+ pane_id
+ ),
+ );
+ assert_eq!(cleared["result"]["type"], "ok");
+ let waited = waiter.join().unwrap();
+ assert!(
+ waited.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&waited.stderr)
+ );
+ let waited: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap();
+ assert_eq!(waited["result"]["agent"]["agent_status"], "unknown");
+ assert_eq!(waited["result"]["agent"]["name"], "worker");
+
+ assert!(run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &pane_id,
+ "--source",
+ "custom:return",
+ "--agent",
+ "pi",
+ "--state",
+ "working",
+ ],
+ )
+ .status
+ .success());
+ let wait_socket = socket_path.clone();
+ let wait_pane = pane_id.clone();
+ let waiter = thread::spawn(move || {
+ run_cli(
+ &wait_socket,
+ &[
+ "agent",
+ "wait",
+ &wait_pane,
+ "--until",
+ "idle",
+ "--timeout",
+ "2000",
+ ],
+ )
+ });
+ thread::sleep(Duration::from_millis(150));
+ assert!(
+ run_cli(&socket_path, &["agent", "rename", "worker", "reviewer"])
+ .status
+ .success()
+ );
+ assert!(run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &pane_id,
+ "--source",
+ "custom:return",
+ "--agent",
+ "pi",
+ "--state",
+ "idle",
+ ],
+ )
+ .status
+ .success());
+ let waited = waiter.join().unwrap();
+ assert!(
+ waited.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&waited.stderr)
+ );
+ let waited: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap();
+ assert_eq!(waited["result"]["agent"]["agent_status"], "idle");
+ assert_eq!(waited["result"]["agent"]["name"], "reviewer");
+
+ 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_not_running");
+
+ 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_eq!(error["error"]["code"], "agent_not_running");
+
+ cleanup_spawned_herdr(herdr, base);
+}
diff --git a/tests/cli/harness.rs b/tests/cli/harness.rs
new file mode 100644
index 00000000..91dab149
--- /dev/null
+++ b/tests/cli/harness.rs
@@ -0,0 +1,608 @@
+pub(super) use std::fs;
+pub(super) use std::io::{BufRead, BufReader, Write};
+pub(super) use std::os::unix::net::{UnixListener, UnixStream};
+pub(super) use std::path::{Path, PathBuf};
+pub(super) use std::process::{Command, Stdio};
+pub(super) use std::thread;
+pub(super) use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+pub(super) use crate::support::{
+ cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
+ unregister_spawned_herdr_pid, CURRENT_PROTOCOL,
+};
+use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
+
+pub(super) const WORKTREE_BOOTSTRAP_MANAGED_COMPONENT: &str =
+ "example.worktree-bootstrap-ef876653ffc3";
+
+pub(super) fn unique_test_dir() -> PathBuf {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ PathBuf::from(format!("/tmp/hcli-{}-{nanos}", std::process::id()))
+}
+
+pub(super) fn managed_github_plugin_dir(config_home: &Path) -> PathBuf {
+ config_home.join("herdr-dev").join("plugins").join("github")
+}
+
+pub(super) fn path_missing_or_empty(path: &Path) -> bool {
+ match fs::read_dir(path) {
+ Ok(mut entries) => entries.next().is_none(),
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
+ Err(err) => panic!("failed to read {}: {err}", path.display()),
+ }
+}
+
+pub(super) fn run_git(repo: &Path, args: &[&str]) {
+ let status = Command::new("git")
+ .arg("-C")
+ .arg(repo)
+ .args(args)
+ .status()
+ .unwrap();
+ assert!(
+ status.success(),
+ "git command failed: git -C {} {}",
+ repo.display(),
+ args.join(" ")
+ );
+}
+
+pub(super) fn create_committed_repo(path: &Path) {
+ fs::create_dir_all(path).unwrap();
+ run_git(path, &["init", "--quiet"]);
+ run_git(path, &["config", "user.email", "herdr@example.invalid"]);
+ run_git(path, &["config", "user.name", "Herdr Test"]);
+ fs::write(path.join("README.md"), "test\n").unwrap();
+ run_git(path, &["add", "README.md"]);
+ run_git(path, &["commit", "--quiet", "-m", "initial"]);
+}
+
+pub(super) struct SpawnedHerdr {
+ _master: Box<dyn MasterPty + Send>,
+ pub(super) child: Box<dyn Child + Send + Sync>,
+}
+
+pub(super) struct SpawnedServerProcess {
+ child: std::process::Child,
+}
+
+impl Drop for SpawnedServerProcess {
+ fn drop(&mut self) {
+ let pid = self.child.id();
+ let _ = self.child.kill();
+ let _ = self.child.wait();
+ unregister_spawned_herdr_pid(Some(pid));
+ }
+}
+
+impl Drop for SpawnedHerdr {
+ fn drop(&mut self) {
+ let pid = self.child.process_id();
+ let _ = self.child.kill();
+
+ if let Some(pid) = pid {
+ let deadline = Instant::now() + Duration::from_secs(2);
+ while Instant::now() < deadline {
+ let mut status = 0;
+ let result =
+ unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) };
+ if result == pid as libc::pid_t || result == -1 {
+ break;
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+
+ unregister_spawned_herdr_pid(Some(pid));
+ }
+ }
+}
+
+pub(super) fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) {
+ drop(spawned);
+ cleanup_test_base(&base);
+}
+
+pub(super) fn wait_for_socket(path: &Path, timeout: Duration) {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if path.exists() && std::os::unix::net::UnixStream::connect(path).is_ok() {
+ return;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!("socket did not appear at {}", path.display());
+}
+
+pub(super) fn spawn_herdr(
+ config_home: &Path,
+ runtime_dir: &Path,
+ socket_path: &Path,
+) -> SpawnedHerdr {
+ spawn_herdr_with_config(
+ config_home,
+ runtime_dir,
+ socket_path,
+ None,
+ "onboarding = false\n",
+ )
+}
+
+pub(super) fn spawn_herdr_with_pane_history(
+ config_home: &Path,
+ runtime_dir: &Path,
+ socket_path: &Path,
+) -> SpawnedHerdr {
+ spawn_herdr_with_config(
+ config_home,
+ runtime_dir,
+ socket_path,
+ None,
+ "onboarding = false\n[experimental]\npane_history = true\n",
+ )
+}
+
+pub(super) fn app_dir_name() -> &'static str {
+ if cfg!(debug_assertions) {
+ "herdr-dev"
+ } else {
+ "herdr"
+ }
+}
+
+pub(super) fn named_session_socket(config_home: &Path, session: &str) -> PathBuf {
+ config_home
+ .join(app_dir_name())
+ .join("sessions")
+ .join(session)
+ .join("herdr.sock")
+}
+
+pub(super) fn spawn_named_server(
+ config_home: &Path,
+ runtime_dir: &Path,
+ session: &str,
+) -> SpawnedServerProcess {
+ fs::create_dir_all(config_home.join(app_dir_name())).unwrap();
+ fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
+ fs::write(
+ config_home.join(app_dir_name()).join("config.toml"),
+ "onboarding = false\n",
+ )
+ .unwrap();
+
+ let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
+ command
+ .args(["--session", session, "server"])
+ .env("XDG_CONFIG_HOME", config_home)
+ .env("XDG_RUNTIME_DIR", runtime_dir)
+ .env_remove("HERDR_SOCKET_PATH")
+ .env_remove("HERDR_CLIENT_SOCKET_PATH")
+ .env_remove("HERDR_ENV")
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null());
+
+ let child = command.spawn().unwrap();
+ register_spawned_herdr_pid(Some(child.id()));
+ SpawnedServerProcess { child }
+}
+
+pub(super) fn run_named_cli(
+ config_home: &Path,
+ runtime_dir: &Path,
+ args: &[&str],
+) -> std::process::Output {
+ run_named_cli_with_socket_override(config_home, runtime_dir, args, None)
+}
+
+pub(super) fn run_named_cli_with_socket_override(
+ config_home: &Path,
+ runtime_dir: &Path,
+ args: &[&str],
+ socket_override: Option<&Path>,
+) -> std::process::Output {
+ run_named_cli_with_env_and_socket_override(config_home, runtime_dir, args, &[], socket_override)
+}
+
+pub(super) fn run_named_cli_with_env(
+ config_home: &Path,
+ runtime_dir: &Path,
+ args: &[&str],
+ envs: &[(&str, &Path)],
+) -> std::process::Output {
+ run_named_cli_with_env_and_socket_override(config_home, runtime_dir, args, envs, None)
+}
+
+pub(super) fn run_named_cli_with_env_and_socket_override(
+ config_home: &Path,
+ runtime_dir: &Path,
+ args: &[&str],
+ envs: &[(&str, &Path)],
+ socket_override: Option<&Path>,
+) -> std::process::Output {
+ let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
+ command
+ .args(args)
+ .env("XDG_CONFIG_HOME", config_home)
+ .env("XDG_RUNTIME_DIR", runtime_dir)
+ .env_remove("HERDR_CLIENT_SOCKET_PATH")
+ .env_remove("HERDR_ENV");
+ for (key, value) in envs {
+ command.env(key, value);
+ }
+ if let Some(socket_override) = socket_override {
+ command.env("HERDR_SOCKET_PATH", socket_override);
+ } else {
+ command.env_remove("HERDR_SOCKET_PATH");
+ }
+ command.output().unwrap()
+}
+
+pub(super) fn run_named_cli_json(
+ config_home: &Path,
+ runtime_dir: &Path,
+ args: &[&str],
+) -> serde_json::Value {
+ let output = run_named_cli(config_home, runtime_dir, args);
+ assert!(
+ output.status.success(),
+ "command failed: herdr {}\nstatus: {:?}\nstderr: {}\nstdout: {}",
+ args.join(" "),
+ output.status.code(),
+ String::from_utf8_lossy(&output.stderr),
+ String::from_utf8_lossy(&output.stdout)
+ );
+ serde_json::from_slice(&output.stdout).unwrap()
+}
+
+pub(super) fn spawn_herdr_with_path(
+ config_home: &Path,
+ runtime_dir: &Path,
+ socket_path: &Path,
+ path_override: Option<&Path>,
+) -> SpawnedHerdr {
+ spawn_herdr_with_config(
+ config_home,
+ runtime_dir,
+ socket_path,
+ path_override,
+ "onboarding = false\n",
+ )
+}
+
+pub(super) fn spawn_herdr_with_config(
+ config_home: &Path,
+ runtime_dir: &Path,
+ socket_path: &Path,
+ path_override: Option<&Path>,
+ config_toml: &str,
+) -> SpawnedHerdr {
+ fs::create_dir_all(config_home.join(app_dir_name())).unwrap();
+ fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
+ fs::write(
+ config_home.join(app_dir_name()).join("config.toml"),
+ config_toml,
+ )
+ .unwrap();
+
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("server");
+ cmd.env("XDG_CONFIG_HOME", config_home);
+ cmd.env("XDG_RUNTIME_DIR", runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", socket_path);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+ if let Some(path) = path_override {
+ cmd.env("PATH", path);
+ }
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ SpawnedHerdr {
+ _master: pair.master,
+ child,
+ }
+}
+
+pub(super) fn run_cli(socket_path: &Path, args: &[&str]) -> std::process::Output {
+ let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
+ command.args(args);
+ command.env("HERDR_SOCKET_PATH", socket_path);
+ command.output().unwrap()
+}
+
+pub(super) fn run_cli_in_dir(
+ socket_path: &Path,
+ args: &[&str],
+ current_dir: &Path,
+) -> std::process::Output {
+ let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
+ command.args(args);
+ command.current_dir(current_dir);
+ command.env("HERDR_SOCKET_PATH", socket_path);
+ command.output().unwrap()
+}
+
+pub(super) 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()
+}
+
+pub(super) 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)
+}
+
+pub(super) fn run_cli_json_in_dir(
+ socket_path: &Path,
+ args: &[&str],
+ current_dir: &Path,
+) -> serde_json::Value {
+ let output = run_cli_in_dir(socket_path, args, current_dir);
+ parse_cli_json_output(args, output)
+}
+
+pub(super) fn parse_cli_json_output(
+ args: &[&str],
+ output: std::process::Output,
+) -> serde_json::Value {
+ assert!(
+ output.status.success(),
+ "command failed: herdr {}\nstatus: {:?}\nstderr: {}\nstdout: {}",
+ args.join(" "),
+ output.status.code(),
+ String::from_utf8_lossy(&output.stderr),
+ String::from_utf8_lossy(&output.stdout)
+ );
+
+ serde_json::from_slice(&output.stdout).unwrap_or_else(|err| {
+ panic!(
+ "failed to parse JSON response for `herdr {}`: {}\nstdout: {}\nstderr: {}",
+ args.join(" "),
+ err,
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ )
+ })
+}
+
+pub(super) fn wait_until(
+ timeout: Duration,
+ interval: Duration,
+ mut condition: impl FnMut() -> bool,
+) -> bool {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if condition() {
+ return true;
+ }
+ thread::sleep(interval);
+ }
+ false
+}
+
+pub(super) fn pane_read_recent_contains(socket_path: &Path, pane_id: &str, expected: &str) -> bool {
+ let output = run_cli(
+ socket_path,
+ &["pane", "read", pane_id, "--source", "recent"],
+ );
+ if !output.status.success() {
+ return false;
+ }
+ String::from_utf8_lossy(&output.stdout).contains(expected)
+}
+
+pub(super) fn process_exists(pid: u32) -> bool {
+ let result = unsafe { libc::kill(pid as i32, 0) };
+ if result == 0 {
+ true
+ } else {
+ std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
+ }
+}
+
+pub(super) fn wait_for_pid_exit(pid: u32, timeout: Duration) -> bool {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if !process_exists(pid) {
+ return true;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ !process_exists(pid)
+}
+
+pub(super) fn wait_for_pid_file(pid_file: &Path, timeout: Duration) -> Result<u32, String> {
+ const STABLE_PID_CONTENT_WINDOW: Duration = Duration::from_millis(250);
+
+ let deadline = Instant::now() + timeout;
+ let mut last_contents = String::new();
+ let mut stable_candidate: Option<(String, u32, Instant)> = None;
+
+ while Instant::now() < deadline {
+ if let Ok(contents) = fs::read_to_string(pid_file) {
+ let trimmed = contents.trim().to_string();
+ last_contents = contents;
+
+ if let Ok(pid) = trimmed.parse::<u32>() {
+ match &stable_candidate {
+ Some((candidate_text, candidate_pid, stable_since))
+ if candidate_text == &trimmed && *candidate_pid == pid =>
+ {
+ if stable_since.elapsed() >= STABLE_PID_CONTENT_WINDOW {
+ return Ok(pid);
+ }
+ }
+ _ => {
+ stable_candidate = Some((trimmed, pid, Instant::now()));
+ }
+ }
+ } else {
+ stable_candidate = None;
+ }
+ }
+
+ thread::sleep(Duration::from_millis(25));
+ }
+
+ Err(format!(
+ "pid file {} did not contain stable parseable pid before timeout; last contents={:?}",
+ pid_file.display(),
+ last_contents
+ ))
+}
+
+#[test]
+fn wait_for_pid_file_retries_until_pid_is_written() {
+ let base = unique_test_dir();
+ fs::create_dir_all(&base).unwrap();
+ let pid_file = base.join("delayed.pid");
+ fs::write(&pid_file, "").unwrap();
+
+ let writer = thread::spawn({
+ let pid_file = pid_file.clone();
+ move || {
+ thread::sleep(Duration::from_millis(100));
+ fs::write(pid_file, "424242\n").unwrap();
+ }
+ });
+
+ let pid = wait_for_pid_file(&pid_file, Duration::from_secs(2)).unwrap();
+ assert_eq!(pid, 424242);
+
+ writer.join().unwrap();
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn wait_for_pid_file_errors_when_file_never_contains_pid() {
+ let base = unique_test_dir();
+ fs::create_dir_all(&base).unwrap();
+ let pid_file = base.join("empty.pid");
+ fs::write(&pid_file, "").unwrap();
+
+ let err = wait_for_pid_file(&pid_file, Duration::from_millis(150)).unwrap_err();
+ assert!(
+ err.contains("did not contain stable parseable pid"),
+ "unexpected error: {err}"
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn wait_for_pid_file_rejects_unparseable_partial_write_until_stable_contents() {
+ let base = unique_test_dir();
+ fs::create_dir_all(&base).unwrap();
+ let pid_file = base.join("partial-race.pid");
+ fs::write(&pid_file, "").unwrap();
+
+ let writer = thread::spawn({
+ let pid_file = pid_file.clone();
+ move || {
+ thread::sleep(Duration::from_millis(40));
+ fs::write(&pid_file, "pid=").unwrap();
+ thread::sleep(Duration::from_millis(40));
+ fs::write(&pid_file, "pid=424242").unwrap();
+ thread::sleep(Duration::from_millis(40));
+ fs::write(&pid_file, "424242\n").unwrap();
+ }
+ });
+
+ let start = Instant::now();
+ let pid = wait_for_pid_file(&pid_file, Duration::from_secs(2)).unwrap();
+ assert_eq!(pid, 424242);
+ assert!(
+ start.elapsed() >= Duration::from_millis(300),
+ "helper should wait for stable complete contents, elapsed={:?}",
+ start.elapsed()
+ );
+
+ writer.join().unwrap();
+ cleanup_test_base(&base);
+}
+
+pub(super) fn send_request(socket_path: &Path, json: &str) -> serde_json::Value {
+ let mut stream = UnixStream::connect(socket_path).unwrap();
+ stream.write_all(json.as_bytes()).unwrap();
+ stream.write_all(b"\n").unwrap();
+ stream.flush().unwrap();
+
+ let mut line = String::new();
+ let mut reader = BufReader::new(stream);
+ reader.read_line(&mut line).unwrap();
+ serde_json::from_str(&line).unwrap()
+}
+
+pub(super) fn write_fake_pong(
+ stream: &mut UnixStream,
+ request: &serde_json::Value,
+ version: &str,
+ protocol: u32,
+) {
+ writeln!(
+ stream,
+ "{}",
+ serde_json::json!({
+ "id": request["id"],
+ "result": {
+ "type": "pong",
+ "version": version,
+ "protocol": protocol,
+ "capabilities": {
+ "live_handoff": true,
+ "detached_server_daemon": true
+ }
+ }
+ })
+ )
+ .unwrap();
+ stream.flush().unwrap();
+}
+
+pub(super) fn accept_fake_cli_operation(listener: &UnixListener) -> (UnixStream, String) {
+ loop {
+ let (mut stream, _) = listener.accept().unwrap();
+ let mut line = String::new();
+ let mut reader = BufReader::new(stream.try_clone().unwrap());
+ reader.read_line(&mut line).unwrap();
+ let request: serde_json::Value = serde_json::from_str(&line).unwrap();
+ if request["method"] != "ping" {
+ return (stream, line);
+ }
+
+ write_fake_pong(
+ &mut stream,
+ &request,
+ "different-build-same-protocol",
+ CURRENT_PROTOCOL,
+ );
+ }
+}
diff --git a/tests/cli/hooks.rs b/tests/cli/hooks.rs
new file mode 100644
index 00000000..b2edd3e7
--- /dev/null
+++ b/tests/cli/hooks.rs
@@ -0,0 +1,308 @@
+use super::harness::*;
+
+fn run_claude_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> {
+ run_shell_hook(
+ "src/integration/assets/claude/herdr-agent-state.sh",
+ &[action],
+ hook_input,
+ )
+}
+
+fn run_codex_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> {
+ run_shell_hook(
+ "src/integration/assets/codex/herdr-agent-state.sh",
+ &[action],
+ hook_input,
+ )
+}
+
+fn run_copilot_hook(hook_input: &str) -> Option<serde_json::Value> {
+ run_shell_hook(
+ "src/integration/assets/copilot/herdr-agent-state.sh",
+ &[],
+ hook_input,
+ )
+}
+
+fn run_devin_hook(
+ action: &str,
+ hook_input: &str,
+ envs: &[(&str, &str)],
+) -> Option<serde_json::Value> {
+ run_shell_hook_with_env(
+ "src/integration/assets/devin/herdr-agent-state.sh",
+ &[action],
+ hook_input,
+ envs,
+ )
+}
+
+fn run_shell_hook(asset_path: &str, args: &[&str], hook_input: &str) -> Option<serde_json::Value> {
+ run_shell_hook_with_env(asset_path, args, hook_input, &[])
+}
+
+fn run_shell_hook_with_env(
+ asset_path: &str,
+ args: &[&str],
+ hook_input: &str,
+ envs: &[(&str, &str)],
+) -> Option<serde_json::Value> {
+ 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 || {
+ listener.set_nonblocking(true).unwrap();
+ let deadline = Instant::now() + Duration::from_millis(700);
+ while Instant::now() < deadline {
+ match listener.accept() {
+ Ok((mut stream, _)) => {
+ let mut line = String::new();
+ let mut reader = BufReader::new(stream.try_clone().unwrap());
+ reader.read_line(&mut line).unwrap();
+ let _ = stream.write_all(br#"{"id":"test","result":{"type":"ok"}}"#);
+ let _ = stream.write_all(b"\n");
+ let _ = stream.flush();
+ return Some(line);
+ }
+ Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
+ thread::sleep(Duration::from_millis(10));
+ }
+ Err(err) => panic!("accept failed: {err}"),
+ }
+ }
+ None
+ });
+
+ let hook_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(asset_path);
+ let mut command = Command::new("bash");
+ command
+ .arg(hook_path)
+ .args(args)
+ .env("HERDR_ENV", "1")
+ .env("HERDR_SOCKET_PATH", &socket_path)
+ .env("HERDR_PANE_ID", "p_test")
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped());
+ for (key, value) in envs {
+ command.env(key, value);
+ }
+ let mut child = command.spawn().unwrap();
+ let mut stdin = child.stdin.take().unwrap();
+ stdin.write_all(hook_input.as_bytes()).unwrap();
+ drop(stdin);
+
+ let output = child.wait_with_output().unwrap();
+ assert!(
+ output.status.success(),
+ "hook failed: status={:?} stderr={} stdout={}",
+ output.status.code(),
+ String::from_utf8_lossy(&output.stderr),
+ String::from_utf8_lossy(&output.stdout)
+ );
+
+ let request = server.join().unwrap();
+ cleanup_test_base(&base);
+ request.map(|line| serde_json::from_str(&line).unwrap())
+}
+
+#[test]
+fn claude_hook_ignores_state_actions() {
+ let subagent_input = r#"{"hook_event_name":"Notification","agent_id":"agent-abc123","agent_type":"Explore","notification_type":"permission_prompt"}"#;
+
+ assert!(run_claude_hook("working", subagent_input).is_none());
+ assert!(run_claude_hook("blocked", subagent_input).is_none());
+}
+
+#[test]
+fn claude_hook_ignores_subagent_completion_reports() {
+ let subagent_input =
+ r#"{"hook_event_name":"SubagentStop","agent_id":"agent-abc123","agent_type":"Explore"}"#;
+
+ assert!(run_claude_hook("working", subagent_input).is_none());
+ assert!(run_claude_hook("idle", subagent_input).is_none());
+ assert!(run_claude_hook("release", subagent_input).is_none());
+}
+
+#[test]
+fn claude_hook_keeps_parent_agent_type_only_blocked() {
+ let request = run_claude_hook(
+ "blocked",
+ r#"{"hook_event_name":"PermissionRequest","agent_type":"Explore"}"#,
+ );
+
+ assert!(request.is_none());
+}
+
+#[test]
+fn claude_hook_reports_session_id_from_stdin() {
+ let request = run_claude_hook(
+ "session",
+ r#"{"hook_event_name":"SessionStart","session_id":"claude-session"}"#,
+ )
+ .expect("session start should report session identity");
+
+ assert_eq!(request["method"], "pane.report_agent_session");
+ assert_eq!(request["params"]["agent_session_id"], "claude-session");
+ assert!(request["params"].get("state").is_none());
+}
+
+#[test]
+fn codex_hook_reports_session_id_from_stdin() {
+ let request = run_codex_hook(
+ "session",
+ r#"{"hook_event_name":"SessionStart","session_id":"codex-session"}"#,
+ )
+ .expect("codex hook should report session identity");
+
+ assert_eq!(request["method"], "pane.report_agent_session");
+ assert_eq!(request["params"]["agent_session_id"], "codex-session");
+ assert!(request["params"].get("state").is_none());
+}
+
+#[test]
+fn copilot_hook_reports_session_id_from_stdin() {
+ let request = run_copilot_hook(
+ r#"{"hook_event_name":"SessionStart","session_id":"copilot-session","source":"resume"}"#,
+ )
+ .expect("copilot session start should report session identity");
+
+ assert_eq!(request["method"], "pane.report_agent_session");
+ assert_eq!(request["params"]["agent"], "copilot");
+ assert_eq!(request["params"]["agent_session_id"], "copilot-session");
+ assert!(request["params"].get("state").is_none());
+
+ let camel = run_copilot_hook(
+ r#"{"sessionId":"copilot-camel-session","source":"new","initialPrompt":"run tests"}"#,
+ )
+ .expect("copilot camelCase session start should report session identity");
+
+ assert_eq!(camel["method"], "pane.report_agent_session");
+ assert_eq!(camel["params"]["agent_session_id"], "copilot-camel-session");
+ assert!(camel["params"].get("state").is_none());
+}
+
+#[test]
+fn copilot_hook_does_not_report_lifecycle_state() {
+ for payload in [
+ r#"{"hook_event_name":"UserPromptSubmit","session_id":"copilot-session","prompt":"run tests"}"#,
+ r#"{"hook_event_name":"PreToolUse","session_id":"copilot-session","tool_name":"ask_user"}"#,
+ r#"{"hook_event_name":"notification","session_id":"copilot-session","notification_type":"permission_prompt"}"#,
+ r#"{"hook_event_name":"agentStop","session_id":"copilot-session","stop_reason":"end_turn"}"#,
+ r#"{"hook_event_name":"SessionEnd","session_id":"copilot-session","reason":"user_exit"}"#,
+ ] {
+ assert!(
+ run_copilot_hook(payload).is_none(),
+ "copilot session-only hook should ignore lifecycle payload {payload}"
+ );
+ }
+}
+
+#[test]
+fn devin_hook_ignores_prompt_session_list_fallback() {
+ let request = run_devin_hook(
+ "session",
+ r#"{"hook_event_name":"UserPromptSubmit","prompt":"run tests"}"#,
+ &[
+ ("DEVIN_PROJECT_DIR", "/tmp/project"),
+ (
+ "HERDR_DEVIN_LIST_JSON",
+ r#"[{"id":"older-session","working_directory":"/tmp/other"},{"id":"devin-session","working_directory":"/tmp/project"}]"#,
+ ),
+ ],
+ );
+
+ assert!(request.is_none());
+}
+
+#[test]
+fn devin_hook_reports_session_id_from_stdin_without_state() {
+ let request = run_devin_hook(
+ "session",
+ r#"{"hook_event_name":"SessionStart","session_id":"devin-session","source":"startup"}"#,
+ &[("HERDR_DEVIN_LIST_JSON", r#"[{"id":"older-session"}]"#)],
+ )
+ .expect("devin session start should report session identity");
+
+ assert_eq!(request["method"], "pane.report_agent_session");
+ assert_eq!(request["params"]["agent"], "devin");
+ assert_eq!(request["params"]["agent_session_id"], "devin-session");
+ assert!(request["params"].get("state").is_none());
+}
+
+#[test]
+fn devin_hook_prefers_hook_session_id_over_list() {
+ let request = run_devin_hook(
+ "session",
+ r#"{"hook_event_name":"PreToolUse","sessionId":"fresh-session","tool_name":"exec"}"#,
+ &[
+ ("DEVIN_PROJECT_DIR", "/tmp/project"),
+ (
+ "HERDR_DEVIN_LIST_JSON",
+ r#"[{"id":"older-session","working_directory":"/tmp/project"}]"#,
+ ),
+ ],
+ )
+ .expect("devin tool hook should report session identity");
+
+ assert_eq!(request["method"], "pane.report_agent_session");
+ assert_eq!(request["params"]["agent_session_id"], "fresh-session");
+ assert!(request["params"].get("state").is_none());
+}
+
+#[test]
+fn devin_hook_reports_tool_session_from_list_without_state() {
+ let request = run_devin_hook(
+ "session",
+ r#"{"hook_event_name":"PreToolUse","tool_name":"exec"}"#,
+ &[
+ ("DEVIN_PROJECT_DIR", "/tmp/project"),
+ (
+ "HERDR_DEVIN_LIST_JSON",
+ r#"[{"id":"older-session","working_directory":"/tmp/other"},{"id":"devin-session","working_directory":"/tmp/project"}]"#,
+ ),
+ ],
+ )
+ .expect("devin tool hook should report session identity");
+
+ assert_eq!(request["method"], "pane.report_agent_session");
+ assert_eq!(request["params"]["agent"], "devin");
+ assert_eq!(request["params"]["agent_session_id"], "devin-session");
+ assert!(request["params"].get("state").is_none());
+}
+
+#[test]
+fn devin_hook_ignores_startup_session_list_fallback() {
+ let request = run_devin_hook(
+ "session",
+ r#"{"hook_event_name":"SessionStart","source":"startup"}"#,
+ &[
+ ("DEVIN_PROJECT_DIR", "/tmp/project"),
+ (
+ "HERDR_DEVIN_LIST_JSON",
+ r#"[{"id":"stale-session","working_directory":"/tmp/project"}]"#,
+ ),
+ ],
+ );
+
+ assert!(request.is_none());
+}
+
+#[test]
+fn devin_hook_ignores_non_matching_session_list_entries() {
+ let request = run_devin_hook(
+ "session",
+ r#"{"hook_event_name":"PreToolUse","tool_name":"exec"}"#,
+ &[
+ ("DEVIN_PROJECT_DIR", "/tmp/project"),
+ (
+ "HERDR_DEVIN_LIST_JSON",
+ r#"[{"id":"other-session","working_directory":"/tmp/other"}]"#,
+ ),
+ ],
+ );
+
+ assert!(request.is_none());
+}
diff --git a/tests/cli/mod.rs b/tests/cli/mod.rs
new file mode 100644
index 00000000..0fdff6f1
--- /dev/null
+++ b/tests/cli/mod.rs
@@ -0,0 +1,13 @@
+mod agent_options;
+mod agent_transport;
+mod agent_wait;
+mod agents;
+mod harness;
+mod hooks;
+mod panes;
+mod plugins;
+mod protocol;
+mod protocol_guard;
+mod sessions;
+mod surface;
+mod workspace;
diff --git a/tests/cli/panes.rs b/tests/cli/panes.rs
new file mode 100644
index 00000000..32110b36
--- /dev/null
+++ b/tests/cli/panes.rs
@@ -0,0 +1,568 @@
+use super::harness::*;
+
+#[test]
+fn pane_close_only_removes_the_target_tab_when_other_tabs_exist() {
+ 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(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ assert!(created.status.success());
+ let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
+ let workspace_id = created_json["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let created_tab = run_cli(
+ &socket_path,
+ &["tab", "create", "--workspace", &workspace_id],
+ );
+ assert!(created_tab.status.success());
+ let created_tab_json: serde_json::Value = serde_json::from_slice(&created_tab.stdout).unwrap();
+ let second_root_pane_id = created_tab_json["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let closed = run_cli(&socket_path, &["pane", "close", &second_root_pane_id]);
+ assert!(closed.status.success());
+ let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap();
+ assert_eq!(closed_json["result"]["type"], "ok");
+
+ let workspaces = run_cli(&socket_path, &["workspace", "list"]);
+ assert!(workspaces.status.success());
+ let workspaces_json: serde_json::Value = serde_json::from_slice(&workspaces.stdout).unwrap();
+ assert_eq!(
+ workspaces_json["result"]["workspaces"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 1
+ );
+ assert_eq!(
+ workspaces_json["result"]["workspaces"][0]["workspace_id"],
+ workspace_id
+ );
+
+ let tabs = run_cli(&socket_path, &["tab", "list", "--workspace", &workspace_id]);
+ assert!(tabs.status.success());
+ let tabs_json: serde_json::Value = serde_json::from_slice(&tabs.stdout).unwrap();
+ assert_eq!(tabs_json["result"]["tabs"].as_array().unwrap().len(), 1);
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn pane_close_removes_the_workspace_when_it_closes_the_last_pane() {
+ 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(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ assert!(created.status.success());
+ let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
+ let root_pane_id = created_json["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let closed = run_cli(&socket_path, &["pane", "close", &root_pane_id]);
+ assert!(closed.status.success());
+ let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap();
+ assert_eq!(closed_json["result"]["type"], "ok");
+
+ let workspaces = run_cli(&socket_path, &["workspace", "list"]);
+ assert!(workspaces.status.success());
+ let workspaces_json: serde_json::Value = serde_json::from_slice(&workspaces.stdout).unwrap();
+ assert!(workspaces_json["result"]["workspaces"]
+ .as_array()
+ .unwrap()
+ .is_empty());
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn pane_run_read_and_wait_commands_work() {
+ 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));
+
+ send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_cli_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
+ base.display()
+ ),
+ );
+ let create = run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "run",
+ "1-1",
+ "echo alpha && echo beta && printf 'ready\\n'",
+ ],
+ );
+ assert!(create.status.success());
+
+ let started = Instant::now();
+ let waited = run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "wait-output",
+ "1-1",
+ "--match",
+ "ready",
+ "--source",
+ "recent",
+ "--lines",
+ "40",
+ "--timeout",
+ "5000",
+ ],
+ );
+ let elapsed = started.elapsed();
+ assert!(
+ waited.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&waited.stderr)
+ );
+ assert!(
+ elapsed < Duration::from_millis(500),
+ "already-matching wait took {elapsed:?}"
+ );
+ let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap();
+ assert_eq!(waited_json["result"]["type"], "output_matched");
+
+ let read = run_cli(
+ &socket_path,
+ &["pane", "read", "1-1", "--source", "recent", "--lines", "40"],
+ );
+ assert!(read.status.success());
+ let text = String::from_utf8(read.stdout).unwrap();
+ assert!(text.contains("alpha"));
+ assert!(text.contains("ready"));
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn wait_output_matches_recent_unwrapped_text() {
+ 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(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ assert!(created.status.success());
+
+ let token = "WRAP_WAIT_TEST_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789";
+ let script = base.join("emit-long-token.sh");
+ std::fs::write(&script, format!("#!/bin/sh\nprintf '%s\\n' '{token}'\n")).unwrap();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mut perms = std::fs::metadata(&script).unwrap().permissions();
+ perms.set_mode(0o755);
+ std::fs::set_permissions(&script, perms).unwrap();
+ }
+
+ let run = run_cli(
+ &socket_path,
+ &["pane", "run", "1-1", &format!("sh {}", script.display())],
+ );
+ assert!(run.status.success());
+
+ let waited = run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "wait-output",
+ "1-1",
+ "--match",
+ token,
+ "--source",
+ "recent",
+ "--lines",
+ "80",
+ "--timeout",
+ "5000",
+ ],
+ );
+ assert!(
+ waited.status.success(),
+ "stderr: {} stdout: {}",
+ String::from_utf8_lossy(&waited.stderr),
+ String::from_utf8_lossy(&waited.stdout)
+ );
+
+ let read = run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "read",
+ "1-1",
+ "--source",
+ "recent-unwrapped",
+ "--lines",
+ "80",
+ ],
+ );
+ assert!(read.status.success());
+ let text = String::from_utf8(read.stdout).unwrap();
+ assert!(text.contains(token));
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn closing_pane_terminates_processes_inside_it() {
+ 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(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ assert!(created.status.success());
+
+ let split = run_cli(
+ &socket_path,
+ &["pane", "split", "1-1", "--direction", "right"],
+ );
+ assert!(split.status.success());
+ let split_json: serde_json::Value = serde_json::from_slice(&split.stdout).unwrap();
+ let pane_id = split_json["result"]["pane"]["pane_id"].as_str().unwrap();
+
+ let pid_file = base.join("pane-close.pid");
+ let command = format!(
+ "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'",
+ pid_file.display()
+ );
+ let ran = run_cli(&socket_path, &["pane", "run", pane_id, &command]);
+ assert!(
+ ran.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&ran.stderr)
+ );
+
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while Instant::now() < deadline && !pid_file.exists() {
+ thread::sleep(Duration::from_millis(25));
+ }
+ assert!(pid_file.exists(), "pid file was not created");
+
+ let pid = wait_for_pid_file(&pid_file, Duration::from_secs(3)).unwrap_or_else(|err| {
+ panic!("failed to read pane child pid: {err}");
+ });
+ assert!(process_exists(pid), "child process was not running");
+
+ let closed = run_cli(&socket_path, &["pane", "close", pane_id]);
+ assert!(
+ closed.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&closed.stderr)
+ );
+ assert!(
+ wait_for_pid_exit(pid, Duration::from_secs(3)),
+ "process {pid} survived pane close"
+ );
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn closing_workspace_terminates_processes_inside_it() {
+ 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(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ assert!(created.status.success());
+
+ let pid_file = base.join("workspace-close.pid");
+ let command = format!(
+ "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'",
+ pid_file.display()
+ );
+ let ran = run_cli(&socket_path, &["pane", "run", "1-1", &command]);
+ assert!(
+ ran.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&ran.stderr)
+ );
+
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while Instant::now() < deadline && !pid_file.exists() {
+ thread::sleep(Duration::from_millis(25));
+ }
+ assert!(pid_file.exists(), "pid file was not created");
+
+ let pid = wait_for_pid_file(&pid_file, Duration::from_secs(3)).unwrap_or_else(|err| {
+ panic!("failed to read pane child pid: {err}");
+ });
+ assert!(process_exists(pid), "child process was not running");
+
+ let closed = run_cli(&socket_path, &["workspace", "close", "1"]);
+ assert!(
+ closed.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&closed.stderr)
+ );
+ assert!(
+ wait_for_pid_exit(pid, Duration::from_secs(3)),
+ "process {pid} survived workspace close"
+ );
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn workspace_ids_and_public_pane_ids_are_stable() {
+ 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 ws1_json = run_cli_json(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ let ws1_id = ws1_json["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let split_12_json = run_cli_json(
+ &socket_path,
+ &["pane", "split", "1-1", "--direction", "right", "--no-focus"],
+ );
+ assert_eq!(
+ split_12_json["result"]["pane"]["pane_id"],
+ format!("{ws1_id}:p2")
+ );
+
+ let split_13_json = run_cli_json(
+ &socket_path,
+ &["pane", "split", "1-1", "--direction", "down", "--no-focus"],
+ );
+ assert_eq!(
+ split_13_json["result"]["pane"]["pane_id"],
+ format!("{ws1_id}:p3")
+ );
+
+ let ws2_json = run_cli_json(
+ &socket_path,
+ &["workspace", "create", "--cwd", "/tmp", "--no-focus"],
+ );
+ let ws2_id = ws2_json["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ assert_ne!(ws2_id, ws1_id);
+
+ let ws2_focus = run_cli(&socket_path, &["workspace", "focus", &ws2_id]);
+ assert!(
+ ws2_focus.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&ws2_focus.stderr)
+ );
+
+ let ws2_split_json = run_cli_json(
+ &socket_path,
+ &["pane", "split", "2-1", "--direction", "right", "--no-focus"],
+ );
+ assert_eq!(
+ ws2_split_json["result"]["pane"]["pane_id"],
+ format!("{ws2_id}:p2")
+ );
+
+ let ws3_json = run_cli_json(
+ &socket_path,
+ &["workspace", "create", "--cwd", "/", "--no-focus"],
+ );
+ let ws3_id = ws3_json["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ assert_ne!(ws3_id, ws1_id);
+ assert_ne!(ws3_id, ws2_id);
+
+ let close_ws2 = run_cli(&socket_path, &["workspace", "close", &ws2_id]);
+ assert!(
+ close_ws2.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&close_ws2.stderr)
+ );
+
+ let workspaces_json = run_cli_json(&socket_path, &["workspace", "list"]);
+ let ids: Vec<String> = workspaces_json["result"]["workspaces"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|ws| ws["workspace_id"].as_str().unwrap().to_string())
+ .collect();
+ assert_eq!(ids, vec![ws1_id.clone(), ws3_id.clone()]);
+
+ let new_ws_json = run_cli_json(
+ &socket_path,
+ &["workspace", "create", "--cwd", "/var/tmp", "--no-focus"],
+ );
+ let new_ws_id = new_ws_json["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ assert_ne!(new_ws_id, ws1_id);
+ assert_ne!(new_ws_id, ws2_id);
+ assert_ne!(new_ws_id, ws3_id);
+
+ let ws3_panes_json = run_cli_json(&socket_path, &["pane", "list", "--workspace", &ws3_id]);
+ assert_eq!(
+ ws3_panes_json["result"]["panes"][0]["pane_id"],
+ format!("{ws3_id}:p1")
+ );
+
+ let close_middle = run_cli(&socket_path, &["pane", "close", &format!("{ws1_id}-2")]);
+ assert!(
+ close_middle.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&close_middle.stderr)
+ );
+
+ let ws1_panes_json = run_cli_json(&socket_path, &["pane", "list", "--workspace", &ws1_id]);
+ let pane_ids: Vec<String> = ws1_panes_json["result"]["panes"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|pane| pane["pane_id"].as_str().unwrap().to_string())
+ .collect();
+ assert_eq!(
+ pane_ids,
+ vec![format!("{ws1_id}:p1"), format!("{ws1_id}:p3")]
+ );
+
+ let closed_lookup = run_cli(&socket_path, &["pane", "get", &format!("{ws1_id}:p2")]);
+ assert!(
+ !closed_lookup.status.success(),
+ "closed pane id should not retarget: {}",
+ String::from_utf8_lossy(&closed_lookup.stdout)
+ );
+
+ let split_14_json = run_cli_json(
+ &socket_path,
+ &[
+ "pane",
+ "split",
+ &format!("{ws1_id}:p1"),
+ "--direction",
+ "right",
+ "--no-focus",
+ ],
+ );
+ assert_eq!(
+ split_14_json["result"]["pane"]["pane_id"],
+ format!("{ws1_id}:p4")
+ );
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn pane_shell_gets_herdr_socket_and_pane_env() {
+ 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 = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_env_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
+ base.display()
+ ),
+ );
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let env_capture = base.join("pane-env.txt");
+ let ran = run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "run",
+ "1-1",
+ &format!(
+ "printf '%s\\n%s\\n' \"$HERDR_SOCKET_PATH\" \"$HERDR_PANE_ID\" > {}",
+ env_capture.display()
+ ),
+ ],
+ );
+ assert!(ran.status.success());
+
+ let deadline = Instant::now() + Duration::from_secs(3);
+ let mut text = String::new();
+ while Instant::now() < deadline {
+ if env_capture.exists() {
+ text = fs::read_to_string(&env_capture).unwrap();
+ if text.contains(&socket_path.display().to_string()) && text.contains(&pane_id) {
+ break;
+ }
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ assert!(env_capture.exists(), "env capture file was not created");
+ assert!(
+ text.contains(&socket_path.display().to_string()),
+ "env file was: {text:?}"
+ );
+ assert!(text.contains(&pane_id), "env file was: {text:?}");
+
+ cleanup_spawned_herdr(herdr, base);
+}
diff --git a/tests/cli/plugins.rs b/tests/cli/plugins.rs
new file mode 100644
index 00000000..ab1dd21a
--- /dev/null
+++ b/tests/cli/plugins.rs
@@ -0,0 +1,932 @@
+use super::harness::*;
+
+#[test]
+fn plugin_link_list_unlink_cli_smoke_test() {
+ 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 plugin_dir = base.join("plugins").join("layout");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.layout"
+name = "Layout"
+version = "0.1.0"
+min_herdr_version = "0.6.10"
+description = "Apply a preferred Herdr layout"
+
+[[actions]]
+id = "apply"
+title = "Apply layout"
+contexts = ["workspace"]
+command = ["sh", "-c", "echo layout"]
+
+[[events]]
+on = "worktree.created"
+command = ["sh", "-c", "echo worktree"]
+
+[[panes]]
+id = "board"
+title = "Board"
+placement = "tab"
+command = ["sh", "-c", "sleep 5"]
+"#,
+ )
+ .unwrap();
+
+ let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+ let workspace = run_cli_json(
+ &socket_path,
+ &[
+ "workspace",
+ "create",
+ "--cwd",
+ base.to_str().unwrap(),
+ "--focus",
+ ],
+ );
+ assert_eq!(workspace["result"]["type"], "workspace_created");
+
+ let linked = run_cli_json_in_dir(&socket_path, &["plugin", "link", "plugins/layout"], &base);
+ assert_eq!(linked["result"]["type"], "plugin_linked");
+ assert_eq!(linked["result"]["plugin"]["plugin_id"], "example.layout");
+ assert_eq!(linked["result"]["plugin"]["actions"][0]["id"], "apply");
+ assert_eq!(
+ linked["result"]["plugin"]["events"][0]["on"],
+ "worktree.created"
+ );
+ assert_eq!(linked["result"]["plugin"]["panes"][0]["id"], "board");
+
+ let listed_human = run_cli(&socket_path, &["plugin", "list"]);
+ assert!(listed_human.status.success());
+ assert!(String::from_utf8_lossy(&listed_human.stdout).contains("example.layout"));
+
+ let listed = run_cli_json(&socket_path, &["plugin", "list", "--json"]);
+ assert_eq!(listed["result"]["type"], "plugin_list");
+ assert_eq!(
+ listed["result"]["plugins"][0]["plugin_id"],
+ "example.layout"
+ );
+
+ let invoked = run_cli_json(
+ &socket_path,
+ &[
+ "plugin",
+ "action",
+ "invoke",
+ "apply",
+ "--plugin",
+ "example.layout",
+ ],
+ );
+ assert_eq!(invoked["result"]["type"], "plugin_action_invoked");
+ assert_eq!(invoked["result"]["action"]["action_id"], "apply");
+
+ let logs = run_cli_json(
+ &socket_path,
+ &[
+ "plugin",
+ "log",
+ "list",
+ "--plugin",
+ "example.layout",
+ "--limit",
+ "5",
+ ],
+ );
+ assert_eq!(logs["result"]["type"], "plugin_log_list");
+ assert!(!logs["result"]["logs"].as_array().unwrap().is_empty());
+
+ let pane = run_cli_json(
+ &socket_path,
+ &[
+ "plugin",
+ "pane",
+ "open",
+ "--plugin",
+ "example.layout",
+ "--entrypoint",
+ "board",
+ "--env",
+ "HERDR_ROLE=board",
+ "--no-focus",
+ ],
+ );
+ assert_eq!(pane["result"]["type"], "plugin_pane_opened");
+ assert_eq!(pane["result"]["plugin_pane"]["entrypoint"], "board");
+
+ let missing_plugin_value = run_cli(&socket_path, &["plugin", "list", "--plugin"]);
+ assert_eq!(missing_plugin_value.status.code(), Some(2));
+ assert!(String::from_utf8_lossy(&missing_plugin_value.stderr)
+ .contains("missing value for --plugin"));
+
+ let invalid_limit = run_cli(
+ &socket_path,
+ &["plugin", "log", "list", "--limit", "not-a-number"],
+ );
+ assert_eq!(invalid_limit.status.code(), Some(2));
+ assert!(String::from_utf8_lossy(&invalid_limit.stderr).contains("invalid --limit value"));
+
+ let unlinked = run_cli_json(&socket_path, &["plugin", "unlink", "example.layout"]);
+ assert_eq!(unlinked["result"]["type"], "plugin_unlinked");
+ assert_eq!(unlinked["result"]["removed"], true);
+
+ let listed = run_cli_json(&socket_path, &["plugin", "list", "--json"]);
+ assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn plugin_install_list_uninstall_offline_cli_smoke_test() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let source_repo = base.join("source-repo");
+ let plugin_dir = source_repo.join("worktree-bootstrap");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.worktree-bootstrap"
+name = "Worktree Bootstrap"
+version = "0.1.0"
+min_herdr_version = "0.6.10"
+platforms = ["linux", "macos", "windows"]
+
+[[build]]
+command = ["sh", "-c", "echo built > built.txt; if [ -n \"$HERDR_SESSION\" ]; then echo \"$HERDR_SESSION\" > leaked-session.txt; fi"]
+
+[[actions]]
+id = "bootstrap"
+title = "Bootstrap"
+command = ["sh", "-c", "echo bootstrap"]
+"#,
+ )
+ .unwrap();
+ run_git(
+ &source_repo,
+ &["add", "worktree-bootstrap/herdr-plugin.toml"],
+ );
+ run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]);
+
+ fs::create_dir_all(&config_home).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ let git_config = base.join("gitconfig");
+ fs::write(
+ &git_config,
+ format!(
+ "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
+ source_repo.display()
+ ),
+ )
+ .unwrap();
+
+ let install = run_named_cli_with_env(
+ &config_home,
+ &runtime_dir,
+ &[
+ "--session",
+ "plugins",
+ "plugin",
+ "install",
+ "ogulcancelik/herdr-plugin-examples/worktree-bootstrap",
+ "--yes",
+ ],
+ &[
+ ("GIT_CONFIG_GLOBAL", &git_config),
+ ("HERDR_SESSION", Path::new("leaked-session")),
+ ],
+ );
+ assert!(
+ install.status.success(),
+ "install failed\nstdout: {}\nstderr: {}",
+ String::from_utf8_lossy(&install.stdout),
+ String::from_utf8_lossy(&install.stderr)
+ );
+
+ let listed = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "plugins", "plugin", "list", "--json"],
+ );
+ let plugin = &listed["result"]["plugins"][0];
+ assert_eq!(plugin["plugin_id"], "example.worktree-bootstrap");
+ assert_eq!(plugin["source"]["kind"], "github");
+ assert_eq!(plugin["source"]["owner"], "ogulcancelik");
+ assert_eq!(plugin["source"]["repo"], "herdr-plugin-examples");
+ assert_eq!(plugin["source"]["subdir"], "worktree-bootstrap");
+ assert!(plugin["source"]["resolved_commit"].as_str().is_some());
+ let managed_path = PathBuf::from(plugin["source"]["managed_path"].as_str().unwrap());
+ assert!(managed_path.exists(), "managed checkout should exist");
+ assert!(
+ managed_path
+ .join("worktree-bootstrap")
+ .join("built.txt")
+ .exists(),
+ "build artifact should be preserved in managed checkout"
+ );
+ assert!(
+ !managed_path
+ .join("worktree-bootstrap")
+ .join("leaked-session.txt")
+ .exists(),
+ "build command should not inherit HERDR_SESSION"
+ );
+
+ let uninstall = run_named_cli(
+ &config_home,
+ &runtime_dir,
+ &[
+ "--session",
+ "plugins",
+ "plugin",
+ "uninstall",
+ "example.worktree-bootstrap",
+ ],
+ );
+ assert!(
+ uninstall.status.success(),
+ "uninstall failed\nstdout: {}\nstderr: {}",
+ String::from_utf8_lossy(&uninstall.stdout),
+ String::from_utf8_lossy(&uninstall.stderr)
+ );
+ assert!(
+ !managed_path.exists(),
+ "managed checkout should be deleted on uninstall"
+ );
+
+ let listed = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "plugins", "plugin", "list", "--json"],
+ );
+ assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn plugin_install_build_failure_does_not_register_or_create_checkout() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let source_repo = base.join("source-repo");
+ let plugin_dir = source_repo.join("build-fail");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.build-fail"
+name = "Build Fail"
+version = "0.1.0"
+min_herdr_version = "0.6.10"
+platforms = ["linux", "macos", "windows"]
+
+[[build]]
+command = ["sh", "-c", "echo before-fail && echo failed-build >&2 && exit 7"]
+
+[[actions]]
+id = "run"
+title = "Run"
+command = ["sh", "-c", "echo should-not-install"]
+"#,
+ )
+ .unwrap();
+ run_git(&source_repo, &["add", "build-fail/herdr-plugin.toml"]);
+ run_git(
+ &source_repo,
+ &["commit", "--quiet", "-m", "add failing plugin"],
+ );
+
+ fs::create_dir_all(&config_home).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ let git_config = base.join("gitconfig");
+ fs::write(
+ &git_config,
+ format!(
+ "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
+ source_repo.display()
+ ),
+ )
+ .unwrap();
+
+ let install = run_named_cli_with_env(
+ &config_home,
+ &runtime_dir,
+ &[
+ "--session",
+ "plugins",
+ "plugin",
+ "install",
+ "ogulcancelik/herdr-plugin-examples/build-fail",
+ "--yes",
+ ],
+ &[("GIT_CONFIG_GLOBAL", &git_config)],
+ );
+ assert!(
+ !install.status.success(),
+ "install should fail when build command fails"
+ );
+ assert_eq!(install.status.code(), Some(1));
+ let stderr = String::from_utf8_lossy(&install.stderr);
+ assert!(stderr.contains("error: plugin build failed"), "{stderr}");
+ assert!(stderr.contains(" plugin: example.build-fail"), "{stderr}");
+ assert!(stderr.contains(" build: 1/1"), "{stderr}");
+ assert!(stderr.contains(" cwd: "), "{stderr}");
+ assert!(
+ stderr.contains(" command: sh -c echo before-fail && echo failed-build >&2 && exit 7"),
+ "{stderr}"
+ );
+ assert!(stderr.contains(" status: exit status: 7"), "{stderr}");
+ assert!(stderr.contains("stdout:\nbefore-fail"), "{stderr}");
+ assert!(stderr.contains("stderr:\nfailed-build"), "{stderr}");
+ assert!(stderr.contains("Plugin was not installed."), "{stderr}");
+ assert!(!stderr.contains("Error: Custom"), "{stderr}");
+
+ let listed = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "plugins", "plugin", "list", "--json"],
+ );
+ assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
+
+ assert!(
+ path_missing_or_empty(&managed_github_plugin_dir(&config_home)),
+ "failed build should not leave managed checkouts"
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn plugin_install_build_spawn_failure_prints_clean_error() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let source_repo = base.join("source-repo");
+ let plugin_dir = source_repo.join("missing-tool");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.missing-tool"
+name = "Missing Tool"
+version = "0.1.0"
+min_herdr_version = "0.6.10"
+platforms = ["linux", "macos", "windows"]
+
+[[build]]
+command = ["definitely-missing-herdr-build-tool-xyz"]
+
+[[actions]]
+id = "run"
+title = "Run"
+command = ["sh", "-c", "echo should-not-install"]
+"#,
+ )
+ .unwrap();
+ run_git(&source_repo, &["add", "missing-tool/herdr-plugin.toml"]);
+ run_git(
+ &source_repo,
+ &["commit", "--quiet", "-m", "add missing tool plugin"],
+ );
+
+ fs::create_dir_all(&config_home).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ let git_config = base.join("gitconfig");
+ fs::write(
+ &git_config,
+ format!(
+ "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
+ source_repo.display()
+ ),
+ )
+ .unwrap();
+
+ let install = run_named_cli_with_env(
+ &config_home,
+ &runtime_dir,
+ &[
+ "--session",
+ "plugins",
+ "plugin",
+ "install",
+ "ogulcancelik/herdr-plugin-examples/missing-tool",
+ "--yes",
+ ],
+ &[("GIT_CONFIG_GLOBAL", &git_config)],
+ );
+ assert!(
+ !install.status.success(),
+ "install should fail when build command cannot start"
+ );
+ assert_eq!(install.status.code(), Some(1));
+ let stderr = String::from_utf8_lossy(&install.stderr);
+ assert!(stderr.contains("error: plugin build failed"), "{stderr}");
+ assert!(
+ stderr.contains(" plugin: example.missing-tool"),
+ "{stderr}"
+ );
+ assert!(stderr.contains(" build: 1/1"), "{stderr}");
+ assert!(
+ stderr.contains(" command: definitely-missing-herdr-build-tool-xyz"),
+ "{stderr}"
+ );
+ assert!(stderr.contains(" error: failed to start:"), "{stderr}");
+ assert!(stderr.contains("Plugin was not installed."), "{stderr}");
+ assert!(!stderr.contains("Error: Custom"), "{stderr}");
+
+ let listed = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "plugins", "plugin", "list", "--json"],
+ );
+ assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn plugin_install_rejects_manifest_changed_by_build() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let source_repo = base.join("source-repo");
+ let plugin_dir = source_repo.join("manifest-mutator");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.manifest-mutator"
+name = "Manifest Mutator"
+version = "0.1.0"
+min_herdr_version = "0.6.10"
+platforms = ["linux", "macos", "windows"]
+
+[[build]]
+command = ["sh", "mutate.sh"]
+
+[[actions]]
+id = "run"
+title = "Run reviewed command"
+command = ["sh", "-c", "echo reviewed"]
+"#,
+ )
+ .unwrap();
+ fs::write(
+ plugin_dir.join("mutate.sh"),
+ r#"cat > herdr-plugin.toml <<'EOF'
+id = "example.manifest-mutator"
+name = "Manifest Mutator"
+version = "0.1.0"
+min_herdr_version = "0.0.1"
+platforms = ["linux", "macos", "windows"]
+
+[[build]]
+command = ["sh", "mutate.sh"]
+
+[[actions]]
+id = "run"
+title = "Run reviewed command"
+command = ["sh", "-c", "echo reviewed"]
+EOF
+"#,
+ )
+ .unwrap();
+ run_git(&source_repo, &["add", "manifest-mutator"]);
+ run_git(
+ &source_repo,
+ &["commit", "--quiet", "-m", "add mutating plugin"],
+ );
+
+ fs::create_dir_all(&config_home).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ let git_config = base.join("gitconfig");
+ fs::write(
+ &git_config,
+ format!(
+ "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
+ source_repo.display()
+ ),
+ )
+ .unwrap();
+
+ let install = run_named_cli_with_env(
+ &config_home,
+ &runtime_dir,
+ &[
+ "--session",
+ "plugins",
+ "plugin",
+ "install",
+ "ogulcancelik/herdr-plugin-examples/manifest-mutator",
+ "--yes",
+ ],
+ &[("GIT_CONFIG_GLOBAL", &git_config)],
+ );
+ assert!(
+ !install.status.success(),
+ "install should fail when build changes reviewed manifest"
+ );
+ let stderr = String::from_utf8_lossy(&install.stderr);
+ assert!(
+ stderr.contains("plugin build changed herdr-plugin.toml after install preview"),
+ "{stderr}"
+ );
+
+ let listed = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "plugins", "plugin", "list", "--json"],
+ );
+ assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
+
+ assert!(
+ path_missing_or_empty(&managed_github_plugin_dir(&config_home)),
+ "manifest mutation should not leave managed checkouts"
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn plugin_install_restores_previous_checkout_when_registration_fails() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("fake-herdr.sock");
+ let source_repo = base.join("source-repo");
+ let plugin_dir = source_repo.join("worktree-bootstrap");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.worktree-bootstrap"
+name = "Worktree Bootstrap"
+version = "0.2.0"
+min_herdr_version = "0.6.10"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "bootstrap"
+title = "Bootstrap"
+command = ["sh", "-c", "echo new"]
+"#,
+ )
+ .unwrap();
+ run_git(
+ &source_repo,
+ &["add", "worktree-bootstrap/herdr-plugin.toml"],
+ );
+ run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]);
+
+ fs::create_dir_all(&config_home).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ let managed_checkout = config_home
+ .join("herdr-dev")
+ .join("plugins")
+ .join("github")
+ .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT);
+ fs::create_dir_all(&managed_checkout).unwrap();
+ fs::write(managed_checkout.join("old-marker"), "old checkout\n").unwrap();
+
+ let git_config = base.join("gitconfig");
+ fs::write(
+ &git_config,
+ format!(
+ "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
+ source_repo.display()
+ ),
+ )
+ .unwrap();
+
+ let listener = UnixListener::bind(&socket_path).unwrap();
+ let managed_checkout_for_server = managed_checkout.clone();
+ let server = thread::spawn(move || {
+ let (mut first, first_line) = accept_fake_cli_operation(&listener);
+ let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap();
+ assert_eq!(first_request["method"], "plugin.list");
+ writeln!(
+ first,
+ "{}",
+ serde_json::json!({
+ "id": "cli:plugin",
+ "result": {
+ "type": "plugin_list",
+ "plugins": [{
+ "plugin_id": "example.worktree-bootstrap",
+ "name": "Worktree Bootstrap",
+ "version": "0.1.0",
+ "min_herdr_version": "0.6.10",
+ "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(),
+ "plugin_root": managed_checkout_for_server.display().to_string(),
+ "enabled": true,
+ "source": {
+ "kind": "github",
+ "owner": "ogulcancelik",
+ "repo": "herdr-plugin-examples",
+ "subdir": "worktree-bootstrap",
+ "resolved_commit": "old",
+ "managed_path": managed_checkout_for_server.display().to_string(),
+ "installed_unix_ms": 1
+ }
+ }]
+ }
+ })
+ )
+ .unwrap();
+ first.flush().unwrap();
+
+ let (mut second, second_line) = accept_fake_cli_operation(&listener);
+ let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap();
+ assert_eq!(second_request["method"], "plugin.link");
+ second
+ .write_all(
+ br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced failure"}}"#,
+ )
+ .unwrap();
+ second.write_all(b"\n").unwrap();
+ second.flush().unwrap();
+ });
+
+ let install = run_named_cli_with_env_and_socket_override(
+ &config_home,
+ &runtime_dir,
+ &[
+ "plugin",
+ "install",
+ "ogulcancelik/herdr-plugin-examples/worktree-bootstrap",
+ "--yes",
+ ],
+ &[("GIT_CONFIG_GLOBAL", &git_config)],
+ Some(&socket_path),
+ );
+ assert!(
+ !install.status.success(),
+ "install should fail when plugin.link fails"
+ );
+ server.join().unwrap();
+ assert!(
+ managed_checkout.join("old-marker").exists(),
+ "old checkout should be restored after registration failure"
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn plugin_install_rejects_server_that_drops_source_metadata() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("fake-herdr.sock");
+ let source_repo = base.join("source-repo");
+ let plugin_dir = source_repo.join("worktree-bootstrap");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.worktree-bootstrap"
+name = "Worktree Bootstrap"
+version = "0.1.0"
+min_herdr_version = "0.6.10"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "bootstrap"
+title = "Bootstrap"
+command = ["sh", "-c", "echo install"]
+"#,
+ )
+ .unwrap();
+ run_git(
+ &source_repo,
+ &["add", "worktree-bootstrap/herdr-plugin.toml"],
+ );
+ run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]);
+
+ fs::create_dir_all(&config_home).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ let managed_checkout = config_home
+ .join("herdr-dev")
+ .join("plugins")
+ .join("github")
+ .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT);
+ let git_config = base.join("gitconfig");
+ fs::write(
+ &git_config,
+ format!(
+ "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
+ source_repo.display()
+ ),
+ )
+ .unwrap();
+
+ let listener = UnixListener::bind(&socket_path).unwrap();
+ let managed_checkout_for_server = managed_checkout.clone();
+ let server = thread::spawn(move || {
+ let (mut first, first_line) = accept_fake_cli_operation(&listener);
+ let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap();
+ assert_eq!(first_request["method"], "plugin.list");
+ first
+ .write_all(br#"{"id":"cli:plugin","result":{"type":"plugin_list","plugins":[]}}"#)
+ .unwrap();
+ first.write_all(b"\n").unwrap();
+ first.flush().unwrap();
+
+ let (mut second, second_line) = accept_fake_cli_operation(&listener);
+ let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap();
+ assert_eq!(second_request["method"], "plugin.link");
+ writeln!(
+ second,
+ "{}",
+ serde_json::json!({
+ "id": "cli:plugin",
+ "result": {
+ "type": "plugin_linked",
+ "plugin": {
+ "plugin_id": "example.worktree-bootstrap",
+ "name": "Worktree Bootstrap",
+ "version": "0.1.0",
+ "min_herdr_version": "0.6.10",
+ "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(),
+ "plugin_root": managed_checkout_for_server.display().to_string(),
+ "enabled": true,
+ "source": {"kind": "local"}
+ }
+ }
+ })
+ )
+ .unwrap();
+ second.flush().unwrap();
+
+ let (mut third, third_line) = accept_fake_cli_operation(&listener);
+ let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap();
+ assert_eq!(third_request["method"], "plugin.unlink");
+ assert_eq!(
+ third_request["params"]["plugin_id"],
+ "example.worktree-bootstrap"
+ );
+ third
+ .write_all(
+ br#"{"id":"cli:plugin","result":{"type":"plugin_unlinked","plugin_id":"example.worktree-bootstrap","removed":true}}"#,
+ )
+ .unwrap();
+ third.write_all(b"\n").unwrap();
+ third.flush().unwrap();
+ });
+
+ let install = run_named_cli_with_env_and_socket_override(
+ &config_home,
+ &runtime_dir,
+ &[
+ "plugin",
+ "install",
+ "ogulcancelik/herdr-plugin-examples/worktree-bootstrap",
+ "--yes",
+ ],
+ &[("GIT_CONFIG_GLOBAL", &git_config)],
+ Some(&socket_path),
+ );
+ assert!(
+ !install.status.success(),
+ "install should fail when server drops GitHub source metadata"
+ );
+ server.join().unwrap();
+ assert!(
+ !managed_checkout.exists(),
+ "new checkout should be removed after incompatible plugin.link response"
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn plugin_install_keeps_checkout_when_incompatible_server_cleanup_fails() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("fake-herdr.sock");
+ let source_repo = base.join("source-repo");
+ let plugin_dir = source_repo.join("worktree-bootstrap");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.worktree-bootstrap"
+name = "Worktree Bootstrap"
+version = "0.1.0"
+min_herdr_version = "0.6.10"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "bootstrap"
+title = "Bootstrap"
+command = ["sh", "-c", "echo install"]
+"#,
+ )
+ .unwrap();
+ run_git(
+ &source_repo,
+ &["add", "worktree-bootstrap/herdr-plugin.toml"],
+ );
+ run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]);
+
+ fs::create_dir_all(&config_home).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ let managed_checkout = config_home
+ .join("herdr-dev")
+ .join("plugins")
+ .join("github")
+ .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT);
+ let git_config = base.join("gitconfig");
+ fs::write(
+ &git_config,
+ format!(
+ "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
+ source_repo.display()
+ ),
+ )
+ .unwrap();
+
+ let listener = UnixListener::bind(&socket_path).unwrap();
+ let managed_checkout_for_server = managed_checkout.clone();
+ let server = thread::spawn(move || {
+ let (mut first, _first_line) = accept_fake_cli_operation(&listener);
+ first
+ .write_all(br#"{"id":"cli:plugin","result":{"type":"plugin_list","plugins":[]}}"#)
+ .unwrap();
+ first.write_all(b"\n").unwrap();
+ first.flush().unwrap();
+
+ let (mut second, _second_line) = accept_fake_cli_operation(&listener);
+ writeln!(
+ second,
+ "{}",
+ serde_json::json!({
+ "id": "cli:plugin",
+ "result": {
+ "type": "plugin_linked",
+ "plugin": {
+ "plugin_id": "example.worktree-bootstrap",
+ "name": "Worktree Bootstrap",
+ "version": "0.1.0",
+ "min_herdr_version": "0.6.10",
+ "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(),
+ "plugin_root": managed_checkout_for_server.display().to_string(),
+ "enabled": true,
+ "source": {"kind": "local"}
+ }
+ }
+ })
+ )
+ .unwrap();
+ second.flush().unwrap();
+
+ let (mut third, third_line) = accept_fake_cli_operation(&listener);
+ let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap();
+ assert_eq!(third_request["method"], "plugin.unlink");
+ third
+ .write_all(
+ br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced unlink failure"}}"#,
+ )
+ .unwrap();
+ third.write_all(b"\n").unwrap();
+ third.flush().unwrap();
+ });
+
+ let install = run_named_cli_with_env_and_socket_override(
+ &config_home,
+ &runtime_dir,
+ &[
+ "plugin",
+ "install",
+ "ogulcancelik/herdr-plugin-examples/worktree-bootstrap",
+ "--yes",
+ ],
+ &[("GIT_CONFIG_GLOBAL", &git_config)],
+ Some(&socket_path),
+ );
+ assert!(
+ !install.status.success(),
+ "install should fail when source metadata is dropped and cleanup fails"
+ );
+ server.join().unwrap();
+ assert!(
+ managed_checkout.exists(),
+ "checkout should stay when server cleanup fails"
+ );
+
+ cleanup_test_base(&base);
+}
diff --git a/tests/cli/protocol.rs b/tests/cli/protocol.rs
new file mode 100644
index 00000000..0c5571b8
--- /dev/null
+++ b/tests/cli/protocol.rs
@@ -0,0 +1,122 @@
+use super::harness::*;
+
+#[test]
+fn cli_allows_same_protocol_different_version_and_preserves_server_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 || {
+ 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,
+ "0.7.1-compatible-build",
+ CURRENT_PROTOCOL,
+ );
+
+ let (mut operation_stream, _) = listener.accept().unwrap();
+ let mut operation_line = String::new();
+ BufReader::new(operation_stream.try_clone().unwrap())
+ .read_line(&mut operation_line)
+ .unwrap();
+ let operation: serde_json::Value = serde_json::from_str(&operation_line).unwrap();
+ assert_eq!(operation["method"], "agent.wait");
+ operation_stream
+ .write_all(
+ br#"{"id":"cli:agent:wait","error":{"code":"not_implemented","message":"compatible server error"}}"#,
+ )
+ .unwrap();
+ operation_stream.write_all(b"\n").unwrap();
+ operation_stream.flush().unwrap();
+ });
+
+ let waited = run_cli(
+ &socket_path,
+ &[
+ "agent",
+ "wait",
+ "1-1",
+ "--until",
+ "idle",
+ "--timeout",
+ "5000",
+ ],
+ );
+
+ assert_eq!(waited.status.code(), Some(1));
+ assert!(waited.stdout.is_empty());
+ let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap();
+ assert_eq!(error["id"], "cli:agent:wait");
+ assert_eq!(error["error"]["code"], "not_implemented");
+ assert_eq!(error["error"]["message"], "compatible server error");
+ server.join().unwrap();
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn server_live_handoff_bypasses_protocol_guard() {
+ 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 || {
+ let (mut stream, _) = listener.accept().unwrap();
+ let mut line = String::new();
+ BufReader::new(stream.try_clone().unwrap())
+ .read_line(&mut line)
+ .unwrap();
+ let request: serde_json::Value = serde_json::from_str(&line).unwrap();
+ assert_eq!(request["method"], "server.live_handoff");
+ stream
+ .write_all(br#"{"id":"cli:server:live-handoff","result":{"type":"ok"}}"#)
+ .unwrap();
+ stream.write_all(b"\n").unwrap();
+ stream.flush().unwrap();
+ });
+
+ let handoff = run_cli(&socket_path, &["server", "live-handoff"]);
+ assert!(
+ handoff.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&handoff.stderr)
+ );
+ server.join().unwrap();
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn plugin_list_preserves_protocol_mismatch_envelope() {
+ 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 || {
+ let (mut stream, _) = listener.accept().unwrap();
+ let mut line = String::new();
+ BufReader::new(stream.try_clone().unwrap())
+ .read_line(&mut line)
+ .unwrap();
+ let request: serde_json::Value = serde_json::from_str(&line).unwrap();
+ assert_eq!(request["method"], "ping");
+ write_fake_pong(&mut stream, &request, "0.7.1", 14);
+ });
+
+ let listed = run_cli(&socket_path, &["plugin", "list", "--json"]);
+ assert_eq!(listed.status.code(), Some(1));
+ assert!(listed.stdout.is_empty());
+ let error: serde_json::Value = serde_json::from_slice(&listed.stderr).unwrap();
+ assert_eq!(error["id"], "cli:plugin");
+ assert_eq!(error["error"]["code"], "protocol_mismatch");
+ server.join().unwrap();
+ cleanup_test_base(&base);
+}
diff --git a/tests/cli/protocol_guard.rs b/tests/cli/protocol_guard.rs
new file mode 100644
index 00000000..4272ed28
--- /dev/null
+++ b/tests/cli/protocol_guard.rs
@@ -0,0 +1,87 @@
+use super::harness::*;
+
+#[test]
+fn cli_rejects_protocol_mismatch_before_agent_wait_request() {
+ 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 || {
+ let (mut first_stream, _) = listener.accept().unwrap();
+ let mut first_line = String::new();
+ let mut first_reader = BufReader::new(first_stream.try_clone().unwrap());
+ first_reader.read_line(&mut first_line).unwrap();
+ let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap();
+ if first_request["method"] == "ping" {
+ write_fake_pong(&mut first_stream, &first_request, "0.7.1", 14);
+ } else {
+ first_stream
+ .write_all(
+ br#"{"id":"cli:agent:wait","error":{"code":"not_implemented","message":"method not implemented yet"}}"#,
+ )
+ .unwrap();
+ }
+ if first_request["method"] != "ping" {
+ first_stream.write_all(b"\n").unwrap();
+ first_stream.flush().unwrap();
+ }
+
+ let mut second_line = None;
+ listener.set_nonblocking(true).unwrap();
+ let deadline = Instant::now() + Duration::from_millis(250);
+ while Instant::now() < deadline {
+ match listener.accept() {
+ Ok((second_stream, _)) => {
+ let mut line = String::new();
+ let mut reader = BufReader::new(second_stream);
+ reader.read_line(&mut line).unwrap();
+ second_line = Some(line);
+ break;
+ }
+ Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
+ thread::sleep(Duration::from_millis(10));
+ }
+ Err(err) => panic!("second accept failed: {err}"),
+ }
+ }
+
+ (first_line, second_line)
+ });
+
+ let waited = run_cli(
+ &socket_path,
+ &[
+ "agent",
+ "wait",
+ "1-1",
+ "--until",
+ "idle",
+ "--timeout",
+ "5000",
+ ],
+ );
+
+ assert_eq!(waited.status.code(), Some(1));
+ assert!(waited.stdout.is_empty());
+ let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap();
+ assert_eq!(error["id"], "cli:agent:wait");
+ assert_eq!(error["error"]["code"], "protocol_mismatch");
+ let message = error["error"]["message"].as_str().unwrap();
+ assert!(
+ message.contains(&format!("client protocol {CURRENT_PROTOCOL}")),
+ "message: {message}"
+ );
+ assert!(message.contains("server protocol 14"), "message: {message}");
+ assert!(message.contains("restart"), "message: {message}");
+
+ let (first_line, second_line) = server.join().unwrap();
+ let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap();
+ assert_eq!(first_request["method"], "ping");
+ assert!(
+ second_line.is_none(),
+ "mismatched CLI sent an operational request: {second_line:?}"
+ );
+
+ cleanup_test_base(&base);
+}
diff --git a/tests/cli/sessions.rs b/tests/cli/sessions.rs
new file mode 100644
index 00000000..4a4430e1
--- /dev/null
+++ b/tests/cli/sessions.rs
@@ -0,0 +1,671 @@
+use super::harness::*;
+
+#[test]
+fn named_sessions_use_separate_servers_and_workspace_state() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+
+ let alpha = spawn_named_server(&config_home, &runtime_dir, "alpha");
+ let beta = spawn_named_server(&config_home, &runtime_dir, "beta");
+
+ wait_for_socket(
+ &named_session_socket(&config_home, "alpha"),
+ Duration::from_secs(5),
+ );
+ wait_for_socket(
+ &named_session_socket(&config_home, "beta"),
+ Duration::from_secs(5),
+ );
+
+ run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &[
+ "--session",
+ "alpha",
+ "workspace",
+ "create",
+ "--label",
+ "alpha-ws",
+ "--no-focus",
+ ],
+ );
+ run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &[
+ "--session",
+ "beta",
+ "workspace",
+ "create",
+ "--label",
+ "beta-ws",
+ "--no-focus",
+ ],
+ );
+
+ let alpha_list = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "alpha", "workspace", "list"],
+ );
+ let beta_list = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "beta", "workspace", "list"],
+ );
+
+ let alpha_labels: Vec<_> = alpha_list["result"]["workspaces"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|workspace| workspace["label"].as_str().unwrap())
+ .collect();
+ let beta_labels: Vec<_> = beta_list["result"]["workspaces"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|workspace| workspace["label"].as_str().unwrap())
+ .collect();
+
+ assert_eq!(alpha_labels, vec!["alpha-ws"]);
+ assert_eq!(beta_labels, vec!["beta-ws"]);
+
+ let beta_via_explicit_session = run_named_cli_with_socket_override(
+ &config_home,
+ &runtime_dir,
+ &["--session", "beta", "workspace", "list"],
+ Some(&named_session_socket(&config_home, "alpha")),
+ );
+ assert!(
+ beta_via_explicit_session.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&beta_via_explicit_session.stderr)
+ );
+ let beta_via_explicit_session: serde_json::Value =
+ serde_json::from_slice(&beta_via_explicit_session.stdout).unwrap();
+ let labels_via_explicit: Vec<_> = beta_via_explicit_session["result"]["workspaces"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|workspace| workspace["label"].as_str().unwrap())
+ .collect();
+ assert_eq!(labels_via_explicit, vec!["beta-ws"]);
+
+ let human_sessions = run_named_cli(&config_home, &runtime_dir, &["session", "list"]);
+ assert!(human_sessions.status.success());
+ let human_sessions = String::from_utf8_lossy(&human_sessions.stdout);
+ assert!(human_sessions.contains("name"), "stdout: {human_sessions}");
+ assert!(
+ human_sessions.contains("status"),
+ "stdout: {human_sessions}"
+ );
+ assert!(human_sessions.contains("alpha"), "stdout: {human_sessions}");
+ assert!(
+ human_sessions.contains("running"),
+ "stdout: {human_sessions}"
+ );
+ assert!(
+ human_sessions.contains("/sessions/beta"),
+ "stdout: {human_sessions}"
+ );
+
+ let sessions = run_named_cli_json(&config_home, &runtime_dir, &["session", "list", "--json"]);
+ let sessions = sessions["sessions"].as_array().unwrap();
+ let default_session = sessions
+ .iter()
+ .find(|session| session["name"] == "default")
+ .unwrap();
+ let alpha_session = sessions
+ .iter()
+ .find(|session| session["name"] == "alpha")
+ .unwrap();
+ let beta_session = sessions
+ .iter()
+ .find(|session| session["name"] == "beta")
+ .unwrap();
+ assert_eq!(default_session["default"], true);
+ assert_eq!(default_session["running"], false);
+ assert_eq!(alpha_session["running"], true);
+ assert_eq!(beta_session["running"], true);
+ assert!(alpha_session["socket_path"]
+ .as_str()
+ .unwrap()
+ .ends_with("/sessions/alpha/herdr.sock"));
+ assert!(beta_session["session_dir"]
+ .as_str()
+ .unwrap()
+ .ends_with("/sessions/beta"));
+
+ let delete_running = run_named_cli(&config_home, &runtime_dir, &["session", "delete", "alpha"]);
+ assert_eq!(delete_running.status.code(), Some(1));
+ assert!(
+ String::from_utf8_lossy(&delete_running.stderr).contains("stop it before deleting"),
+ "stderr: {}",
+ String::from_utf8_lossy(&delete_running.stderr)
+ );
+
+ let delete_default = run_named_cli(
+ &config_home,
+ &runtime_dir,
+ &["session", "delete", "default"],
+ );
+ assert_eq!(delete_default.status.code(), Some(1));
+ assert!(
+ String::from_utf8_lossy(&delete_default.stderr).contains("default session"),
+ "stderr: {}",
+ String::from_utf8_lossy(&delete_default.stderr)
+ );
+
+ let stopped_alpha = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["session", "stop", "alpha", "--json"],
+ );
+ assert_eq!(stopped_alpha["stopped"], true);
+ assert_eq!(stopped_alpha["session"]["running"], false);
+
+ let deleted_alpha = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["session", "delete", "alpha", "--json"],
+ );
+ assert_eq!(deleted_alpha["deleted"], true);
+ assert!(!config_home
+ .join(app_dir_name())
+ .join("sessions")
+ .join("alpha")
+ .exists());
+
+ let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "beta"]);
+ drop(alpha);
+ drop(beta);
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn integration_commands_run_locally_when_server_is_missing() {
+ let base = unique_test_dir();
+ let home_dir = base.join("home");
+ let extensions_dir = home_dir.join(".pi/agent/extensions");
+ fs::create_dir_all(&extensions_dir).unwrap();
+
+ let runtime_dir = base.join("runtime");
+ fs::create_dir_all(&runtime_dir).unwrap();
+ register_runtime_dir(&runtime_dir);
+ let missing_socket = runtime_dir.join("missing.sock");
+
+ let expected_extension = extensions_dir.join("herdr-agent-state.ts");
+ assert!(
+ !expected_extension.exists(),
+ "test setup should start without extension file"
+ );
+
+ let workspace_list = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["workspace", "list"])
+ .env("HERDR_SOCKET_PATH", &missing_socket)
+ .env("HOME", &home_dir)
+ .output()
+ .unwrap();
+ assert_eq!(workspace_list.status.code(), Some(1));
+
+ let integration_install = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["integration", "install", "pi"])
+ .env("HERDR_SOCKET_PATH", &missing_socket)
+ .env("HOME", &home_dir)
+ .output()
+ .unwrap();
+ assert_eq!(integration_install.status.code(), Some(0));
+ assert!(
+ expected_extension.exists(),
+ "integration install should write local files without a server"
+ );
+
+ let integration_status = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["integration", "status"])
+ .env("HERDR_SOCKET_PATH", &missing_socket)
+ .env("HOME", &home_dir)
+ .output()
+ .unwrap();
+ assert_eq!(integration_status.status.code(), Some(0));
+ let status_stdout = String::from_utf8_lossy(&integration_status.stdout);
+ assert!(status_stdout.contains("pi: current (v6)"));
+ assert!(status_stdout.contains("claude: not installed"));
+
+ let integration_uninstall = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["integration", "uninstall", "pi"])
+ .env("HERDR_SOCKET_PATH", &missing_socket)
+ .env("HOME", &home_dir)
+ .output()
+ .unwrap();
+ assert_eq!(integration_uninstall.status.code(), Some(0));
+ assert!(
+ !expected_extension.exists(),
+ "integration uninstall should remove local files without a server"
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn integration_status_outdated_only_prints_action_for_legacy_install() {
+ let base = unique_test_dir();
+ let home_dir = base.join("home");
+ let extensions_dir = home_dir.join(".pi/agent/extensions");
+ fs::create_dir_all(&extensions_dir).unwrap();
+ fs::write(
+ extensions_dir.join("herdr-agent-state.ts"),
+ "// legacy herdr integration\n",
+ )
+ .unwrap();
+
+ let runtime_dir = base.join("runtime");
+ fs::create_dir_all(&runtime_dir).unwrap();
+ register_runtime_dir(&runtime_dir);
+ let missing_socket = runtime_dir.join("missing.sock");
+
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["integration", "status", "--outdated-only"])
+ .env("HERDR_SOCKET_PATH", &missing_socket)
+ .env("HOME", &home_dir)
+ .output()
+ .unwrap();
+
+ assert_eq!(output.status.code(), Some(0));
+ assert!(output.stdout.is_empty());
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(stderr.contains("installed herdr integrations need updating"));
+ assert!(stderr.contains("herdr integration install pi"));
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn integration_status_rejects_unknown_flags() {
+ let base = unique_test_dir();
+ let home_dir = base.join("home");
+ fs::create_dir_all(&home_dir).unwrap();
+ let runtime_dir = base.join("runtime");
+ fs::create_dir_all(&runtime_dir).unwrap();
+ register_runtime_dir(&runtime_dir);
+ let missing_socket = runtime_dir.join("missing.sock");
+
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["integration", "status", "--wat"])
+ .env("HERDR_SOCKET_PATH", &missing_socket)
+ .env("HOME", &home_dir)
+ .output()
+ .unwrap();
+
+ assert_eq!(output.status.code(), Some(2));
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn status_commands_report_client_and_server_versions() {
+ 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 full = run_cli(&socket_path, &["status"]);
+ assert!(
+ full.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&full.stderr)
+ );
+ let full_stdout = String::from_utf8_lossy(&full.stdout);
+ assert!(full_stdout.contains("client:\n"), "stdout: {full_stdout}");
+ assert!(
+ full_stdout.contains(&format!(" version: {}", env!("CARGO_PKG_VERSION"))),
+ "stdout: {full_stdout}"
+ );
+ assert!(
+ full_stdout.contains(" protocol: 17"),
+ "stdout: {full_stdout}"
+ );
+ assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}");
+ assert!(
+ full_stdout.contains(" status: running"),
+ "stdout: {full_stdout}"
+ );
+ assert!(
+ full_stdout.contains(" compatible: yes"),
+ "stdout: {full_stdout}"
+ );
+ assert!(
+ full_stdout.contains(" restart_needed: no"),
+ "stdout: {full_stdout}"
+ );
+ assert!(
+ full_stdout.contains(&socket_path.display().to_string()),
+ "stdout: {full_stdout}"
+ );
+
+ let server = run_cli(&socket_path, &["status", "server"]);
+ assert!(server.status.success());
+ let server_stdout = String::from_utf8_lossy(&server.stdout);
+ assert!(
+ server_stdout.contains("status: running"),
+ "stdout: {server_stdout}"
+ );
+ assert!(
+ server_stdout.contains(&format!("version: {}", env!("CARGO_PKG_VERSION"))),
+ "stdout: {server_stdout}"
+ );
+ assert!(
+ server_stdout.contains("protocol: 17"),
+ "stdout: {server_stdout}"
+ );
+
+ let client = run_cli(&socket_path, &["status", "client"]);
+ assert!(client.status.success());
+ let client_stdout = String::from_utf8_lossy(&client.stdout);
+ assert!(
+ client_stdout.contains(&format!("version: {}", env!("CARGO_PKG_VERSION"))),
+ "stdout: {client_stdout}"
+ );
+ assert!(
+ client_stdout.contains("protocol: 17"),
+ "stdout: {client_stdout}"
+ );
+ assert!(
+ client_stdout.contains("binary: "),
+ "stdout: {client_stdout}"
+ );
+
+ 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"], 17);
+ assert_eq!(full_json["server"]["status"], "running");
+ assert_eq!(full_json["server"]["running"], true);
+ assert_eq!(full_json["server"]["compatible"], true);
+ assert_eq!(
+ full_json["server"]["socket"],
+ socket_path.display().to_string()
+ );
+ assert_eq!(full_json["server"]["restart_needed"], false);
+ assert_eq!(full_json["update"]["restart_needed"], false);
+
+ 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"], 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"], 17);
+ assert!(client_json["binary"]
+ .as_str()
+ .is_some_and(|path| !path.is_empty()));
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn status_reports_not_running_when_server_socket_is_missing() {
+ let base = unique_test_dir();
+ let runtime_dir = base.join("runtime");
+ fs::create_dir_all(&runtime_dir).unwrap();
+ register_runtime_dir(&runtime_dir);
+ let socket_path = runtime_dir.join("missing.sock");
+
+ let status = run_cli(&socket_path, &["status"]);
+ assert!(status.status.success());
+ let stdout = String::from_utf8_lossy(&status.stdout);
+ assert!(stdout.contains(" status: not running"), "stdout: {stdout}");
+ assert!(stdout.contains(" restart_needed: no"), "stdout: {stdout}");
+ assert!(
+ stdout.contains(&socket_path.display().to_string()),
+ "stdout: {stdout}"
+ );
+
+ let status_json = run_cli_json(&socket_path, &["status", "--json"]);
+ assert_eq!(status_json["server"]["status"], "not_running");
+ assert_eq!(status_json["server"]["running"], false);
+ assert_eq!(
+ status_json["server"]["socket"],
+ socket_path.display().to_string()
+ );
+ assert_eq!(status_json["server"]["restart_needed"], false);
+ assert_eq!(status_json["update"]["restart_needed"], false);
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn server_stop_command_shuts_down_running_server() {
+ 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 client_socket = runtime_dir.join("herdr-client.sock");
+
+ let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+ wait_for_socket(&client_socket, Duration::from_secs(5));
+
+ let stopped = run_cli(&socket_path, &["server", "stop"]);
+ assert!(
+ stopped.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&stopped.stderr)
+ );
+ assert!(
+ stopped.stdout.is_empty(),
+ "server stop should not print stdout: {}",
+ String::from_utf8_lossy(&stopped.stdout)
+ );
+ assert!(
+ !socket_path.exists() || UnixStream::connect(&socket_path).is_err(),
+ "api socket should be removed or stale before server stop returns"
+ );
+ assert!(
+ !client_socket.exists() || UnixStream::connect(&client_socket).is_err(),
+ "client socket should be removed or stale before server stop returns"
+ );
+
+ let pid = herdr.child.process_id();
+ let exit_status = herdr.child.wait().unwrap();
+ unregister_spawned_herdr_pid(pid);
+ assert!(exit_status.success(), "server stop should exit cleanly");
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn server_stop_then_restart_restores_pane_history() {
+ 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 client_socket = runtime_dir.join("herdr-client.sock");
+ let marker = "PERSISTED_HISTORY_AFTER_STOP";
+
+ let mut herdr = spawn_herdr_with_pane_history(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+ wait_for_socket(&client_socket, Duration::from_secs(5));
+
+ let created = run_cli_json(
+ &socket_path,
+ &[
+ "workspace",
+ "create",
+ "--cwd",
+ base.to_str().expect("test path should be utf-8"),
+ "--label",
+ "history-restart",
+ ],
+ );
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .expect("workspace create should return root pane id")
+ .to_string();
+ let sent = run_cli(
+ &socket_path,
+ &["pane", "send-text", &pane_id, &format!("echo {marker}\n")],
+ );
+ assert!(
+ sent.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&sent.stderr)
+ );
+ assert!(
+ wait_until(Duration::from_secs(3), Duration::from_millis(25), || {
+ pane_read_recent_contains(&socket_path, &pane_id, marker)
+ }),
+ "pane should contain marker before server stop"
+ );
+
+ let stopped = run_cli(&socket_path, &["server", "stop"]);
+ assert!(
+ stopped.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&stopped.stderr)
+ );
+
+ let pid = herdr.child.process_id();
+ let exit_status = herdr.child.wait().unwrap();
+ unregister_spawned_herdr_pid(pid);
+ assert!(exit_status.success(), "server stop should exit cleanly");
+ drop(herdr);
+
+ let restarted = spawn_herdr_with_pane_history(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+ wait_for_socket(&client_socket, Duration::from_secs(5));
+
+ let workspaces = run_cli_json(&socket_path, &["workspace", "list"]);
+ let workspace_id = workspaces["result"]["workspaces"]
+ .as_array()
+ .expect("workspace.list should return workspaces")
+ .iter()
+ .find(|workspace| workspace["label"] == "history-restart")
+ .and_then(|workspace| workspace["workspace_id"].as_str())
+ .expect("restored workspace should exist")
+ .to_string();
+ let panes = run_cli_json(
+ &socket_path,
+ &["pane", "list", "--workspace", &workspace_id],
+ );
+ let restored_pane_id = panes["result"]["panes"]
+ .as_array()
+ .expect("pane.list should return panes")
+ .first()
+ .and_then(|pane| pane["pane_id"].as_str())
+ .expect("restored pane should exist")
+ .to_string();
+
+ assert!(
+ wait_until(Duration::from_secs(3), Duration::from_millis(25), || {
+ pane_read_recent_contains(&socket_path, &restored_pane_id, marker)
+ }),
+ "restarted server should restore saved pane history"
+ );
+
+ cleanup_spawned_herdr(restarted, base);
+}
+
+#[test]
+fn server_start_restores_legacy_session_through_api_identity() {
+ 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 client_socket = runtime_dir.join("herdr-client.sock");
+ let data_dir = config_home.join(app_dir_name());
+ let pion_cwd = base.join("legacy-pion");
+ let herdr_cwd = base.join("legacy-herdr");
+
+ fs::create_dir_all(&pion_cwd).unwrap();
+ fs::create_dir_all(&herdr_cwd).unwrap();
+ fs::create_dir_all(&data_dir).unwrap();
+ let pion_cwd = pion_cwd.to_str().expect("test cwd should be UTF-8");
+ let herdr_cwd = herdr_cwd.to_str().expect("test cwd should be UTF-8");
+ let legacy_session = include_str!("../fixtures/session/legacy-pre-tabs-v2.json")
+ .replace("/tmp/pion", pion_cwd)
+ .replace("/tmp/herdr", herdr_cwd);
+ fs::write(data_dir.join("session.json"), legacy_session).unwrap();
+
+ let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+ wait_for_socket(&client_socket, Duration::from_secs(5));
+
+ let workspaces = run_cli_json(&socket_path, &["workspace", "list"]);
+ let restored_workspace = workspaces["result"]["workspaces"]
+ .as_array()
+ .expect("workspace.list should return workspaces")
+ .iter()
+ .find(|workspace| workspace["label"] == "legacy")
+ .expect("legacy workspace should restore");
+ let workspace_id = restored_workspace["workspace_id"]
+ .as_str()
+ .expect("restored workspace should have public id")
+ .to_string();
+ assert_eq!(restored_workspace["pane_count"], 2);
+ assert_eq!(restored_workspace["tab_count"], 1);
+ assert_eq!(
+ restored_workspace["active_tab_id"],
+ format!("{workspace_id}:t1")
+ );
+
+ let panes = run_cli_json(
+ &socket_path,
+ &["pane", "list", "--workspace", &workspace_id],
+ );
+ let panes = panes["result"]["panes"]
+ .as_array()
+ .expect("pane.list should return panes");
+ assert_eq!(panes.len(), 2);
+ let root_pane_id = format!("{workspace_id}:p1");
+ let focused_pane_id = format!("{workspace_id}:p2");
+ assert!(panes.iter().any(|pane| {
+ pane["pane_id"] == root_pane_id
+ && pane["tab_id"] == format!("{workspace_id}:t1")
+ && pane["cwd"] == pion_cwd
+ && pane["focused"] == false
+ }));
+ assert!(panes.iter().any(|pane| {
+ pane["pane_id"] == focused_pane_id
+ && pane["tab_id"] == format!("{workspace_id}:t1")
+ && pane["cwd"] == herdr_cwd
+ && pane["focused"] == true
+ }));
+
+ let reported = run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-agent",
+ &focused_pane_id,
+ "--source",
+ "test",
+ "--agent",
+ "pi",
+ "--state",
+ "working",
+ ],
+ );
+ assert!(
+ reported.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&reported.stderr)
+ );
+
+ let agents = run_cli_json(&socket_path, &["agent", "list"]);
+ let agents = agents["result"]["agents"]
+ .as_array()
+ .expect("agent.list should return agents");
+ assert_eq!(agents.len(), 1);
+ assert_eq!(agents[0]["pane_id"], focused_pane_id);
+ assert_eq!(agents[0]["workspace_id"], workspace_id);
+ assert_eq!(agents[0]["agent"], "pi");
+ assert_eq!(agents[0]["agent_status"], "working");
+
+ cleanup_spawned_herdr(herdr, base);
+}
diff --git a/tests/cli/surface.rs b/tests/cli/surface.rs
new file mode 100644
index 00000000..2507804e
--- /dev/null
+++ b/tests/cli/surface.rs
@@ -0,0 +1,603 @@
+use super::harness::*;
+
+#[test]
+fn pane_run_sends_one_send_input_request_with_enter_key() {
+ 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 || {
+ let (mut first_stream, first_line) = accept_fake_cli_operation(&listener);
+ first_stream
+ .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#)
+ .unwrap();
+ first_stream.write_all(b"\n").unwrap();
+ first_stream.flush().unwrap();
+
+ let mut second_line = None;
+ listener.set_nonblocking(true).unwrap();
+ let deadline = Instant::now() + Duration::from_millis(250);
+ while Instant::now() < deadline {
+ match listener.accept() {
+ Ok((mut second_stream, _)) => {
+ let mut line = String::new();
+ let mut reader = BufReader::new(second_stream.try_clone().unwrap());
+ reader.read_line(&mut line).unwrap();
+ second_stream
+ .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#)
+ .unwrap();
+ second_stream.write_all(b"\n").unwrap();
+ second_stream.flush().unwrap();
+ second_line = Some(line);
+ break;
+ }
+ Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
+ thread::sleep(Duration::from_millis(10));
+ }
+ Err(err) => panic!("second accept failed: {err}"),
+ }
+ }
+
+ (first_line, second_line)
+ });
+
+ let run = run_cli(&socket_path, &["pane", "run", "1-1", "echo hello"]);
+ assert!(
+ run.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&run.stderr)
+ );
+
+ let (first_line, second_line) = server.join().unwrap();
+ let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap();
+ assert_eq!(first_request["method"], "pane.send_input");
+ assert_eq!(first_request["params"]["pane_id"], "1-1");
+ assert_eq!(first_request["params"]["text"], "echo hello");
+ assert_eq!(
+ first_request["params"]["keys"],
+ serde_json::json!(["Enter"])
+ );
+ assert!(
+ second_line.is_none(),
+ "pane run sent an unexpected second request: {:?}",
+ second_line
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn workspace_report_metadata_sends_token_patch() {
+ 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 || {
+ let (mut stream, line) = accept_fake_cli_operation(&listener);
+ stream
+ .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#)
+ .unwrap();
+ stream.write_all(b"\n").unwrap();
+ stream.flush().unwrap();
+ line
+ });
+
+ let run = run_cli(
+ &socket_path,
+ &[
+ "workspace",
+ "report-metadata",
+ "2",
+ "--source",
+ "user:jj",
+ "--token",
+ "jj_status=2 changes",
+ "--token",
+ "summary=clean",
+ "--clear-token",
+ "old",
+ "--ttl-ms",
+ "5000",
+ ],
+ );
+ assert!(
+ run.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&run.stderr)
+ );
+
+ let request: serde_json::Value = serde_json::from_str(&server.join().unwrap()).unwrap();
+ assert_eq!(request["method"], "workspace.report_metadata");
+ assert_eq!(request["params"]["workspace_id"], "2");
+ assert_eq!(request["params"]["tokens"]["jj_status"], "2 changes");
+ assert_eq!(request["params"]["tokens"]["summary"], "clean");
+ assert!(request["params"]["tokens"]["old"].is_null());
+ assert_eq!(request["params"]["ttl_ms"], 5000);
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn pane_report_metadata_sends_presentation_request() {
+ 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 || {
+ let (mut stream, line) = accept_fake_cli_operation(&listener);
+ stream
+ .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#)
+ .unwrap();
+ stream.write_all(b"\n").unwrap();
+ stream.flush().unwrap();
+ line
+ });
+
+ let run = run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-metadata",
+ "1-1",
+ "--source",
+ "user:claude-title",
+ "--agent",
+ "claude",
+ "--title",
+ "Refactor auth",
+ "--display-agent",
+ "Claude auth",
+ "--state-label",
+ "working=deep in the mines",
+ "--token",
+ "summary=reviewing auth",
+ "--token",
+ "model=opus",
+ "--clear-token",
+ "old",
+ "--ttl-ms",
+ "3600000",
+ ],
+ );
+ assert!(
+ run.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&run.stderr)
+ );
+
+ let line = server.join().unwrap();
+ let request: serde_json::Value = serde_json::from_str(&line).unwrap();
+ assert_eq!(request["method"], "pane.report_metadata");
+ assert_eq!(request["params"]["pane_id"], "1-1");
+ assert_eq!(request["params"]["source"], "user:claude-title");
+ assert_eq!(request["params"]["agent"], "claude");
+ assert!(request["params"]["applies_to_source"].is_null());
+ assert_eq!(request["params"]["title"], "Refactor auth");
+ assert_eq!(request["params"]["display_agent"], "Claude auth");
+ assert_eq!(
+ request["params"]["state_labels"]["working"],
+ "deep in the mines"
+ );
+ assert_eq!(request["params"]["tokens"]["summary"], "reviewing auth");
+ assert_eq!(request["params"]["tokens"]["model"], "opus");
+ assert!(request["params"]["tokens"]["old"].is_null());
+ assert_eq!(request["params"]["ttl_ms"], 3_600_000);
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn pane_report_metadata_rejects_blank_source_before_socket_request() {
+ let base = unique_test_dir();
+ fs::create_dir_all(&base).unwrap();
+ let socket_path = base.join("missing.sock");
+
+ let run = run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-metadata",
+ "1-1",
+ "--source",
+ " ",
+ "--token",
+ "summary=middleware",
+ ],
+ );
+
+ assert_eq!(run.status.code(), Some(2));
+ assert!(
+ String::from_utf8_lossy(&run.stderr).contains("missing required --source"),
+ "stderr: {}",
+ String::from_utf8_lossy(&run.stderr)
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn pane_report_metadata_rejects_blank_applies_to_source_before_socket_request() {
+ let base = unique_test_dir();
+ fs::create_dir_all(&base).unwrap();
+ let socket_path = base.join("missing.sock");
+
+ let run = run_cli(
+ &socket_path,
+ &[
+ "pane",
+ "report-metadata",
+ "1-1",
+ "--source",
+ "user:claude-title",
+ "--applies-to-source",
+ " ",
+ "--token",
+ "summary=middleware",
+ ],
+ );
+
+ assert_eq!(run.status.code(), Some(2));
+ assert!(
+ String::from_utf8_lossy(&run.stderr).contains("missing value for --applies-to-source"),
+ "stderr: {}",
+ String::from_utf8_lossy(&run.stderr)
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn help_commands_exit_successfully() {
+ let help_cases: &[&[&str]] = &[
+ &["--help"],
+ &["agent", "wait", "--help"],
+ &["terminal", "session", "control", "-h"],
+ ];
+
+ for args in help_cases {
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(*args)
+ .output()
+ .unwrap();
+ assert!(
+ output.status.success(),
+ "herdr {} failed: status={:?} stdout={} stderr={}",
+ args.join(" "),
+ output.status.code(),
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ }
+}
+
+#[test]
+fn subcommand_help_explains_automation_semantics_without_a_server() {
+ let cases: &[(&[&str], &str)] = &[
+ (&["agent", "wait", "--help"], "Without --until"),
+ (
+ &["agent", "prompt", "--help"],
+ "first matching state observed after submission",
+ ),
+ (&["agent", "start", "--help"], "ready for input"),
+ (
+ &["agent", "send-keys", "--help"],
+ "canonical Escape key name",
+ ),
+ (
+ &["pane", "wait-output", "--help"],
+ "including existing output",
+ ),
+ (
+ &["pane", "send-keys", "--help"],
+ "canonical Escape key name",
+ ),
+ ];
+
+ for (args, expected) in cases {
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(*args)
+ .env_remove("HERDR_SOCKET_PATH")
+ .env_remove("HERDR_CLIENT_SOCKET_PATH")
+ .env_remove("HERDR_ENV")
+ .output()
+ .unwrap();
+ assert!(
+ output.status.success(),
+ "herdr {} failed: status={:?} stdout={} stderr={}",
+ args.join(" "),
+ output.status.code(),
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(
+ stdout.contains(expected),
+ "herdr {} help did not contain {expected:?}: {stdout}",
+ args.join(" ")
+ );
+ }
+}
+
+#[test]
+fn removed_wait_and_agent_send_commands_are_rejected() {
+ let wait = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["wait", "output", "w1:p1", "--match", "ready"])
+ .output()
+ .unwrap();
+ assert_eq!(wait.status.code(), Some(2));
+ assert!(String::from_utf8_lossy(&wait.stderr).contains("unknown command: wait"));
+ let help = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .arg("--help")
+ .output()
+ .unwrap();
+ assert!(!String::from_utf8_lossy(&help.stdout).contains("herdr wait <subcommand>"));
+
+ let send = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["agent", "send", "reviewer", "hello"])
+ .output()
+ .unwrap();
+ assert_eq!(send.status.code(), Some(2));
+ let stderr = String::from_utf8_lossy(&send.stderr);
+ assert!(stderr.contains("herdr agent send-keys"));
+ assert!(!stderr.contains("herdr agent send <"));
+}
+
+#[test]
+fn agent_cli_rejects_invalid_wait_and_rename_grammar_locally() {
+ for args in [
+ &["agent", "wait", "reviewer", "--until", "finished"][..],
+ &["agent", "wait", "reviewer", "--timeout", "later"][..],
+ &[
+ "agent", "prompt", "reviewer", "work", "--wait", "--until", "finished",
+ ][..],
+ &[
+ "agent",
+ "prompt",
+ "reviewer",
+ "work",
+ "--wait",
+ "--timeout",
+ "later",
+ ][..],
+ &[
+ "agent",
+ "start",
+ "reviewer",
+ "--kind",
+ "pi",
+ "--pane",
+ "w1:p1",
+ "--timeout",
+ "later",
+ ][..],
+ &["agent", "rename", "reviewer"][..],
+ &["agent", "rename", "reviewer", "worker", "--clear"][..],
+ ] {
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(args)
+ .env("HERDR_SOCKET_PATH", "/nonexistent/herdr.sock")
+ .output()
+ .unwrap();
+ assert_eq!(
+ output.status.code(),
+ Some(2),
+ "herdr {}: stdout={} stderr={}",
+ args.join(" "),
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ }
+}
+
+#[test]
+fn completion_command_prints_zsh_script_without_session_startup() {
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["completion", "zsh"])
+ .env_remove("HERDR_SOCKET_PATH")
+ .env_remove("HERDR_CLIENT_SOCKET_PATH")
+ .env_remove("HERDR_ENV")
+ .output()
+ .unwrap();
+
+ assert!(
+ output.status.success(),
+ "status={:?} stderr={}",
+ output.status.code(),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(stdout.contains("#compdef herdr"), "stdout: {stdout}");
+ assert!(
+ !stdout.contains("--cwd=[]"),
+ "zsh completions should not suggest equals-style values unsupported by most manual parsers: {stdout}"
+ );
+ assert!(
+ !stdout.contains("--direction=[]"),
+ "zsh completions should not suggest equals-style direction values: {stdout}"
+ );
+ assert!(
+ !stdout.contains("live-handoff"),
+ "internal server handoff command should not be completed: {stdout}"
+ );
+}
+
+#[test]
+fn root_help_hides_explicit_client_command() {
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .arg("--help")
+ .output()
+ .unwrap();
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(
+ !stdout.contains("herdr client"),
+ "root help should not advertise the internal client command: {stdout}"
+ );
+}
+
+#[test]
+fn root_help_advertises_api_schema_command_group() {
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .arg("--help")
+ .output()
+ .unwrap();
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(
+ stdout.contains("herdr api <subcommand>"),
+ "root help should advertise the api command group: {stdout}"
+ );
+}
+
+#[test]
+fn api_schema_default_output_is_a_short_summary() {
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["api", "schema"])
+ .output()
+ .unwrap();
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(stdout.contains("Herdr API schema"), "stdout: {stdout}");
+ assert!(
+ stdout.contains("Use `herdr api schema --json`"),
+ "stdout: {stdout}"
+ );
+ assert!(
+ stdout.len() < 400,
+ "summary should stay small enough for terminal output: {stdout}"
+ );
+}
+
+#[test]
+fn api_schema_json_prints_bundled_schema() {
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["api", "schema", "--json"])
+ .output()
+ .unwrap();
+
+ assert!(output.status.success());
+ let schema: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert!(schema
+ .get("protocol")
+ .and_then(serde_json::Value::as_u64)
+ .is_some_and(|protocol| protocol > 0));
+ assert_eq!(
+ schema
+ .get("schemas")
+ .and_then(serde_json::Value::as_object)
+ .map(serde_json::Map::len),
+ Some(5)
+ );
+}
+
+#[test]
+fn api_snapshot_prints_live_session_snapshot() {
+ 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({
+ let socket_path = socket_path.clone();
+ move || {
+ let (mut stream, line) = accept_fake_cli_operation(&listener);
+ let request: serde_json::Value = serde_json::from_str(&line).unwrap();
+ assert_eq!(request["method"], "session.snapshot");
+ assert_eq!(request["id"], "cli:api:snapshot");
+
+ let response = serde_json::json!({
+ "id": "cli:api:snapshot",
+ "result": {
+ "type": "ok",
+ "marker": "snapshot-passthrough"
+ }
+ });
+ writeln!(stream, "{response}").unwrap();
+ stream.flush().unwrap();
+ let _ = fs::remove_file(socket_path);
+ }
+ });
+
+ let value = run_cli_json(&socket_path, &["api", "snapshot"]);
+
+ assert_eq!(value["result"]["marker"], "snapshot-passthrough");
+ server.join().unwrap();
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn api_schema_output_writes_bundled_schema_to_file() {
+ let base = unique_test_dir();
+ fs::create_dir_all(&base).unwrap();
+ let schema_path = base.join("herdr-api.schema.json");
+
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["api", "schema", "--output"])
+ .arg(&schema_path)
+ .output()
+ .unwrap();
+
+ assert!(output.status.success());
+ assert!(
+ String::from_utf8_lossy(&output.stdout).contains("wrote API schema"),
+ "stdout: {}",
+ String::from_utf8_lossy(&output.stdout)
+ );
+ let schema: serde_json::Value =
+ serde_json::from_slice(&fs::read(&schema_path).unwrap()).unwrap();
+ assert!(schema
+ .get("protocol")
+ .and_then(serde_json::Value::as_u64)
+ .is_some_and(|protocol| protocol > 0));
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn explicit_client_command_respects_nested_guard() {
+ let base = unique_test_dir();
+ fs::create_dir_all(&base).unwrap();
+
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .arg("client")
+ .env("HERDR_ENV", "1")
+ .env("XDG_CONFIG_HOME", &base)
+ .env_remove("HERDR_CONFIG_PATH")
+ .output()
+ .unwrap();
+
+ cleanup_test_base(&base);
+
+ assert_eq!(output.status.code(), Some(1));
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("nested herdr is disabled by default"),
+ "client should fail at the nested guard before connecting: {stderr}"
+ );
+}
+
+#[test]
+fn removed_show_changelog_flag_fails_before_nested_guard() {
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .arg("--show-changelog")
+ .env("HERDR_ENV", "1")
+ .output()
+ .unwrap();
+
+ assert_eq!(output.status.code(), Some(2));
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("unknown option: --show-changelog"),
+ "stderr: {stderr}"
+ );
+ assert!(
+ !stderr.contains("nested herdr"),
+ "unknown flag should be rejected before nested guard: {stderr}"
+ );
+}
diff --git a/tests/cli/workspace.rs b/tests/cli/workspace.rs
new file mode 100644
index 00000000..8c435b2d
--- /dev/null
+++ b/tests/cli/workspace.rs
@@ -0,0 +1,663 @@
+use super::harness::*;
+
+#[test]
+fn workspace_and_pane_management_commands_work() {
+ 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 reloaded = run_cli(&socket_path, &["server", "reload-config"]);
+ assert!(
+ reloaded.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&reloaded.stderr)
+ );
+ let reload_json: serde_json::Value = serde_json::from_slice(&reloaded.stdout).unwrap();
+ assert_eq!(reload_json["result"]["type"], "config_reload");
+ assert_eq!(reload_json["result"]["status"], "applied");
+
+ let listed = run_cli(&socket_path, &["workspace", "list"]);
+ assert!(listed.status.success());
+ let listed_json: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap();
+ assert_eq!(listed_json["result"]["type"], "workspace_list");
+ assert_eq!(
+ listed_json["result"]["workspaces"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 0
+ );
+
+ let created = run_cli(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ assert!(created.status.success());
+ let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
+ let workspace_id = created_json["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let panes = run_cli(&socket_path, &["pane", "list", "--workspace", "1"]);
+ assert!(panes.status.success());
+ let panes_json: serde_json::Value = serde_json::from_slice(&panes.stdout).unwrap();
+ assert_eq!(panes_json["result"]["panes"].as_array().unwrap().len(), 1);
+
+ let split = run_cli(
+ &socket_path,
+ &["pane", "split", "1-1", "--direction", "right"],
+ );
+ assert!(
+ split.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&split.stderr)
+ );
+ let split_json: serde_json::Value = serde_json::from_slice(&split.stdout).unwrap();
+ let split_pane_id = split_json["result"]["pane"]["pane_id"].as_str().unwrap();
+
+ let fetched = run_cli(&socket_path, &["pane", "get", split_pane_id]);
+ assert!(fetched.status.success());
+ let fetched_json: serde_json::Value = serde_json::from_slice(&fetched.stdout).unwrap();
+ assert_eq!(fetched_json["result"]["pane"]["pane_id"], split_pane_id);
+
+ let closed = run_cli(&socket_path, &["pane", "close", split_pane_id]);
+ assert!(closed.status.success());
+ let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap();
+ assert_eq!(closed_json["result"]["type"], "ok");
+
+ let renamed = run_cli(
+ &socket_path,
+ &["workspace", "rename", &workspace_id, "demo"],
+ );
+ assert!(renamed.status.success());
+ let renamed_json: serde_json::Value = serde_json::from_slice(&renamed.stdout).unwrap();
+ assert_eq!(renamed_json["result"]["workspace"]["label"], "demo");
+
+ let focused = run_cli(&socket_path, &["workspace", "focus", &workspace_id]);
+ assert!(focused.status.success());
+
+ let closed_workspace = run_cli(&socket_path, &["workspace", "close", &workspace_id]);
+ assert!(closed_workspace.status.success());
+ let closed_workspace_json: serde_json::Value =
+ serde_json::from_slice(&closed_workspace.stdout).unwrap();
+ assert_eq!(closed_workspace_json["result"]["type"], "ok");
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn worktree_management_commands_work() {
+ 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 repo = base.join("repo");
+ let checkout = base.join("checkout");
+ create_committed_repo(&repo);
+
+ let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+
+ let branch = "worktree/cli-wrapper";
+ let created = run_cli_json(
+ &socket_path,
+ &[
+ "worktree",
+ "create",
+ "--cwd",
+ repo.to_str().unwrap(),
+ "--branch",
+ branch,
+ "--path",
+ checkout.to_str().unwrap(),
+ "--json",
+ ],
+ );
+ assert_eq!(created["result"]["type"], "worktree_created");
+ assert_eq!(created["result"]["worktree"]["branch"], branch);
+ let child_workspace_id = created["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ assert!(checkout.join("README.md").exists());
+
+ let workspaces = run_cli_json(&socket_path, &["workspace", "list"]);
+ let workspace_list = workspaces["result"]["workspaces"].as_array().unwrap();
+ let parent_workspace_id = workspace_list
+ .iter()
+ .find(|workspace| workspace["worktree"]["is_linked_worktree"].as_bool() == Some(false))
+ .and_then(|workspace| workspace["workspace_id"].as_str())
+ .unwrap()
+ .to_string();
+ assert!(workspace_list.iter().any(|workspace| {
+ workspace["workspace_id"].as_str() == Some(child_workspace_id.as_str())
+ && workspace["worktree"]["is_linked_worktree"].as_bool() == Some(true)
+ }));
+
+ let listed = run_cli_json(
+ &socket_path,
+ &[
+ "worktree",
+ "list",
+ "--workspace",
+ &parent_workspace_id,
+ "--json",
+ ],
+ );
+ let listed_entry = listed["result"]["worktrees"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .find(|entry| entry["branch"].as_str() == Some(branch))
+ .unwrap();
+ assert_eq!(
+ listed_entry["open_workspace_id"].as_str(),
+ Some(child_workspace_id.as_str())
+ );
+
+ let opened = run_cli_json(
+ &socket_path,
+ &[
+ "worktree",
+ "open",
+ "--workspace",
+ &parent_workspace_id,
+ "--branch",
+ branch,
+ "--json",
+ ],
+ );
+ assert_eq!(opened["result"]["type"], "worktree_opened");
+ assert_eq!(opened["result"]["already_open"], true);
+ assert_eq!(
+ opened["result"]["workspace"]["workspace_id"].as_str(),
+ Some(child_workspace_id.as_str())
+ );
+
+ fs::write(checkout.join("README.md"), "dirty\n").unwrap();
+ let safe_remove = run_cli(
+ &socket_path,
+ &[
+ "worktree",
+ "remove",
+ "--workspace",
+ &child_workspace_id,
+ "--json",
+ ],
+ );
+ assert_eq!(safe_remove.status.code(), Some(1));
+ let safe_remove_json: serde_json::Value = serde_json::from_slice(&safe_remove.stderr).unwrap();
+ assert_eq!(
+ safe_remove_json["error"]["code"],
+ "dirty_worktree_requires_force"
+ );
+ assert!(checkout.exists());
+
+ let force_removed = run_cli_json(
+ &socket_path,
+ &[
+ "worktree",
+ "remove",
+ "--workspace",
+ &child_workspace_id,
+ "--force",
+ "--json",
+ ],
+ );
+ assert_eq!(force_removed["result"]["type"], "worktree_removed");
+ assert_eq!(force_removed["result"]["forced"], true);
+ assert!(!checkout.exists());
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn forced_worktree_remove_terminates_processes_inside_checkout() {
+ 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 repo = base.join("repo");
+ let checkout = base.join("checkout-with-process");
+ create_committed_repo(&repo);
+
+ 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,
+ &[
+ "worktree",
+ "create",
+ "--cwd",
+ repo.to_str().unwrap(),
+ "--branch",
+ "worktree/force-process",
+ "--path",
+ checkout.to_str().unwrap(),
+ "--json",
+ ],
+ );
+ let child_workspace_id = created["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let pid_file = base.join("worktree-remove-force.pid");
+ let command = format!(
+ "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'",
+ pid_file.display()
+ );
+ let ran = run_cli(&socket_path, &["pane", "run", &pane_id, &command]);
+ assert!(
+ ran.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&ran.stderr)
+ );
+ let pid = wait_for_pid_file(&pid_file, Duration::from_secs(5)).unwrap_or_else(|err| {
+ panic!("failed to read pane child pid: {err}");
+ });
+ assert!(process_exists(pid), "child process was not running");
+
+ let removed = run_cli_json(
+ &socket_path,
+ &[
+ "worktree",
+ "remove",
+ "--workspace",
+ &child_workspace_id,
+ "--force",
+ "--json",
+ ],
+ );
+ assert_eq!(removed["result"]["type"], "worktree_removed");
+ assert!(wait_for_pid_exit(pid, Duration::from_secs(3)));
+ assert!(!checkout.exists());
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn worktree_open_existing_checkout_by_path_and_branch() {
+ 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 repo = base.join("repo");
+ let checkout = base.join("external-checkout");
+ create_committed_repo(&repo);
+ let branch = "worktree/cli-open-existing";
+ run_git(
+ &repo,
+ &[
+ "worktree",
+ "add",
+ "--quiet",
+ "-b",
+ branch,
+ checkout.to_str().unwrap(),
+ "HEAD",
+ ],
+ );
+
+ let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+
+ let opened = run_cli_json_in_dir(
+ &socket_path,
+ &[
+ "worktree",
+ "open",
+ "--cwd",
+ "repo",
+ "--path",
+ "external-checkout",
+ "--json",
+ ],
+ &base,
+ );
+ assert_eq!(opened["result"]["type"], "worktree_opened");
+ assert_eq!(opened["result"]["already_open"], false);
+ assert_eq!(opened["result"]["worktree"]["branch"], branch);
+ assert_eq!(
+ opened["result"]["workspace"]["worktree"]["is_linked_worktree"],
+ true
+ );
+ let child_workspace_id = opened["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let workspaces = run_cli_json(&socket_path, &["workspace", "list"]);
+ let workspace_list = workspaces["result"]["workspaces"].as_array().unwrap();
+ let parent_workspace_id = workspace_list
+ .iter()
+ .find(|workspace| workspace["worktree"]["is_linked_worktree"].as_bool() == Some(false))
+ .and_then(|workspace| workspace["workspace_id"].as_str())
+ .unwrap()
+ .to_string();
+
+ let listed = run_cli_json(
+ &socket_path,
+ &[
+ "worktree",
+ "list",
+ "--workspace",
+ &parent_workspace_id,
+ "--json",
+ ],
+ );
+ let listed_entry = listed["result"]["worktrees"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .find(|entry| entry["branch"].as_str() == Some(branch))
+ .unwrap();
+ assert_eq!(
+ listed_entry["open_workspace_id"].as_str(),
+ Some(child_workspace_id.as_str())
+ );
+
+ let reopened = run_cli_json(
+ &socket_path,
+ &[
+ "worktree",
+ "open",
+ "--workspace",
+ &parent_workspace_id,
+ "--branch",
+ branch,
+ "--json",
+ ],
+ );
+ assert_eq!(reopened["result"]["type"], "worktree_opened");
+ assert_eq!(reopened["result"]["already_open"], true);
+ assert_eq!(
+ reopened["result"]["workspace"]["workspace_id"].as_str(),
+ Some(child_workspace_id.as_str())
+ );
+
+ let removed = run_cli_json(
+ &socket_path,
+ &[
+ "worktree",
+ "remove",
+ "--workspace",
+ &child_workspace_id,
+ "--force",
+ "--json",
+ ],
+ );
+ assert_eq!(removed["result"]["type"], "worktree_removed");
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn config_check_reports_invalid_config_without_server() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let config_dir = config_home.join(app_dir_name());
+ fs::create_dir_all(&config_dir).unwrap();
+ fs::write(
+ config_dir.join("config.toml"),
+ "[keys\nnew_workspace = \"g\"\n",
+ )
+ .unwrap();
+
+ let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]);
+
+ assert_eq!(checked.status.code(), Some(1));
+ assert!(
+ checked.stderr.is_empty(),
+ "stderr: {}",
+ String::from_utf8_lossy(&checked.stderr)
+ );
+ let stdout = String::from_utf8_lossy(&checked.stdout);
+ assert!(stdout.contains("config: issues found"), "{stdout}");
+ assert!(stdout.contains("TOML parse error"), "{stdout}");
+ assert!(stdout.contains("line 1"), "{stdout}");
+
+ fs::write(
+ config_dir.join("config.toml"),
+ "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\n",
+ )
+ .unwrap();
+ let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]);
+ let stdout = String::from_utf8_lossy(&checked.stdout);
+ assert_eq!(checked.status.code(), Some(1));
+ assert!(stdout.contains("sidebar_min_width (50)"), "{stdout}");
+ assert!(stdout.contains("sidebar_max_width (30)"), "{stdout}");
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn config_check_reports_unknown_keys_without_treating_comments_as_keys() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let config_dir = config_home.join(app_dir_name());
+ fs::create_dir_all(&config_dir).unwrap();
+ fs::write(
+ config_dir.join("config.toml"),
+ r#"made_up_toplevel = 1
+"made.up" = 2
+# commented_out_toplevel = true
+
+[ui]
+mouse_capture = true
+mouse_captur = false
+# commented_out_ui_key = true
+
+[keys]
+new_tabb = "ctrl+t"
+"#,
+ )
+ .unwrap();
+
+ let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]);
+
+ assert_eq!(checked.status.code(), Some(1));
+ assert!(
+ checked.stderr.is_empty(),
+ "stderr: {}",
+ String::from_utf8_lossy(&checked.stderr)
+ );
+ let stdout = String::from_utf8_lossy(&checked.stdout);
+ assert!(stdout.contains("config: issues found"), "{stdout}");
+ assert!(
+ stdout.contains("unknown config key made_up_toplevel; ignoring key"),
+ "{stdout}"
+ );
+ assert!(
+ stdout.contains("unknown config key \"made.up\"; ignoring key"),
+ "{stdout}"
+ );
+ assert!(
+ stdout.contains("unknown config key ui.mouse_captur; ignoring key"),
+ "{stdout}"
+ );
+ assert!(
+ stdout.contains("unknown config key keys.new_tabb; ignoring key"),
+ "{stdout}"
+ );
+ assert!(!stdout.contains("commented_out"), "{stdout}");
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn config_check_reports_unreadable_config_path() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let config_path = config_home.join(app_dir_name()).join("config.toml");
+ fs::create_dir_all(&config_path).unwrap();
+
+ let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]);
+
+ assert_eq!(checked.status.code(), Some(1));
+ let stdout = String::from_utf8_lossy(&checked.stdout);
+ assert!(stdout.contains("config: issues found"), "{stdout}");
+ assert!(stdout.contains("config read error"), "{stdout}");
+ assert!(stdout.contains("using defaults"), "{stdout}");
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn config_check_reports_ok_when_config_is_missing() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+
+ let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]);
+
+ assert!(checked.status.success());
+ let stdout = String::from_utf8_lossy(&checked.stdout);
+ assert!(stdout.contains("config: ok"), "{stdout}");
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn config_check_rejects_json_output() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+
+ let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check", "--json"]);
+
+ assert_eq!(checked.status.code(), Some(2));
+ assert!(checked.stdout.is_empty());
+ assert_eq!(
+ String::from_utf8_lossy(&checked.stderr),
+ "usage: herdr config check\n"
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn worktree_cli_rejects_local_argument_errors_before_socket_use() {
+ let base = unique_test_dir();
+ fs::create_dir_all(&base).unwrap();
+ let socket_path = base.join("missing.sock");
+ let cases: &[&[&str]] = &[
+ &["worktree", "list", "--workspace", "1", "--cwd", "/tmp"],
+ &["worktree", "create", "--workspace", "1", "--cwd", "/tmp"],
+ &["worktree", "open", "--workspace", "1"],
+ &[
+ "worktree",
+ "open",
+ "--workspace",
+ "1",
+ "--path",
+ "a",
+ "--branch",
+ "b",
+ ],
+ &[
+ "worktree",
+ "open",
+ "--workspace",
+ "1",
+ "--cwd",
+ "/tmp",
+ "--branch",
+ "b",
+ ],
+ ];
+
+ for args in cases {
+ let output = run_cli(&socket_path, args);
+ assert_eq!(
+ output.status.code(),
+ Some(2),
+ "herdr {} should fail as local parse error; stdout={} stderr={}",
+ args.join(" "),
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ }
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn tab_management_commands_work() {
+ 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(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ assert!(created.status.success());
+ let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
+ let workspace_id = created_json["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let first_tab_id = created_json["result"]["workspace"]["active_tab_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let created_tab = run_cli(
+ &socket_path,
+ &["tab", "create", "--workspace", &workspace_id],
+ );
+ assert!(created_tab.status.success());
+ let created_tab_json: serde_json::Value = serde_json::from_slice(&created_tab.stdout).unwrap();
+ let second_tab_id = created_tab_json["result"]["tab"]["tab_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ assert_eq!(second_tab_id, format!("{workspace_id}:t2"));
+
+ let listed_tabs = run_cli(&socket_path, &["tab", "list", "--workspace", &workspace_id]);
+ assert!(listed_tabs.status.success());
+ let listed_tabs_json: serde_json::Value = serde_json::from_slice(&listed_tabs.stdout).unwrap();
+ assert_eq!(
+ listed_tabs_json["result"]["tabs"].as_array().unwrap().len(),
+ 2
+ );
+
+ let renamed_tab = run_cli(&socket_path, &["tab", "rename", &second_tab_id, "logs"]);
+ assert!(renamed_tab.status.success());
+ let renamed_tab_json: serde_json::Value = serde_json::from_slice(&renamed_tab.stdout).unwrap();
+ assert_eq!(renamed_tab_json["result"]["tab"]["label"], "logs");
+
+ let focused_tab = run_cli(&socket_path, &["tab", "focus", &first_tab_id]);
+ assert!(focused_tab.status.success());
+ let focused_tab_json: serde_json::Value = serde_json::from_slice(&focused_tab.stdout).unwrap();
+ assert_eq!(focused_tab_json["result"]["tab"]["tab_id"], first_tab_id);
+
+ let tab_get = run_cli(&socket_path, &["tab", "get", &second_tab_id]);
+ assert!(tab_get.status.success());
+ let tab_get_json: serde_json::Value = serde_json::from_slice(&tab_get.stdout).unwrap();
+ assert_eq!(tab_get_json["result"]["tab"]["tab_id"], second_tab_id);
+
+ let closed_tab = run_cli(&socket_path, &["tab", "close", &second_tab_id]);
+ assert!(closed_tab.status.success());
+ let closed_tab_json: serde_json::Value = serde_json::from_slice(&closed_tab.stdout).unwrap();
+ assert_eq!(closed_tab_json["result"]["type"], "ok");
+
+ cleanup_spawned_herdr(herdr, base);
+}
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
deleted file mode 100644
index d218c230..00000000
--- a/tests/cli_wrapper.rs
+++ /dev/null
@@ -1,5651 +0,0 @@
-#![cfg(not(target_os = "macos"))]
-
-mod support;
-
-use std::fs;
-use std::io::{BufRead, BufReader, Write};
-use std::os::unix::net::{UnixListener, UnixStream};
-use std::path::{Path, PathBuf};
-use std::process::{Command, Stdio};
-use std::thread;
-use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
-
-use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
-use support::{
- cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
- unregister_spawned_herdr_pid, CURRENT_PROTOCOL,
-};
-
-const WORKTREE_BOOTSTRAP_MANAGED_COMPONENT: &str = "example.worktree-bootstrap-ef876653ffc3";
-
-fn unique_test_dir() -> PathBuf {
- let nanos = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map(|d| d.as_nanos())
- .unwrap_or(0);
- PathBuf::from(format!("/tmp/hcli-{}-{nanos}", std::process::id()))
-}
-
-fn managed_github_plugin_dir(config_home: &Path) -> PathBuf {
- config_home.join("herdr-dev").join("plugins").join("github")
-}
-
-fn path_missing_or_empty(path: &Path) -> bool {
- match fs::read_dir(path) {
- Ok(mut entries) => entries.next().is_none(),
- Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
- Err(err) => panic!("failed to read {}: {err}", path.display()),
- }
-}
-
-fn run_git(repo: &Path, args: &[&str]) {
- let status = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(args)
- .status()
- .unwrap();
- assert!(
- status.success(),
- "git command failed: git -C {} {}",
- repo.display(),
- args.join(" ")
- );
-}
-
-fn create_committed_repo(path: &Path) {
- fs::create_dir_all(path).unwrap();
- run_git(path, &["init", "--quiet"]);
- run_git(path, &["config", "user.email", "herdr@example.invalid"]);
- run_git(path, &["config", "user.name", "Herdr Test"]);
- fs::write(path.join("README.md"), "test\n").unwrap();
- run_git(path, &["add", "README.md"]);
- run_git(path, &["commit", "--quiet", "-m", "initial"]);
-}
-
-struct SpawnedHerdr {
- _master: Box<dyn MasterPty + Send>,
- child: Box<dyn Child + Send + Sync>,
-}
-
-struct SpawnedServerProcess {
- child: std::process::Child,
-}
-
-impl Drop for SpawnedServerProcess {
- fn drop(&mut self) {
- let pid = self.child.id();
- let _ = self.child.kill();
- let _ = self.child.wait();
- unregister_spawned_herdr_pid(Some(pid));
- }
-}
-
-impl Drop for SpawnedHerdr {
- fn drop(&mut self) {
- let pid = self.child.process_id();
- let _ = self.child.kill();
-
- if let Some(pid) = pid {
- let deadline = Instant::now() + Duration::from_secs(2);
- while Instant::now() < deadline {
- let mut status = 0;
- let result =
- unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) };
- if result == pid as libc::pid_t || result == -1 {
- break;
- }
- thread::sleep(Duration::from_millis(20));
- }
-
- unregister_spawned_herdr_pid(Some(pid));
- }
- }
-}
-
-fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) {
- drop(spawned);
- cleanup_test_base(&base);
-}
-
-fn wait_for_socket(path: &Path, timeout: Duration) {
- let deadline = Instant::now() + timeout;
- while Instant::now() < deadline {
- if path.exists() && std::os::unix::net::UnixStream::connect(path).is_ok() {
- return;
- }
- thread::sleep(Duration::from_millis(25));
- }
- panic!("socket did not appear at {}", path.display());
-}
-
-fn spawn_herdr(config_home: &Path, runtime_dir: &Path, socket_path: &Path) -> SpawnedHerdr {
- spawn_herdr_with_config(
- config_home,
- runtime_dir,
- socket_path,
- None,
- "onboarding = false\n",
- )
-}
-
-fn spawn_herdr_with_pane_history(
- config_home: &Path,
- runtime_dir: &Path,
- socket_path: &Path,
-) -> SpawnedHerdr {
- spawn_herdr_with_config(
- config_home,
- runtime_dir,
- socket_path,
- None,
- "onboarding = false\n[experimental]\npane_history = true\n",
- )
-}
-
-fn app_dir_name() -> &'static str {
- if cfg!(debug_assertions) {
- "herdr-dev"
- } else {
- "herdr"
- }
-}
-
-fn named_session_socket(config_home: &Path, session: &str) -> PathBuf {
- config_home
- .join(app_dir_name())
- .join("sessions")
- .join(session)
- .join("herdr.sock")
-}
-
-fn spawn_named_server(
- config_home: &Path,
- runtime_dir: &Path,
- session: &str,
-) -> SpawnedServerProcess {
- fs::create_dir_all(config_home.join(app_dir_name())).unwrap();
- fs::create_dir_all(runtime_dir).unwrap();
- register_runtime_dir(runtime_dir);
- fs::write(
- config_home.join(app_dir_name()).join("config.toml"),
- "onboarding = false\n",
- )
- .unwrap();
-
- let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
- command
- .args(["--session", session, "server"])
- .env("XDG_CONFIG_HOME", config_home)
- .env("XDG_RUNTIME_DIR", runtime_dir)
- .env_remove("HERDR_SOCKET_PATH")
- .env_remove("HERDR_CLIENT_SOCKET_PATH")
- .env_remove("HERDR_ENV")
- .stdin(std::process::Stdio::null())
- .stdout(std::process::Stdio::null())
- .stderr(std::process::Stdio::null());
-
- let child = command.spawn().unwrap();
- register_spawned_herdr_pid(Some(child.id()));
- SpawnedServerProcess { child }
-}
-
-fn run_named_cli(config_home: &Path, runtime_dir: &Path, args: &[&str]) -> std::process::Output {
- run_named_cli_with_socket_override(config_home, runtime_dir, args, None)
-}
-
-fn run_named_cli_with_socket_override(
- config_home: &Path,
- runtime_dir: &Path,
- args: &[&str],
- socket_override: Option<&Path>,
-) -> std::process::Output {
- run_named_cli_with_env_and_socket_override(config_home, runtime_dir, args, &[], socket_override)
-}
-
-fn run_named_cli_with_env(
- config_home: &Path,
- runtime_dir: &Path,
- args: &[&str],
- envs: &[(&str, &Path)],
-) -> std::process::Output {
- run_named_cli_with_env_and_socket_override(config_home, runtime_dir, args, envs, None)
-}
-
-fn run_named_cli_with_env_and_socket_override(
- config_home: &Path,
- runtime_dir: &Path,
- args: &[&str],
- envs: &[(&str, &Path)],
- socket_override: Option<&Path>,
-) -> std::process::Output {
- let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
- command
- .args(args)
- .env("XDG_CONFIG_HOME", config_home)
- .env("XDG_RUNTIME_DIR", runtime_dir)
- .env_remove("HERDR_CLIENT_SOCKET_PATH")
- .env_remove("HERDR_ENV");
- for (key, value) in envs {
- command.env(key, value);
- }
- if let Some(socket_override) = socket_override {
- command.env("HERDR_SOCKET_PATH", socket_override);
- } else {
- command.env_remove("HERDR_SOCKET_PATH");
- }
- command.output().unwrap()
-}
-
-fn run_named_cli_json(config_home: &Path, runtime_dir: &Path, args: &[&str]) -> serde_json::Value {
- let output = run_named_cli(config_home, runtime_dir, args);
- assert!(
- output.status.success(),
- "command failed: herdr {}\nstatus: {:?}\nstderr: {}\nstdout: {}",
- args.join(" "),
- output.status.code(),
- String::from_utf8_lossy(&output.stderr),
- String::from_utf8_lossy(&output.stdout)
- );
- serde_json::from_slice(&output.stdout).unwrap()
-}
-
-fn spawn_herdr_with_path(
- config_home: &Path,
- runtime_dir: &Path,
- socket_path: &Path,
- path_override: Option<&Path>,
-) -> SpawnedHerdr {
- spawn_herdr_with_config(
- config_home,
- runtime_dir,
- socket_path,
- path_override,
- "onboarding = false\n",
- )
-}
-
-fn spawn_herdr_with_config(
- config_home: &Path,
- runtime_dir: &Path,
- socket_path: &Path,
- path_override: Option<&Path>,
- config_toml: &str,
-) -> SpawnedHerdr {
- fs::create_dir_all(config_home.join(app_dir_name())).unwrap();
- fs::create_dir_all(runtime_dir).unwrap();
- register_runtime_dir(runtime_dir);
- fs::write(
- config_home.join(app_dir_name()).join("config.toml"),
- config_toml,
- )
- .unwrap();
-
- let pair = native_pty_system()
- .openpty(PtySize {
- rows: 24,
- cols: 80,
- pixel_width: 0,
- pixel_height: 0,
- })
- .unwrap();
-
- let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
- cmd.arg("server");
- cmd.env("XDG_CONFIG_HOME", config_home);
- cmd.env("XDG_RUNTIME_DIR", runtime_dir);
- cmd.env("HERDR_SOCKET_PATH", socket_path);
- cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
- cmd.env("SHELL", "/bin/sh");
- cmd.env_remove("HERDR_ENV");
- if let Some(path) = path_override {
- cmd.env("PATH", path);
- }
-
- let child = pair.slave.spawn_command(cmd).unwrap();
- register_spawned_herdr_pid(child.process_id());
- SpawnedHerdr {
- _master: pair.master,
- child,
- }
-}
-
-fn run_cli(socket_path: &Path, args: &[&str]) -> std::process::Output {
- let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
- command.args(args);
- command.env("HERDR_SOCKET_PATH", socket_path);
- command.output().unwrap()
-}
-
-fn run_cli_in_dir(socket_path: &Path, args: &[&str], current_dir: &Path) -> std::process::Output {
- let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
- command.args(args);
- command.current_dir(current_dir);
- command.env("HERDR_SOCKET_PATH", socket_path);
- 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)
-}
-
-fn run_cli_json_in_dir(socket_path: &Path, args: &[&str], current_dir: &Path) -> serde_json::Value {
- let output = run_cli_in_dir(socket_path, args, current_dir);
- parse_cli_json_output(args, output)
-}
-
-fn parse_cli_json_output(args: &[&str], output: std::process::Output) -> serde_json::Value {
- assert!(
- output.status.success(),
- "command failed: herdr {}\nstatus: {:?}\nstderr: {}\nstdout: {}",
- args.join(" "),
- output.status.code(),
- String::from_utf8_lossy(&output.stderr),
- String::from_utf8_lossy(&output.stdout)
- );
-
- serde_json::from_slice(&output.stdout).unwrap_or_else(|err| {
- panic!(
- "failed to parse JSON response for `herdr {}`: {}\nstdout: {}\nstderr: {}",
- args.join(" "),
- err,
- String::from_utf8_lossy(&output.stdout),
- String::from_utf8_lossy(&output.stderr)
- )
- })
-}
-
-fn wait_until(timeout: Duration, interval: Duration, mut condition: impl FnMut() -> bool) -> bool {
- let deadline = Instant::now() + timeout;
- while Instant::now() < deadline {
- if condition() {
- return true;
- }
- thread::sleep(interval);
- }
- false
-}
-
-fn pane_read_recent_contains(socket_path: &Path, pane_id: &str, expected: &str) -> bool {
- let output = run_cli(
- socket_path,
- &["pane", "read", pane_id, "--source", "recent"],
- );
- if !output.status.success() {
- return false;
- }
- String::from_utf8_lossy(&output.stdout).contains(expected)
-}
-
-fn process_exists(pid: u32) -> bool {
- let result = unsafe { libc::kill(pid as i32, 0) };
- if result == 0 {
- true
- } else {
- std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
- }
-}
-
-fn wait_for_pid_exit(pid: u32, timeout: Duration) -> bool {
- let deadline = Instant::now() + timeout;
- while Instant::now() < deadline {
- if !process_exists(pid) {
- return true;
- }
- thread::sleep(Duration::from_millis(25));
- }
- !process_exists(pid)
-}
-
-fn wait_for_pid_file(pid_file: &Path, timeout: Duration) -> Result<u32, String> {
- const STABLE_PID_CONTENT_WINDOW: Duration = Duration::from_millis(250);
-
- let deadline = Instant::now() + timeout;
- let mut last_contents = String::new();
- let mut stable_candidate: Option<(String, u32, Instant)> = None;
-
- while Instant::now() < deadline {
- if let Ok(contents) = fs::read_to_string(pid_file) {
- let trimmed = contents.trim().to_string();
- last_contents = contents;
-
- if let Ok(pid) = trimmed.parse::<u32>() {
- match &stable_candidate {
- Some((candidate_text, candidate_pid, stable_since))
- if candidate_text == &trimmed && *candidate_pid == pid =>
- {
- if stable_since.elapsed() >= STABLE_PID_CONTENT_WINDOW {
- return Ok(pid);
- }
- }
- _ => {
- stable_candidate = Some((trimmed, pid, Instant::now()));
- }
- }
- } else {
- stable_candidate = None;
- }
- }
-
- thread::sleep(Duration::from_millis(25));
- }
-
- Err(format!(
- "pid file {} did not contain stable parseable pid before timeout; last contents={:?}",
- pid_file.display(),
- last_contents
- ))
-}
-
-#[test]
-fn wait_for_pid_file_retries_until_pid_is_written() {
- let base = unique_test_dir();
- fs::create_dir_all(&base).unwrap();
- let pid_file = base.join("delayed.pid");
- fs::write(&pid_file, "").unwrap();
-
- let writer = thread::spawn({
- let pid_file = pid_file.clone();
- move || {
- thread::sleep(Duration::from_millis(100));
- fs::write(pid_file, "424242\n").unwrap();
- }
- });
-
- let pid = wait_for_pid_file(&pid_file, Duration::from_secs(2)).unwrap();
- assert_eq!(pid, 424242);
-
- writer.join().unwrap();
- cleanup_test_base(&base);
-}
-
-#[test]
-fn wait_for_pid_file_errors_when_file_never_contains_pid() {
- let base = unique_test_dir();
- fs::create_dir_all(&base).unwrap();
- let pid_file = base.join("empty.pid");
- fs::write(&pid_file, "").unwrap();
-
- let err = wait_for_pid_file(&pid_file, Duration::from_millis(150)).unwrap_err();
- assert!(
- err.contains("did not contain stable parseable pid"),
- "unexpected error: {err}"
- );
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn wait_for_pid_file_rejects_unparseable_partial_write_until_stable_contents() {
- let base = unique_test_dir();
- fs::create_dir_all(&base).unwrap();
- let pid_file = base.join("partial-race.pid");
- fs::write(&pid_file, "").unwrap();
-
- let writer = thread::spawn({
- let pid_file = pid_file.clone();
- move || {
- thread::sleep(Duration::from_millis(40));
- fs::write(&pid_file, "pid=").unwrap();
- thread::sleep(Duration::from_millis(40));
- fs::write(&pid_file, "pid=424242").unwrap();
- thread::sleep(Duration::from_millis(40));
- fs::write(&pid_file, "424242\n").unwrap();
- }
- });
-
- let start = Instant::now();
- let pid = wait_for_pid_file(&pid_file, Duration::from_secs(2)).unwrap();
- assert_eq!(pid, 424242);
- assert!(
- start.elapsed() >= Duration::from_millis(300),
- "helper should wait for stable complete contents, elapsed={:?}",
- start.elapsed()
- );
-
- writer.join().unwrap();
- cleanup_test_base(&base);
-}
-
-fn send_request(socket_path: &Path, json: &str) -> serde_json::Value {
- let mut stream = UnixStream::connect(socket_path).unwrap();
- stream.write_all(json.as_bytes()).unwrap();
- stream.write_all(b"\n").unwrap();
- stream.flush().unwrap();
-
- let mut line = String::new();
- let mut reader = BufReader::new(stream);
- reader.read_line(&mut line).unwrap();
- serde_json::from_str(&line).unwrap()
-}
-
-fn write_fake_pong(
- stream: &mut UnixStream,
- request: &serde_json::Value,
- version: &str,
- protocol: u32,
-) {
- writeln!(
- stream,
- "{}",
- serde_json::json!({
- "id": request["id"],
- "result": {
- "type": "pong",
- "version": version,
- "protocol": protocol,
- "capabilities": {
- "live_handoff": true,
- "detached_server_daemon": true
- }
- }
- })
- )
- .unwrap();
- stream.flush().unwrap();
-}
-
-fn accept_fake_cli_operation(listener: &UnixListener) -> (UnixStream, String) {
- loop {
- let (mut stream, _) = listener.accept().unwrap();
- let mut line = String::new();
- let mut reader = BufReader::new(stream.try_clone().unwrap());
- reader.read_line(&mut line).unwrap();
- let request: serde_json::Value = serde_json::from_str(&line).unwrap();
- if request["method"] != "ping" {
- return (stream, line);
- }
-
- write_fake_pong(
- &mut stream,
- &request,
- "different-build-same-protocol",
- CURRENT_PROTOCOL,
- );
- }
-}
-
-fn run_claude_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> {
- run_shell_hook(
- "src/integration/assets/claude/herdr-agent-state.sh",
- &[action],
- hook_input,
- )
-}
-
-fn run_codex_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> {
- run_shell_hook(
- "src/integration/assets/codex/herdr-agent-state.sh",
- &[action],
- hook_input,
- )
-}
-
-fn run_copilot_hook(hook_input: &str) -> Option<serde_json::Value> {
- run_shell_hook(
- "src/integration/assets/copilot/herdr-agent-state.sh",
- &[],
- hook_input,
- )
-}
-
-fn run_devin_hook(
- action: &str,
- hook_input: &str,
- envs: &[(&str, &str)],
-) -> Option<serde_json::Value> {
- run_shell_hook_with_env(
- "src/integration/assets/devin/herdr-agent-state.sh",
- &[action],
- hook_input,
- envs,
- )
-}
-
-fn run_shell_hook(asset_path: &str, args: &[&str], hook_input: &str) -> Option<serde_json::Value> {
- run_shell_hook_with_env(asset_path, args, hook_input, &[])
-}
-
-fn run_shell_hook_with_env(
- asset_path: &str,
- args: &[&str],
- hook_input: &str,
- envs: &[(&str, &str)],
-) -> Option<serde_json::Value> {
- 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 || {
- listener.set_nonblocking(true).unwrap();
- let deadline = Instant::now() + Duration::from_millis(700);
- while Instant::now() < deadline {
- match listener.accept() {
- Ok((mut stream, _)) => {
- let mut line = String::new();
- let mut reader = BufReader::new(stream.try_clone().unwrap());
- reader.read_line(&mut line).unwrap();
- let _ = stream.write_all(br#"{"id":"test","result":{"type":"ok"}}"#);
- let _ = stream.write_all(b"\n");
- let _ = stream.flush();
- return Some(line);
- }
- Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
- thread::sleep(Duration::from_millis(10));
- }
- Err(err) => panic!("accept failed: {err}"),
- }
- }
- None
- });
-
- let hook_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(asset_path);
- let mut command = Command::new("bash");
- command
- .arg(hook_path)
- .args(args)
- .env("HERDR_ENV", "1")
- .env("HERDR_SOCKET_PATH", &socket_path)
- .env("HERDR_PANE_ID", "p_test")
- .stdin(Stdio::piped())
- .stdout(Stdio::piped())
- .stderr(Stdio::piped());
- for (key, value) in envs {
- command.env(key, value);
- }
- let mut child = command.spawn().unwrap();
- let mut stdin = child.stdin.take().unwrap();
- stdin.write_all(hook_input.as_bytes()).unwrap();
- drop(stdin);
-
- let output = child.wait_with_output().unwrap();
- assert!(
- output.status.success(),
- "hook failed: status={:?} stderr={} stdout={}",
- output.status.code(),
- String::from_utf8_lossy(&output.stderr),
- String::from_utf8_lossy(&output.stdout)
- );
-
- let request = server.join().unwrap();
- cleanup_test_base(&base);
- request.map(|line| serde_json::from_str(&line).unwrap())
-}
-
-#[test]
-fn claude_hook_ignores_state_actions() {
- let subagent_input = r#"{"hook_event_name":"Notification","agent_id":"agent-abc123","agent_type":"Explore","notification_type":"permission_prompt"}"#;
-
- assert!(run_claude_hook("working", subagent_input).is_none());
- assert!(run_claude_hook("blocked", subagent_input).is_none());
-}
-
-#[test]
-fn claude_hook_ignores_subagent_completion_reports() {
- let subagent_input =
- r#"{"hook_event_name":"SubagentStop","agent_id":"agent-abc123","agent_type":"Explore"}"#;
-
- assert!(run_claude_hook("working", subagent_input).is_none());
- assert!(run_claude_hook("idle", subagent_input).is_none());
- assert!(run_claude_hook("release", subagent_input).is_none());
-}
-
-#[test]
-fn claude_hook_keeps_parent_agent_type_only_blocked() {
- let request = run_claude_hook(
- "blocked",
- r#"{"hook_event_name":"PermissionRequest","agent_type":"Explore"}"#,
- );
-
- assert!(request.is_none());
-}
-
-#[test]
-fn claude_hook_reports_session_id_from_stdin() {
- let request = run_claude_hook(
- "session",
- r#"{"hook_event_name":"SessionStart","session_id":"claude-session"}"#,
- )
- .expect("session start should report session identity");
-
- assert_eq!(request["method"], "pane.report_agent_session");
- assert_eq!(request["params"]["agent_session_id"], "claude-session");
- assert!(request["params"].get("state").is_none());
-}
-
-#[test]
-fn codex_hook_reports_session_id_from_stdin() {
- let request = run_codex_hook(
- "session",
- r#"{"hook_event_name":"SessionStart","session_id":"codex-session"}"#,
- )
- .expect("codex hook should report session identity");
-
- assert_eq!(request["method"], "pane.report_agent_session");
- assert_eq!(request["params"]["agent_session_id"], "codex-session");
- assert!(request["params"].get("state").is_none());
-}
-
-#[test]
-fn copilot_hook_reports_session_id_from_stdin() {
- let request = run_copilot_hook(
- r#"{"hook_event_name":"SessionStart","session_id":"copilot-session","source":"resume"}"#,
- )
- .expect("copilot session start should report session identity");
-
- assert_eq!(request["method"], "pane.report_agent_session");
- assert_eq!(request["params"]["agent"], "copilot");
- assert_eq!(request["params"]["agent_session_id"], "copilot-session");
- assert!(request["params"].get("state").is_none());
-
- let camel = run_copilot_hook(
- r#"{"sessionId":"copilot-camel-session","source":"new","initialPrompt":"run tests"}"#,
- )
- .expect("copilot camelCase session start should report session identity");
-
- assert_eq!(camel["method"], "pane.report_agent_session");
- assert_eq!(camel["params"]["agent_session_id"], "copilot-camel-session");
- assert!(camel["params"].get("state").is_none());
-}
-
-#[test]
-fn copilot_hook_does_not_report_lifecycle_state() {
- for payload in [
- r#"{"hook_event_name":"UserPromptSubmit","session_id":"copilot-session","prompt":"run tests"}"#,
- r#"{"hook_event_name":"PreToolUse","session_id":"copilot-session","tool_name":"ask_user"}"#,
- r#"{"hook_event_name":"notification","session_id":"copilot-session","notification_type":"permission_prompt"}"#,
- r#"{"hook_event_name":"agentStop","session_id":"copilot-session","stop_reason":"end_turn"}"#,
- r#"{"hook_event_name":"SessionEnd","session_id":"copilot-session","reason":"user_exit"}"#,
- ] {
- assert!(
- run_copilot_hook(payload).is_none(),
- "copilot session-only hook should ignore lifecycle payload {payload}"
- );
- }
-}
-
-#[test]
-fn devin_hook_ignores_prompt_session_list_fallback() {
- let request = run_devin_hook(
- "session",
- r#"{"hook_event_name":"UserPromptSubmit","prompt":"run tests"}"#,
- &[
- ("DEVIN_PROJECT_DIR", "/tmp/project"),
- (
- "HERDR_DEVIN_LIST_JSON",
- r#"[{"id":"older-session","working_directory":"/tmp/other"},{"id":"devin-session","working_directory":"/tmp/project"}]"#,
- ),
- ],
- );
-
- assert!(request.is_none());
-}
-
-#[test]
-fn devin_hook_reports_session_id_from_stdin_without_state() {
- let request = run_devin_hook(
- "session",
- r#"{"hook_event_name":"SessionStart","session_id":"devin-session","source":"startup"}"#,
- &[("HERDR_DEVIN_LIST_JSON", r#"[{"id":"older-session"}]"#)],
- )
- .expect("devin session start should report session identity");
-
- assert_eq!(request["method"], "pane.report_agent_session");
- assert_eq!(request["params"]["agent"], "devin");
- assert_eq!(request["params"]["agent_session_id"], "devin-session");
- assert!(request["params"].get("state").is_none());
-}
-
-#[test]
-fn devin_hook_prefers_hook_session_id_over_list() {
- let request = run_devin_hook(
- "session",
- r#"{"hook_event_name":"PreToolUse","sessionId":"fresh-session","tool_name":"exec"}"#,
- &[
- ("DEVIN_PROJECT_DIR", "/tmp/project"),
- (
- "HERDR_DEVIN_LIST_JSON",
- r#"[{"id":"older-session","working_directory":"/tmp/project"}]"#,
- ),
- ],
- )
- .expect("devin tool hook should report session identity");
-
- assert_eq!(request["method"], "pane.report_agent_session");
- assert_eq!(request["params"]["agent_session_id"], "fresh-session");
- assert!(request["params"].get("state").is_none());
-}
-
-#[test]
-fn devin_hook_reports_tool_session_from_list_without_state() {
- let request = run_devin_hook(
- "session",
- r#"{"hook_event_name":"PreToolUse","tool_name":"exec"}"#,
- &[
- ("DEVIN_PROJECT_DIR", "/tmp/project"),
- (
- "HERDR_DEVIN_LIST_JSON",
- r#"[{"id":"older-session","working_directory":"/tmp/other"},{"id":"devin-session","working_directory":"/tmp/project"}]"#,
- ),
- ],
- )
- .expect("devin tool hook should report session identity");
-
- assert_eq!(request["method"], "pane.report_agent_session");
- assert_eq!(request["params"]["agent"], "devin");
- assert_eq!(request["params"]["agent_session_id"], "devin-session");
- assert!(request["params"].get("state").is_none());
-}
-
-#[test]
-fn devin_hook_ignores_startup_session_list_fallback() {
- let request = run_devin_hook(
- "session",
- r#"{"hook_event_name":"SessionStart","source":"startup"}"#,
- &[
- ("DEVIN_PROJECT_DIR", "/tmp/project"),
- (
- "HERDR_DEVIN_LIST_JSON",
- r#"[{"id":"stale-session","working_directory":"/tmp/project"}]"#,
- ),
- ],
- );
-
- assert!(request.is_none());
-}
-
-#[test]
-fn devin_hook_ignores_non_matching_session_list_entries() {
- let request = run_devin_hook(
- "session",
- r#"{"hook_event_name":"PreToolUse","tool_name":"exec"}"#,
- &[
- ("DEVIN_PROJECT_DIR", "/tmp/project"),
- (
- "HERDR_DEVIN_LIST_JSON",
- r#"[{"id":"other-session","working_directory":"/tmp/other"}]"#,
- ),
- ],
- );
-
- assert!(request.is_none());
-}
-
-#[test]
-fn pane_run_sends_one_send_input_request_with_enter_key() {
- 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 || {
- let (mut first_stream, first_line) = accept_fake_cli_operation(&listener);
- first_stream
- .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#)
- .unwrap();
- first_stream.write_all(b"\n").unwrap();
- first_stream.flush().unwrap();
-
- let mut second_line = None;
- listener.set_nonblocking(true).unwrap();
- let deadline = Instant::now() + Duration::from_millis(250);
- while Instant::now() < deadline {
- match listener.accept() {
- Ok((mut second_stream, _)) => {
- let mut line = String::new();
- let mut reader = BufReader::new(second_stream.try_clone().unwrap());
- reader.read_line(&mut line).unwrap();
- second_stream
- .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#)
- .unwrap();
- second_stream.write_all(b"\n").unwrap();
- second_stream.flush().unwrap();
- second_line = Some(line);
- break;
- }
- Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
- thread::sleep(Duration::from_millis(10));
- }
- Err(err) => panic!("second accept failed: {err}"),
- }
- }
-
- (first_line, second_line)
- });
-
- let run = run_cli(&socket_path, &["pane", "run", "1-1", "echo hello"]);
- assert!(
- run.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&run.stderr)
- );
-
- let (first_line, second_line) = server.join().unwrap();
- let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap();
- assert_eq!(first_request["method"], "pane.send_input");
- assert_eq!(first_request["params"]["pane_id"], "1-1");
- assert_eq!(first_request["params"]["text"], "echo hello");
- assert_eq!(
- first_request["params"]["keys"],
- serde_json::json!(["Enter"])
- );
- assert!(
- second_line.is_none(),
- "pane run sent an unexpected second request: {:?}",
- second_line
- );
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn workspace_report_metadata_sends_token_patch() {
- 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 || {
- let (mut stream, line) = accept_fake_cli_operation(&listener);
- stream
- .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#)
- .unwrap();
- stream.write_all(b"\n").unwrap();
- stream.flush().unwrap();
- line
- });
-
- let run = run_cli(
- &socket_path,
- &[
- "workspace",
- "report-metadata",
- "2",
- "--source",
- "user:jj",
- "--token",
- "jj_status=2 changes",
- "--token",
- "summary=clean",
- "--clear-token",
- "old",
- "--ttl-ms",
- "5000",
- ],
- );
- assert!(
- run.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&run.stderr)
- );
-
- let request: serde_json::Value = serde_json::from_str(&server.join().unwrap()).unwrap();
- assert_eq!(request["method"], "workspace.report_metadata");
- assert_eq!(request["params"]["workspace_id"], "2");
- assert_eq!(request["params"]["tokens"]["jj_status"], "2 changes");
- assert_eq!(request["params"]["tokens"]["summary"], "clean");
- assert!(request["params"]["tokens"]["old"].is_null());
- assert_eq!(request["params"]["ttl_ms"], 5000);
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn pane_report_metadata_sends_presentation_request() {
- 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 || {
- let (mut stream, line) = accept_fake_cli_operation(&listener);
- stream
- .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#)
- .unwrap();
- stream.write_all(b"\n").unwrap();
- stream.flush().unwrap();
- line
- });
-
- let run = run_cli(
- &socket_path,
- &[
- "pane",
- "report-metadata",
- "1-1",
- "--source",
- "user:claude-title",
- "--agent",
- "claude",
- "--title",
- "Refactor auth",
- "--display-agent",
- "Claude auth",
- "--state-label",
- "working=deep in the mines",
- "--token",
- "summary=reviewing auth",
- "--token",
- "model=opus",
- "--clear-token",
- "old",
- "--ttl-ms",
- "3600000",
- ],
- );
- assert!(
- run.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&run.stderr)
- );
-
- let line = server.join().unwrap();
- let request: serde_json::Value = serde_json::from_str(&line).unwrap();
- assert_eq!(request["method"], "pane.report_metadata");
- assert_eq!(request["params"]["pane_id"], "1-1");
- assert_eq!(request["params"]["source"], "user:claude-title");
- assert_eq!(request["params"]["agent"], "claude");
- assert!(request["params"]["applies_to_source"].is_null());
- assert_eq!(request["params"]["title"], "Refactor auth");
- assert_eq!(request["params"]["display_agent"], "Claude auth");
- assert_eq!(
- request["params"]["state_labels"]["working"],
- "deep in the mines"
- );
- assert_eq!(request["params"]["tokens"]["summary"], "reviewing auth");
- assert_eq!(request["params"]["tokens"]["model"], "opus");
- assert!(request["params"]["tokens"]["old"].is_null());
- assert_eq!(request["params"]["ttl_ms"], 3_600_000);
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn pane_report_metadata_rejects_blank_source_before_socket_request() {
- let base = unique_test_dir();
- fs::create_dir_all(&base).unwrap();
- let socket_path = base.join("missing.sock");
-
- let run = run_cli(
- &socket_path,
- &[
- "pane",
- "report-metadata",
- "1-1",
- "--source",
- " ",
- "--token",
- "summary=middleware",
- ],
- );
-
- assert_eq!(run.status.code(), Some(2));
- assert!(
- String::from_utf8_lossy(&run.stderr).contains("missing required --source"),
- "stderr: {}",
- String::from_utf8_lossy(&run.stderr)
- );
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn pane_report_metadata_rejects_blank_applies_to_source_before_socket_request() {
- let base = unique_test_dir();
- fs::create_dir_all(&base).unwrap();
- let socket_path = base.join("missing.sock");
-
- let run = run_cli(
- &socket_path,
- &[
- "pane",
- "report-metadata",
- "1-1",
- "--source",
- "user:claude-title",
- "--applies-to-source",
- " ",
- "--token",
- "summary=middleware",
- ],
- );
-
- assert_eq!(run.status.code(), Some(2));
- assert!(
- String::from_utf8_lossy(&run.stderr).contains("missing value for --applies-to-source"),
- "stderr: {}",
- String::from_utf8_lossy(&run.stderr)
- );
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn help_commands_exit_successfully() {
- let help_cases: &[&[&str]] = &[
- &["-h"],
- &["--help"],
- &["api", "-h"],
- &["api", "schema", "-h"],
- &["completion", "-h"],
- &["status", "-h"],
- &["server", "-h"],
- &["workspace", "-h"],
- &["worktree", "-h"],
- &["tab", "-h"],
- &["pane", "-h"],
- &["wait", "-h"],
- &["session", "-h"],
- &["session", "attach", "-h"],
- &["integration", "-h"],
- ];
-
- for args in help_cases {
- let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
- .args(*args)
- .output()
- .unwrap();
- assert!(
- output.status.success(),
- "herdr {} failed: status={:?} stdout={} stderr={}",
- args.join(" "),
- output.status.code(),
- String::from_utf8_lossy(&output.stdout),
- String::from_utf8_lossy(&output.stderr)
- );
- }
-}
-
-#[test]
-fn completion_command_prints_zsh_script_without_session_startup() {
- let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
- .args(["completion", "zsh"])
- .env_remove("HERDR_SOCKET_PATH")
- .env_remove("HERDR_CLIENT_SOCKET_PATH")
- .env_remove("HERDR_ENV")
- .output()
- .unwrap();
-
- assert!(
- output.status.success(),
- "status={:?} stderr={}",
- output.status.code(),
- String::from_utf8_lossy(&output.stderr)
- );
- let stdout = String::from_utf8_lossy(&output.stdout);
- assert!(stdout.contains("#compdef herdr"), "stdout: {stdout}");
- assert!(
- stdout.contains("bash elvish fish powershell zsh"),
- "stdout: {stdout}"
- );
- assert!(
- stdout.contains("'--direction[]:DIRECTION:(right down)'"),
- "pane split/plugin pane open should complete --direction right|down without requiring equals: {stdout}"
- );
- assert!(
- !stdout.contains("--cwd=[]"),
- "zsh completions should not suggest equals-style values unsupported by most manual parsers: {stdout}"
- );
- assert!(
- !stdout.contains("--direction=[]"),
- "zsh completions should not suggest equals-style direction values: {stdout}"
- );
- assert!(
- !stdout.contains("live-handoff"),
- "internal server handoff command should not be completed: {stdout}"
- );
-}
-
-#[test]
-fn root_help_hides_explicit_client_command() {
- let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
- .arg("--help")
- .output()
- .unwrap();
-
- assert!(output.status.success());
- let stdout = String::from_utf8_lossy(&output.stdout);
- assert!(
- !stdout.contains("herdr client"),
- "root help should not advertise the internal client command: {stdout}"
- );
-}
-
-#[test]
-fn root_help_advertises_api_schema_command_group() {
- let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
- .arg("--help")
- .output()
- .unwrap();
-
- assert!(output.status.success());
- let stdout = String::from_utf8_lossy(&output.stdout);
- assert!(
- stdout.contains("herdr api <subcommand>"),
- "root help should advertise the api command group: {stdout}"
- );
-}
-
-#[test]
-fn api_schema_default_output_is_a_short_summary() {
- let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
- .args(["api", "schema"])
- .output()
- .unwrap();
-
- assert!(output.status.success());
- let stdout = String::from_utf8_lossy(&output.stdout);
- assert!(stdout.contains("Herdr API schema"), "stdout: {stdout}");
- assert!(
- stdout.contains("Use `herdr api schema --json`"),
- "stdout: {stdout}"
- );
- assert!(
- stdout.len() < 400,
- "summary should stay small enough for terminal output: {stdout}"
- );
-}
-
-#[test]
-fn api_schema_json_prints_bundled_schema() {
- let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
- .args(["api", "schema", "--json"])
- .output()
- .unwrap();
-
- assert!(output.status.success());
- let schema: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
- assert!(schema
- .get("protocol")
- .and_then(serde_json::Value::as_u64)
- .is_some_and(|protocol| protocol > 0));
- assert_eq!(
- schema
- .get("schemas")
- .and_then(serde_json::Value::as_object)
- .map(serde_json::Map::len),
- Some(5)
- );
-}
-
-#[test]
-fn api_snapshot_prints_live_session_snapshot() {
- 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({
- let socket_path = socket_path.clone();
- move || {
- let (mut stream, line) = accept_fake_cli_operation(&listener);
- let request: serde_json::Value = serde_json::from_str(&line).unwrap();
- assert_eq!(request["method"], "session.snapshot");
- assert_eq!(request["id"], "cli:api:snapshot");
-
- let response = serde_json::json!({
- "id": "cli:api:snapshot",
- "result": {
- "type": "ok",
- "marker": "snapshot-passthrough"
- }
- });
- writeln!(stream, "{response}").unwrap();
- stream.flush().unwrap();
- let _ = fs::remove_file(socket_path);
- }
- });
-
- let value = run_cli_json(&socket_path, &["api", "snapshot"]);
-
- assert_eq!(value["result"]["marker"], "snapshot-passthrough");
- server.join().unwrap();
- cleanup_test_base(&base);
-}
-
-#[test]
-fn api_schema_output_writes_bundled_schema_to_file() {
- let base = unique_test_dir();
- fs::create_dir_all(&base).unwrap();
- let schema_path = base.join("herdr-api.schema.json");
-
- let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
- .args(["api", "schema", "--output"])
- .arg(&schema_path)
- .output()
- .unwrap();
-
- assert!(output.status.success());
- assert!(
- String::from_utf8_lossy(&output.stdout).contains("wrote API schema"),
- "stdout: {}",
- String::from_utf8_lossy(&output.stdout)
- );
- let schema: serde_json::Value =
- serde_json::from_slice(&fs::read(&schema_path).unwrap()).unwrap();
- assert!(schema
- .get("protocol")
- .and_then(serde_json::Value::as_u64)
- .is_some_and(|protocol| protocol > 0));
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn explicit_client_command_respects_nested_guard() {
- let base = unique_test_dir();
- fs::create_dir_all(&base).unwrap();
-
- let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
- .arg("client")
- .env("HERDR_ENV", "1")
- .env("XDG_CONFIG_HOME", &base)
- .env_remove("HERDR_CONFIG_PATH")
- .output()
- .unwrap();
-
- cleanup_test_base(&base);
-
- assert_eq!(output.status.code(), Some(1));
- let stderr = String::from_utf8_lossy(&output.stderr);
- assert!(
- stderr.contains("nested herdr is disabled by default"),
- "client should fail at the nested guard before connecting: {stderr}"
- );
-}
-
-#[test]
-fn removed_show_changelog_flag_fails_before_nested_guard() {
- let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
- .arg("--show-changelog")
- .env("HERDR_ENV", "1")
- .output()
- .unwrap();
-
- assert_eq!(output.status.code(), Some(1));
- let stderr = String::from_utf8_lossy(&output.stderr);
- assert!(
- stderr.contains("unknown option: --show-changelog"),
- "stderr: {stderr}"
- );
- assert!(
- !stderr.contains("nested herdr"),
- "unknown flag should be rejected before nested guard: {stderr}"
- );
-}
-
-#[test]
-fn named_sessions_use_separate_servers_and_workspace_state() {
- let base = unique_test_dir();
- let config_home = base.join("config");
- let runtime_dir = base.join("runtime");
-
- let alpha = spawn_named_server(&config_home, &runtime_dir, "alpha");
- let beta = spawn_named_server(&config_home, &runtime_dir, "beta");
-
- wait_for_socket(
- &named_session_socket(&config_home, "alpha"),
- Duration::from_secs(5),
- );
- wait_for_socket(
- &named_session_socket(&config_home, "beta"),
- Duration::from_secs(5),
- );
-
- run_named_cli_json(
- &config_home,
- &runtime_dir,
- &[
- "--session",
- "alpha",
- "workspace",
- "create",
- "--label",
- "alpha-ws",
- "--no-focus",
- ],
- );
- run_named_cli_json(
- &config_home,
- &runtime_dir,
- &[
- "--session",
- "beta",
- "workspace",
- "create",
- "--label",
- "beta-ws",
- "--no-focus",
- ],
- );
-
- let alpha_list = run_named_cli_json(
- &config_home,
- &runtime_dir,
- &["--session", "alpha", "workspace", "list"],
- );
- let beta_list = run_named_cli_json(
- &config_home,
- &runtime_dir,
- &["--session", "beta", "workspace", "list"],
- );
-
- let alpha_labels: Vec<_> = alpha_list["result"]["workspaces"]
- .as_array()
- .unwrap()
- .iter()
- .map(|workspace| workspace["label"].as_str().unwrap())
- .collect();
- let beta_labels: Vec<_> = beta_list["result"]["workspaces"]
- .as_array()
- .unwrap()
- .iter()
- .map(|workspace| workspace["label"].as_str().unwrap())
- .collect();
-
- assert_eq!(alpha_labels, vec!["alpha-ws"]);
- assert_eq!(beta_labels, vec!["beta-ws"]);
-
- let beta_via_explicit_session = run_named_cli_with_socket_override(
- &config_home,
- &runtime_dir,
- &["--session", "beta", "workspace", "list"],
- Some(&named_session_socket(&config_home, "alpha")),
- );
- assert!(
- beta_via_explicit_session.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&beta_via_explicit_session.stderr)
- );
- let beta_via_explicit_session: serde_json::Value =
- serde_json::from_slice(&beta_via_explicit_session.stdout).unwrap();
- let labels_via_explicit: Vec<_> = beta_via_explicit_session["result"]["workspaces"]
- .as_array()
- .unwrap()
- .iter()
- .map(|workspace| workspace["label"].as_str().unwrap())
- .collect();
- assert_eq!(labels_via_explicit, vec!["beta-ws"]);
-
- let human_sessions = run_named_cli(&config_home, &runtime_dir, &["session", "list"]);
- assert!(human_sessions.status.success());
- let human_sessions = String::from_utf8_lossy(&human_sessions.stdout);
- assert!(human_sessions.contains("name"), "stdout: {human_sessions}");
- assert!(
- human_sessions.contains("status"),
- "stdout: {human_sessions}"
- );
- assert!(human_sessions.contains("alpha"), "stdout: {human_sessions}");
- assert!(
- human_sessions.contains("running"),
- "stdout: {human_sessions}"
- );
- assert!(
- human_sessions.contains("/sessions/beta"),
- "stdout: {human_sessions}"
- );
-
- let sessions = run_named_cli_json(&config_home, &runtime_dir, &["session", "list", "--json"]);
- let sessions = sessions["sessions"].as_array().unwrap();
- let default_session = sessions
- .iter()
- .find(|session| session["name"] == "default")
- .unwrap();
- let alpha_session = sessions
- .iter()
- .find(|session| session["name"] == "alpha")
- .unwrap();
- let beta_session = sessions
- .iter()
- .find(|session| session["name"] == "beta")
- .unwrap();
- assert_eq!(default_session["default"], true);
- assert_eq!(default_session["running"], false);
- assert_eq!(alpha_session["running"], true);
- assert_eq!(beta_session["running"], true);
- assert!(alpha_session["socket_path"]
- .as_str()
- .unwrap()
- .ends_with("/sessions/alpha/herdr.sock"));
- assert!(beta_session["session_dir"]
- .as_str()
- .unwrap()
- .ends_with("/sessions/beta"));
-
- let delete_running = run_named_cli(&config_home, &runtime_dir, &["session", "delete", "alpha"]);
- assert_eq!(delete_running.status.code(), Some(1));
- assert!(
- String::from_utf8_lossy(&delete_running.stderr).contains("stop it before deleting"),
- "stderr: {}",
- String::from_utf8_lossy(&delete_running.stderr)
- );
-
- let delete_default = run_named_cli(
- &config_home,
- &runtime_dir,
- &["session", "delete", "default"],
- );
- assert_eq!(delete_default.status.code(), Some(1));
- assert!(
- String::from_utf8_lossy(&delete_default.stderr).contains("default session"),
- "stderr: {}",
- String::from_utf8_lossy(&delete_default.stderr)
- );
-
- let stopped_alpha = run_named_cli_json(
- &config_home,
- &runtime_dir,
- &["session", "stop", "alpha", "--json"],
- );
- assert_eq!(stopped_alpha["stopped"], true);
- assert_eq!(stopped_alpha["session"]["running"], false);
-
- let deleted_alpha = run_named_cli_json(
- &config_home,
- &runtime_dir,
- &["session", "delete", "alpha", "--json"],
- );
- assert_eq!(deleted_alpha["deleted"], true);
- assert!(!config_home
- .join(app_dir_name())
- .join("sessions")
- .join("alpha")
- .exists());
-
- let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "beta"]);
- drop(alpha);
- drop(beta);
- cleanup_test_base(&base);
-}
-
-#[test]
-fn integration_commands_run_locally_when_server_is_missing() {
- let base = unique_test_dir();
- let home_dir = base.join("home");
- let extensions_dir = home_dir.join(".pi/agent/extensions");
- fs::create_dir_all(&extensions_dir).unwrap();
-
- let runtime_dir = base.join("runtime");
- fs::create_dir_all(&runtime_dir).unwrap();
- register_runtime_dir(&runtime_dir);
- let missing_socket = runtime_dir.join("missing.sock");
-
- let expected_extension = extensions_dir.join("herdr-agent-state.ts");
- assert!(
- !expected_extension.exists(),
- "test setup should start without extension file"
- );
-
- let workspace_list = Command::new(env!("CARGO_BIN_EXE_herdr"))
- .args(["workspace", "list"])
- .env("HERDR_SOCKET_PATH", &missing_socket)
- .env("HOME", &home_dir)
- .output()
- .unwrap();
- assert_eq!(workspace_list.status.code(), Some(1));
-
- let integration_install = Command::new(env!("CARGO_BIN_EXE_herdr"))
- .args(["integration", "install", "pi"])
- .env("HERDR_SOCKET_PATH", &missing_socket)
- .env("HOME", &home_dir)
- .output()
- .unwrap();
- assert_eq!(integration_install.status.code(), Some(0));
- assert!(
- expected_extension.exists(),
- "integration install should write local files without a server"
- );
-
- let integration_status = Command::new(env!("CARGO_BIN_EXE_herdr"))
- .args(["integration", "status"])
- .env("HERDR_SOCKET_PATH", &missing_socket)
- .env("HOME", &home_dir)
- .output()
- .unwrap();
- assert_eq!(integration_status.status.code(), Some(0));
- let status_stdout = String::from_utf8_lossy(&integration_status.stdout);
- assert!(status_stdout.contains("pi: current (v6)"));
- assert!(status_stdout.contains("claude: not installed"));
-
- let integration_uninstall = Command::new(env!("CARGO_BIN_EXE_herdr"))
- .args(["integration", "uninstall", "pi"])
- .env("HERDR_SOCKET_PATH", &missing_socket)
- .env("HOME", &home_dir)
- .output()
- .unwrap();
- assert_eq!(integration_uninstall.status.code(), Some(0));
- assert!(
- !expected_extension.exists(),
- "integration uninstall should remove local files without a server"
- );
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn integration_status_outdated_only_prints_action_for_legacy_install() {
- let base = unique_test_dir();
- let home_dir = base.join("home");
- let extensions_dir = home_dir.join(".pi/agent/extensions");
- fs::create_dir_all(&extensions_dir).unwrap();
- fs::write(
- extensions_dir.join("herdr-agent-state.ts"),
- "// legacy herdr integration\n",
- )
- .unwrap();
-
- let runtime_dir = base.join("runtime");
- fs::create_dir_all(&runtime_dir).unwrap();
- register_runtime_dir(&runtime_dir);
- let missing_socket = runtime_dir.join("missing.sock");
-
- let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
- .args(["integration", "status", "--outdated-only"])
- .env("HERDR_SOCKET_PATH", &missing_socket)
- .env("HOME", &home_dir)
- .output()
- .unwrap();
-
- assert_eq!(output.status.code(), Some(0));
- assert!(output.stdout.is_empty());
- let stderr = String::from_utf8_lossy(&output.stderr);
- assert!(stderr.contains("installed herdr integrations need updating"));
- assert!(stderr.contains("herdr integration install pi"));
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn integration_status_rejects_unknown_flags() {
- let base = unique_test_dir();
- let home_dir = base.join("home");
- fs::create_dir_all(&home_dir).unwrap();
- let runtime_dir = base.join("runtime");
- fs::create_dir_all(&runtime_dir).unwrap();
- register_runtime_dir(&runtime_dir);
- let missing_socket = runtime_dir.join("missing.sock");
-
- let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
- .args(["integration", "status", "--wat"])
- .env("HERDR_SOCKET_PATH", &missing_socket)
- .env("HOME", &home_dir)
- .output()
- .unwrap();
-
- assert_eq!(output.status.code(), Some(2));
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn status_commands_report_client_and_server_versions() {
- 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 full = run_cli(&socket_path, &["status"]);
- assert!(
- full.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&full.stderr)
- );
- let full_stdout = String::from_utf8_lossy(&full.stdout);
- assert!(full_stdout.contains("client:\n"), "stdout: {full_stdout}");
- assert!(
- full_stdout.contains(&format!(" version: {}", env!("CARGO_PKG_VERSION"))),
- "stdout: {full_stdout}"
- );
- assert!(
- full_stdout.contains(" protocol: 17"),
- "stdout: {full_stdout}"
- );
- assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}");
- assert!(
- full_stdout.contains(" status: running"),
- "stdout: {full_stdout}"
- );
- assert!(
- full_stdout.contains(" compatible: yes"),
- "stdout: {full_stdout}"
- );
- assert!(
- full_stdout.contains(" restart_needed: no"),
- "stdout: {full_stdout}"
- );
- assert!(
- full_stdout.contains(&socket_path.display().to_string()),
- "stdout: {full_stdout}"
- );
-
- let server = run_cli(&socket_path, &["status", "server"]);
- assert!(server.status.success());
- let server_stdout = String::from_utf8_lossy(&server.stdout);
- assert!(
- server_stdout.contains("status: running"),
- "stdout: {server_stdout}"
- );
- assert!(
- server_stdout.contains(&format!("version: {}", env!("CARGO_PKG_VERSION"))),
- "stdout: {server_stdout}"
- );
- assert!(
- server_stdout.contains("protocol: 17"),
- "stdout: {server_stdout}"
- );
-
- let client = run_cli(&socket_path, &["status", "client"]);
- assert!(client.status.success());
- let client_stdout = String::from_utf8_lossy(&client.stdout);
- assert!(
- client_stdout.contains(&format!("version: {}", env!("CARGO_PKG_VERSION"))),
- "stdout: {client_stdout}"
- );
- assert!(
- client_stdout.contains("protocol: 17"),
- "stdout: {client_stdout}"
- );
- assert!(
- client_stdout.contains("binary: "),
- "stdout: {client_stdout}"
- );
-
- 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"], 17);
- assert_eq!(full_json["server"]["status"], "running");
- assert_eq!(full_json["server"]["running"], true);
- assert_eq!(full_json["server"]["compatible"], true);
- assert_eq!(
- full_json["server"]["socket"],
- socket_path.display().to_string()
- );
- assert_eq!(full_json["server"]["restart_needed"], false);
- assert_eq!(full_json["update"]["restart_needed"], false);
-
- 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"], 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"], 17);
- assert!(client_json["binary"]
- .as_str()
- .is_some_and(|path| !path.is_empty()));
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn status_reports_not_running_when_server_socket_is_missing() {
- let base = unique_test_dir();
- let runtime_dir = base.join("runtime");
- fs::create_dir_all(&runtime_dir).unwrap();
- register_runtime_dir(&runtime_dir);
- let socket_path = runtime_dir.join("missing.sock");
-
- let status = run_cli(&socket_path, &["status"]);
- assert!(status.status.success());
- let stdout = String::from_utf8_lossy(&status.stdout);
- assert!(stdout.contains(" status: not running"), "stdout: {stdout}");
- assert!(stdout.contains(" restart_needed: no"), "stdout: {stdout}");
- assert!(
- stdout.contains(&socket_path.display().to_string()),
- "stdout: {stdout}"
- );
-
- let status_json = run_cli_json(&socket_path, &["status", "--json"]);
- assert_eq!(status_json["server"]["status"], "not_running");
- assert_eq!(status_json["server"]["running"], false);
- assert_eq!(
- status_json["server"]["socket"],
- socket_path.display().to_string()
- );
- assert_eq!(status_json["server"]["restart_needed"], false);
- assert_eq!(status_json["update"]["restart_needed"], false);
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn server_stop_command_shuts_down_running_server() {
- 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 client_socket = runtime_dir.join("herdr-client.sock");
-
- let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
- wait_for_socket(&socket_path, Duration::from_secs(5));
- wait_for_socket(&client_socket, Duration::from_secs(5));
-
- let stopped = run_cli(&socket_path, &["server", "stop"]);
- assert!(
- stopped.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&stopped.stderr)
- );
- assert!(
- stopped.stdout.is_empty(),
- "server stop should not print stdout: {}",
- String::from_utf8_lossy(&stopped.stdout)
- );
- assert!(
- !socket_path.exists() || UnixStream::connect(&socket_path).is_err(),
- "api socket should be removed or stale before server stop returns"
- );
- assert!(
- !client_socket.exists() || UnixStream::connect(&client_socket).is_err(),
- "client socket should be removed or stale before server stop returns"
- );
-
- let pid = herdr.child.process_id();
- let exit_status = herdr.child.wait().unwrap();
- unregister_spawned_herdr_pid(pid);
- assert!(exit_status.success(), "server stop should exit cleanly");
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn server_stop_then_restart_restores_pane_history() {
- 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 client_socket = runtime_dir.join("herdr-client.sock");
- let marker = "PERSISTED_HISTORY_AFTER_STOP";
-
- let mut herdr = spawn_herdr_with_pane_history(&config_home, &runtime_dir, &socket_path);
- wait_for_socket(&socket_path, Duration::from_secs(5));
- wait_for_socket(&client_socket, Duration::from_secs(5));
-
- let created = run_cli_json(
- &socket_path,
- &[
- "workspace",
- "create",
- "--cwd",
- base.to_str().expect("test path should be utf-8"),
- "--label",
- "history-restart",
- ],
- );
- let pane_id = created["result"]["root_pane"]["pane_id"]
- .as_str()
- .expect("workspace create should return root pane id")
- .to_string();
- let sent = run_cli(
- &socket_path,
- &["pane", "send-text", &pane_id, &format!("echo {marker}\n")],
- );
- assert!(
- sent.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&sent.stderr)
- );
- assert!(
- wait_until(Duration::from_secs(3), Duration::from_millis(25), || {
- pane_read_recent_contains(&socket_path, &pane_id, marker)
- }),
- "pane should contain marker before server stop"
- );
-
- let stopped = run_cli(&socket_path, &["server", "stop"]);
- assert!(
- stopped.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&stopped.stderr)
- );
-
- let pid = herdr.child.process_id();
- let exit_status = herdr.child.wait().unwrap();
- unregister_spawned_herdr_pid(pid);
- assert!(exit_status.success(), "server stop should exit cleanly");
- drop(herdr);
-
- let restarted = spawn_herdr_with_pane_history(&config_home, &runtime_dir, &socket_path);
- wait_for_socket(&socket_path, Duration::from_secs(5));
- wait_for_socket(&client_socket, Duration::from_secs(5));
-
- let workspaces = run_cli_json(&socket_path, &["workspace", "list"]);
- let workspace_id = workspaces["result"]["workspaces"]
- .as_array()
- .expect("workspace.list should return workspaces")
- .iter()
- .find(|workspace| workspace["label"] == "history-restart")
- .and_then(|workspace| workspace["workspace_id"].as_str())
- .expect("restored workspace should exist")
- .to_string();
- let panes = run_cli_json(
- &socket_path,
- &["pane", "list", "--workspace", &workspace_id],
- );
- let restored_pane_id = panes["result"]["panes"]
- .as_array()
- .expect("pane.list should return panes")
- .first()
- .and_then(|pane| pane["pane_id"].as_str())
- .expect("restored pane should exist")
- .to_string();
-
- assert!(
- wait_until(Duration::from_secs(3), Duration::from_millis(25), || {
- pane_read_recent_contains(&socket_path, &restored_pane_id, marker)
- }),
- "restarted server should restore saved pane history"
- );
-
- cleanup_spawned_herdr(restarted, base);
-}
-
-#[test]
-fn server_start_restores_legacy_session_through_api_identity() {
- 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 client_socket = runtime_dir.join("herdr-client.sock");
- let data_dir = config_home.join(app_dir_name());
- let pion_cwd = base.join("legacy-pion");
- let herdr_cwd = base.join("legacy-herdr");
-
- fs::create_dir_all(&pion_cwd).unwrap();
- fs::create_dir_all(&herdr_cwd).unwrap();
- fs::create_dir_all(&data_dir).unwrap();
- let pion_cwd = pion_cwd.to_str().expect("test cwd should be UTF-8");
- let herdr_cwd = herdr_cwd.to_str().expect("test cwd should be UTF-8");
- let legacy_session = include_str!("fixtures/session/legacy-pre-tabs-v2.json")
- .replace("/tmp/pion", pion_cwd)
- .replace("/tmp/herdr", herdr_cwd);
- fs::write(data_dir.join("session.json"), legacy_session).unwrap();
-
- let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
- wait_for_socket(&socket_path, Duration::from_secs(5));
- wait_for_socket(&client_socket, Duration::from_secs(5));
-
- let workspaces = run_cli_json(&socket_path, &["workspace", "list"]);
- let restored_workspace = workspaces["result"]["workspaces"]
- .as_array()
- .expect("workspace.list should return workspaces")
- .iter()
- .find(|workspace| workspace["label"] == "legacy")
- .expect("legacy workspace should restore");
- let workspace_id = restored_workspace["workspace_id"]
- .as_str()
- .expect("restored workspace should have public id")
- .to_string();
- assert_eq!(restored_workspace["pane_count"], 2);
- assert_eq!(restored_workspace["tab_count"], 1);
- assert_eq!(
- restored_workspace["active_tab_id"],
- format!("{workspace_id}:t1")
- );
-
- let panes = run_cli_json(
- &socket_path,
- &["pane", "list", "--workspace", &workspace_id],
- );
- let panes = panes["result"]["panes"]
- .as_array()
- .expect("pane.list should return panes");
- assert_eq!(panes.len(), 2);
- let root_pane_id = format!("{workspace_id}:p1");
- let focused_pane_id = format!("{workspace_id}:p2");
- assert!(panes.iter().any(|pane| {
- pane["pane_id"] == root_pane_id
- && pane["tab_id"] == format!("{workspace_id}:t1")
- && pane["cwd"] == pion_cwd
- && pane["focused"] == false
- }));
- assert!(panes.iter().any(|pane| {
- pane["pane_id"] == focused_pane_id
- && pane["tab_id"] == format!("{workspace_id}:t1")
- && pane["cwd"] == herdr_cwd
- && pane["focused"] == true
- }));
-
- let reported = run_cli(
- &socket_path,
- &[
- "pane",
- "report-agent",
- &focused_pane_id,
- "--source",
- "test",
- "--agent",
- "pi",
- "--state",
- "working",
- ],
- );
- assert!(
- reported.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&reported.stderr)
- );
-
- let agents = run_cli_json(&socket_path, &["agent", "list"]);
- let agents = agents["result"]["agents"]
- .as_array()
- .expect("agent.list should return agents");
- assert_eq!(agents.len(), 1);
- assert_eq!(agents[0]["pane_id"], focused_pane_id);
- assert_eq!(agents[0]["workspace_id"], workspace_id);
- assert_eq!(agents[0]["agent"], "pi");
- assert_eq!(agents[0]["agent_status"], "working");
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn workspace_and_pane_management_commands_work() {
- 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 reloaded = run_cli(&socket_path, &["server", "reload-config"]);
- assert!(
- reloaded.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&reloaded.stderr)
- );
- let reload_json: serde_json::Value = serde_json::from_slice(&reloaded.stdout).unwrap();
- assert_eq!(reload_json["result"]["type"], "config_reload");
- assert_eq!(reload_json["result"]["status"], "applied");
-
- let listed = run_cli(&socket_path, &["workspace", "list"]);
- assert!(listed.status.success());
- let listed_json: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap();
- assert_eq!(listed_json["result"]["type"], "workspace_list");
- assert_eq!(
- listed_json["result"]["workspaces"]
- .as_array()
- .unwrap()
- .len(),
- 0
- );
-
- let created = run_cli(
- &socket_path,
- &["workspace", "create", "--cwd", base.to_str().unwrap()],
- );
- assert!(created.status.success());
- let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
- let workspace_id = created_json["result"]["workspace"]["workspace_id"]
- .as_str()
- .unwrap()
- .to_string();
-
- let panes = run_cli(&socket_path, &["pane", "list", "--workspace", "1"]);
- assert!(panes.status.success());
- let panes_json: serde_json::Value = serde_json::from_slice(&panes.stdout).unwrap();
- assert_eq!(panes_json["result"]["panes"].as_array().unwrap().len(), 1);
-
- let split = run_cli(
- &socket_path,
- &["pane", "split", "1-1", "--direction", "right"],
- );
- assert!(
- split.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&split.stderr)
- );
- let split_json: serde_json::Value = serde_json::from_slice(&split.stdout).unwrap();
- let split_pane_id = split_json["result"]["pane"]["pane_id"].as_str().unwrap();
-
- let fetched = run_cli(&socket_path, &["pane", "get", split_pane_id]);
- assert!(fetched.status.success());
- let fetched_json: serde_json::Value = serde_json::from_slice(&fetched.stdout).unwrap();
- assert_eq!(fetched_json["result"]["pane"]["pane_id"], split_pane_id);
-
- let closed = run_cli(&socket_path, &["pane", "close", split_pane_id]);
- assert!(closed.status.success());
- let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap();
- assert_eq!(closed_json["result"]["type"], "ok");
-
- let renamed = run_cli(
- &socket_path,
- &["workspace", "rename", &workspace_id, "demo"],
- );
- assert!(renamed.status.success());
- let renamed_json: serde_json::Value = serde_json::from_slice(&renamed.stdout).unwrap();
- assert_eq!(renamed_json["result"]["workspace"]["label"], "demo");
-
- let focused = run_cli(&socket_path, &["workspace", "focus", &workspace_id]);
- assert!(focused.status.success());
-
- let closed_workspace = run_cli(&socket_path, &["workspace", "close", &workspace_id]);
- assert!(closed_workspace.status.success());
- let closed_workspace_json: serde_json::Value =
- serde_json::from_slice(&closed_workspace.stdout).unwrap();
- assert_eq!(closed_workspace_json["result"]["type"], "ok");
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn worktree_management_commands_work() {
- 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 repo = base.join("repo");
- let checkout = base.join("checkout");
- create_committed_repo(&repo);
-
- let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
- wait_for_socket(&socket_path, Duration::from_secs(5));
-
- let branch = "worktree/cli-wrapper";
- let created = run_cli_json(
- &socket_path,
- &[
- "worktree",
- "create",
- "--cwd",
- repo.to_str().unwrap(),
- "--branch",
- branch,
- "--path",
- checkout.to_str().unwrap(),
- "--json",
- ],
- );
- assert_eq!(created["result"]["type"], "worktree_created");
- assert_eq!(created["result"]["worktree"]["branch"], branch);
- let child_workspace_id = created["result"]["workspace"]["workspace_id"]
- .as_str()
- .unwrap()
- .to_string();
- assert!(checkout.join("README.md").exists());
-
- let workspaces = run_cli_json(&socket_path, &["workspace", "list"]);
- let workspace_list = workspaces["result"]["workspaces"].as_array().unwrap();
- let parent_workspace_id = workspace_list
- .iter()
- .find(|workspace| workspace["worktree"]["is_linked_worktree"].as_bool() == Some(false))
- .and_then(|workspace| workspace["workspace_id"].as_str())
- .unwrap()
- .to_string();
- assert!(workspace_list.iter().any(|workspace| {
- workspace["workspace_id"].as_str() == Some(child_workspace_id.as_str())
- && workspace["worktree"]["is_linked_worktree"].as_bool() == Some(true)
- }));
-
- let listed = run_cli_json(
- &socket_path,
- &[
- "worktree",
- "list",
- "--workspace",
- &parent_workspace_id,
- "--json",
- ],
- );
- let listed_entry = listed["result"]["worktrees"]
- .as_array()
- .unwrap()
- .iter()
- .find(|entry| entry["branch"].as_str() == Some(branch))
- .unwrap();
- assert_eq!(
- listed_entry["open_workspace_id"].as_str(),
- Some(child_workspace_id.as_str())
- );
-
- let opened = run_cli_json(
- &socket_path,
- &[
- "worktree",
- "open",
- "--workspace",
- &parent_workspace_id,
- "--branch",
- branch,
- "--json",
- ],
- );
- assert_eq!(opened["result"]["type"], "worktree_opened");
- assert_eq!(opened["result"]["already_open"], true);
- assert_eq!(
- opened["result"]["workspace"]["workspace_id"].as_str(),
- Some(child_workspace_id.as_str())
- );
-
- fs::write(checkout.join("README.md"), "dirty\n").unwrap();
- let safe_remove = run_cli(
- &socket_path,
- &[
- "worktree",
- "remove",
- "--workspace",
- &child_workspace_id,
- "--json",
- ],
- );
- assert_eq!(safe_remove.status.code(), Some(1));
- let safe_remove_json: serde_json::Value = serde_json::from_slice(&safe_remove.stderr).unwrap();
- assert_eq!(
- safe_remove_json["error"]["code"],
- "dirty_worktree_requires_force"
- );
- assert!(checkout.exists());
-
- let force_removed = run_cli_json(
- &socket_path,
- &[
- "worktree",
- "remove",
- "--workspace",
- &child_workspace_id,
- "--force",
- "--json",
- ],
- );
- assert_eq!(force_removed["result"]["type"], "worktree_removed");
- assert_eq!(force_removed["result"]["forced"], true);
- assert!(!checkout.exists());
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn forced_worktree_remove_terminates_processes_inside_checkout() {
- 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 repo = base.join("repo");
- let checkout = base.join("checkout-with-process");
- create_committed_repo(&repo);
-
- 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,
- &[
- "worktree",
- "create",
- "--cwd",
- repo.to_str().unwrap(),
- "--branch",
- "worktree/force-process",
- "--path",
- checkout.to_str().unwrap(),
- "--json",
- ],
- );
- let child_workspace_id = created["result"]["workspace"]["workspace_id"]
- .as_str()
- .unwrap()
- .to_string();
- let pane_id = created["result"]["root_pane"]["pane_id"]
- .as_str()
- .unwrap()
- .to_string();
-
- let pid_file = base.join("worktree-remove-force.pid");
- let command = format!(
- "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'",
- pid_file.display()
- );
- let ran = run_cli(&socket_path, &["pane", "run", &pane_id, &command]);
- assert!(
- ran.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&ran.stderr)
- );
- let pid = wait_for_pid_file(&pid_file, Duration::from_secs(5)).unwrap_or_else(|err| {
- panic!("failed to read pane child pid: {err}");
- });
- assert!(process_exists(pid), "child process was not running");
-
- let removed = run_cli_json(
- &socket_path,
- &[
- "worktree",
- "remove",
- "--workspace",
- &child_workspace_id,
- "--force",
- "--json",
- ],
- );
- assert_eq!(removed["result"]["type"], "worktree_removed");
- assert!(wait_for_pid_exit(pid, Duration::from_secs(3)));
- assert!(!checkout.exists());
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn worktree_open_existing_checkout_by_path_and_branch() {
- 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 repo = base.join("repo");
- let checkout = base.join("external-checkout");
- create_committed_repo(&repo);
- let branch = "worktree/cli-open-existing";
- run_git(
- &repo,
- &[
- "worktree",
- "add",
- "--quiet",
- "-b",
- branch,
- checkout.to_str().unwrap(),
- "HEAD",
- ],
- );
-
- let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
- wait_for_socket(&socket_path, Duration::from_secs(5));
-
- let opened = run_cli_json_in_dir(
- &socket_path,
- &[
- "worktree",
- "open",
- "--cwd",
- "repo",
- "--path",
- "external-checkout",
- "--json",
- ],
- &base,
- );
- assert_eq!(opened["result"]["type"], "worktree_opened");
- assert_eq!(opened["result"]["already_open"], false);
- assert_eq!(opened["result"]["worktree"]["branch"], branch);
- assert_eq!(
- opened["result"]["workspace"]["worktree"]["is_linked_worktree"],
- true
- );
- let child_workspace_id = opened["result"]["workspace"]["workspace_id"]
- .as_str()
- .unwrap()
- .to_string();
-
- let workspaces = run_cli_json(&socket_path, &["workspace", "list"]);
- let workspace_list = workspaces["result"]["workspaces"].as_array().unwrap();
- let parent_workspace_id = workspace_list
- .iter()
- .find(|workspace| workspace["worktree"]["is_linked_worktree"].as_bool() == Some(false))
- .and_then(|workspace| workspace["workspace_id"].as_str())
- .unwrap()
- .to_string();
-
- let listed = run_cli_json(
- &socket_path,
- &[
- "worktree",
- "list",
- "--workspace",
- &parent_workspace_id,
- "--json",
- ],
- );
- let listed_entry = listed["result"]["worktrees"]
- .as_array()
- .unwrap()
- .iter()
- .find(|entry| entry["branch"].as_str() == Some(branch))
- .unwrap();
- assert_eq!(
- listed_entry["open_workspace_id"].as_str(),
- Some(child_workspace_id.as_str())
- );
-
- let reopened = run_cli_json(
- &socket_path,
- &[
- "worktree",
- "open",
- "--workspace",
- &parent_workspace_id,
- "--branch",
- branch,
- "--json",
- ],
- );
- assert_eq!(reopened["result"]["type"], "worktree_opened");
- assert_eq!(reopened["result"]["already_open"], true);
- assert_eq!(
- reopened["result"]["workspace"]["workspace_id"].as_str(),
- Some(child_workspace_id.as_str())
- );
-
- let removed = run_cli_json(
- &socket_path,
- &[
- "worktree",
- "remove",
- "--workspace",
- &child_workspace_id,
- "--force",
- "--json",
- ],
- );
- assert_eq!(removed["result"]["type"], "worktree_removed");
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn config_check_reports_invalid_config_without_server() {
- let base = unique_test_dir();
- let config_home = base.join("config");
- let runtime_dir = base.join("runtime");
- let config_dir = config_home.join(app_dir_name());
- fs::create_dir_all(&config_dir).unwrap();
- fs::write(
- config_dir.join("config.toml"),
- "[keys\nnew_workspace = \"g\"\n",
- )
- .unwrap();
-
- let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]);
-
- assert_eq!(checked.status.code(), Some(1));
- assert!(
- checked.stderr.is_empty(),
- "stderr: {}",
- String::from_utf8_lossy(&checked.stderr)
- );
- let stdout = String::from_utf8_lossy(&checked.stdout);
- assert!(stdout.contains("config: issues found"), "{stdout}");
- assert!(stdout.contains("TOML parse error"), "{stdout}");
- assert!(stdout.contains("line 1"), "{stdout}");
-
- fs::write(
- config_dir.join("config.toml"),
- "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\n",
- )
- .unwrap();
- let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]);
- let stdout = String::from_utf8_lossy(&checked.stdout);
- assert_eq!(checked.status.code(), Some(1));
- assert!(stdout.contains("sidebar_min_width (50)"), "{stdout}");
- assert!(stdout.contains("sidebar_max_width (30)"), "{stdout}");
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn config_check_reports_unknown_keys_without_treating_comments_as_keys() {
- let base = unique_test_dir();
- let config_home = base.join("config");
- let runtime_dir = base.join("runtime");
- let config_dir = config_home.join(app_dir_name());
- fs::create_dir_all(&config_dir).unwrap();
- fs::write(
- config_dir.join("config.toml"),
- r#"made_up_toplevel = 1
-"made.up" = 2
-# commented_out_toplevel = true
-
-[ui]
-mouse_capture = true
-mouse_captur = false
-# commented_out_ui_key = true
-
-[keys]
-new_tabb = "ctrl+t"
-"#,
- )
- .unwrap();
-
- let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]);
-
- assert_eq!(checked.status.code(), Some(1));
- assert!(
- checked.stderr.is_empty(),
- "stderr: {}",
- String::from_utf8_lossy(&checked.stderr)
- );
- let stdout = String::from_utf8_lossy(&checked.stdout);
- assert!(stdout.contains("config: issues found"), "{stdout}");
- assert!(
- stdout.contains("unknown config key made_up_toplevel; ignoring key"),
- "{stdout}"
- );
- assert!(
- stdout.contains("unknown config key \"made.up\"; ignoring key"),
- "{stdout}"
- );
- assert!(
- stdout.contains("unknown config key ui.mouse_captur; ignoring key"),
- "{stdout}"
- );
- assert!(
- stdout.contains("unknown config key keys.new_tabb; ignoring key"),
- "{stdout}"
- );
- assert!(!stdout.contains("commented_out"), "{stdout}");
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn config_check_reports_unreadable_config_path() {
- let base = unique_test_dir();
- let config_home = base.join("config");
- let runtime_dir = base.join("runtime");
- let config_path = config_home.join(app_dir_name()).join("config.toml");
- fs::create_dir_all(&config_path).unwrap();
-
- let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]);
-
- assert_eq!(checked.status.code(), Some(1));
- let stdout = String::from_utf8_lossy(&checked.stdout);
- assert!(stdout.contains("config: issues found"), "{stdout}");
- assert!(stdout.contains("config read error"), "{stdout}");
- assert!(stdout.contains("using defaults"), "{stdout}");
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn config_check_reports_ok_when_config_is_missing() {
- let base = unique_test_dir();
- let config_home = base.join("config");
- let runtime_dir = base.join("runtime");
-
- let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]);
-
- assert!(checked.status.success());
- let stdout = String::from_utf8_lossy(&checked.stdout);
- assert!(stdout.contains("config: ok"), "{stdout}");
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn config_check_rejects_json_output() {
- let base = unique_test_dir();
- let config_home = base.join("config");
- let runtime_dir = base.join("runtime");
-
- let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check", "--json"]);
-
- assert_eq!(checked.status.code(), Some(2));
- assert!(checked.stdout.is_empty());
- assert_eq!(
- String::from_utf8_lossy(&checked.stderr),
- "usage: herdr config check\n"
- );
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn worktree_cli_rejects_local_argument_errors_before_socket_use() {
- let base = unique_test_dir();
- fs::create_dir_all(&base).unwrap();
- let socket_path = base.join("missing.sock");
- let cases: &[&[&str]] = &[
- &["worktree", "list", "--workspace", "1", "--cwd", "/tmp"],
- &["worktree", "create", "--workspace", "1", "--cwd", "/tmp"],
- &["worktree", "open", "--workspace", "1"],
- &[
- "worktree",
- "open",
- "--workspace",
- "1",
- "--path",
- "a",
- "--branch",
- "b",
- ],
- &[
- "worktree",
- "open",
- "--workspace",
- "1",
- "--cwd",
- "/tmp",
- "--branch",
- "b",
- ],
- ];
-
- for args in cases {
- let output = run_cli(&socket_path, args);
- assert_eq!(
- output.status.code(),
- Some(2),
- "herdr {} should fail as local parse error; stdout={} stderr={}",
- args.join(" "),
- String::from_utf8_lossy(&output.stdout),
- String::from_utf8_lossy(&output.stderr)
- );
- }
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn tab_management_commands_work() {
- 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(
- &socket_path,
- &["workspace", "create", "--cwd", base.to_str().unwrap()],
- );
- assert!(created.status.success());
- let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
- let workspace_id = created_json["result"]["workspace"]["workspace_id"]
- .as_str()
- .unwrap()
- .to_string();
- let first_tab_id = created_json["result"]["workspace"]["active_tab_id"]
- .as_str()
- .unwrap()
- .to_string();
-
- let created_tab = run_cli(
- &socket_path,
- &["tab", "create", "--workspace", &workspace_id],
- );
- assert!(created_tab.status.success());
- let created_tab_json: serde_json::Value = serde_json::from_slice(&created_tab.stdout).unwrap();
- let second_tab_id = created_tab_json["result"]["tab"]["tab_id"]
- .as_str()
- .unwrap()
- .to_string();
- assert_eq!(second_tab_id, format!("{workspace_id}:t2"));
-
- let listed_tabs = run_cli(&socket_path, &["tab", "list", "--workspace", &workspace_id]);
- assert!(listed_tabs.status.success());
- let listed_tabs_json: serde_json::Value = serde_json::from_slice(&listed_tabs.stdout).unwrap();
- assert_eq!(
- listed_tabs_json["result"]["tabs"].as_array().unwrap().len(),
- 2
- );
-
- let renamed_tab = run_cli(&socket_path, &["tab", "rename", &second_tab_id, "logs"]);
- assert!(renamed_tab.status.success());
- let renamed_tab_json: serde_json::Value = serde_json::from_slice(&renamed_tab.stdout).unwrap();
- assert_eq!(renamed_tab_json["result"]["tab"]["label"], "logs");
-
- let focused_tab = run_cli(&socket_path, &["tab", "focus", &first_tab_id]);
- assert!(focused_tab.status.success());
- let focused_tab_json: serde_json::Value = serde_json::from_slice(&focused_tab.stdout).unwrap();
- assert_eq!(focused_tab_json["result"]["tab"]["tab_id"], first_tab_id);
-
- let tab_get = run_cli(&socket_path, &["tab", "get", &second_tab_id]);
- assert!(tab_get.status.success());
- let tab_get_json: serde_json::Value = serde_json::from_slice(&tab_get.stdout).unwrap();
- assert_eq!(tab_get_json["result"]["tab"]["tab_id"], second_tab_id);
-
- let closed_tab = run_cli(&socket_path, &["tab", "close", &second_tab_id]);
- assert!(closed_tab.status.success());
- let closed_tab_json: serde_json::Value = serde_json::from_slice(&closed_tab.stdout).unwrap();
- assert_eq!(closed_tab_json["result"]["type"], "ok");
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[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_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,
- &[
- "agent",
- "start",
- "main",
- "--kind",
- "pi",
- "--pane",
- &pane_id,
- "--timeout",
- "8000",
- "--",
- "--name",
- "scratch",
- "--no-session",
- ],
- );
- assert_eq!(started["result"]["type"], "agent_started");
- assert_eq!(started["result"]["agent"]["name"], "main");
- 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 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 after = run_cli_json(&socket_path, &["pane", "list"]);
- assert_eq!(pane_topology_snapshot(&after), before_topology);
-
- let stale_idle = run_cli(
- &socket_path,
- &[
- "agent",
- "prompt",
- "main",
- "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);
-}
-
-#[test]
-fn agent_commands_work() {
- 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(
- &socket_path,
- &["workspace", "create", "--cwd", base.to_str().unwrap()],
- );
- assert!(created.status.success());
- let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
- let root_pane_id = created_json["result"]["root_pane"]["pane_id"]
- .as_str()
- .unwrap()
- .to_string();
- let terminal_id = created_json["result"]["root_pane"]["terminal_id"]
- .as_str()
- .unwrap()
- .to_string();
-
- let renamed = run_cli(&socket_path, &["agent", "rename", &root_pane_id, "worker"]);
- assert!(renamed.status.success());
-
- let listed = run_cli_json(&socket_path, &["agent", "list"]);
- assert_eq!(listed["result"]["type"], "agent_list");
- assert_eq!(listed["result"]["agents"][0]["terminal_id"], terminal_id);
- assert_eq!(listed["result"]["agents"][0]["name"], "worker");
-
- 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,
- &[
- "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",
- ],
- )
- .status
- .success());
- let unknown = run_cli(
- &socket_path,
- &["agent", "wait", "worker", "--timeout", "1000"],
- );
- 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,
- &["agent", "read", &terminal_id, "--source", "visible"],
- );
- assert_eq!(read["result"]["type"], "pane_read");
-
- let sent = run_cli(
- &socket_path,
- &["agent", "send", "worker", "echo cli-agent-ok\n"],
- );
- assert!(sent.status.success());
-
- let agent_renamed = run_cli_json(&socket_path, &["agent", "rename", "worker", "reviewer"]);
- assert_eq!(agent_renamed["result"]["agent"]["name"], "reviewer");
-
- let focused = run_cli_json(&socket_path, &["agent", "focus", "reviewer"]);
- assert_eq!(focused["result"]["agent"]["focused"], true);
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[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");
- 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(
- &socket_path,
- &["workspace", "create", "--cwd", base.to_str().unwrap()],
- );
- assert!(created.status.success());
- let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
- let workspace_id = created_json["result"]["workspace"]["workspace_id"]
- .as_str()
- .unwrap()
- .to_string();
-
- let created_tab = run_cli(
- &socket_path,
- &["tab", "create", "--workspace", &workspace_id],
- );
- assert!(created_tab.status.success());
- let created_tab_json: serde_json::Value = serde_json::from_slice(&created_tab.stdout).unwrap();
- let second_root_pane_id = created_tab_json["result"]["root_pane"]["pane_id"]
- .as_str()
- .unwrap()
- .to_string();
-
- let closed = run_cli(&socket_path, &["pane", "close", &second_root_pane_id]);
- assert!(closed.status.success());
- let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap();
- assert_eq!(closed_json["result"]["type"], "ok");
-
- let workspaces = run_cli(&socket_path, &["workspace", "list"]);
- assert!(workspaces.status.success());
- let workspaces_json: serde_json::Value = serde_json::from_slice(&workspaces.stdout).unwrap();
- assert_eq!(
- workspaces_json["result"]["workspaces"]
- .as_array()
- .unwrap()
- .len(),
- 1
- );
- assert_eq!(
- workspaces_json["result"]["workspaces"][0]["workspace_id"],
- workspace_id
- );
-
- let tabs = run_cli(&socket_path, &["tab", "list", "--workspace", &workspace_id]);
- assert!(tabs.status.success());
- let tabs_json: serde_json::Value = serde_json::from_slice(&tabs.stdout).unwrap();
- assert_eq!(tabs_json["result"]["tabs"].as_array().unwrap().len(), 1);
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn pane_close_removes_the_workspace_when_it_closes_the_last_pane() {
- 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(
- &socket_path,
- &["workspace", "create", "--cwd", base.to_str().unwrap()],
- );
- assert!(created.status.success());
- let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
- let root_pane_id = created_json["result"]["root_pane"]["pane_id"]
- .as_str()
- .unwrap()
- .to_string();
-
- let closed = run_cli(&socket_path, &["pane", "close", &root_pane_id]);
- assert!(closed.status.success());
- let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap();
- assert_eq!(closed_json["result"]["type"], "ok");
-
- let workspaces = run_cli(&socket_path, &["workspace", "list"]);
- assert!(workspaces.status.success());
- let workspaces_json: serde_json::Value = serde_json::from_slice(&workspaces.stdout).unwrap();
- assert!(workspaces_json["result"]["workspaces"]
- .as_array()
- .unwrap()
- .is_empty());
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn pane_run_read_and_wait_commands_work() {
- 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));
-
- send_request(
- &socket_path,
- &format!(
- r#"{{"id":"req_cli_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
- base.display()
- ),
- );
- let create = run_cli(
- &socket_path,
- &[
- "pane",
- "run",
- "1-1",
- "echo alpha && echo beta && printf 'ready\\n'",
- ],
- );
- assert!(create.status.success());
-
- let started = Instant::now();
- let waited = run_cli(
- &socket_path,
- &[
- "wait",
- "output",
- "1-1",
- "--match",
- "ready",
- "--source",
- "recent",
- "--lines",
- "40",
- "--timeout",
- "5000",
- ],
- );
- let elapsed = started.elapsed();
- assert!(
- waited.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&waited.stderr)
- );
- assert!(
- elapsed < Duration::from_millis(500),
- "already-matching wait took {elapsed:?}"
- );
- let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap();
- assert_eq!(waited_json["result"]["type"], "output_matched");
-
- let read = run_cli(
- &socket_path,
- &["pane", "read", "1-1", "--source", "recent", "--lines", "40"],
- );
- assert!(read.status.success());
- let text = String::from_utf8(read.stdout).unwrap();
- assert!(text.contains("alpha"));
- assert!(text.contains("ready"));
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn wait_output_matches_recent_unwrapped_text() {
- 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(
- &socket_path,
- &["workspace", "create", "--cwd", base.to_str().unwrap()],
- );
- assert!(created.status.success());
-
- let token = "WRAP_WAIT_TEST_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789";
- let script = base.join("emit-long-token.sh");
- std::fs::write(&script, format!("#!/bin/sh\nprintf '%s\\n' '{token}'\n")).unwrap();
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- let mut perms = std::fs::metadata(&script).unwrap().permissions();
- perms.set_mode(0o755);
- std::fs::set_permissions(&script, perms).unwrap();
- }
-
- let run = run_cli(
- &socket_path,
- &["pane", "run", "1-1", &format!("sh {}", script.display())],
- );
- assert!(run.status.success());
-
- let waited = run_cli(
- &socket_path,
- &[
- "wait",
- "output",
- "1-1",
- "--match",
- token,
- "--source",
- "recent",
- "--lines",
- "80",
- "--timeout",
- "5000",
- ],
- );
- assert!(
- waited.status.success(),
- "stderr: {} stdout: {}",
- String::from_utf8_lossy(&waited.stderr),
- String::from_utf8_lossy(&waited.stdout)
- );
-
- let read = run_cli(
- &socket_path,
- &[
- "pane",
- "read",
- "1-1",
- "--source",
- "recent-unwrapped",
- "--lines",
- "80",
- ],
- );
- assert!(read.status.success());
- let text = String::from_utf8(read.stdout).unwrap();
- assert!(text.contains(token));
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn closing_pane_terminates_processes_inside_it() {
- 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(
- &socket_path,
- &["workspace", "create", "--cwd", base.to_str().unwrap()],
- );
- assert!(created.status.success());
-
- let split = run_cli(
- &socket_path,
- &["pane", "split", "1-1", "--direction", "right"],
- );
- assert!(split.status.success());
- let split_json: serde_json::Value = serde_json::from_slice(&split.stdout).unwrap();
- let pane_id = split_json["result"]["pane"]["pane_id"].as_str().unwrap();
-
- let pid_file = base.join("pane-close.pid");
- let command = format!(
- "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'",
- pid_file.display()
- );
- let ran = run_cli(&socket_path, &["pane", "run", pane_id, &command]);
- assert!(
- ran.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&ran.stderr)
- );
-
- let deadline = Instant::now() + Duration::from_secs(5);
- while Instant::now() < deadline && !pid_file.exists() {
- thread::sleep(Duration::from_millis(25));
- }
- assert!(pid_file.exists(), "pid file was not created");
-
- let pid = wait_for_pid_file(&pid_file, Duration::from_secs(3)).unwrap_or_else(|err| {
- panic!("failed to read pane child pid: {err}");
- });
- assert!(process_exists(pid), "child process was not running");
-
- let closed = run_cli(&socket_path, &["pane", "close", pane_id]);
- assert!(
- closed.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&closed.stderr)
- );
- assert!(
- wait_for_pid_exit(pid, Duration::from_secs(3)),
- "process {pid} survived pane close"
- );
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn closing_workspace_terminates_processes_inside_it() {
- 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(
- &socket_path,
- &["workspace", "create", "--cwd", base.to_str().unwrap()],
- );
- assert!(created.status.success());
-
- let pid_file = base.join("workspace-close.pid");
- let command = format!(
- "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'",
- pid_file.display()
- );
- let ran = run_cli(&socket_path, &["pane", "run", "1-1", &command]);
- assert!(
- ran.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&ran.stderr)
- );
-
- let deadline = Instant::now() + Duration::from_secs(5);
- while Instant::now() < deadline && !pid_file.exists() {
- thread::sleep(Duration::from_millis(25));
- }
- assert!(pid_file.exists(), "pid file was not created");
-
- let pid = wait_for_pid_file(&pid_file, Duration::from_secs(3)).unwrap_or_else(|err| {
- panic!("failed to read pane child pid: {err}");
- });
- assert!(process_exists(pid), "child process was not running");
-
- let closed = run_cli(&socket_path, &["workspace", "close", "1"]);
- assert!(
- closed.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&closed.stderr)
- );
- assert!(
- wait_for_pid_exit(pid, Duration::from_secs(3)),
- "process {pid} survived workspace close"
- );
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn workspace_ids_and_public_pane_ids_are_stable() {
- 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 ws1_json = run_cli_json(
- &socket_path,
- &["workspace", "create", "--cwd", base.to_str().unwrap()],
- );
- let ws1_id = ws1_json["result"]["workspace"]["workspace_id"]
- .as_str()
- .unwrap()
- .to_string();
-
- let split_12_json = run_cli_json(
- &socket_path,
- &["pane", "split", "1-1", "--direction", "right", "--no-focus"],
- );
- assert_eq!(
- split_12_json["result"]["pane"]["pane_id"],
- format!("{ws1_id}:p2")
- );
-
- let split_13_json = run_cli_json(
- &socket_path,
- &["pane", "split", "1-1", "--direction", "down", "--no-focus"],
- );
- assert_eq!(
- split_13_json["result"]["pane"]["pane_id"],
- format!("{ws1_id}:p3")
- );
-
- let ws2_json = run_cli_json(
- &socket_path,
- &["workspace", "create", "--cwd", "/tmp", "--no-focus"],
- );
- let ws2_id = ws2_json["result"]["workspace"]["workspace_id"]
- .as_str()
- .unwrap()
- .to_string();
- assert_ne!(ws2_id, ws1_id);
-
- let ws2_focus = run_cli(&socket_path, &["workspace", "focus", &ws2_id]);
- assert!(
- ws2_focus.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&ws2_focus.stderr)
- );
-
- let ws2_split_json = run_cli_json(
- &socket_path,
- &["pane", "split", "2-1", "--direction", "right", "--no-focus"],
- );
- assert_eq!(
- ws2_split_json["result"]["pane"]["pane_id"],
- format!("{ws2_id}:p2")
- );
-
- let ws3_json = run_cli_json(
- &socket_path,
- &["workspace", "create", "--cwd", "/", "--no-focus"],
- );
- let ws3_id = ws3_json["result"]["workspace"]["workspace_id"]
- .as_str()
- .unwrap()
- .to_string();
- assert_ne!(ws3_id, ws1_id);
- assert_ne!(ws3_id, ws2_id);
-
- let close_ws2 = run_cli(&socket_path, &["workspace", "close", &ws2_id]);
- assert!(
- close_ws2.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&close_ws2.stderr)
- );
-
- let workspaces_json = run_cli_json(&socket_path, &["workspace", "list"]);
- let ids: Vec<String> = workspaces_json["result"]["workspaces"]
- .as_array()
- .unwrap()
- .iter()
- .map(|ws| ws["workspace_id"].as_str().unwrap().to_string())
- .collect();
- assert_eq!(ids, vec![ws1_id.clone(), ws3_id.clone()]);
-
- let new_ws_json = run_cli_json(
- &socket_path,
- &["workspace", "create", "--cwd", "/var/tmp", "--no-focus"],
- );
- let new_ws_id = new_ws_json["result"]["workspace"]["workspace_id"]
- .as_str()
- .unwrap()
- .to_string();
- assert_ne!(new_ws_id, ws1_id);
- assert_ne!(new_ws_id, ws2_id);
- assert_ne!(new_ws_id, ws3_id);
-
- let ws3_panes_json = run_cli_json(&socket_path, &["pane", "list", "--workspace", &ws3_id]);
- assert_eq!(
- ws3_panes_json["result"]["panes"][0]["pane_id"],
- format!("{ws3_id}:p1")
- );
-
- let close_middle = run_cli(&socket_path, &["pane", "close", &format!("{ws1_id}-2")]);
- assert!(
- close_middle.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&close_middle.stderr)
- );
-
- let ws1_panes_json = run_cli_json(&socket_path, &["pane", "list", "--workspace", &ws1_id]);
- let pane_ids: Vec<String> = ws1_panes_json["result"]["panes"]
- .as_array()
- .unwrap()
- .iter()
- .map(|pane| pane["pane_id"].as_str().unwrap().to_string())
- .collect();
- assert_eq!(
- pane_ids,
- vec![format!("{ws1_id}:p1"), format!("{ws1_id}:p3")]
- );
-
- let closed_lookup = run_cli(&socket_path, &["pane", "get", &format!("{ws1_id}:p2")]);
- assert!(
- !closed_lookup.status.success(),
- "closed pane id should not retarget: {}",
- String::from_utf8_lossy(&closed_lookup.stdout)
- );
-
- let split_14_json = run_cli_json(
- &socket_path,
- &[
- "pane",
- "split",
- &format!("{ws1_id}:p1"),
- "--direction",
- "right",
- "--no-focus",
- ],
- );
- assert_eq!(
- split_14_json["result"]["pane"]["pane_id"],
- format!("{ws1_id}:p4")
- );
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn pane_shell_gets_herdr_socket_and_pane_env() {
- 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 = send_request(
- &socket_path,
- &format!(
- r#"{{"id":"req_env_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
- base.display()
- ),
- );
- let pane_id = created["result"]["root_pane"]["pane_id"]
- .as_str()
- .unwrap()
- .to_string();
-
- let env_capture = base.join("pane-env.txt");
- let ran = run_cli(
- &socket_path,
- &[
- "pane",
- "run",
- "1-1",
- &format!(
- "printf '%s\\n%s\\n' \"$HERDR_SOCKET_PATH\" \"$HERDR_PANE_ID\" > {}",
- env_capture.display()
- ),
- ],
- );
- assert!(ran.status.success());
-
- let deadline = Instant::now() + Duration::from_secs(3);
- let mut text = String::new();
- while Instant::now() < deadline {
- if env_capture.exists() {
- text = fs::read_to_string(&env_capture).unwrap();
- if text.contains(&socket_path.display().to_string()) && text.contains(&pane_id) {
- break;
- }
- }
- thread::sleep(Duration::from_millis(25));
- }
- assert!(env_capture.exists(), "env capture file was not created");
- assert!(
- text.contains(&socket_path.display().to_string()),
- "env file was: {text:?}"
- );
- assert!(text.contains(&pane_id), "env file was: {text:?}");
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn wait_agent_status_exits_when_idle_status_matches() {
- 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_dir = base.join("bin");
-
- fs::create_dir_all(&bin_dir).unwrap();
- let fake_pi = bin_dir.join("pi");
- fs::write(
- &fake_pi,
- "#!/bin/sh\nprintf 'starting\\n'\nsleep 4\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n",
- )
- .unwrap();
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- let mut perms = fs::metadata(&fake_pi).unwrap().permissions();
- perms.set_mode(0o755);
- fs::set_permissions(&fake_pi, perms).unwrap();
- }
-
- let inherited_path = std::env::var("PATH").unwrap_or_default();
- let path_override = format!("{}:{}", bin_dir.display(), inherited_path);
- let herdr = spawn_herdr_with_path(
- &config_home,
- &runtime_dir,
- &socket_path,
- Some(Path::new(&path_override)),
- );
-
- wait_for_socket(&socket_path, Duration::from_secs(5));
-
- let created = send_request(
- &socket_path,
- &format!(
- r#"{{"id":"req_cli_2","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
- base.display()
- ),
- );
- assert!(created["result"]["workspace"]["workspace_id"].is_string());
-
- let start_pi = run_cli(&socket_path, &["pane", "run", "1-1", "pi"]);
- assert!(start_pi.status.success());
-
- let waited = run_cli(
- &socket_path,
- &[
- "wait",
- "agent-status",
- "1-1",
- "--status",
- "idle",
- "--timeout",
- "10000",
- ],
- );
- assert!(
- waited.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&waited.stderr)
- );
- let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap();
- assert_eq!(waited_json["event"], "pane.agent_status_changed");
- assert_eq!(waited_json["data"]["agent_status"], "idle");
- assert_eq!(waited_json["data"]["agent"], "pi");
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn plugin_link_list_unlink_cli_smoke_test() {
- 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 plugin_dir = base.join("plugins").join("layout");
- fs::create_dir_all(&plugin_dir).unwrap();
- fs::write(
- plugin_dir.join("herdr-plugin.toml"),
- r#"
-id = "example.layout"
-name = "Layout"
-version = "0.1.0"
-min_herdr_version = "0.6.10"
-description = "Apply a preferred Herdr layout"
-
-[[actions]]
-id = "apply"
-title = "Apply layout"
-contexts = ["workspace"]
-command = ["sh", "-c", "echo layout"]
-
-[[events]]
-on = "worktree.created"
-command = ["sh", "-c", "echo worktree"]
-
-[[panes]]
-id = "board"
-title = "Board"
-placement = "tab"
-command = ["sh", "-c", "sleep 5"]
-"#,
- )
- .unwrap();
-
- let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
- wait_for_socket(&socket_path, Duration::from_secs(5));
- let workspace = run_cli_json(
- &socket_path,
- &[
- "workspace",
- "create",
- "--cwd",
- base.to_str().unwrap(),
- "--focus",
- ],
- );
- assert_eq!(workspace["result"]["type"], "workspace_created");
-
- let linked = run_cli_json_in_dir(&socket_path, &["plugin", "link", "plugins/layout"], &base);
- assert_eq!(linked["result"]["type"], "plugin_linked");
- assert_eq!(linked["result"]["plugin"]["plugin_id"], "example.layout");
- assert_eq!(linked["result"]["plugin"]["actions"][0]["id"], "apply");
- assert_eq!(
- linked["result"]["plugin"]["events"][0]["on"],
- "worktree.created"
- );
- assert_eq!(linked["result"]["plugin"]["panes"][0]["id"], "board");
-
- let listed_human = run_cli(&socket_path, &["plugin", "list"]);
- assert!(listed_human.status.success());
- assert!(String::from_utf8_lossy(&listed_human.stdout).contains("example.layout"));
-
- let listed = run_cli_json(&socket_path, &["plugin", "list", "--json"]);
- assert_eq!(listed["result"]["type"], "plugin_list");
- assert_eq!(
- listed["result"]["plugins"][0]["plugin_id"],
- "example.layout"
- );
-
- let invoked = run_cli_json(
- &socket_path,
- &[
- "plugin",
- "action",
- "invoke",
- "apply",
- "--plugin",
- "example.layout",
- ],
- );
- assert_eq!(invoked["result"]["type"], "plugin_action_invoked");
- assert_eq!(invoked["result"]["action"]["action_id"], "apply");
-
- let logs = run_cli_json(
- &socket_path,
- &[
- "plugin",
- "log",
- "list",
- "--plugin",
- "example.layout",
- "--limit",
- "5",
- ],
- );
- assert_eq!(logs["result"]["type"], "plugin_log_list");
- assert!(!logs["result"]["logs"].as_array().unwrap().is_empty());
-
- let pane = run_cli_json(
- &socket_path,
- &[
- "plugin",
- "pane",
- "open",
- "--plugin",
- "example.layout",
- "--entrypoint",
- "board",
- "--env",
- "HERDR_ROLE=board",
- "--no-focus",
- ],
- );
- assert_eq!(pane["result"]["type"], "plugin_pane_opened");
- assert_eq!(pane["result"]["plugin_pane"]["entrypoint"], "board");
-
- let missing_plugin_value = run_cli(&socket_path, &["plugin", "list", "--plugin"]);
- assert_eq!(missing_plugin_value.status.code(), Some(2));
- assert!(String::from_utf8_lossy(&missing_plugin_value.stderr)
- .contains("missing value for --plugin"));
-
- let invalid_limit = run_cli(
- &socket_path,
- &["plugin", "log", "list", "--limit", "not-a-number"],
- );
- assert_eq!(invalid_limit.status.code(), Some(2));
- assert!(String::from_utf8_lossy(&invalid_limit.stderr).contains("invalid --limit value"));
-
- let unlinked = run_cli_json(&socket_path, &["plugin", "unlink", "example.layout"]);
- assert_eq!(unlinked["result"]["type"], "plugin_unlinked");
- assert_eq!(unlinked["result"]["removed"], true);
-
- let listed = run_cli_json(&socket_path, &["plugin", "list", "--json"]);
- assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn plugin_install_list_uninstall_offline_cli_smoke_test() {
- let base = unique_test_dir();
- let config_home = base.join("config");
- let runtime_dir = base.join("runtime");
- let source_repo = base.join("source-repo");
- let plugin_dir = source_repo.join("worktree-bootstrap");
- fs::create_dir_all(&plugin_dir).unwrap();
- create_committed_repo(&source_repo);
- fs::write(
- plugin_dir.join("herdr-plugin.toml"),
- r#"
-id = "example.worktree-bootstrap"
-name = "Worktree Bootstrap"
-version = "0.1.0"
-min_herdr_version = "0.6.10"
-platforms = ["linux", "macos", "windows"]
-
-[[build]]
-command = ["sh", "-c", "echo built > built.txt; if [ -n \"$HERDR_SESSION\" ]; then echo \"$HERDR_SESSION\" > leaked-session.txt; fi"]
-
-[[actions]]
-id = "bootstrap"
-title = "Bootstrap"
-command = ["sh", "-c", "echo bootstrap"]
-"#,
- )
- .unwrap();
- run_git(
- &source_repo,
- &["add", "worktree-bootstrap/herdr-plugin.toml"],
- );
- run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]);
-
- fs::create_dir_all(&config_home).unwrap();
- fs::create_dir_all(&runtime_dir).unwrap();
- let git_config = base.join("gitconfig");
- fs::write(
- &git_config,
- format!(
- "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
- source_repo.display()
- ),
- )
- .unwrap();
-
- let install = run_named_cli_with_env(
- &config_home,
- &runtime_dir,
- &[
- "--session",
- "plugins",
- "plugin",
- "install",
- "ogulcancelik/herdr-plugin-examples/worktree-bootstrap",
- "--yes",
- ],
- &[
- ("GIT_CONFIG_GLOBAL", &git_config),
- ("HERDR_SESSION", Path::new("leaked-session")),
- ],
- );
- assert!(
- install.status.success(),
- "install failed\nstdout: {}\nstderr: {}",
- String::from_utf8_lossy(&install.stdout),
- String::from_utf8_lossy(&install.stderr)
- );
-
- let listed = run_named_cli_json(
- &config_home,
- &runtime_dir,
- &["--session", "plugins", "plugin", "list", "--json"],
- );
- let plugin = &listed["result"]["plugins"][0];
- assert_eq!(plugin["plugin_id"], "example.worktree-bootstrap");
- assert_eq!(plugin["source"]["kind"], "github");
- assert_eq!(plugin["source"]["owner"], "ogulcancelik");
- assert_eq!(plugin["source"]["repo"], "herdr-plugin-examples");
- assert_eq!(plugin["source"]["subdir"], "worktree-bootstrap");
- assert!(plugin["source"]["resolved_commit"].as_str().is_some());
- let managed_path = PathBuf::from(plugin["source"]["managed_path"].as_str().unwrap());
- assert!(managed_path.exists(), "managed checkout should exist");
- assert!(
- managed_path
- .join("worktree-bootstrap")
- .join("built.txt")
- .exists(),
- "build artifact should be preserved in managed checkout"
- );
- assert!(
- !managed_path
- .join("worktree-bootstrap")
- .join("leaked-session.txt")
- .exists(),
- "build command should not inherit HERDR_SESSION"
- );
-
- let uninstall = run_named_cli(
- &config_home,
- &runtime_dir,
- &[
- "--session",
- "plugins",
- "plugin",
- "uninstall",
- "example.worktree-bootstrap",
- ],
- );
- assert!(
- uninstall.status.success(),
- "uninstall failed\nstdout: {}\nstderr: {}",
- String::from_utf8_lossy(&uninstall.stdout),
- String::from_utf8_lossy(&uninstall.stderr)
- );
- assert!(
- !managed_path.exists(),
- "managed checkout should be deleted on uninstall"
- );
-
- let listed = run_named_cli_json(
- &config_home,
- &runtime_dir,
- &["--session", "plugins", "plugin", "list", "--json"],
- );
- assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn plugin_install_build_failure_does_not_register_or_create_checkout() {
- let base = unique_test_dir();
- let config_home = base.join("config");
- let runtime_dir = base.join("runtime");
- let source_repo = base.join("source-repo");
- let plugin_dir = source_repo.join("build-fail");
- fs::create_dir_all(&plugin_dir).unwrap();
- create_committed_repo(&source_repo);
- fs::write(
- plugin_dir.join("herdr-plugin.toml"),
- r#"
-id = "example.build-fail"
-name = "Build Fail"
-version = "0.1.0"
-min_herdr_version = "0.6.10"
-platforms = ["linux", "macos", "windows"]
-
-[[build]]
-command = ["sh", "-c", "echo before-fail && echo failed-build >&2 && exit 7"]
-
-[[actions]]
-id = "run"
-title = "Run"
-command = ["sh", "-c", "echo should-not-install"]
-"#,
- )
- .unwrap();
- run_git(&source_repo, &["add", "build-fail/herdr-plugin.toml"]);
- run_git(
- &source_repo,
- &["commit", "--quiet", "-m", "add failing plugin"],
- );
-
- fs::create_dir_all(&config_home).unwrap();
- fs::create_dir_all(&runtime_dir).unwrap();
- let git_config = base.join("gitconfig");
- fs::write(
- &git_config,
- format!(
- "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
- source_repo.display()
- ),
- )
- .unwrap();
-
- let install = run_named_cli_with_env(
- &config_home,
- &runtime_dir,
- &[
- "--session",
- "plugins",
- "plugin",
- "install",
- "ogulcancelik/herdr-plugin-examples/build-fail",
- "--yes",
- ],
- &[("GIT_CONFIG_GLOBAL", &git_config)],
- );
- assert!(
- !install.status.success(),
- "install should fail when build command fails"
- );
- assert_eq!(install.status.code(), Some(1));
- let stderr = String::from_utf8_lossy(&install.stderr);
- assert!(stderr.contains("error: plugin build failed"), "{stderr}");
- assert!(stderr.contains(" plugin: example.build-fail"), "{stderr}");
- assert!(stderr.contains(" build: 1/1"), "{stderr}");
- assert!(stderr.contains(" cwd: "), "{stderr}");
- assert!(
- stderr.contains(" command: sh -c echo before-fail && echo failed-build >&2 && exit 7"),
- "{stderr}"
- );
- assert!(stderr.contains(" status: exit status: 7"), "{stderr}");
- assert!(stderr.contains("stdout:\nbefore-fail"), "{stderr}");
- assert!(stderr.contains("stderr:\nfailed-build"), "{stderr}");
- assert!(stderr.contains("Plugin was not installed."), "{stderr}");
- assert!(!stderr.contains("Error: Custom"), "{stderr}");
-
- let listed = run_named_cli_json(
- &config_home,
- &runtime_dir,
- &["--session", "plugins", "plugin", "list", "--json"],
- );
- assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
-
- assert!(
- path_missing_or_empty(&managed_github_plugin_dir(&config_home)),
- "failed build should not leave managed checkouts"
- );
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn plugin_install_build_spawn_failure_prints_clean_error() {
- let base = unique_test_dir();
- let config_home = base.join("config");
- let runtime_dir = base.join("runtime");
- let source_repo = base.join("source-repo");
- let plugin_dir = source_repo.join("missing-tool");
- fs::create_dir_all(&plugin_dir).unwrap();
- create_committed_repo(&source_repo);
- fs::write(
- plugin_dir.join("herdr-plugin.toml"),
- r#"
-id = "example.missing-tool"
-name = "Missing Tool"
-version = "0.1.0"
-min_herdr_version = "0.6.10"
-platforms = ["linux", "macos", "windows"]
-
-[[build]]
-command = ["definitely-missing-herdr-build-tool-xyz"]
-
-[[actions]]
-id = "run"
-title = "Run"
-command = ["sh", "-c", "echo should-not-install"]
-"#,
- )
- .unwrap();
- run_git(&source_repo, &["add", "missing-tool/herdr-plugin.toml"]);
- run_git(
- &source_repo,
- &["commit", "--quiet", "-m", "add missing tool plugin"],
- );
-
- fs::create_dir_all(&config_home).unwrap();
- fs::create_dir_all(&runtime_dir).unwrap();
- let git_config = base.join("gitconfig");
- fs::write(
- &git_config,
- format!(
- "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
- source_repo.display()
- ),
- )
- .unwrap();
-
- let install = run_named_cli_with_env(
- &config_home,
- &runtime_dir,
- &[
- "--session",
- "plugins",
- "plugin",
- "install",
- "ogulcancelik/herdr-plugin-examples/missing-tool",
- "--yes",
- ],
- &[("GIT_CONFIG_GLOBAL", &git_config)],
- );
- assert!(
- !install.status.success(),
- "install should fail when build command cannot start"
- );
- assert_eq!(install.status.code(), Some(1));
- let stderr = String::from_utf8_lossy(&install.stderr);
- assert!(stderr.contains("error: plugin build failed"), "{stderr}");
- assert!(
- stderr.contains(" plugin: example.missing-tool"),
- "{stderr}"
- );
- assert!(stderr.contains(" build: 1/1"), "{stderr}");
- assert!(
- stderr.contains(" command: definitely-missing-herdr-build-tool-xyz"),
- "{stderr}"
- );
- assert!(stderr.contains(" error: failed to start:"), "{stderr}");
- assert!(stderr.contains("Plugin was not installed."), "{stderr}");
- assert!(!stderr.contains("Error: Custom"), "{stderr}");
-
- let listed = run_named_cli_json(
- &config_home,
- &runtime_dir,
- &["--session", "plugins", "plugin", "list", "--json"],
- );
- assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn plugin_install_rejects_manifest_changed_by_build() {
- let base = unique_test_dir();
- let config_home = base.join("config");
- let runtime_dir = base.join("runtime");
- let source_repo = base.join("source-repo");
- let plugin_dir = source_repo.join("manifest-mutator");
- fs::create_dir_all(&plugin_dir).unwrap();
- create_committed_repo(&source_repo);
- fs::write(
- plugin_dir.join("herdr-plugin.toml"),
- r#"
-id = "example.manifest-mutator"
-name = "Manifest Mutator"
-version = "0.1.0"
-min_herdr_version = "0.6.10"
-platforms = ["linux", "macos", "windows"]
-
-[[build]]
-command = ["sh", "mutate.sh"]
-
-[[actions]]
-id = "run"
-title = "Run reviewed command"
-command = ["sh", "-c", "echo reviewed"]
-"#,
- )
- .unwrap();
- fs::write(
- plugin_dir.join("mutate.sh"),
- r#"cat > herdr-plugin.toml <<'EOF'
-id = "example.manifest-mutator"
-name = "Manifest Mutator"
-version = "0.1.0"
-min_herdr_version = "0.0.1"
-platforms = ["linux", "macos", "windows"]
-
-[[build]]
-command = ["sh", "mutate.sh"]
-
-[[actions]]
-id = "run"
-title = "Run reviewed command"
-command = ["sh", "-c", "echo reviewed"]
-EOF
-"#,
- )
- .unwrap();
- run_git(&source_repo, &["add", "manifest-mutator"]);
- run_git(
- &source_repo,
- &["commit", "--quiet", "-m", "add mutating plugin"],
- );
-
- fs::create_dir_all(&config_home).unwrap();
- fs::create_dir_all(&runtime_dir).unwrap();
- let git_config = base.join("gitconfig");
- fs::write(
- &git_config,
- format!(
- "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
- source_repo.display()
- ),
- )
- .unwrap();
-
- let install = run_named_cli_with_env(
- &config_home,
- &runtime_dir,
- &[
- "--session",
- "plugins",
- "plugin",
- "install",
- "ogulcancelik/herdr-plugin-examples/manifest-mutator",
- "--yes",
- ],
- &[("GIT_CONFIG_GLOBAL", &git_config)],
- );
- assert!(
- !install.status.success(),
- "install should fail when build changes reviewed manifest"
- );
- let stderr = String::from_utf8_lossy(&install.stderr);
- assert!(
- stderr.contains("plugin build changed herdr-plugin.toml after install preview"),
- "{stderr}"
- );
-
- let listed = run_named_cli_json(
- &config_home,
- &runtime_dir,
- &["--session", "plugins", "plugin", "list", "--json"],
- );
- assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
-
- assert!(
- path_missing_or_empty(&managed_github_plugin_dir(&config_home)),
- "manifest mutation should not leave managed checkouts"
- );
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn plugin_install_restores_previous_checkout_when_registration_fails() {
- let base = unique_test_dir();
- let config_home = base.join("config");
- let runtime_dir = base.join("runtime");
- let socket_path = runtime_dir.join("fake-herdr.sock");
- let source_repo = base.join("source-repo");
- let plugin_dir = source_repo.join("worktree-bootstrap");
- fs::create_dir_all(&plugin_dir).unwrap();
- create_committed_repo(&source_repo);
- fs::write(
- plugin_dir.join("herdr-plugin.toml"),
- r#"
-id = "example.worktree-bootstrap"
-name = "Worktree Bootstrap"
-version = "0.2.0"
-min_herdr_version = "0.6.10"
-platforms = ["linux", "macos", "windows"]
-
-[[actions]]
-id = "bootstrap"
-title = "Bootstrap"
-command = ["sh", "-c", "echo new"]
-"#,
- )
- .unwrap();
- run_git(
- &source_repo,
- &["add", "worktree-bootstrap/herdr-plugin.toml"],
- );
- run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]);
-
- fs::create_dir_all(&config_home).unwrap();
- fs::create_dir_all(&runtime_dir).unwrap();
- let managed_checkout = config_home
- .join("herdr-dev")
- .join("plugins")
- .join("github")
- .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT);
- fs::create_dir_all(&managed_checkout).unwrap();
- fs::write(managed_checkout.join("old-marker"), "old checkout\n").unwrap();
-
- let git_config = base.join("gitconfig");
- fs::write(
- &git_config,
- format!(
- "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
- source_repo.display()
- ),
- )
- .unwrap();
-
- let listener = UnixListener::bind(&socket_path).unwrap();
- let managed_checkout_for_server = managed_checkout.clone();
- let server = thread::spawn(move || {
- let (mut first, first_line) = accept_fake_cli_operation(&listener);
- let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap();
- assert_eq!(first_request["method"], "plugin.list");
- writeln!(
- first,
- "{}",
- serde_json::json!({
- "id": "cli:plugin",
- "result": {
- "type": "plugin_list",
- "plugins": [{
- "plugin_id": "example.worktree-bootstrap",
- "name": "Worktree Bootstrap",
- "version": "0.1.0",
- "min_herdr_version": "0.6.10",
- "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(),
- "plugin_root": managed_checkout_for_server.display().to_string(),
- "enabled": true,
- "source": {
- "kind": "github",
- "owner": "ogulcancelik",
- "repo": "herdr-plugin-examples",
- "subdir": "worktree-bootstrap",
- "resolved_commit": "old",
- "managed_path": managed_checkout_for_server.display().to_string(),
- "installed_unix_ms": 1
- }
- }]
- }
- })
- )
- .unwrap();
- first.flush().unwrap();
-
- let (mut second, second_line) = accept_fake_cli_operation(&listener);
- let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap();
- assert_eq!(second_request["method"], "plugin.link");
- second
- .write_all(
- br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced failure"}}"#,
- )
- .unwrap();
- second.write_all(b"\n").unwrap();
- second.flush().unwrap();
- });
-
- let install = run_named_cli_with_env_and_socket_override(
- &config_home,
- &runtime_dir,
- &[
- "plugin",
- "install",
- "ogulcancelik/herdr-plugin-examples/worktree-bootstrap",
- "--yes",
- ],
- &[("GIT_CONFIG_GLOBAL", &git_config)],
- Some(&socket_path),
- );
- assert!(
- !install.status.success(),
- "install should fail when plugin.link fails"
- );
- server.join().unwrap();
- assert!(
- managed_checkout.join("old-marker").exists(),
- "old checkout should be restored after registration failure"
- );
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn plugin_install_rejects_server_that_drops_source_metadata() {
- let base = unique_test_dir();
- let config_home = base.join("config");
- let runtime_dir = base.join("runtime");
- let socket_path = runtime_dir.join("fake-herdr.sock");
- let source_repo = base.join("source-repo");
- let plugin_dir = source_repo.join("worktree-bootstrap");
- fs::create_dir_all(&plugin_dir).unwrap();
- create_committed_repo(&source_repo);
- fs::write(
- plugin_dir.join("herdr-plugin.toml"),
- r#"
-id = "example.worktree-bootstrap"
-name = "Worktree Bootstrap"
-version = "0.1.0"
-min_herdr_version = "0.6.10"
-platforms = ["linux", "macos", "windows"]
-
-[[actions]]
-id = "bootstrap"
-title = "Bootstrap"
-command = ["sh", "-c", "echo install"]
-"#,
- )
- .unwrap();
- run_git(
- &source_repo,
- &["add", "worktree-bootstrap/herdr-plugin.toml"],
- );
- run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]);
-
- fs::create_dir_all(&config_home).unwrap();
- fs::create_dir_all(&runtime_dir).unwrap();
- let managed_checkout = config_home
- .join("herdr-dev")
- .join("plugins")
- .join("github")
- .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT);
- let git_config = base.join("gitconfig");
- fs::write(
- &git_config,
- format!(
- "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
- source_repo.display()
- ),
- )
- .unwrap();
-
- let listener = UnixListener::bind(&socket_path).unwrap();
- let managed_checkout_for_server = managed_checkout.clone();
- let server = thread::spawn(move || {
- let (mut first, first_line) = accept_fake_cli_operation(&listener);
- let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap();
- assert_eq!(first_request["method"], "plugin.list");
- first
- .write_all(br#"{"id":"cli:plugin","result":{"type":"plugin_list","plugins":[]}}"#)
- .unwrap();
- first.write_all(b"\n").unwrap();
- first.flush().unwrap();
-
- let (mut second, second_line) = accept_fake_cli_operation(&listener);
- let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap();
- assert_eq!(second_request["method"], "plugin.link");
- writeln!(
- second,
- "{}",
- serde_json::json!({
- "id": "cli:plugin",
- "result": {
- "type": "plugin_linked",
- "plugin": {
- "plugin_id": "example.worktree-bootstrap",
- "name": "Worktree Bootstrap",
- "version": "0.1.0",
- "min_herdr_version": "0.6.10",
- "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(),
- "plugin_root": managed_checkout_for_server.display().to_string(),
- "enabled": true,
- "source": {"kind": "local"}
- }
- }
- })
- )
- .unwrap();
- second.flush().unwrap();
-
- let (mut third, third_line) = accept_fake_cli_operation(&listener);
- let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap();
- assert_eq!(third_request["method"], "plugin.unlink");
- assert_eq!(
- third_request["params"]["plugin_id"],
- "example.worktree-bootstrap"
- );
- third
- .write_all(
- br#"{"id":"cli:plugin","result":{"type":"plugin_unlinked","plugin_id":"example.worktree-bootstrap","removed":true}}"#,
- )
- .unwrap();
- third.write_all(b"\n").unwrap();
- third.flush().unwrap();
- });
-
- let install = run_named_cli_with_env_and_socket_override(
- &config_home,
- &runtime_dir,
- &[
- "plugin",
- "install",
- "ogulcancelik/herdr-plugin-examples/worktree-bootstrap",
- "--yes",
- ],
- &[("GIT_CONFIG_GLOBAL", &git_config)],
- Some(&socket_path),
- );
- assert!(
- !install.status.success(),
- "install should fail when server drops GitHub source metadata"
- );
- server.join().unwrap();
- assert!(
- !managed_checkout.exists(),
- "new checkout should be removed after incompatible plugin.link response"
- );
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn plugin_install_keeps_checkout_when_incompatible_server_cleanup_fails() {
- let base = unique_test_dir();
- let config_home = base.join("config");
- let runtime_dir = base.join("runtime");
- let socket_path = runtime_dir.join("fake-herdr.sock");
- let source_repo = base.join("source-repo");
- let plugin_dir = source_repo.join("worktree-bootstrap");
- fs::create_dir_all(&plugin_dir).unwrap();
- create_committed_repo(&source_repo);
- fs::write(
- plugin_dir.join("herdr-plugin.toml"),
- r#"
-id = "example.worktree-bootstrap"
-name = "Worktree Bootstrap"
-version = "0.1.0"
-min_herdr_version = "0.6.10"
-platforms = ["linux", "macos", "windows"]
-
-[[actions]]
-id = "bootstrap"
-title = "Bootstrap"
-command = ["sh", "-c", "echo install"]
-"#,
- )
- .unwrap();
- run_git(
- &source_repo,
- &["add", "worktree-bootstrap/herdr-plugin.toml"],
- );
- run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]);
-
- fs::create_dir_all(&config_home).unwrap();
- fs::create_dir_all(&runtime_dir).unwrap();
- let managed_checkout = config_home
- .join("herdr-dev")
- .join("plugins")
- .join("github")
- .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT);
- let git_config = base.join("gitconfig");
- fs::write(
- &git_config,
- format!(
- "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n",
- source_repo.display()
- ),
- )
- .unwrap();
-
- let listener = UnixListener::bind(&socket_path).unwrap();
- let managed_checkout_for_server = managed_checkout.clone();
- let server = thread::spawn(move || {
- let (mut first, _first_line) = accept_fake_cli_operation(&listener);
- first
- .write_all(br#"{"id":"cli:plugin","result":{"type":"plugin_list","plugins":[]}}"#)
- .unwrap();
- first.write_all(b"\n").unwrap();
- first.flush().unwrap();
-
- let (mut second, _second_line) = accept_fake_cli_operation(&listener);
- writeln!(
- second,
- "{}",
- serde_json::json!({
- "id": "cli:plugin",
- "result": {
- "type": "plugin_linked",
- "plugin": {
- "plugin_id": "example.worktree-bootstrap",
- "name": "Worktree Bootstrap",
- "version": "0.1.0",
- "min_herdr_version": "0.6.10",
- "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(),
- "plugin_root": managed_checkout_for_server.display().to_string(),
- "enabled": true,
- "source": {"kind": "local"}
- }
- }
- })
- )
- .unwrap();
- second.flush().unwrap();
-
- let (mut third, third_line) = accept_fake_cli_operation(&listener);
- let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap();
- assert_eq!(third_request["method"], "plugin.unlink");
- third
- .write_all(
- br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced unlink failure"}}"#,
- )
- .unwrap();
- third.write_all(b"\n").unwrap();
- third.flush().unwrap();
- });
-
- let install = run_named_cli_with_env_and_socket_override(
- &config_home,
- &runtime_dir,
- &[
- "plugin",
- "install",
- "ogulcancelik/herdr-plugin-examples/worktree-bootstrap",
- "--yes",
- ],
- &[("GIT_CONFIG_GLOBAL", &git_config)],
- Some(&socket_path),
- );
- assert!(
- !install.status.success(),
- "install should fail when source metadata is dropped and cleanup fails"
- );
- server.join().unwrap();
- assert!(
- managed_checkout.exists(),
- "checkout should stay when server cleanup fails"
- );
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn cli_rejects_protocol_mismatch_before_wait_agent_status_request() {
- 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 || {
- let (mut first_stream, _) = listener.accept().unwrap();
- let mut first_line = String::new();
- let mut first_reader = BufReader::new(first_stream.try_clone().unwrap());
- first_reader.read_line(&mut first_line).unwrap();
- let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap();
- if first_request["method"] == "ping" {
- write_fake_pong(&mut first_stream, &first_request, "0.7.1", 14);
- } else {
- first_stream
- .write_all(
- br#"{"id":"cli:wait:agent-status","error":{"code":"not_implemented","message":"method not implemented yet"}}"#,
- )
- .unwrap();
- }
- if first_request["method"] != "ping" {
- first_stream.write_all(b"\n").unwrap();
- first_stream.flush().unwrap();
- }
-
- let mut second_line = None;
- listener.set_nonblocking(true).unwrap();
- let deadline = Instant::now() + Duration::from_millis(250);
- while Instant::now() < deadline {
- match listener.accept() {
- Ok((second_stream, _)) => {
- let mut line = String::new();
- let mut reader = BufReader::new(second_stream);
- reader.read_line(&mut line).unwrap();
- second_line = Some(line);
- break;
- }
- Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
- thread::sleep(Duration::from_millis(10));
- }
- Err(err) => panic!("second accept failed: {err}"),
- }
- }
-
- (first_line, second_line)
- });
-
- let waited = run_cli(
- &socket_path,
- &[
- "wait",
- "agent-status",
- "1-1",
- "--status",
- "idle",
- "--timeout",
- "5000",
- ],
- );
-
- assert_eq!(waited.status.code(), Some(1));
- assert!(waited.stdout.is_empty());
- let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap();
- assert_eq!(error["id"], "cli:wait:agent-status");
- assert_eq!(error["error"]["code"], "protocol_mismatch");
- let message = error["error"]["message"].as_str().unwrap();
- assert!(
- message.contains(&format!("client protocol {CURRENT_PROTOCOL}")),
- "message: {message}"
- );
- assert!(message.contains("server protocol 14"), "message: {message}");
- assert!(message.contains("restart"), "message: {message}");
-
- let (first_line, second_line) = server.join().unwrap();
- let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap();
- assert_eq!(first_request["method"], "ping");
- assert!(
- second_line.is_none(),
- "mismatched CLI sent an operational request: {second_line:?}"
- );
-
- cleanup_test_base(&base);
-}
-
-#[test]
-fn cli_allows_same_protocol_different_version_and_preserves_server_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 || {
- 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,
- "0.7.1-compatible-build",
- CURRENT_PROTOCOL,
- );
-
- let (mut operation_stream, _) = listener.accept().unwrap();
- let mut operation_line = String::new();
- BufReader::new(operation_stream.try_clone().unwrap())
- .read_line(&mut operation_line)
- .unwrap();
- let operation: serde_json::Value = serde_json::from_str(&operation_line).unwrap();
- assert_eq!(operation["method"], "events.wait");
- operation_stream
- .write_all(
- br#"{"id":"cli:wait:agent-status","error":{"code":"not_implemented","message":"compatible server error"}}"#,
- )
- .unwrap();
- operation_stream.write_all(b"\n").unwrap();
- operation_stream.flush().unwrap();
- });
-
- let waited = run_cli(
- &socket_path,
- &[
- "wait",
- "agent-status",
- "1-1",
- "--status",
- "idle",
- "--timeout",
- "5000",
- ],
- );
-
- assert_eq!(waited.status.code(), Some(1));
- assert!(waited.stdout.is_empty());
- let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap();
- assert_eq!(error["id"], "cli:wait:agent-status");
- assert_eq!(error["error"]["code"], "not_implemented");
- assert_eq!(error["error"]["message"], "compatible server error");
- server.join().unwrap();
- cleanup_test_base(&base);
-}
-
-#[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();
- let socket_path = base.join("herdr.sock");
- let listener = UnixListener::bind(&socket_path).unwrap();
-
- let server = thread::spawn(move || {
- let (mut first_ping_stream, _) = listener.accept().unwrap();
- let mut first_ping_line = String::new();
- BufReader::new(first_ping_stream.try_clone().unwrap())
- .read_line(&mut first_ping_line)
- .unwrap();
- let first_ping: serde_json::Value = serde_json::from_str(&first_ping_line).unwrap();
- assert_eq!(first_ping["method"], "ping");
- write_fake_pong(
- &mut first_ping_stream,
- &first_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_request: serde_json::Value = serde_json::from_str(&get_line).unwrap();
- assert_eq!(get_request["method"], "agent.get");
- get_stream
- .write_all(
- 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();
- get_stream.flush().unwrap();
-
- let (mut second_ping_stream, _) = listener.accept().unwrap();
- let mut second_ping_line = String::new();
- BufReader::new(second_ping_stream.try_clone().unwrap())
- .read_line(&mut second_ping_line)
- .unwrap();
- let second_ping: serde_json::Value = serde_json::from_str(&second_ping_line).unwrap();
- assert_eq!(second_ping["method"], "ping");
- write_fake_pong(&mut second_ping_stream, &second_ping, "0.7.1", 14);
-
- listener.set_nonblocking(true).unwrap();
- let deadline = Instant::now() + Duration::from_millis(250);
- while Instant::now() < deadline {
- match listener.accept() {
- 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));
- }
- Err(err) => panic!("accept failed: {err}"),
- }
- }
- });
-
- let waited = run_cli(
- &socket_path,
- &["agent", "wait", "worker", "--timeout", "5000"],
- );
-
- assert_eq!(waited.status.code(), Some(1));
- assert!(waited.stdout.is_empty());
- let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap();
- assert_eq!(error["id"], "cli:agent:wait");
- assert_eq!(error["error"]["code"], "protocol_mismatch");
- server.join().unwrap();
- cleanup_test_base(&base);
-}
-
-#[test]
-fn server_live_handoff_bypasses_protocol_guard() {
- 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 || {
- let (mut stream, _) = listener.accept().unwrap();
- let mut line = String::new();
- BufReader::new(stream.try_clone().unwrap())
- .read_line(&mut line)
- .unwrap();
- let request: serde_json::Value = serde_json::from_str(&line).unwrap();
- assert_eq!(request["method"], "server.live_handoff");
- stream
- .write_all(br#"{"id":"cli:server:live-handoff","result":{"type":"ok"}}"#)
- .unwrap();
- stream.write_all(b"\n").unwrap();
- stream.flush().unwrap();
- });
-
- let handoff = run_cli(&socket_path, &["server", "live-handoff"]);
- assert!(
- handoff.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&handoff.stderr)
- );
- server.join().unwrap();
- cleanup_test_base(&base);
-}
-
-#[test]
-fn plugin_list_preserves_protocol_mismatch_envelope() {
- 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 || {
- let (mut stream, _) = listener.accept().unwrap();
- let mut line = String::new();
- BufReader::new(stream.try_clone().unwrap())
- .read_line(&mut line)
- .unwrap();
- let request: serde_json::Value = serde_json::from_str(&line).unwrap();
- assert_eq!(request["method"], "ping");
- write_fake_pong(&mut stream, &request, "0.7.1", 14);
- });
-
- let listed = run_cli(&socket_path, &["plugin", "list", "--json"]);
- assert_eq!(listed.status.code(), Some(1));
- assert!(listed.stdout.is_empty());
- let error: serde_json::Value = serde_json::from_slice(&listed.stderr).unwrap();
- assert_eq!(error["id"], "cli:plugin");
- assert_eq!(error["error"]["code"], "protocol_mismatch");
- server.join().unwrap();
- cleanup_test_base(&base);
-}
-
-#[test]
-fn wait_agent_status_exits_immediately_when_status_already_matches() {
- 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 = send_request(
- &socket_path,
- &format!(
- r#"{{"id":"req_cli_immediate_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
- base.display()
- ),
- );
- let workspace_id = created["result"]["workspace"]["workspace_id"]
- .as_str()
- .unwrap()
- .to_string();
- let pane_id = format!("{workspace_id}:p1");
-
- let reported = send_request(
- &socket_path,
- &format!(
- r#"{{"id":"req_cli_immediate_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"idle"}}}}"#,
- pane_id
- ),
- );
- assert_eq!(reported["result"]["type"], "ok");
-
- let waited = run_cli(
- &socket_path,
- &[
- "wait",
- "agent-status",
- "1-1",
- "--status",
- "idle",
- "--timeout",
- "1000",
- ],
- );
- assert!(
- waited.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&waited.stderr)
- );
- let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap();
- assert_eq!(waited_json["event"], "pane.agent_status_changed");
- assert_eq!(waited_json["data"]["agent_status"], "idle");
- assert_eq!(waited_json["data"]["agent"], "pi");
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn wait_agent_status_times_out_when_status_does_not_match() {
- 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 = send_request(
- &socket_path,
- &format!(
- r#"{{"id":"req_cli_timeout_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
- base.display()
- ),
- );
- assert_eq!(created["result"]["type"], "workspace_created");
-
- let waited = run_cli(
- &socket_path,
- &[
- "wait",
- "agent-status",
- "1-1",
- "--status",
- "blocked",
- "--timeout",
- "100",
- ],
- );
- assert!(!waited.status.success());
- assert!(
- String::from_utf8_lossy(&waited.stderr)
- .contains("timed out waiting for agent status change"),
- "stderr: {}",
- String::from_utf8_lossy(&waited.stderr)
- );
-
- cleanup_spawned_herdr(herdr, base);
-}
-
-#[test]
-fn wait_agent_status_exits_when_done_status_matches() {
- 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_dir = base.join("bin");
-
- fs::create_dir_all(&bin_dir).unwrap();
- let fake_pi = bin_dir.join("pi");
- fs::write(
- &fake_pi,
- "#!/bin/sh\nprintf 'starting\\n'\nsleep 4\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n",
- )
- .unwrap();
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- let mut perms = fs::metadata(&fake_pi).unwrap().permissions();
- perms.set_mode(0o755);
- fs::set_permissions(&fake_pi, perms).unwrap();
- }
-
- let inherited_path = std::env::var("PATH").unwrap_or_default();
- let path_override = format!("{}:{}", bin_dir.display(), inherited_path);
- let herdr = spawn_herdr_with_path(
- &config_home,
- &runtime_dir,
- &socket_path,
- Some(Path::new(&path_override)),
- );
-
- wait_for_socket(&socket_path, Duration::from_secs(5));
-
- let created = send_request(
- &socket_path,
- &format!(
- r#"{{"id":"req_cli_status_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
- base.display()
- ),
- );
- let workspace_id = created["result"]["workspace"]["workspace_id"]
- .as_str()
- .unwrap()
- .to_string();
-
- let tab_created = send_request(
- &socket_path,
- &format!(
- r#"{{"id":"req_cli_status_2","method":"tab.create","params":{{"workspace_id":"{}","focus":true}}}}"#,
- workspace_id
- ),
- );
- assert_eq!(tab_created["result"]["type"], "tab_created");
-
- let start_pi = run_cli(&socket_path, &["pane", "run", "1-1", "pi"]);
- assert!(start_pi.status.success());
-
- let waited = run_cli(
- &socket_path,
- &[
- "wait",
- "agent-status",
- "1-1",
- "--status",
- "done",
- "--timeout",
- "10000",
- ],
- );
- assert!(
- waited.status.success(),
- "stderr: {}",
- String::from_utf8_lossy(&waited.stderr)
- );
- let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap();
- assert_eq!(waited_json["event"], "pane.agent_status_changed");
- assert_eq!(waited_json["data"]["agent_status"], "done");
- assert_eq!(waited_json["data"]["agent"], "pi");
-
- cleanup_spawned_herdr(herdr, base);
-}
diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs
index 9f607195..c93898b6 100644
--- a/tests/live_handoff.rs
+++ b/tests/live_handoff.rs
@@ -1212,6 +1212,94 @@ fn live_handoff_accepts_canonical_pane_id_from_child_env() {
}
#[test]
+fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let old_session = base.join("old-session.jsonl");
+ let new_session = base.join("new-session.jsonl");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ register_runtime_dir(&runtime_dir);
+ let created = request(
+ &api_socket,
+ serde_json::json!({
+ "id": "test:workspace:create",
+ "method": "workspace.create",
+ "params": {"cwd": "/tmp", "focus": true}
+ }),
+ );
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ assert_ok(request(
+ &api_socket,
+ serde_json::json!({
+ "id": "test:agent:report",
+ "method": "pane.report_agent",
+ "params": {
+ "pane_id": pane_id,
+ "source": "herdr:pi",
+ "agent": "pi",
+ "state": "idle",
+ "seq": 1,
+ "agent_session_path": old_session
+ }
+ }),
+ ));
+ assert_ok(request(
+ &api_socket,
+ serde_json::json!({
+ "id": "test:agent:rename",
+ "method": "agent.rename",
+ "params": {"target": pane_id, "name": "reviewer"}
+ }),
+ ));
+
+ assert_ok(request(
+ &api_socket,
+ serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}),
+ ));
+ drop(spawned);
+ wait_for_api(&api_socket, Duration::from_secs(10));
+
+ assert_ok(request(
+ &api_socket,
+ serde_json::json!({
+ "id": "test:agent:new-session",
+ "method": "pane.report_agent_session",
+ "params": {
+ "pane_id": pane_id,
+ "source": "herdr:pi",
+ "agent": "pi",
+ "seq": 2,
+ "agent_session_path": new_session,
+ "session_start_source": "new"
+ }
+ }),
+ ));
+ let old_name = request(
+ &api_socket,
+ serde_json::json!({
+ "id": "test:agent:get-old-name",
+ "method": "agent.get",
+ "params": {"target": "reviewer"}
+ }),
+ );
+ assert_eq!(old_name["error"]["code"], "agent_not_found", "{old_name}");
+
+ let _ = request(
+ &api_socket,
+ serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}),
+ );
+ cleanup_test_base(&base);
+}
+
+#[test]
fn live_handoff_keeps_agent_started_pane_after_agent_exits() {
use std::os::unix::fs::PermissionsExt;