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