From 6a9898f57762a5acfdaefb3d2ac2805dfceb729d Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Tue, 6 Oct 2026 22:03:41 -0700 Subject: [PATCH] agent: kernel-locked manifest writes off the runtime, LISTEN_PID required, one frame deadline Re-audit of #135: - The manifest cache's write lock was a create-new lockfile broken once older than 10 s: two waiters could both judge it stale, remove and recreate it, and both hold it. It is now the repo's session lock (`session_store::acquire_session_lock`) on `.lock`, a kernel file lock that dies with its holder, so there is nothing to judge stale. The lock wait blocks, so `store`/`forget`/`store_skills` are async and run it with `spawn_blocking`; they were called from connection tasks on tokio workers. - `serve` adopted socket-activated descriptors with `LISTEN_PID` unset (`listenfd` only checks it when present), and the comment claimed the guard was enforced. Production now requires `LISTEN_PID` to name this process and refuses otherwise at startup. The test harness's `HeldPort` starts `serve` the way an activator does (a shell exports its own pid and execs in place), so the binary has no test-only path. - One frame deadline (`FRAME_DEADLINE`, 60 s) for every `serve` reader: `serve_frames` for stdout in all serve_* suites, `ws_next_frame` for WebSockets, and `skills_env::Serve`, which is now built on it. A stalled run fails promptly instead of at the runner's kill; `serve_harness_deadlines` holds every serve_* suite to it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk --- crates/agent/ARCHITECTURE.md | 6 +- crates/agent/src/serve_ws.rs | 54 +++- crates/agent/src/tools/mcp.rs | 5 +- crates/agent/src/tools/mcp_manifest.rs | 249 ++++++++++++------ crates/agent/src/tools/mcp_skills.rs | 3 +- .../agent/tests/common/mcp_events_fixture.rs | 11 +- crates/agent/tests/common/mod.rs | 116 +++++++- crates/agent/tests/common/skills_env.rs | 23 +- crates/agent/tests/mcp_events_v1a.rs | 13 +- crates/agent/tests/mcp_events_webhook.rs | 10 +- crates/agent/tests/serve_approval.rs | 26 +- crates/agent/tests/serve_auth.rs | 12 +- crates/agent/tests/serve_block_images.rs | 12 +- crates/agent/tests/serve_busy_self_abort.rs | 12 +- crates/agent/tests/serve_compaction_retry.rs | 44 ++-- crates/agent/tests/serve_exec_endpoint.rs | 14 +- crates/agent/tests/serve_gateway_headers.rs | 6 +- crates/agent/tests/serve_grant.rs | 4 +- crates/agent/tests/serve_harness_deadlines.rs | 101 +++++++ crates/agent/tests/serve_lifecycle.rs | 20 +- crates/agent/tests/serve_list_paging.rs | 16 +- crates/agent/tests/serve_memory.rs | 8 +- crates/agent/tests/serve_models_thinking.rs | 38 +-- .../agent/tests/serve_oauth_model_switch.rs | 4 +- crates/agent/tests/serve_prompt_flow.rs | 36 +-- .../agent/tests/serve_session_addressing.rs | 6 +- crates/agent/tests/serve_session_lifecycle.rs | 58 ++-- crates/agent/tests/serve_session_memory.rs | 10 +- crates/agent/tests/serve_session_title.rs | 10 +- crates/agent/tests/serve_session_tree.rs | 78 +++--- crates/agent/tests/serve_state_reporting.rs | 36 ++- crates/agent/tests/serve_structured_output.rs | 18 +- crates/agent/tests/serve_subagent.rs | 6 +- crates/agent/tests/serve_systemd.rs | 62 +++++ crates/agent/tests/serve_todo.rs | 14 +- crates/agent/tests/serve_tools_bash.rs | 40 +-- crates/agent/tests/serve_trust_prompts.rs | 24 +- 37 files changed, 775 insertions(+), 430 deletions(-) create mode 100644 crates/agent/tests/serve_harness_deadlines.rs diff --git a/crates/agent/ARCHITECTURE.md b/crates/agent/ARCHITECTURE.md index 0ade9def..98cabc4a 100644 --- a/crates/agent/ARCHITECTURE.md +++ b/crates/agent/ARCHITECTURE.md @@ -33,7 +33,7 @@ The harness layers several capabilities over the bare tools + loop: not "anything on `127.0.0.1`" — decide who may connect; stale sockets are reclaimed with a connect-probe (never clobbering a live daemon). Under **systemd socket activation** (`LISTEN_FDS` set, no explicit listen flag), the daemon _adopts_ the socket systemd bound and passed as fd 3 - (`listenfd`, honoring the `LISTEN_PID` guard) instead of binding its own — so the socket outlives a + (`listenfd`) instead of binding its own — only when `LISTEN_PID` is set and names this process (`serve_ws::check_listen_pid`; `listenfd` itself would also take the descriptors with `LISTEN_PID` unset, and every real activator sets it, so an unset or foreign one is refused at startup rather than guessed at) — so the socket outlives a `systemctl restart` (connections queue in the kernel) and systemd owns its perms/lifecycle. The protocol is byte-identical across all transports; all feed the transport-agnostic `serve::serve_session` core, which reads @@ -3691,7 +3691,7 @@ context. Only host-authored records count: a skill-tool result (matched to its ` its `skill://` resources are ordinary resources. - **Manifest cache.** `mcp_manifest` (version 3) records the entries (`None` = extension not declared) and their diagnostics, so a reaped or never-started server still lists its skills; the - first load dials it (and re-fetches the entry, whose freshness a cache cannot vouch for). Every write is a read-modify-write of one file shared by all connections (a connect, a forget, a refreshed listing), so each runs under a lockfile (`mcp_manifest::FileLock`, the auth and settings stores' pattern) and renames a temporary of its own into place; concurrent refreshes lose nobody's update. + first load dials it (and re-fetches the entry, whose freshness a cache cannot vouch for). Every write is a read-modify-write of one file shared by all connections (a connect, a forget, a refreshed listing), so each runs under `.lock` — the session lock (`session_store::acquire_session_lock`), a kernel file lock that dies with its holder, so a crashed writer's leftover file is simply unlocked and no two waiters can both take it — and renames a temporary of its own into place; concurrent refreshes lose nobody's update. Waiting for the lock blocks, so the public writers (`store`, `forget`, `store_skills`) are `async` and run it with `spawn_blocking`: a connection task never holds an async worker while another writer finishes. - **Nested-request attribution.** Every skills request (`skills/list`, `skills/get`, `resources/read`) goes through `mcp::request_tracked`, registered with `track_call` under the calling session's host for as long as it is outstanding, so a nested request the server raises @@ -4111,7 +4111,7 @@ backend an operator points it at. | `src/bin/mcp_skills_fixture_server.rs` | Hand-rolled SEP-2640 server for `tests/mcp_skills*.rs` (stdio, or `--http`): an ordinary skill with a supporting file and a nested skill, a same-name pair, a `disable-model-invocation` skill, a tampered one, a dynamic one, an unlisted one, two over the per-skill limits, and a late one published by its `publish_late` tool (with `notifications/resources/list_changed`); caching hints from env; `_meta` on every result like a `2026-07-28` server; logs every request so tests assert what was (not) fetched | | `benches/search.rs` | Criterion macro-bench: `grep` (1 vs auto threads) and `find` (sequential) over a 5,000-file tree | | `benches/serve_runtime.rs` | Process-level Tokio runtime A/B for `serve --listen`: spawns the real binary under `current_thread` vs multi-thread (`BEYOND_AI_AGENT_TOKIO_WORKER_THREADS`) and drives concurrent WebSocket sessions, reporting RSS, throughput, p95 latency, and CPU. Custom harness, not Criterion/divan — the cost is thread stacks and per-thread heaps, which an in-process alloc profiler never sees. | -| `tests/common/mod.rs` | Shared test harness: mock Anthropic-SSE model server (an in-order server answers `serve`'s session-title request itself, off the conversation script and out of its record, counting them for `title_calls`; a test about titles matches `SESSION_TITLE_MARKER` on a routed one), ports with no window for another process to take them (`spawn_listening`: `serve --listen 127.0.0.1:0`, the bound port read from its announcement; `HeldPort`: a listener the test binds and hands over by socket activation, for a port named in `serve`'s own arguments; `DeadPort`: bound, never listening — `free_port` is left only for the gateway and `nats-server`), `frames_with_deadline` (a stdout reader that fails instead of hanging), `serve`/`run` command builders, `read_until_response`, gateway binary locator, port/connection helpers, process-lifetime JetStream for managed-gateway tests — used by every file below | +| `tests/common/mod.rs` | Shared test harness: mock Anthropic-SSE model server (an in-order server answers `serve`'s session-title request itself, off the conversation script and out of its record, counting them for `title_calls`; a test about titles matches `SESSION_TITLE_MARKER` on a routed one), ports with no window for another process to take them (`spawn_listening`: `serve --listen 127.0.0.1:0`, the bound port read from its announcement; `HeldPort`: a listener the test binds and hands over by socket activation, for a port named in `serve`'s own arguments; `DeadPort`: bound, never listening — `free_port` is left only for the gateway and `nats-server`), one frame deadline for every `serve` reader (`FRAME_DEADLINE`, 60s: `serve_frames` for stdout, `ws_next_frame` for WebSockets, and `skills_env::Serve`), so a stalled run fails promptly instead of waiting for the runner's kill — `tests/serve_harness_deadlines.rs` holds every `serve_*` suite to it; `HeldPort` starts `serve` through a shell that exports its own pid as `LISTEN_PID` and `exec`s in place, as an activator does; `serve`/`run` command builders, `read_until_response`, gateway binary locator, port/connection helpers, process-lifetime JetStream for managed-gateway tests — used by every file below | | `tests/run_*.rs` | `run` binary against a mock model server (no gateway in the loop), split by domain: `run_core_flow` (tool round trips, refusal exit codes, text/json mode, stdin/@file input), `run_skills_prompts` (skill/prompt-template expansion, trust gating), `run_session_management` (`--session`/`--continue`/`--name`/`--fork`, same- and cross-project), `run_cli_flags` (`--version`/`--help`/`list-models`/`export`, flags reaching the wire request, including `models.json` header/api_key overrides, `--idle-timeout-ms`, `--block-images`), `run_stdout_robustness` (Task #10: a closed stdout pipe, simulated by dropping the child's stdout handle mid-stream, must exit 0 rather than panic on `EPIPE`, in both text and `--json` mode) | | `tests/serve_*.rs` | `serve` binary NDJSON protocol round-trip, split by domain: `serve_session_lifecycle` (startup/resume/crash recovery/jsonl framing/export), `serve_state_reporting` (`get_state`/`get_session_stats`/`cwd_stale`), `serve_session_tree` (branches/forks/`get_tree`), `serve_models_thinking` (model/thinking-level/`--models`), `serve_compaction_retry` (compaction + whole-run auto-retry), `serve_trust_prompts` (trust gating + system prompt), `serve_tools_bash` (tool exclusion + host `bash`), `serve_prompt_flow` (`prompt`/`steer`/`follow_up` queuing, busy semantics, refusal, abort), `serve_lifecycle` (host-emitted run lifecycle POSTs: started/progress/terminal) — each a separate Cargo test binary, ~400-1200 lines apiece rather than one ~6,200-line file | | `tests/gateway_e2e.rs` | `run` binary → real gateway binary → mock upstream (proves key-swap + the virtual key never reaches upstream). Boots a real nats-server so the fail-closed allowance watcher can seed | diff --git a/crates/agent/src/serve_ws.rs b/crates/agent/src/serve_ws.rs index 12930984..4689fa66 100644 --- a/crates/agent/src/serve_ws.rs +++ b/crates/agent/src/serve_ws.rs @@ -1534,15 +1534,43 @@ async fn bind_uds( Ok(listener) } +/// Whether `LISTEN_PID` says the passed sockets are this process's: systemd's protocol sets it to the +/// pid it execs, so a descriptor list inherited by anything else (a child of the activated process, +/// a shell that exported the variables) is not taken. Required, not merely checked when present: +/// `listenfd` skips the check when `LISTEN_PID` is unset, and every real activator (systemd, +/// `systemd-socket-activate`) sets it — so an unset one means the variables did not come from +/// activation, and adopting whatever descriptors happen to sit at those numbers would be a guess. +#[cfg(unix)] +fn check_listen_pid(listen_pid: Option<&str>, ours: u32) -> Result<(), String> { + match listen_pid { + Some(pid) if pid.trim().parse::().ok() == Some(ours) => Ok(()), + Some(pid) => Err(format!( + "LISTEN_FDS is set, but LISTEN_PID={pid} names another process (this is {ours}): the \ + sockets were passed to that process, not to this one; refusing them" + )), + None => Err( + "LISTEN_FDS is set without LISTEN_PID: socket activation always names the process its \ + sockets are for, so these did not come from it; refusing them" + .to_string(), + ), + } +} + /// Adopt the listener socket systemd passed via socket activation (`LISTEN_FDS`/`LISTEN_PID`). Returns -/// `(tcp, uds)` — whichever transport systemd activated. `listenfd::ListenFd::from_env` reads the env -/// honoring the `LISTEN_PID == getpid()` guard (so we never grab a parent's fds), and hands back the -/// inherited std listeners; we set them non-blocking and wrap as tokio listeners. Tries a unix socket -/// first (the local-daemon case), then TCP. Errors if systemd set `LISTEN_FDS` but passed nothing we -/// can serve. Must run on a tokio runtime — `from_std` registers the listener with the reactor. +/// `(tcp, uds)` — whichever transport systemd activated. The sockets are taken only when `LISTEN_PID` +/// is set and names this process ([`check_listen_pid`]; `listenfd` alone would also take them when it +/// is unset), so we never grab a parent's fds; `listenfd::ListenFd::from_env` then hands back the +/// inherited std listeners, marked close-on-exec, and we set them non-blocking and wrap them as tokio +/// listeners. Tries a unix socket first (the local-daemon case), then TCP. Errors if systemd set +/// `LISTEN_FDS` but passed nothing we can serve. Must run on a tokio runtime — `from_std` registers +/// the listener with the reactor. #[cfg(unix)] fn adopt_systemd_listeners() -> Result<(Option, Option), Box> { + check_listen_pid( + std::env::var("LISTEN_PID").ok().as_deref(), + std::process::id(), + )?; let mut fds = listenfd::ListenFd::from_env(); // `.ok().flatten()` so a wrong-type fd (e.g. asking for unix when it's tcp) falls through rather // than propagating — we then try the other type. @@ -2824,6 +2852,22 @@ async fn write_http_err( mod tests { use super::*; + /// Passed sockets are this process's only when `LISTEN_PID` says so — unset is not "anyone's". + #[cfg(unix)] + #[test] + fn passed_sockets_are_taken_only_when_listen_pid_names_this_process() { + assert!(check_listen_pid(Some("4242"), 4242).is_ok()); + assert!(check_listen_pid(Some(" 4242\n"), 4242).is_ok()); + let other = check_listen_pid(Some("1"), 4242).unwrap_err(); + assert!( + other.contains("LISTEN_PID=1") && other.contains("4242"), + "{other}" + ); + let unset = check_listen_pid(None, 4242).unwrap_err(); + assert!(unset.contains("without LISTEN_PID"), "{unset}"); + assert!(check_listen_pid(Some("not-a-pid"), 4242).is_err()); + } + /// Build a `Live` handle with no session behind it. The input receiver is returned and must be held /// to keep the session "alive"; dropping it is how a test models a session whose loop ended. fn handle( diff --git a/crates/agent/src/tools/mcp.rs b/crates/agent/src/tools/mcp.rs index eba0fdde..bef76fef 100644 --- a/crates/agent/src/tools/mcp.rs +++ b/crates/agent/src/tools/mcp.rs @@ -3050,7 +3050,7 @@ async fn tools_from_client( // header). Such a server is forgotten instead, so the next boot asks it again. let private_listing = skill_listing.as_ref().is_some_and(|l| l.private); if let (Some(dir), true) = (manifest_dir, private_listing) { - crate::tools::mcp_manifest::forget(dir, config); + crate::tools::mcp_manifest::forget(dir, config).await; } if let (Some(dir), false) = (manifest_dir, private_listing) { crate::tools::mcp_manifest::store( @@ -3097,7 +3097,8 @@ async fn tools_from_client( .as_ref() .map(|l| l.diagnostics.clone()) .unwrap_or_default(), - ); + ) + .await; } let apps = dial.apps.then(|| { diff --git a/crates/agent/src/tools/mcp_manifest.rs b/crates/agent/src/tools/mcp_manifest.rs index 1995064d..2ab42b35 100644 --- a/crates/agent/src/tools/mcp_manifest.rs +++ b/crates/agent/src/tools/mcp_manifest.rs @@ -26,9 +26,8 @@ //! longer exists; it cannot make a call silently do the wrong thing. use std::collections::BTreeMap; -use std::io::ErrorKind; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant, SystemTime}; +use std::path::PathBuf; +use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; @@ -177,13 +176,39 @@ pub fn load(dir: &ManifestDir, config: &McpServerConfig) -> Option.lock` — a kernel file lock, which +/// the kernel releases when its holder exits, however it exits. So there is no staleness to judge and +/// no lockfile to break: a crashed writer's leftover file is simply unlocked, and two waiters cannot +/// both take it. (A create-new lockfile with an age-based break could: both judge it stale, both +/// remove and recreate it, both "hold" it.) Blocking — call it off the async runtime. +fn lock_store(dir: &ManifestDir) -> Option { + let path = dir.file(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok()?; + } + let deadline = Instant::now() + LOCK_TIMEOUT; + loop { + match crate::session_store::acquire_session_lock(&path) { + Ok(Some(lock)) => return Some(lock), + Ok(None) if Instant::now() < deadline => std::thread::sleep(LOCK_RETRY_INTERVAL), + Ok(None) | Err(_) => return None, + } + } +} + /// Read the store, apply `change`, and write it back if `change` says it changed anything — under -/// the cross-process [`FileLock`], since several connections (and agents) update one file: a skills -/// refresh on one server racing a connect on another must not drop either's entry. Best-effort like -/// everything here: a lock that cannot be had skips the write, which costs a server spawn at the next -/// boot, never a wrong answer. +/// [`lock_store`], since several connections (and agents) update one file: a skills refresh on one +/// server racing a connect on another must not drop either's entry. Best-effort like everything here: +/// a lock that cannot be had skips the write, which costs a server spawn at the next boot, never a +/// wrong answer. Blocking (it may wait for the lock), so the public writers below run it on the +/// blocking pool: they are called from connection tasks on the async runtime. fn update_store(dir: &ManifestDir, change: impl FnOnce(&mut Store) -> bool) { - let Ok(_lock) = FileLock::acquire(&dir.file()) else { + let Some(_lock) = lock_store(dir) else { return; }; let mut all = read_store(dir); @@ -192,10 +217,20 @@ fn update_store(dir: &ManifestDir, change: impl FnOnce(&mut Store) -> bool) { } } +/// [`update_store`] on the blocking pool, so waiting for another writer never holds an async worker. +async fn update_store_off_runtime( + dir: &ManifestDir, + change: impl FnOnce(&mut Store) -> bool + Send + 'static, +) { + let dir = dir.clone(); + let _ = tokio::task::spawn_blocking(move || update_store(&dir, change)).await; +} + /// Drop `config`'s cached manifest, if any — for a server whose answer must not be cached here (a /// skills listing marked `cacheScope: "private"`). Best-effort, like [`store`]. -pub fn forget(dir: &ManifestDir, config: &McpServerConfig) { - update_store(dir, |all| all.remove(&config.name).is_some()); +pub async fn forget(dir: &ManifestDir, config: &McpServerConfig) { + let name = config.name.clone(); + update_store_off_runtime(dir, move |all| all.remove(&name).is_some()).await; } /// Replace only the skills listing in `config`'s cached manifest — for a listing re-fetched after @@ -204,7 +239,7 @@ pub fn forget(dir: &ManifestDir, config: &McpServerConfig) { /// (`cacheScope: "private"`) forgets the server instead, as at connect. Without a manifest already /// recorded for this exact invocation there is nothing to amend: the next live connect writes a /// whole one. Best-effort, like [`store`]. -pub fn store_skills( +pub async fn store_skills( dir: &ManifestDir, config: &McpServerConfig, skills: Vec, @@ -212,23 +247,25 @@ pub fn store_skills( private: bool, ) { if private { - forget(dir, config); + forget(dir, config).await; return; } let key = invocation_key(config); - update_store(dir, |all| { - let Some(manifest) = all.get_mut(&config.name).filter(|m| m.key == key) else { + let name = config.name.clone(); + update_store_off_runtime(dir, move |all| { + let Some(manifest) = all.get_mut(&name).filter(|m| m.key == key) else { return false; }; manifest.skills = Some(skills); manifest.skill_diagnostics = skill_diagnostics; true - }); + }) + .await; } /// Record what `config`'s server advertises. Best-effort: a cache that cannot be written costs a /// server spawn on the next boot, which is the behavior without it. -pub fn store( +pub async fn store( dir: &ManifestDir, config: &McpServerConfig, tools: Vec, @@ -245,10 +282,12 @@ pub fn store( skills, skill_diagnostics, }; - update_store(dir, |all| { - all.insert(config.name.clone(), manifest); + let name = config.name.clone(); + update_store_off_runtime(dir, move |all| { + all.insert(name, manifest); true - }); + }) + .await; } fn write_store(dir: &ManifestDir, all: &Store) { @@ -261,8 +300,7 @@ fn write_store(dir: &ManifestDir, all: &Store) { } // Write-then-rename: a crash mid-write must not leave a truncated manifest that reads as a // *different* tool set. A miss is fine; a plausible-looking wrong answer is not. The temporary - // name is this writer's own, so a writer that lost the lock to a stale-lock break still cannot - // rename another's half-written file into place. + // name is this writer's own, so no two writes ever share one. static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let tmp = path.with_extension(format!("json.{}.{seq}.tmp", std::process::id())); @@ -271,64 +309,6 @@ fn write_store(dir: &ManifestDir, all: &Store) { } } -const LOCK_TIMEOUT: Duration = Duration::from_secs(5); -const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(5); -const STALE_LOCK_AGE: Duration = Duration::from_secs(10); - -/// A cross-process advisory lock via atomic lockfile creation — `mcp_auth_store.rs::FileLock`'s -/// pattern (itself `trust_store.rs`'s and `settings.rs`'s), duplicated rather than shared like each of -/// those. Held for one read-modify-write of the manifest; a lockfile older than [`STALE_LOCK_AGE`] is -/// a crashed holder's and is broken. -struct FileLock { - path: PathBuf, -} - -impl FileLock { - fn acquire(store_path: &Path) -> std::io::Result { - let mut os = store_path.as_os_str().to_owned(); - os.push(".lock"); - let lock_path = PathBuf::from(os); - if let Some(parent) = lock_path.parent() { - std::fs::create_dir_all(parent)?; - } - let deadline = Instant::now() + LOCK_TIMEOUT; - loop { - match std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&lock_path) - { - Ok(_) => return Ok(Self { path: lock_path }), - Err(e) if e.kind() == ErrorKind::AlreadyExists => { - let stale = std::fs::metadata(&lock_path) - .and_then(|m| m.modified()) - .ok() - .and_then(|t| SystemTime::now().duration_since(t).ok()) - .is_some_and(|age| age > STALE_LOCK_AGE); - if stale { - let _ = std::fs::remove_file(&lock_path); - continue; - } - if Instant::now() >= deadline { - return Err(std::io::Error::new( - ErrorKind::TimedOut, - format!("timed out waiting for {}", lock_path.display()), - )); - } - std::thread::sleep(LOCK_RETRY_INTERVAL); - } - Err(e) => return Err(e), - } - } - } -} - -impl Drop for FileLock { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.path); - } -} - #[cfg(test)] mod tests { use super::*; @@ -368,7 +348,7 @@ mod tests { }); }; race(&|i| { - store( + block_on(store( &manifest, &configs[i], vec![], @@ -376,19 +356,19 @@ mod tests { vec![], Some(vec![]), vec![], - ); + )); }); for (i, c) in configs.iter().enumerate() { assert!(load(&manifest, c).is_some(), "s{i}'s connect was lost"); } race(&|i| { - store_skills( + block_on(store_skills( &manifest, &configs[i], vec![], vec![format!("refreshed-{i}")], false, - ); + )); }); for (i, c) in configs.iter().enumerate() { let m = load(&manifest, c).unwrap_or_else(|| panic!("s{i}'s entry was lost")); @@ -401,11 +381,110 @@ mod tests { let leftovers: Vec<_> = std::fs::read_dir(dir.path()) .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) - .filter(|n| n != FILE) + .filter(|n| n != FILE && *n != format!("{FILE}.lock")) .collect(); assert!( leftovers.is_empty(), - "no lock or temporary file is left: {leftovers:?}" + "no temporary file is left: {leftovers:?}" + ); + } + + /// Run one async writer to completion on a runtime of its own (each test thread is a separate + /// agent process, as far as the manifest is concerned). + fn block_on(f: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(f) + } + + /// A lock left behind by a writer that died — its file still there, long past any age a + /// "stale" judgement would use — never lets two waiters hold the lock at once: many writers + /// racing for it take turns. (An age-based break let every waiter that judged it stale remove and + /// recreate it, and each then believed it held the lock.) + #[test] + fn a_dead_writers_leftover_lock_admits_exactly_one_holder_at_a_time() { + const WAITERS: usize = 16; + for _ in 0..20 { + let dir = tempfile::tempdir().unwrap(); + let manifest = ManifestDir::at(dir.path()); + let lock = dir.path().join(format!("{FILE}.lock")); + let file = std::fs::File::create(&lock).unwrap(); + let long_ago = std::time::SystemTime::now() - Duration::from_secs(3600); + file.set_modified(long_ago).unwrap(); + drop(file); + let holders = std::sync::atomic::AtomicUsize::new(0); + let most = std::sync::atomic::AtomicUsize::new(0); + let barrier = std::sync::Barrier::new(WAITERS); + std::thread::scope(|scope| { + for _ in 0..WAITERS { + scope.spawn(|| { + barrier.wait(); + let held = lock_store(&manifest).expect("the lock is taken in turn"); + let now = holders.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; + most.fetch_max(now, std::sync::atomic::Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(2)); + holders.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + drop(held); + }); + } + }); + assert_eq!( + most.load(std::sync::atomic::Ordering::SeqCst), + 1, + "two writers held the manifest lock at once" + ); + } + } + + /// Waiting for another writer happens off the async runtime: on a single-threaded runtime, a + /// write that has to wait still lets every other task run. + #[test] + fn a_write_waiting_for_the_lock_does_not_block_the_runtime() { + let dir = tempfile::tempdir().unwrap(); + let manifest = ManifestDir::at(dir.path()); + let config = stdio("s", "npx", &["server"]); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + // Another writer holds the lock for 400 ms. + let held = lock_store(&manifest).unwrap(); + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(400)); + drop(held); + }); + let ticks = rt.block_on(async { + let ticks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = ticks.clone(); + let ticker = tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_millis(10)).await; + counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + }); + store( + &manifest, + &config, + vec![], + vec![], + vec![], + Some(vec![]), + vec![], + ) + .await; + ticker.abort(); + ticks.load(std::sync::atomic::Ordering::Relaxed) + }); + release.join().unwrap(); + assert!( + load(&manifest, &config).is_some(), + "the write landed once the lock was free" + ); + assert!( + ticks >= 10, + "the runtime's only thread was blocked while the write waited ({ticks} ticks in ~400 ms)" ); } diff --git a/crates/agent/src/tools/mcp_skills.rs b/crates/agent/src/tools/mcp_skills.rs index b088343a..d83d857e 100644 --- a/crates/agent/src/tools/mcp_skills.rs +++ b/crates/agent/src/tools/mcp_skills.rs @@ -754,7 +754,8 @@ impl ServerSkills { listing.entries.clone(), listing.diagnostics.clone(), listing.private, - ); + ) + .await; } *lock(&self.listing) = listing; true diff --git a/crates/agent/tests/common/mcp_events_fixture.rs b/crates/agent/tests/common/mcp_events_fixture.rs index 2f23982a..b50f932a 100644 --- a/crates/agent/tests/common/mcp_events_fixture.rs +++ b/crates/agent/tests/common/mcp_events_fixture.rs @@ -295,14 +295,9 @@ pub fn spawn_daemon_on( .args(extra) .env("HOME", home) .env("BEYOND_AI_AGENT_MCP_EVENTS_COALESCE_MS", "300") - .env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::from( - std::fs::File::create(home.join(format!("serve-{port}.stderr"))).unwrap(), - )); - held.hand_to(&mut cmd); - cmd.spawn_guarded() + .env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); + let stderr = std::fs::File::create(home.join(format!("serve-{port}.stderr"))).unwrap(); + held.spawn(&cmd, stderr) } /// The next WebSocket frame matching `pred`, or a panic naming `what` after `timeout`. diff --git a/crates/agent/tests/common/mod.rs b/crates/agent/tests/common/mod.rs index 33505f03..9899457e 100644 --- a/crates/agent/tests/common/mod.rs +++ b/crates/agent/tests/common/mod.rs @@ -249,9 +249,13 @@ pub fn spawn_listening_logged(cmd: &mut Command, log: Option) -> /// /// It is passed as the child's stdin (inetd's convention, `LISTEN_FDS_FIRST_FD=0`): std's `dup2` onto /// a standard stream is the one way to give a child a descriptor without `unsafe`, which the workspace -/// forbids, and `serve` never reads stdin once it serves a listener. Drop it once the daemon is up -/// unless it will be restarted: while the test holds the socket, a daemon that died leaves it -/// listening, and a client would wait on it instead of being refused. +/// forbids, and `serve` never reads stdin once it serves a listener. `serve` adopts passed sockets +/// only when `LISTEN_PID` names its own pid, as systemd's protocol says — and a pid is not known +/// before the spawn — so the child is started the way `systemd-socket-activate` starts one: through a +/// shell that exports its own pid as `LISTEN_PID` and `exec`s `serve` in place, keeping that pid. +/// Nothing in the binary is test-only. Drop it once the daemon is up unless it will be restarted: +/// while the test holds the socket, a daemon that died leaves it listening, and a client would wait +/// on it instead of being refused. pub struct HeldPort { listener: TcpListener, port: u16, @@ -268,14 +272,34 @@ impl HeldPort { self.port } - /// Make this socket `cmd`'s listener. Call it after anything else that sets stdin, and pass no - /// `--listen`/`--listen-uds` (either turns socket activation off). - pub fn hand_to(&self, cmd: &mut Command) { + /// Spawn `cmd` (program, arguments, environment and directory; its stdio is not used) with this + /// socket as its socket-activated listener: stdin is the socket, stdout is discarded, stderr goes + /// to `stderr`. Pass no `--listen`/`--listen-uds` (either turns socket activation off). + pub fn spawn(&self, cmd: &Command, stderr: impl Into) -> ChildGuard { + let mut activated = Command::new("sh"); + activated + .arg("-c") + .arg(r#"LISTEN_PID=$$; export LISTEN_PID; exec "$0" "$@""#) + .arg(cmd.get_program()) + .args(cmd.get_args()); + for (key, value) in cmd.get_envs() { + match value { + Some(value) => activated.env(key, value), + None => activated.env_remove(key), + }; + } + if let Some(dir) = cmd.get_current_dir() { + activated.current_dir(dir); + } let fd = std::os::fd::OwnedFd::from(self.listener.try_clone().unwrap()); - cmd.stdin(Stdio::from(fd)) + activated .env("LISTEN_FDS", "1") .env("LISTEN_FDS_FIRST_FD", "0") - .env_remove("LISTEN_PID"); + .stdin(Stdio::from(fd)) + .stdout(Stdio::null()) + .stderr(stderr); + // `activated`, and with it this copy of the socket, drops on return. + ChildGuard::spawn(&mut activated) } /// Between a daemon's death and its restart: until the guard drops, every connection is accepted @@ -458,11 +482,17 @@ pub fn read_until_response(reader: &mut impl BufRead, command: &str) -> Vec, current: Vec, @@ -470,7 +500,13 @@ pub struct Frames { deadline: Duration, } -/// [`Frames`] over `out`. +/// [`Frames`] over a `serve` child's stdout, with [`FRAME_DEADLINE`]. Every stdio frame reader in the +/// `serve_*` suites is one of these. +pub fn serve_frames(out: impl Read + Send + 'static) -> Frames { + frames_with_deadline(out, FRAME_DEADLINE) +} + +/// [`Frames`] over `out`, with its own per-line deadline. pub fn frames_with_deadline(out: impl Read + Send + 'static, deadline: Duration) -> Frames { let (tx, lines) = std::sync::mpsc::channel(); thread::spawn(move || { @@ -492,6 +528,43 @@ pub fn frames_with_deadline(out: impl Read + Send + 'static, deadline: Duration) } } +/// Why [`Frames::next_frame`] returned no frame. +#[derive(Debug, PartialEq, Eq)] +pub enum NoFrame { + /// Nothing arrived within the time allowed. + TimedOut, + /// The stream ended. + Closed, +} + +impl Frames { + /// The next line that parses as JSON, waiting at most `limit` in all. + pub fn next_frame(&mut self, limit: Duration) -> Result { + let deadline = Instant::now() + limit; + loop { + let left = deadline.saturating_duration_since(Instant::now()); + let line = if self.pos < self.current.len() { + let rest = String::from_utf8_lossy(&self.current[self.pos..]).into_owned(); + self.pos = self.current.len(); + rest + } else { + match self.lines.recv_timeout(left) { + Ok(line) => line, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + return Err(NoFrame::TimedOut); + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + return Err(NoFrame::Closed); + } + } + }; + if let Ok(v) = serde_json::from_str::(line.trim()) { + return Ok(v); + } + } + } +} + impl Read for Frames { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { let available = self.fill_buf()?; @@ -807,12 +880,27 @@ where /// Read the next JSON frame (skipping ping/pong/binary), or `None` if the socket closed. pub async fn ws_next_frame(ws: &mut tokio_tungstenite::WebSocketStream) -> Option +where + T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + ws_next_frame_within(ws, FRAME_DEADLINE).await +} + +/// [`ws_next_frame`], panicking if no message arrives within `deadline` of the last one. +pub async fn ws_next_frame_within( + ws: &mut tokio_tungstenite::WebSocketStream, + deadline: Duration, +) -> Option where T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { use futures::StreamExt as _; use tokio_tungstenite::tungstenite::Message; - while let Some(msg) = ws.next().await { + loop { + let Ok(next) = tokio::time::timeout(deadline, ws.next()).await else { + panic!("no WebSocket frame from serve for {deadline:?}: the run is stalled"); + }; + let Some(msg) = next else { break }; match msg.expect("websocket recv") { Message::Text(t) => { if let Ok(v) = serde_json::from_str::(t.as_str()) { diff --git a/crates/agent/tests/common/skills_env.rs b/crates/agent/tests/common/skills_env.rs index 44e90f71..73996bf5 100644 --- a/crates/agent/tests/common/skills_env.rs +++ b/crates/agent/tests/common/skills_env.rs @@ -194,32 +194,21 @@ pub type Bodies = std::sync::Arc>>; /// How long any one wait on `serve` may take before a test fails — so a regression fails fast with /// what was seen, instead of hanging the suite. -pub const DEADLINE: Duration = Duration::from_secs(60); +pub const DEADLINE: Duration = super::FRAME_DEADLINE; /// A running `serve` over stdin/stdout. Its stdout is read on a thread of its own, so every wait has /// a deadline. pub struct Serve { pub child: ChildGuard, pub stdin: ChildStdin, - frames: mpsc::Receiver, + frames: super::Frames, } impl Serve { pub fn spawn(cmd: &mut Command) -> Self { let mut child = cmd.spawn_guarded(); let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - let (tx, frames) = mpsc::channel(); - std::thread::spawn(move || { - for line in stdout.lines() { - let Ok(line) = line else { break }; - if let Ok(v) = serde_json::from_str::(line.trim()) - && tx.send(v).is_err() - { - break; - } - } - }); + let frames = super::serve_frames(child.stdout.take().unwrap()); Self { child, stdin, @@ -255,7 +244,7 @@ impl Serve { let mut frames = Vec::new(); loop { let left = deadline.saturating_duration_since(Instant::now()); - match self.frames.recv_timeout(left) { + match self.frames.next_frame(left) { Ok(v) => { let done = stop(&v); frames.push(v); @@ -263,10 +252,10 @@ impl Serve { return frames; } } - Err(mpsc::RecvTimeoutError::Timeout) => { + Err(super::NoFrame::TimedOut) => { panic!("serve did not answer within {limit:?}; saw: {frames:#?}") } - Err(mpsc::RecvTimeoutError::Disconnected) => { + Err(super::NoFrame::Closed) => { panic!("serve's stdout closed; saw: {frames:#?}") } } diff --git a/crates/agent/tests/mcp_events_v1a.rs b/crates/agent/tests/mcp_events_v1a.rs index 804e9d75..04d49659 100644 --- a/crates/agent/tests/mcp_events_v1a.rs +++ b/crates/agent/tests/mcp_events_v1a.rs @@ -15,8 +15,8 @@ use common::mcp_events_fixture::{ write_settings, ws_next, ws_wait_active, }; use common::{ - BIN, HeldPort, SpawnGuarded, TestWs, serve_dir_cmd, spawn_model_server_routed, turn_text, - ws_connect, ws_next_frame, ws_send, + BIN, HeldPort, TestWs, serve_dir_cmd, spawn_model_server_routed, turn_text, ws_connect, + ws_next_frame, ws_send, }; use serde_json::{Value, json}; @@ -55,12 +55,9 @@ async fn deliveries_must_carry_a_valid_v1a_signature_when_the_server_publishes_a "--mcp-events-callback-url", &format!("http://127.0.0.1:{port}"), ]) - .env("HOME", home.path()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - held.hand_to(&mut cmd); - let _d = cmd.spawn_guarded(); - drop((cmd, held)); + .env("HOME", home.path()); + let _d = held.spawn(&cmd, Stdio::null()); + drop(held); let mut ws = ws_connect(port, Some("mcp-events")).await; // The verification challenge itself was v1a-signed and checked: the subscribe succeeds. let mut active = false; diff --git a/crates/agent/tests/mcp_events_webhook.rs b/crates/agent/tests/mcp_events_webhook.rs index 8287dd27..fb1f4037 100644 --- a/crates/agent/tests/mcp_events_webhook.rs +++ b/crates/agent/tests/mcp_events_webhook.rs @@ -18,7 +18,7 @@ use common::mcp_events_fixture::{ control, emit, eventually, model_requests_with, spawn_http_fixture, state, write_settings, }; use common::{ - BIN, ChildGuard, DeadPort, HeldPort, SpawnGuarded, TestWs, serve_dir_cmd, spawn_listening, + BIN, ChildGuard, DeadPort, HeldPort, TestWs, serve_dir_cmd, spawn_listening, spawn_model_server_routed, turn_text, ws_connect, ws_next_frame, ws_send, }; use serde_json::{Value, json}; @@ -46,12 +46,8 @@ fn start_daemon(mcp_servers: Value) -> Daemon { ]) .env("HOME", home.path()) .env("BEYOND_AI_AGENT_MCP_EVENTS_COALESCE_MS", "300") - .env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::from(std::fs::File::create(&stderr).unwrap())); - held.hand_to(&mut cmd); - let child = cmd.spawn_guarded(); + .env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); + let child = held.spawn(&cmd, std::fs::File::create(&stderr).unwrap()); Daemon { child, port, diff --git a/crates/agent/tests/serve_approval.rs b/crates/agent/tests/serve_approval.rs index acbd7e5c..7eb84c20 100644 --- a/crates/agent/tests/serve_approval.rs +++ b/crates/agent/tests/serve_approval.rs @@ -8,7 +8,7 @@ mod common; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, Write}; use std::process::{ChildStdin, Command, Stdio}; use std::time::Duration; @@ -126,7 +126,7 @@ fn an_allowed_call_actually_runs() { let mut child = serve_approving(&base, &session_file, &["--approve", "writes"]); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, @@ -175,7 +175,7 @@ fn a_denied_call_does_not_run_and_the_model_is_told_why() { let mut child = serve_approving(&base, &session_file, &["--approve", "writes"]); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, @@ -223,7 +223,7 @@ fn an_ungated_tool_is_never_asked_about() { let mut child = serve_approving(&base, &session_file, &["--approve", "writes"]); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, @@ -257,7 +257,7 @@ fn a_static_deny_short_circuits_without_ever_asking() { &["--approve", "all", "--deny-tool", "bash"], ); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send(&mut stdin, json!({ "type": "prompt", "message": "run it" })); let frames = read_until_response(&mut stdout, "prompt"); @@ -286,7 +286,7 @@ fn count_questions(paths: &[&str], decision: &str, scope: &str, session_file: &s let (base, _b) = spawn_model_server(turns); let mut child = serve_approving(&base, session_file, &["--approve", "writes"]); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let mut asked = 0; for _ in paths { @@ -392,7 +392,7 @@ fn an_abort_unblocks_a_pending_question_promptly() { &["--approve", "writes", "--approval-timeout", "0"], ); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send(&mut stdin, json!({ "type": "prompt", "message": "write" })); read_until_type(&mut stdout, "approval_request"); @@ -422,7 +422,7 @@ fn an_unanswered_question_times_out_and_denies() { &["--approve", "writes", "--approval-timeout", "1"], ); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send(&mut stdin, json!({ "type": "prompt", "message": "write" })); read_until_type(&mut stdout, "approval_request"); @@ -451,7 +451,7 @@ fn an_unknown_request_id_is_not_accepted() { let mut child = serve_approving(&base, &session_file, &["--approve", "all"]); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); approve(&mut stdin, "no-such-request", "allow", "once"); let frames = read_until_response(&mut stdout, "approve"); @@ -469,7 +469,7 @@ fn a_malformed_approve_is_rejected_and_a_session_without_a_gate_says_so() { let mut child = serve_approving(&base, &session_file, &["--approve", "all"]); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, @@ -494,7 +494,7 @@ fn a_malformed_approve_is_rejected_and_a_session_without_a_gate_says_so() { let (base, _b) = spawn_model_server(vec![]); let mut child = serve_approving(&base, &session_file, &[]); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); approve(&mut stdin, "x", "allow", "once"); let resp = last(&read_until_response(&mut stdout, "approve")).clone(); assert_eq!(resp["success"], false); @@ -537,7 +537,7 @@ fn two_gated_calls_in_one_turn_are_asked_about_one_at_a_time() { let mut child = serve_approving(&base, &session_file, &["--approve", "writes"]); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, @@ -893,7 +893,7 @@ fn an_always_allow_does_not_survive_a_new_session() { .stderr(Stdio::null()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Grant it for the whole session — the strongest remembered decision there is. send( diff --git a/crates/agent/tests/serve_auth.rs b/crates/agent/tests/serve_auth.rs index 9c85bcc2..ffc87837 100644 --- a/crates/agent/tests/serve_auth.rs +++ b/crates/agent/tests/serve_auth.rs @@ -6,7 +6,7 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use std::process::Command; use common::{SpawnGuarded, read_until_response, serve_cmd}; @@ -42,7 +42,7 @@ fn auth_status_with_nothing_stored_reports_every_provider_logged_out() { let mut child = serve_cmd_with_real_home(bin, &session_file, home.path()).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "id": "1", "type": "auth_status" })).unwrap(); stdin.flush().unwrap(); @@ -73,7 +73,7 @@ fn auth_status_for_a_single_provider_reports_logged_in_when_a_non_expired_creden let mut child = serve_cmd_with_real_home(bin, &session_file, home.path()).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -101,7 +101,7 @@ fn auth_status_for_an_unknown_provider_is_a_clean_error_not_a_crash() { let mut child = serve_cmd_with_real_home(bin, &session_file, home.path()).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -137,7 +137,7 @@ fn logout_removes_a_stored_credential_and_is_idempotent() { let mut child = serve_cmd_with_real_home(bin, &session_file, home.path()).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -179,7 +179,7 @@ fn login_acks_then_a_second_concurrent_login_is_rejected_then_abort_login_cancel let mut child = serve_cmd_with_real_home(bin, &session_file, home.path()).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, diff --git a/crates/agent/tests/serve_block_images.rs b/crates/agent/tests/serve_block_images.rs index d78f65ef..9389fc28 100644 --- a/crates/agent/tests/serve_block_images.rs +++ b/crates/agent/tests/serve_block_images.rs @@ -7,7 +7,7 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use common::{SpawnGuarded, read_until_response, serve_cmd, spawn_model_server, turn_text}; use serde_json::json; @@ -36,7 +36,7 @@ fn serve_rpc_prompt_pushed_image_is_downgraded_at_the_wire_when_block_images_is_ .args(["--block-images"]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -89,7 +89,7 @@ fn serve_persisted_image_is_downgraded_on_resend_once_block_images_is_toggled_on { let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, "{}", @@ -125,7 +125,7 @@ fn serve_persisted_image_is_downgraded_on_resend_once_block_images_is_toggled_on .args(["--block-images"]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, "{}", @@ -169,7 +169,7 @@ fn serve_set_block_images_toggles_and_rejects_a_non_boolean() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -222,7 +222,7 @@ fn serve_set_block_images_true_downgrades_a_persisted_image_mid_session_without_ // No `--block-images` — the image is ingested and sent to the wire as-is. let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, "{}", diff --git a/crates/agent/tests/serve_busy_self_abort.rs b/crates/agent/tests/serve_busy_self_abort.rs index b14aff98..095e36f5 100644 --- a/crates/agent/tests/serve_busy_self_abort.rs +++ b/crates/agent/tests/serve_busy_self_abort.rs @@ -9,8 +9,8 @@ mod common; -use std::io::{BufReader, Write}; -use std::process::{ChildStdin, ChildStdout}; +use std::io::Write; +use std::process::ChildStdin; use std::time::Duration; use common::{ @@ -27,10 +27,10 @@ fn spawn_and_warm_up( bin: &str, base: &str, session_dir: &str, -) -> (ChildGuard, ChildStdin, BufReader) { +) -> (ChildGuard, ChildStdin, common::Frames) { let mut child = serve_dir_cmd(bin, base, session_dir).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, "{}", @@ -49,7 +49,7 @@ fn spawn_and_warm_up( /// self-abort-and-proceeded command's — see `serve.rs`'s `pending_deferred` doc comment for why). fn run_busy_then( stdin: &mut ChildStdin, - stdout: &mut BufReader, + stdout: &mut common::Frames, busy_cmd: &Value, ) -> (Value, Value) { writeln!( @@ -99,7 +99,7 @@ fn serve_busy_compact_self_aborts_and_proceeds_in_one_round_trip() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &session_dir).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let (prompt_resp, compact_resp) = run_busy_then( &mut stdin, diff --git a/crates/agent/tests/serve_compaction_retry.rs b/crates/agent/tests/serve_compaction_retry.rs index 67a52652..2ae2b3c1 100644 --- a/crates/agent/tests/serve_compaction_retry.rs +++ b/crates/agent/tests/serve_compaction_retry.rs @@ -3,7 +3,7 @@ mod common; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, Write}; use std::process::{Command, Stdio}; use common::{ @@ -21,7 +21,7 @@ fn serve_set_auto_compaction_toggles_and_rejects_a_non_boolean() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -72,7 +72,7 @@ fn serve_no_compaction_flag_starts_with_auto_compaction_disabled() { .args(["--no-compaction"]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_state" })).unwrap(); stdin.flush().unwrap(); @@ -104,7 +104,7 @@ fn serve_set_auto_compaction_persists_across_a_restart() { .env("HOME", home.path()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, "{}", @@ -125,7 +125,7 @@ fn serve_set_auto_compaction_persists_across_a_restart() { .env("HOME", home.path()) .spawn_guarded(); let mut stdin2 = child2.stdin.take().unwrap(); - let mut stdout2 = BufReader::new(child2.stdout.take().unwrap()); + let mut stdout2 = common::serve_frames(child2.stdout.take().unwrap()); writeln!(stdin2, "{}", json!({ "type": "get_state" })).unwrap(); stdin2.flush().unwrap(); let frames2 = read_until_response(&mut stdout2, "get_state"); @@ -176,7 +176,7 @@ fn serve_compact_forwards_custom_instructions_to_the_summarization_call() { .stderr(Stdio::null()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); for msg in ["hello", "again"] { writeln!(stdin, "{}", json!({ "type": "prompt", "message": msg })).unwrap(); @@ -244,7 +244,7 @@ fn serve_compact_reports_too_small_reason_for_a_session_with_nothing_to_compact( let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // No `prompt` at all yet — the session is empty, well under `find_split_cut`'s minimum. writeln!(stdin, "{}", json!({ "type": "compact" })).unwrap(); @@ -301,7 +301,7 @@ fn serve_compact_reports_already_compacted_reason_when_nothing_new_followed_the_ let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "compact" })).unwrap(); stdin.flush().unwrap(); @@ -388,7 +388,7 @@ fn serve_proactively_compacts_a_resumed_large_session_on_its_very_next_prompt() .stderr(Stdio::null()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // The very first prompt this (freshly-spawned, freshly-resumed) process ever sends. writeln!( @@ -459,7 +459,7 @@ fn serve_compact_preserves_pre_compaction_entries_in_get_tree() { .stderr(Stdio::null()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); for msg in ["pre-compaction-hello", "pre-compaction-again"] { writeln!(stdin, "{}", json!({ "type": "prompt", "message": msg })).unwrap(); @@ -542,7 +542,7 @@ fn serve_compact_reports_first_kept_entry_id_naming_a_real_pre_compaction_entry( .stderr(Stdio::null()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); for msg in ["pre-compaction-hello", "pre-compaction-again"] { writeln!(stdin, "{}", json!({ "type": "prompt", "message": msg })).unwrap(); @@ -614,7 +614,7 @@ fn serve_set_auto_retry_toggles_and_rejects_a_non_boolean() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -665,7 +665,7 @@ fn serve_set_auto_retry_false_fails_immediately_instead_of_retrying_a_dropped_st let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -717,7 +717,7 @@ fn serve_auto_retries_a_whole_run_after_mid_stream_retry_is_exhausted() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -813,7 +813,7 @@ fn serve_persists_the_retried_attempts_assistant_message_not_only_in_memory() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -841,7 +841,7 @@ fn serve_persists_the_retried_attempts_assistant_message_not_only_in_memory() { // Reload the very same session file in a fresh process and ask again. let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_messages" })).unwrap(); stdin.flush().unwrap(); let frames = read_until_response(&mut stdout, "get_messages"); @@ -894,7 +894,7 @@ fn serve_whole_run_retry_recovery_attempt_can_itself_dispatch_tool_calls() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -947,7 +947,7 @@ fn serve_whole_run_retry_succeeds_on_its_second_attempt_not_its_first() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -1010,7 +1010,7 @@ fn serve_abort_retry_interrupts_a_pending_whole_run_retry_backoff() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -1113,7 +1113,7 @@ fn serve_abort_retry_interrupts_a_pending_whole_run_retry_backoff() { // And it must survive a reload, not just live in memory. let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_messages" })).unwrap(); stdin.flush().unwrap(); let frames = read_until_response(&mut stdout, "get_messages"); @@ -1144,7 +1144,7 @@ fn serve_auto_retry_exhausts_all_attempts_and_reports_failure() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -1222,7 +1222,7 @@ fn serve_idle_timeout_ms_flag_causes_a_stalled_response_to_fail_quickly() { .args(["--idle-timeout-ms", "200", "--retry-max-retries", "0"]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Whole-run auto-retry defaults on; disable it too so a single failed attempt surfaces // immediately instead of being retried (this test is about the idle timeout firing at all, not diff --git a/crates/agent/tests/serve_exec_endpoint.rs b/crates/agent/tests/serve_exec_endpoint.rs index 6c93834d..551f19a1 100644 --- a/crates/agent/tests/serve_exec_endpoint.rs +++ b/crates/agent/tests/serve_exec_endpoint.rs @@ -18,7 +18,7 @@ use common::{ SpawnGuarded, read_until_response, serve_dir_cmd, spawn_model_server, turn_text, turn_tool_use, }; use serde_json::{Value, json}; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, Write}; const BIN: &str = env!("CARGO_BIN_EXE_beyond-ai-agent"); @@ -70,7 +70,7 @@ fn a_session_endpoint_is_set_over_the_protocol_and_cleared_on_session_switch() { let mut child = serve_dir_cmd(BIN, &base, sessions.path().to_str().unwrap()).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut out = BufReader::new(child.stdout.take().unwrap()); + let mut out = common::serve_frames(child.stdout.take().unwrap()); let mut send = |v: Value| { writeln!(stdin, "{v}").unwrap(); stdin.flush().unwrap(); @@ -152,7 +152,7 @@ fn clearing_the_endpoint_returns_the_session_to_the_host() { let mut child = serve_dir_cmd(BIN, &base, sessions.path().to_str().unwrap()).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut out = BufReader::new(child.stdout.take().unwrap()); + let mut out = common::serve_frames(child.stdout.take().unwrap()); let mut send = |v: Value| { writeln!(stdin, "{v}").unwrap(); stdin.flush().unwrap(); @@ -192,7 +192,7 @@ fn a_malformed_endpoint_is_rejected_without_attaching_anything() { let mut child = serve_dir_cmd(BIN, &base, sessions.path().to_str().unwrap()).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut out = BufReader::new(child.stdout.take().unwrap()); + let mut out = common::serve_frames(child.stdout.take().unwrap()); let mut send = |v: Value| { writeln!(stdin, "{v}").unwrap(); stdin.flush().unwrap(); @@ -241,7 +241,7 @@ fn a_resumed_session_reattaches_its_endpoint_and_a_sibling_does_not_inherit_it() let mut child = serve_dir_cmd(BIN, &base, sessions.path().to_str().unwrap()).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut out = BufReader::new(child.stdout.take().unwrap()); + let mut out = common::serve_frames(child.stdout.take().unwrap()); let mut send = |v: Value| { writeln!(stdin, "{v}").unwrap(); stdin.flush().unwrap(); @@ -321,7 +321,7 @@ fn a_cold_restart_reattaches_the_session_endpoint_and_outranks_the_process_defau let mut child = serve_dir_cmd(BIN, &base, sessions.path().to_str().unwrap()).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut out = BufReader::new(child.stdout.take().unwrap()); + let mut out = common::serve_frames(child.stdout.take().unwrap()); let mut send = |v: Value| { writeln!(stdin, "{v}").unwrap(); stdin.flush().unwrap(); @@ -358,7 +358,7 @@ fn a_cold_restart_reattaches_the_session_endpoint_and_outranks_the_process_defau ]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut out = BufReader::new(child.stdout.take().unwrap()); + let mut out = common::serve_frames(child.stdout.take().unwrap()); let mut send = |v: Value| { writeln!(stdin, "{v}").unwrap(); stdin.flush().unwrap(); diff --git a/crates/agent/tests/serve_gateway_headers.rs b/crates/agent/tests/serve_gateway_headers.rs index 6faca6d3..d380b06b 100644 --- a/crates/agent/tests/serve_gateway_headers.rs +++ b/crates/agent/tests/serve_gateway_headers.rs @@ -5,7 +5,7 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use common::{SpawnGuarded, read_until_response, serve_cmd, spawn_model_server, turn_text}; use serde_json::json; @@ -35,7 +35,7 @@ fn serve_startup_model_override_header_reaches_the_wire_request() { .env("AI_AGENT_CONFIG_DIR", &config_dir) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -79,7 +79,7 @@ fn serve_set_model_to_an_overridden_model_carries_that_models_own_header_on_the_ .env("AI_AGENT_CONFIG_DIR", &config_dir) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); diff --git a/crates/agent/tests/serve_grant.rs b/crates/agent/tests/serve_grant.rs index b5ac3cf8..fc684210 100644 --- a/crates/agent/tests/serve_grant.rs +++ b/crates/agent/tests/serve_grant.rs @@ -8,7 +8,7 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -42,7 +42,7 @@ fn serve(dir: &Path) -> Command { fn assert_serves(cmd: &mut Command) { let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_state", "id": "g1" })).unwrap(); let frames = read_until_response(&mut stdout, "get_state"); assert_eq!(frames.last().unwrap()["success"], true, "{frames:#?}"); diff --git a/crates/agent/tests/serve_harness_deadlines.rs b/crates/agent/tests/serve_harness_deadlines.rs new file mode 100644 index 00000000..2ccb5d04 --- /dev/null +++ b/crates/agent/tests/serve_harness_deadlines.rs @@ -0,0 +1,101 @@ +//! The `serve` suites' frame readers fail fast instead of hanging. A run that stalls — a scripted +//! reply taken by the wrong request, a question nobody will answer — used to leave a test blocked in +//! a read until the runner killed it, minutes later and with nothing said about where it stopped. +//! Every reader now has one shared deadline (`common::FRAME_DEADLINE`): stdout through +//! `common::serve_frames`, WebSockets through `common::ws_next_frame`, and `skills_env::Serve`. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// A stdout that stays open and silent fails the read at the deadline. +#[test] +fn a_silent_stdout_fails_the_read_at_its_deadline() { + let mut child = common::ChildGuard::spawn( + Command::new("sleep") + .arg("30") + .stdout(Stdio::piped()) + .stderr(Stdio::null()), + ); + let mut frames = + common::frames_with_deadline(child.stdout.take().unwrap(), Duration::from_millis(300)); + let started = Instant::now(); + let read = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + common::read_until_response(&mut frames, "prompt") + })); + let message = *read.unwrap_err().downcast::().unwrap(); + assert!(message.contains("stalled"), "{message}"); + assert!( + started.elapsed() < Duration::from_secs(10), + "{:?}", + started.elapsed() + ); +} + +/// A WebSocket that stays open and silent fails the read at the deadline. +#[tokio::test] +async fn a_silent_websocket_fails_the_read_at_its_deadline() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let ws = tokio_tungstenite::accept_async(tcp).await.unwrap(); + tokio::time::sleep(Duration::from_secs(30)).await; + drop(ws); + }); + let mut ws = common::ws_connect(port, None).await; + let started = Instant::now(); + let read = tokio::spawn(async move { + common::ws_next_frame_within(&mut ws, Duration::from_millis(300)).await + }) + .await; + let message = *read.unwrap_err().into_panic().downcast::().unwrap(); + assert!(message.contains("stalled"), "{message}"); + assert!( + started.elapsed() < Duration::from_secs(10), + "{:?}", + started.elapsed() + ); + server.abort(); +} + +/// Every `serve_*` suite reads its child's stdout through `serve_frames`, and `skills_env` through +/// the same `Frames` — none through a bare `BufReader`, which has no deadline. +#[test] +fn every_serve_suite_reads_frames_with_the_shared_deadline() { + let tests = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests"); + let mut readers = 0; + let mut bare = Vec::new(); + let mut files: Vec<_> = std::fs::read_dir(&tests) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| { + let name = p.file_name().unwrap().to_string_lossy().into_owned(); + // This file names the pattern it looks for. + name.starts_with("serve_") + && name.ends_with(".rs") + && name != "serve_harness_deadlines.rs" + }) + .collect(); + files.push(tests.join("common/skills_env.rs")); + for path in files { + let text = std::fs::read_to_string(&path).unwrap(); + readers += text.matches("serve_frames(").count(); + for (n, line) in text.lines().enumerate() { + if line.contains("BufReader::new(") && line.contains("stdout") { + bare.push(format!("{}:{}: {}", path.display(), n + 1, line.trim())); + } + } + } + assert!( + bare.is_empty(), + "stdout read without a deadline:\n{}", + bare.join("\n") + ); + assert!( + readers > 200, + "the suites read through serve_frames ({readers} readers)" + ); +} diff --git a/crates/agent/tests/serve_lifecycle.rs b/crates/agent/tests/serve_lifecycle.rs index aa99f956..c0fae364 100644 --- a/crates/agent/tests/serve_lifecycle.rs +++ b/crates/agent/tests/serve_lifecycle.rs @@ -8,7 +8,7 @@ mod common; -use std::io::{BufReader, Read, Write}; +use std::io::{Read, Write}; use std::net::TcpListener; use std::process::Stdio; use std::sync::{Arc, Mutex}; @@ -194,7 +194,7 @@ fn serve_prompt_posts_started_then_succeeded_with_command_id_and_summary() { .args(["--lifecycle-header", "X-Tenant: acme"]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, @@ -238,7 +238,7 @@ fn serve_unix_socket_posts_started_then_succeeded() { let mut child = serve_life(&base, &session_file, &collector.url).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, @@ -273,7 +273,7 @@ fn serve_client_stop_posts_started_then_terminal() { let mut child = serve_life(&base, &session_file, &collector.url).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, @@ -328,7 +328,7 @@ fn serve_todo_and_tool_progress_carries_tools_and_todos() { .args(["--lifecycle-heartbeat-secs", "0"]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, @@ -375,7 +375,7 @@ fn serve_two_prompts_mint_two_run_ids_each_with_one_terminal() { let mut child = serve_life(&base, &session_file, &collector.url).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, @@ -418,7 +418,7 @@ fn serve_unconfigured_posts_nothing() { let mut child = serve_cmd(BIN, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send(&mut stdin, json!({ "type": "prompt", "message": "hi" })); read_until_response(&mut stdout, "prompt"); @@ -444,7 +444,7 @@ fn serve_lifecycle_url_from_env_without_the_flag() { .env("AI_AGENT_LIFECYCLE_URL", &collector.url) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, @@ -495,7 +495,7 @@ fn serve_refusal_is_succeeded_with_refused() { let mut child = serve_life(&base, &session_file, &collector.url).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, @@ -525,7 +525,7 @@ fn a_slow_lifecycle_consumer_does_not_delay_the_prompt_response() { let mut child = serve_life(&base, &session_file, &collector.url).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, diff --git a/crates/agent/tests/serve_list_paging.rs b/crates/agent/tests/serve_list_paging.rs index 444ff65a..a0ce0fbe 100644 --- a/crates/agent/tests/serve_list_paging.rs +++ b/crates/agent/tests/serve_list_paging.rs @@ -9,22 +9,23 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use common::{SpawnGuarded, read_until_response, serve_dir_cmd, spawn_model_server, turn_text}; use serde_json::{Value, json}; /// Three sessions, each whose first user message is its own marker, listed newest-first. -fn three_sessions() -> (tempfile::TempDir, common::ChildGuard) { +fn three_sessions() -> (tempfile::TempDir, common::ChildGuard, common::Frames) { let dir = tempfile::tempdir().unwrap(); let dir_str = dir.path().to_string_lossy().into_owned(); let (base, _bodies) = spawn_model_server(vec![turn_text("ok"), turn_text("ok"), turn_text("ok")]); let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &dir_str).spawn_guarded(); + // One reader for the child's life: it owns stdout, so it is handed back with the child. + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); { let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); for (i, marker) in ["alpha-one", "beta-two", "gamma-three"].iter().enumerate() { if i > 0 { writeln!(stdin, "{}", json!({ "type": "new_session" })).unwrap(); @@ -36,16 +37,14 @@ fn three_sessions() -> (tempfile::TempDir, common::ChildGuard) { read_until_response(&mut stdout, "prompt"); } child.stdin = Some(stdin); - child.stdout = Some(stdout.into_inner()); } - (dir, child) + (dir, child, stdout) } #[test] fn a_listing_is_one_page_and_reports_the_total() { - let (_dir, mut child) = three_sessions(); + let (_dir, mut child, mut stdout) = three_sessions(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); fn page(stdin: &mut W, stdout: &mut R, cmd: Value) -> Value { writeln!(stdin, "{cmd}").unwrap(); @@ -95,9 +94,8 @@ fn a_listing_is_one_page_and_reports_the_total() { #[test] fn a_listing_entry_carries_metadata_and_not_the_transcript() { - let (_dir, mut child) = three_sessions(); + let (_dir, mut child, mut stdout) = three_sessions(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "list_sessions" })).unwrap(); stdin.flush().unwrap(); diff --git a/crates/agent/tests/serve_memory.rs b/crates/agent/tests/serve_memory.rs index 3a345864..2453c8da 100644 --- a/crates/agent/tests/serve_memory.rs +++ b/crates/agent/tests/serve_memory.rs @@ -5,7 +5,7 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{ChildStdin, Command, Stdio}; use std::sync::{Arc, Mutex}; @@ -84,7 +84,7 @@ fn one_prompt( ) -> (Vec, Vec) { let mut child = serve_mem_cmd(base, session_file, home, work, extra).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send(&mut stdin, json!({ "type": "prompt", "message": message })); let frames = read_until_response(&mut stdout, "prompt"); let recorded = bodies.lock().unwrap().clone(); @@ -124,7 +124,7 @@ fn persists_across_a_restart_then_injects_the_index_and_recalls() { let mut child = serve_mem_cmd(&base_a, &session_a, home.path(), work.path(), &[]).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, json!({ "type": "prompt", "message": "remember the deploy command" }), @@ -202,7 +202,7 @@ fn a_memory_written_mid_session_refreshes_the_injected_index_on_the_next_rebuild let mut child = serve_mem_cmd(&base, &session, home.path(), work.path(), &[]).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, diff --git a/crates/agent/tests/serve_models_thinking.rs b/crates/agent/tests/serve_models_thinking.rs index 0ba76f09..f4215f5c 100644 --- a/crates/agent/tests/serve_models_thinking.rs +++ b/crates/agent/tests/serve_models_thinking.rs @@ -3,7 +3,7 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use std::process::{Command, Stdio}; use common::{ @@ -44,7 +44,7 @@ fn serve_switches_model_and_thinking_at_runtime() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // The known-model list is returned and non-empty, each entry a structured capability object (F-M2: // pi's `Model` shape — `id`/`contextWindow`/`reasoning`, minus pricing — not a bare id string). @@ -143,7 +143,7 @@ fn serve_cycle_model_advances_and_wraps() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_available_models" })).unwrap(); stdin.flush().unwrap(); @@ -202,7 +202,7 @@ fn serve_set_model_and_cycle_model_responses_carry_capability_info() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -251,7 +251,7 @@ fn serve_set_model_colon_suffix_resolves_the_id_and_sets_reasoning_effort() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -305,7 +305,7 @@ fn serve_cycle_model_scoped_by_the_models_flag_cycles_only_the_scope_but_lists_t .stderr(Stdio::null()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // `get_available_models` is deliberately NOT scoped by `--models` — that flag only narrows // `cycle_model`'s own candidate list (asserted below). A client's model *picker* still needs to @@ -384,7 +384,7 @@ fn serve_models_flag_expands_a_glob_against_the_known_catalog() { .stderr(Stdio::null()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Pin to the first claude-* entry in catalog order so cycling from a known position is // unambiguous, then walk the whole scoped cycle and confirm it's exactly the claude-* subset of @@ -455,7 +455,7 @@ fn serve_models_flag_pattern_level_suffix_pins_that_models_thinking_level_on_cyc .stderr(Stdio::null()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Pin to the unpinned scoped entry first so cycling onto the pinned one is unambiguous. writeln!( @@ -521,7 +521,7 @@ fn serve_models_flag_rejects_an_invalid_thinking_level_suffix_as_part_of_the_lit .stderr(Stdio::null()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "cycle_model" })).unwrap(); stdin.flush().unwrap(); @@ -544,7 +544,7 @@ fn serve_cycle_thinking_level_advances_through_the_ladder_and_wraps() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Fix 1 (pi-parity gap): a fresh session with no `--reasoning-effort` now starts at "medium" // (pi's own `DEFAULT_THINKING_LEVEL`), not "off" — `claude-test` (this test's model) supports @@ -586,7 +586,7 @@ fn serve_set_reasoning_effort_sets_the_portable_level_directly() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -649,7 +649,7 @@ fn serve_set_reasoning_effort_wins_over_a_stale_set_thinking_override() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -695,7 +695,7 @@ fn serve_starts_clamped_not_off_for_a_model_that_cannot_disable_reasoning() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd_with_model(bin, &base, &session_file, "gpt-5-codex").spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_state" })).unwrap(); stdin.flush().unwrap(); @@ -728,7 +728,7 @@ fn serve_model_flag_off_suffix_starts_with_reasoning_off_not_the_medium_default( let mut child = serve_cmd_with_model(bin, &base, &session_file, "claude-haiku-4-5:off").spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_state" })).unwrap(); stdin.flush().unwrap(); @@ -760,7 +760,7 @@ fn serve_set_model_reclamps_off_when_switching_onto_a_non_disableable_model() { // new model's own floor, not silently carrying it across the switch. let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -817,7 +817,7 @@ fn serve_cycle_thinking_level_never_gets_stuck_for_a_model_without_xhigh_or_off( let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd_with_model(bin, &base, &session_file, "gpt-5-codex").spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Fix 1 (pi-parity gap): starts at "medium" now, not "minimal" (see the dedicated startup test // above) — gpt-5-codex's floor (minimal) is already below "medium", so nothing clamps it further. @@ -855,7 +855,7 @@ fn serve_switch_model_retargets_the_next_turn_of_an_in_flight_prompt() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "start" })).unwrap(); stdin.flush().unwrap(); @@ -914,7 +914,7 @@ fn serve_switch_model_while_idle_is_a_no_op_pointing_at_set_model() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -950,7 +950,7 @@ fn serve_max_tokens_flag_reaches_the_wire_request_and_survives_a_model_switch() .args(["--max-tokens", "777"]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); diff --git a/crates/agent/tests/serve_oauth_model_switch.rs b/crates/agent/tests/serve_oauth_model_switch.rs index 27a1ea9f..7c47ed51 100644 --- a/crates/agent/tests/serve_oauth_model_switch.rs +++ b/crates/agent/tests/serve_oauth_model_switch.rs @@ -11,7 +11,7 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use std::process::{Command, Stdio}; use common::{ @@ -88,7 +88,7 @@ fn set_model_across_oauth_providers_rederives_credential_and_routing() { .stderr(Stdio::null()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // First turn: the Anthropic-OAuth-resolved model, no `--key` given at all — proves the initial // resolution still works with no explicit credential (matches `oauth_e2e.rs`'s existing coverage). diff --git a/crates/agent/tests/serve_prompt_flow.rs b/crates/agent/tests/serve_prompt_flow.rs index 9ba8bf82..515aa20c 100644 --- a/crates/agent/tests/serve_prompt_flow.rs +++ b/crates/agent/tests/serve_prompt_flow.rs @@ -3,7 +3,7 @@ mod common; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, Write}; use common::{ SpawnGuarded, read_until_response, serve_cmd, spawn_model_server, turn_refusal, turn_text, @@ -34,7 +34,7 @@ fn serve_follow_up_steers_an_in_flight_run() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "start" })).unwrap(); stdin.flush().unwrap(); @@ -98,7 +98,7 @@ fn serve_stop_after_turn_ends_the_run_after_the_current_tool_call_completes() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "start" })).unwrap(); stdin.flush().unwrap(); @@ -166,7 +166,7 @@ fn serve_stop_after_turn_is_a_no_op_ack_when_idle() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -208,7 +208,7 @@ fn serve_prompt_ack_arrives_before_the_first_event_frame() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -261,7 +261,7 @@ fn serve_busy_prompt_with_streaming_behavior_is_accepted_not_rejected() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "start" })).unwrap(); stdin.flush().unwrap(); @@ -345,7 +345,7 @@ fn serve_follow_up_queued_while_idle_is_picked_up_by_next_prompt() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Queue the follow-up first, while genuinely idle. writeln!( @@ -411,7 +411,7 @@ fn serve_follow_up_expands_a_skill_invocation_while_idle() { cmd.arg("--trust-project").current_dir(dir.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Queue the skill invocation while genuinely idle, via `follow_up`. writeln!( @@ -484,7 +484,7 @@ fn serve_mid_run_steer_expands_a_skill_invocation() { cmd.arg("--trust-project").current_dir(dir.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "start" })).unwrap(); stdin.flush().unwrap(); @@ -533,7 +533,7 @@ fn serve_follow_up_carries_image_attachments_to_the_model() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -597,7 +597,7 @@ fn serve_no_skills_prevents_discovery_and_leaves_an_invocation_unexpanded() { .current_dir(dir.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_commands" })).unwrap(); stdin.flush().unwrap(); @@ -650,7 +650,7 @@ fn serve_no_prompt_templates_prevents_discovery_and_leaves_an_invocation_unexpan .current_dir(dir.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_commands" })).unwrap(); stdin.flush().unwrap(); @@ -707,7 +707,7 @@ fn serve_default_queue_mode_drains_queued_follow_ups_one_at_a_time() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); for (id, msg) in [("f1", "first follow-up"), ("f2", "second follow-up")] { writeln!( @@ -761,7 +761,7 @@ fn serve_set_queue_mode_all_folds_queued_follow_ups_into_one_injection() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -816,7 +816,7 @@ fn serve_steering_mode_and_follow_up_mode_are_independent_rpc_settings() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -885,7 +885,7 @@ fn serve_refusal_ends_the_run_without_draining_steering() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Queue a follow-up while idle, before the refusal even happens. writeln!( @@ -969,7 +969,7 @@ fn serve_abort_cancels_an_in_flight_prompt() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -1027,7 +1027,7 @@ fn serve_bare_prompt_while_busy_is_rejected_not_queued() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "start" })).unwrap(); stdin.flush().unwrap(); diff --git a/crates/agent/tests/serve_session_addressing.rs b/crates/agent/tests/serve_session_addressing.rs index d4463c57..d0945a65 100644 --- a/crates/agent/tests/serve_session_addressing.rs +++ b/crates/agent/tests/serve_session_addressing.rs @@ -10,7 +10,7 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use std::process::{Command, Stdio}; use common::{ @@ -51,14 +51,14 @@ fn serve_selecting(bin: &str, base: &str, session_dir: &str, extra: &[&str]) -> struct Serve { child: ChildGuard, stdin: std::process::ChildStdin, - stdout: BufReader, + stdout: common::Frames, } impl Serve { fn start(cmd: &mut Command) -> Self { let mut child = cmd.spawn_guarded(); let stdin = child.stdin.take().expect("stdin"); - let stdout = BufReader::new(child.stdout.take().expect("stdout")); + let stdout = common::serve_frames(child.stdout.take().expect("stdout")); Self { child, stdin, diff --git a/crates/agent/tests/serve_session_lifecycle.rs b/crates/agent/tests/serve_session_lifecycle.rs index e413f1e0..f9be1335 100644 --- a/crates/agent/tests/serve_session_lifecycle.rs +++ b/crates/agent/tests/serve_session_lifecycle.rs @@ -42,7 +42,7 @@ fn serve_export_html_writes_a_self_contained_transcript() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -97,7 +97,7 @@ fn serve_export_html_includes_model_change_and_label_events() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -178,7 +178,7 @@ fn serve_export_html_includes_abandoned_branches_not_just_the_active_path() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "first" })).unwrap(); stdin.flush().unwrap(); @@ -260,7 +260,7 @@ fn serve_defaults_to_home_claude_sessions_when_no_session_flag_given() { cmd.current_dir(project.path()).env("HOME", home.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -314,7 +314,7 @@ fn serve_resumes_newest_session_matching_cwd_not_globally_newest() { cmd.current_dir(project_a.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, "{}", @@ -335,7 +335,7 @@ fn serve_resumes_newest_session_matching_cwd_not_globally_newest() { cmd.current_dir(project_b.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, "{}", @@ -356,7 +356,7 @@ fn serve_resumes_newest_session_matching_cwd_not_globally_newest() { cmd.current_dir(project_a.path()).arg("--continue"); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_messages" })).unwrap(); stdin.flush().unwrap(); let frames = read_until_response(&mut stdout, "get_messages"); @@ -394,7 +394,7 @@ fn serve_reattaches_through_a_symlinked_cwd_to_the_session_recorded_under_its_re cmd.current_dir(&real); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, "{}", @@ -414,7 +414,7 @@ fn serve_reattaches_through_a_symlinked_cwd_to_the_session_recorded_under_its_re cmd.current_dir(&link).arg("--continue"); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_messages" })).unwrap(); stdin.flush().unwrap(); let frames = read_until_response(&mut stdout, "get_messages"); @@ -445,7 +445,7 @@ fn serve_list_sessions_streams_progress_frames_correlated_to_the_request_id() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &session_dir).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Drain the `ready` banner. let mut ready = String::new(); stdout.read_line(&mut ready).unwrap(); @@ -543,7 +543,7 @@ fn serve_exits_gracefully_on_sigterm_mid_run() { let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let pid = child.id(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -647,7 +647,7 @@ fn serve_exits_gracefully_on_sighup_mid_run() { let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let pid = child.id(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -727,7 +727,7 @@ fn serve_exits_with_130_on_sigint_mid_run() { let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let pid = child.id(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -799,7 +799,7 @@ fn serve_streams_events_and_reattaches() { // --- First session: prompt, observe streamed events, read transcript --- let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -851,7 +851,7 @@ fn serve_streams_events_and_reattaches() { // --- Reattach: a fresh process over the same session file sees the prior transcript --- let mut child2 = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin2 = child2.stdin.take().unwrap(); - let mut stdout2 = BufReader::new(child2.stdout.take().unwrap()); + let mut stdout2 = common::serve_frames(child2.stdout.take().unwrap()); writeln!(stdin2, "{}", json!({ "type": "get_messages" })).unwrap(); stdin2.flush().unwrap(); let frames3 = read_until_response(&mut stdout2, "get_messages"); @@ -897,7 +897,7 @@ fn serve_survives_a_hard_crash_mid_run_with_both_round_trips_tool_use_already_du let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "go" })).unwrap(); stdin.flush().unwrap(); @@ -915,7 +915,7 @@ fn serve_survives_a_hard_crash_mid_run_with_both_round_trips_tool_use_already_du let (base2, _bodies2) = spawn_model_server(vec![]); let mut child2 = serve_cmd(bin, &base2, &session_file).spawn_guarded(); let mut stdin2 = child2.stdin.take().unwrap(); - let mut stdout2 = BufReader::new(child2.stdout.take().unwrap()); + let mut stdout2 = common::serve_frames(child2.stdout.take().unwrap()); writeln!(stdin2, "{}", json!({ "type": "get_messages" })).unwrap(); stdin2.flush().unwrap(); let frames = read_until_response(&mut stdout2, "get_messages"); @@ -970,7 +970,7 @@ fn serve_reports_success_when_a_failed_checkpoint_is_superseded_by_a_successful_ let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Consume the ready frame — by now `Persistence::open` has already created the file with normal // permissions, so this doesn't race the file's own creation. @@ -1045,7 +1045,7 @@ fn serve_stdout_stays_valid_json_even_when_a_load_warning_fires() { .stderr(Stdio::piped()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let mut stderr = BufReader::new(child.stderr.take().unwrap()); // Deliberately NOT `read_until_response` — it silently skips a line that fails to parse as JSON, @@ -1118,7 +1118,7 @@ fn new_session_reports_failure_and_leaves_the_old_session_active_when_persist_fa let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hello" })).unwrap(); stdin.flush().unwrap(); @@ -1180,7 +1180,7 @@ fn serve_unknown_command_type_reports_a_clear_failure_frame() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "foobar", "id": "test" })).unwrap(); stdin.flush().unwrap(); @@ -1216,7 +1216,7 @@ fn serve_jsonl_framing_preserves_u2028_and_u2029_inside_a_payload() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let title = "a\u{2028}b\u{2029}c"; writeln!( @@ -1261,7 +1261,7 @@ fn serve_jsonl_framing_handles_crlf_delimited_commands() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let cmd_a = json!({ "id": "a", "type": "set_session_name", "title": "first" }).to_string(); let cmd_b = json!({ "id": "b", "type": "set_session_name", "title": "second" }).to_string(); @@ -1302,7 +1302,7 @@ fn serve_jsonl_framing_handles_a_final_command_with_no_trailing_newline() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // No trailing `\n` at all — `write!`, not `writeln!` — then drop `stdin` to close the pipe. write!( @@ -1337,7 +1337,7 @@ fn serve_name_flag_sets_the_initial_session_title() { .args(["--name", "my-serve-session"]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_state" })).unwrap(); stdin.flush().unwrap(); @@ -1366,7 +1366,7 @@ fn serve_session_id_flag_applies_to_a_brand_new_session_file() { .args(["--session-id", "my-chosen-serve-id"]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_state" })).unwrap(); stdin.flush().unwrap(); @@ -1405,7 +1405,7 @@ fn serve_session_id_flag_applies_to_the_default_repo_mode_when_no_session_exists .args(["--session-id", "my-chosen-repo-id"]); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_state" })).unwrap(); stdin.flush().unwrap(); @@ -1437,7 +1437,7 @@ fn serve_session_id_flag_addresses_its_own_session_not_whatever_matched_the_cwd( cmd.current_dir(project.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_state" })).unwrap(); stdin.flush().unwrap(); let state = read_until_response(&mut stdout, "get_state"); @@ -1458,7 +1458,7 @@ fn serve_session_id_flag_addresses_its_own_session_not_whatever_matched_the_cwd( .args(["--session-id", "mine-alone"]); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_state" })).unwrap(); stdin.flush().unwrap(); diff --git a/crates/agent/tests/serve_session_memory.rs b/crates/agent/tests/serve_session_memory.rs index 2689922f..b05442f1 100644 --- a/crates/agent/tests/serve_session_memory.rs +++ b/crates/agent/tests/serve_session_memory.rs @@ -7,7 +7,7 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use std::path::Path; use std::process::ChildStdin; @@ -85,7 +85,7 @@ fn a_fact_written_to_session_survives_compaction_and_is_recovered_after_the_remi .args(["--compaction-keep-recent-tokens", "1"]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // --- Prompt 1: write the fact to /session. ------------------------------------------------------- send( @@ -176,7 +176,7 @@ fn no_session_memory_omits_the_session_root_but_keeps_durable() { .args(["--no-session-memory"]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send(&mut stdin, json!({ "type": "prompt", "message": "hello" })); read_until_response(&mut stdout, "prompt"); @@ -226,7 +226,7 @@ fn a_pressure_nudge_fires_before_compaction_within_the_same_prompt() { ]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send(&mut stdin, json!({ "type": "prompt", "message": "first" })); let p1 = read_until_response(&mut stdout, "prompt"); @@ -292,7 +292,7 @@ fn below_the_pressure_point_and_under_no_session_memory_no_nudge_fires() { .args(extra) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send(&mut stdin, json!({ "type": "prompt", "message": "first" })); read_until_response(&mut stdout, "prompt"); let before = bodies.lock().unwrap().len(); diff --git a/crates/agent/tests/serve_session_title.rs b/crates/agent/tests/serve_session_title.rs index d58e745d..88d77ffb 100644 --- a/crates/agent/tests/serve_session_title.rs +++ b/crates/agent/tests/serve_session_title.rs @@ -9,8 +9,7 @@ mod common; -use std::io::{BufReader, Write}; -use std::time::Duration; +use std::io::Write; use common::{ SpawnGuarded, read_until_response, serve_dir_cmd, spawn_model_server, @@ -42,7 +41,7 @@ fn a_session_names_itself_from_its_opening_exchange() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &dir_str).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -76,7 +75,7 @@ fn an_unusable_title_is_attempted_once_and_costs_the_session_nothing() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &dir_str).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); for msg in ["first", "second"] { writeln!(stdin, "{}", json!({ "type": "prompt", "message": msg })).unwrap(); @@ -136,8 +135,7 @@ fn a_title_call_does_not_consume_the_conversation_script() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &dir_str).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = - common::frames_with_deadline(child.stdout.take().unwrap(), Duration::from_secs(20)); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let mut texts = Vec::new(); for message in ["one", "two", "three"] { writeln!(stdin, "{}", json!({ "type": "prompt", "message": message })).unwrap(); diff --git a/crates/agent/tests/serve_session_tree.rs b/crates/agent/tests/serve_session_tree.rs index ee1a0b0f..3e6b3aa4 100644 --- a/crates/agent/tests/serve_session_tree.rs +++ b/crates/agent/tests/serve_session_tree.rs @@ -3,7 +3,7 @@ mod common; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, Write}; use std::process::{Command, Stdio}; use common::{ @@ -24,7 +24,7 @@ fn serve_get_messages_since_returns_only_what_was_appended_after_a_known_id() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "first" })).unwrap(); stdin.flush().unwrap(); @@ -100,7 +100,7 @@ fn serve_set_label_and_get_label_round_trip_over_the_wire() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -218,7 +218,7 @@ fn serve_append_custom_reaches_the_tree_but_not_the_active_messages() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -298,7 +298,7 @@ fn serve_repo_lists_switches_and_forks_sessions() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &session_dir).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Read the `ready` banner to learn the first session's id. let mut ready = String::new(); @@ -451,7 +451,7 @@ fn serve_clone_forks_the_current_session_at_its_current_tip() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &session_dir).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let mut ready = String::new(); stdout.read_line(&mut ready).unwrap(); @@ -495,7 +495,7 @@ fn serve_new_session_parent_session_overrides_the_default_lineage() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &session_dir).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let mut ready = String::new(); stdout.read_line(&mut ready).unwrap(); @@ -547,7 +547,7 @@ fn serve_delete_session_soft_deletes_another_session_but_refuses_the_active_one( let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &session_dir).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let mut ready = String::new(); stdout.read_line(&mut ready).unwrap(); @@ -643,7 +643,7 @@ fn serve_fork_by_target_id_reaches_an_off_active_path_branch() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &session_dir).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); for msg in ["a", "b", "c"] { writeln!(stdin, "{}", json!({ "type": "prompt", "message": msg })).unwrap(); @@ -777,7 +777,7 @@ fn serve_fork_includes_the_forked_from_messages_text() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &session_dir).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); for msg in ["a", "b"] { writeln!(stdin, "{}", json!({ "type": "prompt", "message": msg })).unwrap(); @@ -845,7 +845,7 @@ fn serve_fork_and_preview_fork_default_before_to_true() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &session_dir).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); for msg in ["a", "b"] { writeln!(stdin, "{}", json!({ "type": "prompt", "message": msg })).unwrap(); @@ -920,7 +920,7 @@ fn serve_list_all_sessions_spans_every_project_under_the_shared_root() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child_a = serve_dir_cmd(bin, &base_a, &dir_a).spawn_guarded(); let mut stdin_a = child_a.stdin.take().unwrap(); - let mut stdout_a = BufReader::new(child_a.stdout.take().unwrap()); + let mut stdout_a = common::serve_frames(child_a.stdout.take().unwrap()); writeln!(stdin_a, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin_a.flush().unwrap(); read_until_response(&mut stdout_a, "prompt"); @@ -928,7 +928,7 @@ fn serve_list_all_sessions_spans_every_project_under_the_shared_root() { let (base_b, _bodies_b) = spawn_model_server(vec![turn_text("answer from b")]); let mut child_b = serve_dir_cmd(bin, &base_b, &dir_b).spawn_guarded(); let mut stdin_b = child_b.stdin.take().unwrap(); - let mut stdout_b = BufReader::new(child_b.stdout.take().unwrap()); + let mut stdout_b = common::serve_frames(child_b.stdout.take().unwrap()); writeln!(stdin_b, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin_b.flush().unwrap(); read_until_response(&mut stdout_b, "prompt"); @@ -983,7 +983,7 @@ fn serve_list_sessions_filters_by_cwd_under_one_shared_session_dir() { .current_dir(cwd_a.path()) .spawn_guarded(); let mut stdin_a = child_a.stdin.take().unwrap(); - let mut stdout_a = BufReader::new(child_a.stdout.take().unwrap()); + let mut stdout_a = common::serve_frames(child_a.stdout.take().unwrap()); writeln!(stdin_a, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin_a.flush().unwrap(); read_until_response(&mut stdout_a, "prompt"); @@ -993,7 +993,7 @@ fn serve_list_sessions_filters_by_cwd_under_one_shared_session_dir() { .current_dir(cwd_b.path()) .spawn_guarded(); let mut stdin_b = child_b.stdin.take().unwrap(); - let mut stdout_b = BufReader::new(child_b.stdout.take().unwrap()); + let mut stdout_b = common::serve_frames(child_b.stdout.take().unwrap()); writeln!(stdin_b, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin_b.flush().unwrap(); read_until_response(&mut stdout_b, "prompt"); @@ -1037,7 +1037,7 @@ fn serve_list_all_sessions_errors_outside_repo_mode() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "list_all_sessions" })).unwrap(); stdin.flush().unwrap(); @@ -1060,7 +1060,7 @@ fn serve_get_tree_reports_every_node_not_just_leaves() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &session_dir).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -1114,7 +1114,7 @@ fn serve_get_tree_since_returns_only_what_was_appended_after_a_known_id() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "first" })).unwrap(); stdin.flush().unwrap(); @@ -1228,7 +1228,7 @@ fn serve_get_tree_leaf_id_is_null_without_persistence_configured() { .stderr(Stdio::null()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_tree" })).unwrap(); stdin.flush().unwrap(); @@ -1258,7 +1258,7 @@ fn serve_switch_branch_summarizes_abandoned_activity_and_navigates() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "first" })).unwrap(); stdin.flush().unwrap(); @@ -1402,7 +1402,7 @@ fn serve_branch_summary_reserve_tokens_flag_independently_bounds_the_summarizati ]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "first" })).unwrap(); stdin.flush().unwrap(); @@ -1470,7 +1470,7 @@ fn serve_switch_branch_abort_cancels_summarization_and_leaves_session_unchanged( let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "first" })).unwrap(); stdin.flush().unwrap(); @@ -1564,7 +1564,7 @@ fn serve_switch_branch_restores_the_model_active_on_that_branch() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "first" })).unwrap(); stdin.flush().unwrap(); @@ -1640,7 +1640,7 @@ fn serve_new_session_in_single_file_mode_does_not_leak_the_discarded_sessions_mo let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Before any message exists in the (soon to be discarded) original session: `set_model` anchors its // `ModelChange` at the tree's own root, not at some message id. @@ -1721,7 +1721,7 @@ fn serve_switch_branch_before_restores_the_model_at_the_resolved_parent_not_the_ let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "first" })).unwrap(); stdin.flush().unwrap(); @@ -1789,7 +1789,7 @@ fn serve_switch_branch_resets_thinking_level_instead_of_bleeding_a_sibling_branc let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "first" })).unwrap(); stdin.flush().unwrap(); @@ -1858,7 +1858,7 @@ fn serve_get_messages_ids_enable_forking_from_any_point() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "first" })).unwrap(); stdin.flush().unwrap(); @@ -1943,7 +1943,7 @@ fn serve_switch_branch_rejects_unknown_target() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -1985,7 +1985,7 @@ fn serve_switch_branch_before_the_first_message_resets_to_root() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "first" })).unwrap(); stdin.flush().unwrap(); @@ -2075,7 +2075,7 @@ fn serve_list_sessions_query_filters_to_matching_sessions_only() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &session_dir_str).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -2168,7 +2168,7 @@ fn serve_list_all_sessions_query_filters_across_every_project() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child_a = serve_dir_cmd(bin, &base_a, &dir_a).spawn_guarded(); let mut stdin_a = child_a.stdin.take().unwrap(); - let mut stdout_a = BufReader::new(child_a.stdout.take().unwrap()); + let mut stdout_a = common::serve_frames(child_a.stdout.take().unwrap()); writeln!( stdin_a, "{}", @@ -2181,7 +2181,7 @@ fn serve_list_all_sessions_query_filters_across_every_project() { let (base_b, _bodies_b) = spawn_model_server(vec![turn_text("ok")]); let mut child_b = serve_dir_cmd(bin, &base_b, &dir_b).spawn_guarded(); let mut stdin_b = child_b.stdin.take().unwrap(); - let mut stdout_b = BufReader::new(child_b.stdout.take().unwrap()); + let mut stdout_b = common::serve_frames(child_b.stdout.take().unwrap()); writeln!( stdin_b, "{}", @@ -2235,7 +2235,7 @@ fn serve_get_fork_messages_lists_user_turn_candidates_for_the_active_path() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -2318,7 +2318,7 @@ fn serve_get_fork_messages_spans_every_branch_not_just_the_active_path() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "first" })).unwrap(); stdin.flush().unwrap(); @@ -2422,7 +2422,7 @@ fn serve_get_fork_messages_is_empty_without_persistence_configured() { .stderr(Stdio::null()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -2452,7 +2452,7 @@ fn serve_switch_session_resolves_a_unique_id_prefix() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &session_dir).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let mut ready = String::new(); stdout.read_line(&mut ready).unwrap(); @@ -2499,7 +2499,7 @@ fn serve_switch_session_rejects_an_ambiguous_prefix_naming_every_candidate() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &session_dir).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -2552,7 +2552,7 @@ fn serve_switch_session_response_carries_reasoning_effort_like_its_three_sibling let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &session_dir).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -2637,7 +2637,7 @@ fn serve_append_custom_refuses_the_kinds_the_agent_writes_itself() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); read_until_response(&mut stdout, "prompt"); diff --git a/crates/agent/tests/serve_state_reporting.rs b/crates/agent/tests/serve_state_reporting.rs index bcfbd880..865ced56 100644 --- a/crates/agent/tests/serve_state_reporting.rs +++ b/crates/agent/tests/serve_state_reporting.rs @@ -6,15 +6,11 @@ mod common; use std::io::{BufRead, Write}; use common::{ - SpawnGuarded, frames_with_deadline, read_until_event, read_until_response, serve_cmd, - serve_dir_cmd, spawn_model_server, turn_text, turn_tool_use, + SpawnGuarded, read_until_event, read_until_response, serve_cmd, serve_dir_cmd, + spawn_model_server, turn_text, turn_tool_use, }; use serde_json::{Value, json}; -/// How long a test waits for `serve`'s next frame before failing. Every run here is a few scripted -/// turns and a sub-second `sleep`; a stall (not slowness) is what this catches. -const FRAME_DEADLINE: std::time::Duration = std::time::Duration::from_secs(20); - #[test] fn serve_get_session_stats_reports_the_same_field_set_idle_or_mid_turn() { // Fix 8 (pi-parity gap): the busy-mode arm used to send the raw `LiveStats::snapshot()` verbatim @@ -45,7 +41,7 @@ fn serve_get_session_stats_reports_the_same_field_set_idle_or_mid_turn() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = frames_with_deadline(child.stdout.take().unwrap(), FRAME_DEADLINE); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -113,7 +109,7 @@ fn serve_get_state_and_get_session_stats_answer_live_during_a_prompt() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = frames_with_deadline(child.stdout.take().unwrap(), FRAME_DEADLINE); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "go" })).unwrap(); stdin.flush().unwrap(); @@ -174,7 +170,7 @@ fn serve_get_tree_since_works_from_the_busy_loop_mid_prompt() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = frames_with_deadline(child.stdout.take().unwrap(), FRAME_DEADLINE); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "first" })).unwrap(); stdin.flush().unwrap(); @@ -248,7 +244,7 @@ fn serve_get_state_reports_pending_tool_ids_while_a_tool_is_running() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = frames_with_deadline(child.stdout.take().unwrap(), FRAME_DEADLINE); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "go" })).unwrap(); stdin.flush().unwrap(); @@ -302,7 +298,7 @@ fn serve_get_state_reports_runtime_settings_and_queue_depth() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = frames_with_deadline(child.stdout.take().unwrap(), FRAME_DEADLINE); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Defaults, nothing queued yet. Fix 1 (pi-parity gap): `claude-test` (this test's model, via // `serve_cmd`) supports reasoning, so a fresh process with no `--reasoning-effort` now starts at @@ -365,7 +361,7 @@ fn serve_steer_and_follow_up_expose_the_actual_queued_text_not_just_a_count() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = frames_with_deadline(child.stdout.take().unwrap(), FRAME_DEADLINE); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -434,7 +430,7 @@ fn serve_reports_cwd_stale_false_for_a_freshly_created_session() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = frames_with_deadline(child.stdout.take().unwrap(), FRAME_DEADLINE); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let mut ready = String::new(); stdout.read_line(&mut ready).unwrap(); @@ -472,7 +468,7 @@ fn serve_reports_cwd_stale_true_when_the_recorded_directory_no_longer_exists() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file.to_string_lossy()).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = frames_with_deadline(child.stdout.take().unwrap(), FRAME_DEADLINE); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let mut ready = String::new(); stdout.read_line(&mut ready).unwrap(); @@ -516,7 +512,7 @@ fn serve_switch_session_reports_cwd_stale_for_the_newly_active_session() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &session_dir.to_string_lossy()).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = frames_with_deadline(child.stdout.take().unwrap(), FRAME_DEADLINE); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // The freshly (auto-)created active session must not be stale. let mut ready = String::new(); @@ -569,7 +565,7 @@ fn serve_switch_session_restores_the_reopened_sessions_own_model() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_dir_cmd(bin, &base, &session_dir.to_string_lossy()).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = frames_with_deadline(child.stdout.take().unwrap(), FRAME_DEADLINE); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let mut ready = String::new(); stdout.read_line(&mut ready).unwrap(); @@ -616,7 +612,7 @@ fn serve_get_session_stats_reports_context_usage_after_a_real_turn() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = frames_with_deadline(child.stdout.take().unwrap(), FRAME_DEADLINE); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_session_stats" })).unwrap(); stdin.flush().unwrap(); @@ -658,7 +654,7 @@ fn serve_set_session_name_and_get_last_assistant_text() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = frames_with_deadline(child.stdout.take().unwrap(), FRAME_DEADLINE); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // No name set yet, no assistant reply yet. writeln!(stdin, "{}", json!({ "type": "get_state" })).unwrap(); @@ -745,7 +741,7 @@ fn serve_set_session_name_strips_newlines_and_pushes_a_session_info_changed_fram let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = frames_with_deadline(child.stdout.take().unwrap(), FRAME_DEADLINE); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -813,7 +809,7 @@ fn get_state_reports_session_file_and_is_streaming_idle_vs_mid_run() { // startup, before any turn ever ran). let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = frames_with_deadline(child.stdout.take().unwrap(), FRAME_DEADLINE); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_state" })).unwrap(); stdin.flush().unwrap(); let idle_frames = read_until_response(&mut stdout, "get_state"); diff --git a/crates/agent/tests/serve_structured_output.rs b/crates/agent/tests/serve_structured_output.rs index 07eb1d72..631b46ad 100644 --- a/crates/agent/tests/serve_structured_output.rs +++ b/crates/agent/tests/serve_structured_output.rs @@ -7,7 +7,7 @@ mod common; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, Write}; use std::process::ChildStdin; use common::{ @@ -62,7 +62,7 @@ fn a_prompt_with_a_schema_returns_the_validated_payload_on_its_terminal_response let mut child = serve_cmd(BIN, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let resp = prompt( &mut stdin, @@ -92,7 +92,7 @@ fn a_prompt_without_a_schema_carries_neither_the_tool_nor_the_field() { let mut child = serve_cmd(BIN, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let resp = prompt( &mut stdin, @@ -125,7 +125,7 @@ fn a_run_that_never_calls_the_tool_reports_a_null_payload_rather_than_omitting_t let mut child = serve_cmd(BIN, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let resp = prompt( &mut stdin, @@ -145,7 +145,7 @@ fn a_malformed_schema_is_rejected_before_the_prompt_is_ever_acknowledged() { let mut child = serve_cmd(BIN, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Not an object schema: a tool's arguments are always an object, so this can never be satisfied. send( @@ -194,7 +194,7 @@ fn a_schema_installed_by_one_prompt_does_not_leak_into_the_next() { let mut child = serve_cmd(BIN, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let first = prompt( &mut stdin, @@ -248,7 +248,7 @@ fn a_second_prompt_reusing_the_same_schema_still_reports_only_its_own_answer() { let mut child = serve_cmd(BIN, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let ask = json!({ "type": "prompt", "message": "review", "output_schema": schema() }); @@ -288,7 +288,7 @@ fn changing_the_schema_between_prompts_rebuilds_the_tool() { let mut child = serve_cmd(BIN, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let first = prompt( &mut stdin, @@ -329,7 +329,7 @@ fn a_schema_violation_is_fed_back_over_the_wire_and_the_model_corrects_itself() let mut child = serve_cmd(BIN, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); let resp = prompt( &mut stdin, diff --git a/crates/agent/tests/serve_subagent.rs b/crates/agent/tests/serve_subagent.rs index 01314d0e..71881d86 100644 --- a/crates/agent/tests/serve_subagent.rs +++ b/crates/agent/tests/serve_subagent.rs @@ -4,7 +4,7 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use common::{SpawnGuarded, read_until_response, spawn_model_server, turn_text, turn_tool_use}; use serde_json::{Value, json}; @@ -44,7 +44,7 @@ fn serve_runs_a_subagent_and_streams_its_progress() { .stderr(std::process::Stdio::piped()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -122,7 +122,7 @@ fn reload_makes_a_newly_added_agent_delegable_without_a_restart() { .stderr(std::process::Stdio::piped()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // First prompt: no agents defined yet. writeln!( diff --git a/crates/agent/tests/serve_systemd.rs b/crates/agent/tests/serve_systemd.rs index efd8f438..e00a785d 100644 --- a/crates/agent/tests/serve_systemd.rs +++ b/crates/agent/tests/serve_systemd.rs @@ -123,3 +123,65 @@ fn serve_adopts_a_systemd_activated_socket() { let _ = child.kill(); let _ = child.wait(); } + +/// A `serve` given a socket by the activation variables but no `LISTEN_PID` (or another process's) +/// refuses it and exits, rather than serving on descriptors it cannot know were meant for it. +#[test] +fn serve_refuses_passed_sockets_without_its_own_listen_pid() { + let (base, _requests) = spawn_model_server(vec![]); + for listen_pid in [None, Some("1")] { + let dir = tempfile::tempdir().unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let mut cmd = common::serve_dir_cmd( + env!("CARGO_BIN_EXE_beyond-ai-agent"), + &base, + &dir.path().join("sessions").to_string_lossy(), + ); + cmd.env("LISTEN_FDS", "1") + .env("LISTEN_FDS_FIRST_FD", "0") + .env_remove("LISTEN_PID") + .stdin(Stdio::from(std::os::fd::OwnedFd::from( + listener.try_clone().unwrap(), + ))) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + if let Some(pid) = listen_pid { + cmd.env("LISTEN_PID", pid); + } + let child = cmd.spawn_guarded(); + drop(cmd); + // Bounded: a `serve` that adopted the socket would run until killed. + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(child.wait_with_output()); + }); + let out = rx + .recv_timeout(std::time::Duration::from_secs(20)) + .unwrap_or_else(|_| panic!("LISTEN_PID={listen_pid:?}: serve adopted the socket")) + .unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{listen_pid:?}: {stderr}"); + assert!(stderr.contains("LISTEN_PID"), "{listen_pid:?}: {stderr}"); + } +} + +/// The same socket handed over with `LISTEN_PID` set to the daemon's own pid (as an activator does: +/// set it, then `exec` the daemon in place) is adopted and served. +#[tokio::test] +async fn serve_adopts_a_passed_socket_whose_listen_pid_is_its_own() { + let (base, _requests) = spawn_model_server(vec![]); + let dir = tempfile::tempdir().unwrap(); + let held = common::HeldPort::bind(); + let port = held.port(); + let cmd = common::serve_dir_cmd( + env!("CARGO_BIN_EXE_beyond-ai-agent"), + &base, + &dir.path().join("sessions").to_string_lossy(), + ); + let _child = held.spawn(&cmd, Stdio::inherit()); + drop(held); + let mut ws = common::ws_connect(port, Some("adopted")).await; + ws_send(&mut ws, json!({ "type": "get_state" })).await; + let frames = ws_read_until_response(&mut ws, "get_state").await; + assert_eq!(frames.last().unwrap()["data"]["session_id"], "adopted"); +} diff --git a/crates/agent/tests/serve_todo.rs b/crates/agent/tests/serve_todo.rs index c1d024e7..421f7977 100644 --- a/crates/agent/tests/serve_todo.rs +++ b/crates/agent/tests/serve_todo.rs @@ -9,7 +9,7 @@ mod common; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, Write}; use std::process::ChildStdin; use common::{ @@ -63,7 +63,7 @@ fn get_todos_is_null_before_the_model_has_made_a_plan() { let mut child = serve_cmd(BIN, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); assert_eq!(get_todos(&mut stdin, &mut stdout), Value::Null); kill(child); @@ -84,7 +84,7 @@ fn get_todos_reads_the_last_call_out_of_the_live_transcript_when_idle() { let mut child = serve_cmd(BIN, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, @@ -122,7 +122,7 @@ fn get_todos_answers_from_the_live_mirror_while_a_run_is_still_in_flight() { let mut child = serve_cmd(BIN, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, @@ -176,7 +176,7 @@ fn a_compaction_carries_the_plan_into_the_summary_and_get_todos_still_finds_it() .args(["--compaction-keep-recent-tokens", "1"]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, @@ -231,7 +231,7 @@ fn the_plan_survives_a_serve_restart_past_a_compaction() { .args(["--compaction-keep-recent-tokens", "1"]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); send( &mut stdin, @@ -248,7 +248,7 @@ fn the_plan_survives_a_serve_restart_past_a_compaction() { let (base2, _b2) = spawn_model_server(vec![]); let mut child = serve_cmd(BIN, &base2, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); assert_eq!( get_todos(&mut stdin, &mut stdout), diff --git a/crates/agent/tests/serve_tools_bash.rs b/crates/agent/tests/serve_tools_bash.rs index d3d4df27..766ce85e 100644 --- a/crates/agent/tests/serve_tools_bash.rs +++ b/crates/agent/tests/serve_tools_bash.rs @@ -3,7 +3,7 @@ mod common; -use std::io::{BufReader, Read, Write}; +use std::io::{Read, Write}; use std::process::Stdio; use common::{ @@ -29,7 +29,7 @@ fn serve_streams_tool_progress_from_a_running_bash() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -84,7 +84,7 @@ fn serve_exclude_tools_removes_a_tool_from_the_advertised_set() { cmd.args(["--exclude-tools", "bash"]); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -119,7 +119,7 @@ fn serve_no_tools_sends_no_tools_field_at_all() { cmd.args(["--no-tools"]); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -146,7 +146,7 @@ fn serve_bash_runs_a_host_command_independent_of_the_model() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -186,7 +186,7 @@ fn serve_bash_records_its_result_into_session_context_by_default() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -227,7 +227,7 @@ fn serve_bash_exclude_from_context_is_recorded_but_hidden_from_the_model() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -307,7 +307,7 @@ fn serve_bash_is_rejected_when_the_tool_is_excluded() { cmd.args(["--exclude-tools", "bash"]); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -348,7 +348,7 @@ fn serve_bash_rpc_command_is_blocked_by_deny_bash_pattern() { cmd.args(["--deny-bash-pattern", "touch"]); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -390,7 +390,7 @@ fn serve_bash_shell_path_overrides_the_auto_resolved_shell() { cmd.args(["--bash-shell-path", "/bin/sh"]); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // `$BASH_VERSION` is set only by bash itself; `/bin/sh` on this host is dash, which leaves it // unset — the POSIX `${VAR:-default}` expansion below works identically under both, so the @@ -423,7 +423,7 @@ fn serve_bash_command_prefix_flag_runs_before_the_given_command() { cmd.args(["--bash-command-prefix", "export PREFIX_VAR=from-prefix"]); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -476,7 +476,7 @@ fn serve_sequential_tools_flag_is_accepted_and_both_calls_still_run() { cmd.args(["--sequential-tools"]); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -528,7 +528,7 @@ fn serve_abort_bash_cancels_a_running_host_command() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -579,7 +579,7 @@ fn serve_abort_bash_returns_the_partial_output_streamed_before_cancellation() { let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Two writes: `bash` throttles progress snapshots to one per `UPDATE_THROTTLE` (100ms) after an // initial empty update, so a lone `printf` microseconds later never streams and the next snapshot @@ -655,7 +655,7 @@ fn serve_bash_terminal_response_carries_structured_exit_code_and_truncation_fiel let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); let mut child = serve_cmd(bin, &base, &session_file).spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "bash", "command": "exit 7" })).unwrap(); stdin.flush().unwrap(); @@ -715,7 +715,7 @@ fn serve_deny_tool_flag_blocks_a_model_invoked_call_end_to_end() { .args(["--deny-tool", "bash"]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -771,7 +771,7 @@ fn serve_deny_path_flag_blocks_a_model_invoked_write_end_to_end() { .args(["--deny-path", "*.env"]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -833,7 +833,7 @@ fn serve_block_images_flag_forces_a_read_tool_image_to_a_text_placeholder() { .current_dir(dir.path()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -886,7 +886,7 @@ fn serve_no_image_auto_resize_flag_skips_downscaling_an_oversized_image() { .current_dir(dir.path()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, @@ -974,7 +974,7 @@ fn serve_set_image_auto_resize_toggles_the_wire_layer_mid_session_without_a_rest .current_dir(dir.path()) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!( stdin, diff --git a/crates/agent/tests/serve_trust_prompts.rs b/crates/agent/tests/serve_trust_prompts.rs index 11cd0231..3dad3a64 100644 --- a/crates/agent/tests/serve_trust_prompts.rs +++ b/crates/agent/tests/serve_trust_prompts.rs @@ -3,7 +3,7 @@ mod common; -use std::io::{BufReader, Read, Write}; +use std::io::{Read, Write}; use std::process::{Command, Stdio}; use common::{SpawnGuarded, read_until_response, serve_cmd, spawn_model_server, turn_text}; @@ -23,7 +23,7 @@ fn serve_injects_project_instructions_into_system_prompt() { cmd.current_dir(dir.path()); // CLAUDE.md is discovered relative to cwd let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -54,7 +54,7 @@ fn serve_caches_the_static_system_prompt_until_reload() { cmd.current_dir(dir.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); // Turn 1: the marker as it existed at startup. writeln!(stdin, "{}", json!({ "type": "prompt", "message": "one" })).unwrap(); @@ -130,7 +130,7 @@ fn serve_gates_skills_and_prompts_on_project_trust() { cmd.current_dir(dir.path()).env("HOME", home.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_commands" })).unwrap(); stdin.flush().unwrap(); @@ -170,7 +170,7 @@ fn serve_gates_skills_and_prompts_on_project_trust() { .env("HOME", home.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_commands" })).unwrap(); stdin.flush().unwrap(); @@ -228,7 +228,7 @@ fn serve_force_untrusted_overrides_a_persisted_trust_grant() { cmd.current_dir(dir.path()).env("HOME", home.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_commands" })).unwrap(); stdin.flush().unwrap(); @@ -258,7 +258,7 @@ fn serve_force_untrusted_overrides_a_persisted_trust_grant() { .env("HOME", home.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_commands" })).unwrap(); stdin.flush().unwrap(); @@ -308,7 +308,7 @@ fn serve_untrusted_project_still_advertises_and_invokes_a_user_global_skill() { cmd.current_dir(dir.path()).env("HOME", home.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_commands" })).unwrap(); stdin.flush().unwrap(); @@ -371,7 +371,7 @@ fn serve_get_commands_reports_a_cross_root_skill_collision() { .env("HOME", home.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_commands" })).unwrap(); stdin.flush().unwrap(); @@ -451,7 +451,7 @@ fn serve_get_commands_reports_scope_and_path_per_entry() { .env("HOME", home.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_commands" })).unwrap(); stdin.flush().unwrap(); @@ -512,7 +512,7 @@ fn serve_get_commands_reports_the_prompt_templates_own_description_not_its_argum .env("HOME", home.path()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "get_commands" })).unwrap(); stdin.flush().unwrap(); @@ -555,7 +555,7 @@ fn serve_warns_on_stderr_when_an_untrusted_projects_gated_resources_are_skipped( cmd.stderr(Stdio::piped()); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::serve_frames(child.stdout.take().unwrap()); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap();