From 72213ed0e8650deb309f6290638f177cb45a5ee3 Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Tue, 6 Oct 2026 22:38:37 -0700 Subject: [PATCH 1/2] agent: file_lock module, unreachable deadline-free readers, LISTEN_PID operator note Audit of #139: - Operators: ARCHITECTURE.md's transport section says what changed for launchers that pass sockets without LISTEN_PID (`systemfd --no-pid`, forking wrappers), the error they now see, and the fix (`exec` the daemon, or set LISTEN_PID in the child just before it runs). - The frame-deadline lint matched `BufReader::new(` and `stdout` on one line. Now every child-stdout read in the serve_* and mcp_* suites and tests/common goes through `common::child_frames`, the events fixture included, and the lint fails on any other `ChildStdout` or `.stdout.take()`/`as_mut`/`as_ref` (comments dropped, whitespace ignored, so formatting cannot hide one). It found one more raw read. - `update_store_off_runtime` logs a JoinError (a panicking manifest write) at warn instead of discarding it. - The kernel lock and same-process path registry are their own module, `file_lock`; the session lock and the manifest lock both use it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk --- crates/agent/ARCHITECTURE.md | 23 ++- crates/agent/src/file_lock.rs | 176 ++++++++++++++++++ crates/agent/src/lib.rs | 1 + crates/agent/src/session_store.rs | 112 +---------- crates/agent/src/tools/mcp_manifest.rs | 79 +++++++- .../agent/tests/common/mcp_events_fixture.rs | 43 ++--- crates/agent/tests/common/mod.rs | 25 ++- crates/agent/tests/common/skills_env.rs | 2 +- crates/agent/tests/mcp_client.rs | 4 +- crates/agent/tests/mcp_elicitation.rs | 6 +- crates/agent/tests/mcp_events_busy.rs | 2 +- crates/agent/tests/mcp_events_durability.rs | 6 +- crates/agent/tests/mcp_events_lifecycle.rs | 2 +- crates/agent/tests/mcp_events_poll.rs | 2 +- crates/agent/tests/mcp_events_push.rs | 2 +- crates/agent/tests/mcp_hotplug.rs | 8 +- crates/agent/tests/mcp_hotplug_subagent.rs | 4 +- crates/agent/tests/mcp_oauth.rs | 2 +- crates/agent/tests/mcp_progress.rs | 4 +- crates/agent/tests/mcp_skills.rs | 4 +- crates/agent/tests/mcp_stdio_transport.rs | 2 +- crates/agent/tests/mcp_surfaces.rs | 6 +- crates/agent/tests/mcp_tasks.rs | 12 +- crates/agent/tests/mcp_tasks_conformance.rs | 13 +- crates/agent/tests/mcp_tasks_durable.rs | 9 +- crates/agent/tests/mcp_tasks_resume.rs | 9 +- crates/agent/tests/serve_approval.rs | 24 +-- crates/agent/tests/serve_auth.rs | 10 +- crates/agent/tests/serve_block_images.rs | 10 +- crates/agent/tests/serve_busy_self_abort.rs | 4 +- crates/agent/tests/serve_compaction_retry.rs | 42 ++--- crates/agent/tests/serve_exec_endpoint.rs | 12 +- crates/agent/tests/serve_gateway_headers.rs | 4 +- crates/agent/tests/serve_grant.rs | 2 +- crates/agent/tests/serve_harness_deadlines.rs | 81 +++++--- crates/agent/tests/serve_lifecycle.rs | 18 +- crates/agent/tests/serve_list_paging.rs | 2 +- crates/agent/tests/serve_memory.rs | 6 +- crates/agent/tests/serve_models_thinking.rs | 36 ++-- .../agent/tests/serve_oauth_model_switch.rs | 2 +- crates/agent/tests/serve_prompt_flow.rs | 34 ++-- .../agent/tests/serve_session_addressing.rs | 2 +- crates/agent/tests/serve_session_lifecycle.rs | 58 +++--- crates/agent/tests/serve_session_memory.rs | 8 +- crates/agent/tests/serve_session_title.rs | 6 +- crates/agent/tests/serve_session_tree.rs | 76 ++++---- crates/agent/tests/serve_state_reporting.rs | 28 +-- crates/agent/tests/serve_structured_output.rs | 16 +- crates/agent/tests/serve_subagent.rs | 4 +- crates/agent/tests/serve_todo.rs | 12 +- crates/agent/tests/serve_tools_bash.rs | 38 ++-- crates/agent/tests/serve_trust_prompts.rs | 22 +-- 52 files changed, 657 insertions(+), 458 deletions(-) create mode 100644 crates/agent/src/file_lock.rs diff --git a/crates/agent/ARCHITECTURE.md b/crates/agent/ARCHITECTURE.md index 20330352..e0535baf 100644 --- a/crates/agent/ARCHITECTURE.md +++ b/crates/agent/ARCHITECTURE.md @@ -34,8 +34,23 @@ The harness layers several capabilities over the bare tools + loop: 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`) 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; + `systemctl restart` (connections queue in the kernel) and systemd owns its perms/lifecycle. + + **Operator note (since #139).** A launcher that passes sockets without naming the process they are + for now gets a startup error instead of a daemon: `serve` exits with `LISTEN_FDS is set without + LISTEN_PID` (unset) or `LISTEN_PID= names another process` (set, but to a different pid). Those + are the launchers that set `LISTEN_FDS` and then fork, or never set `LISTEN_PID` at all — + `systemfd --no-pid`, and wrappers that start the daemon as a _child_ (a shell script that runs + `beyond-ai-agent serve …` without `exec`, a supervisor that forks after exporting the variables). + systemd itself, `systemd-socket-activate`, and `systemfd` without `--no-pid` are unaffected: each + sets `LISTEN_PID` to the very process it starts. The fix is to make the daemon that process — drop + `--no-pid`; in a wrapper script, end with `exec beyond-ai-agent serve …` so the daemon keeps the pid + the launcher named; or, where a wrapper has to fork, set the variable in the child just before it + runs the daemon (`sh -c 'LISTEN_PID=$$; export LISTEN_PID; exec beyond-ai-agent serve …'`, which + is also how the test harness's `HeldPort` starts it). Without activation at all, use `--listen` / + `--listen-uds`. + + The protocol is byte-identical across all transports; all feed the transport-agnostic `serve::serve_session` core, which reads commands from an `mpsc` channel and emits frames to whichever connection is currently attached. The key property this buys the WebSocket path: **the session is a view, not owned by the connection** — a @@ -3694,7 +3709,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 `.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. + 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` — `file_lock::try_lock` — the kernel-lock-plus-registry primitive the session lock (`session_store::acquire_session_lock`) is also built on, 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. A write that panics leaves the file as it was and is logged at warn. - **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 @@ -4115,7 +4130,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`), 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/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: `child_frames` for stdout — the only code in the `serve_*`/`mcp_*` suites and `tests/common` that touches a child's stdout pipe, which `serve_harness_deadlines` enforces, `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/file_lock.rs b/crates/agent/src/file_lock.rs new file mode 100644 index 00000000..bc2e17e2 --- /dev/null +++ b/crates/agent/src/file_lock.rs @@ -0,0 +1,176 @@ +//! An advisory lock on one file: a kernel lock (`flock`, emulated with POSIX record locks over NFS) +//! plus a process-wide registry of the paths this process holds. +//! +//! The kernel releases the lock when its holder exits, however it exits, so there is no staleness to +//! judge and no lockfile to break: a crashed holder's leftover file is simply unlocked. The registry +//! is there because closing *any* descriptor to a POSIX-locked file drops the lock — a second open of +//! the same path in this process would quietly release the first one's hold — so a second attempt in +//! the same process reports "held" instead of opening it. +//! +//! Used for a session's liveness lock (`session_store::acquire_session_lock`) and the MCP manifest +//! cache's write lock (`tools::mcp_manifest`). Non-blocking: [`try_lock`] answers at once, and a +//! caller that wants to wait polls it off the async runtime. + +use std::collections::HashSet; +use std::fs::{File, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +/// How many times [`try_lock`] re-opens after finding it locked a file that is no longer the one at +/// the path (the file, or a directory holding it, renamed away mid-acquire). +const RETRIES: usize = 5; + +/// A held lock. Dropping it closes the descriptor (releasing the kernel lock) and frees the +/// registration. +pub struct FileLock { + _file: File, + _registration: Registration, +} + +/// "This process holds the lock at this path." +struct Registration(PathBuf); + +impl Drop for Registration { + fn drop(&mut self) { + held() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&self.0); + } +} + +fn held() -> &'static Mutex> { + static HELD: OnceLock>> = OnceLock::new(); + HELD.get_or_init(|| Mutex::new(HashSet::new())) +} + +/// Take the lock on `lock_path`, creating the file (mode 0600) if needed. `Ok(None)` means someone — +/// another process, or this one — holds it *right now*. +/// +/// The descriptor is opened read+write, because NFS emulates `flock` with POSIX record locks and those +/// need a writable descriptor. After locking, `fstat` on the held descriptor is compared with `stat` +/// of the path, so a file renamed away between the open and the lock is caught rather than silently +/// "locked". +pub fn try_lock(lock_path: &Path) -> std::io::Result> { + { + let mut held = held() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !held.insert(lock_path.to_path_buf()) { + return Ok(None); + } + } + // Registered from here on, so every path out of this function frees the entry. + let registration = Registration(lock_path.to_path_buf()); + for _ in 0..RETRIES { + let mut opts = OpenOptions::new(); + opts.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let file = opts.open(lock_path)?; + match file.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => return Ok(None), + Err(std::fs::TryLockError::Error(e)) => return Err(e), + } + if same_file(&file, lock_path)? { + return Ok(Some(FileLock { + _file: file, + _registration: registration, + })); + } + // The file we locked is no longer the file at that path. Drop it and look again. + drop(file); + } + Ok(None) +} + +/// Whether the open descriptor and `path` still name the same inode. +fn same_file(file: &File, path: &Path) -> std::io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let a = file.metadata()?; + let Ok(b) = std::fs::metadata(path) else { + return Ok(false); + }; + Ok(a.dev() == b.dev() && a.ino() == b.ino()) + } + #[cfg(not(unix))] + { + let _ = (file, path); + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One holder at a time within the process (the registry), and the lock is free again once the + /// holder drops it. + #[test] + fn one_holder_at_a_time_and_free_again_on_drop() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("x.lock"); + let held = try_lock(&path).unwrap().expect("free at first"); + assert!(try_lock(&path).unwrap().is_none(), "held by this process"); + drop(held); + assert!(try_lock(&path).unwrap().is_some(), "free once dropped"); + } + + /// A path this process holds is refused to it a second time without opening the file — so even + /// when the file at the path is replaced underneath the holder, this process never ends up + /// holding the path twice. (On NFS the kernel lock is a POSIX record lock, which closing *any* + /// descriptor drops; the registry is what keeps a second attempt from opening one.) + #[test] + fn a_path_held_here_is_refused_here_even_if_its_file_is_replaced() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("x.lock"); + let _held = try_lock(&path).unwrap().expect("free at first"); + std::fs::remove_file(&path).unwrap(); + assert!( + try_lock(&path).unwrap().is_none(), + "this process already holds the path" + ); + assert!( + !path.exists(), + "the refused attempt never opened (and so created) the file" + ); + } + + /// Different paths are different locks. + #[test] + fn locks_on_different_paths_are_independent() { + let dir = tempfile::tempdir().unwrap(); + let _a = try_lock(&dir.path().join("a.lock")).unwrap().unwrap(); + assert!(try_lock(&dir.path().join("b.lock")).unwrap().is_some()); + } + + #[cfg(unix)] + #[test] + fn same_file_catches_a_path_whose_inode_moved() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lock"); + std::fs::write(&path, b"").unwrap(); + let held = File::open(&path).unwrap(); + assert!(same_file(&held, &path).unwrap()); + + // Another file takes the path — what a session directory renamed into `.trash/` looks like + // from the lock's point of view. + let other = dir.path().join("other"); + std::fs::write(&other, b"").unwrap(); + std::fs::rename(&other, &path).unwrap(); + assert!( + !same_file(&held, &path).unwrap(), + "the descriptor no longer names the file at that path" + ); + + // And a path that is simply gone is not the same file either. + std::fs::remove_file(&path).unwrap(); + assert!(!same_file(&held, &path).unwrap()); + } +} diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 45b951c4..3683f861 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -14,6 +14,7 @@ pub mod auth_credential_source; pub mod auth_store; pub mod exec_endpoint; pub mod export; +pub mod file_lock; pub mod gateway_credential; pub mod grant; pub mod lifecycle; diff --git a/crates/agent/src/session_store.rs b/crates/agent/src/session_store.rs index 2368bf62..568d6b5d 100644 --- a/crates/agent/src/session_store.rs +++ b/crates/agent/src/session_store.rs @@ -4830,10 +4830,6 @@ const SEAL_TAG_LEN: usize = 16; /// unlinking an older segment, or an NFS file handle going stale. const SEG_READ_RETRIES: usize = 5; -/// How many times [`acquire_session_lock`] re-opens after finding it locked a file that is no longer -/// the one at the path (a session directory renamed into `.trash/` mid-acquire). -const LOCK_RETRIES: usize = 5; - /// `ESTALE`. Not an `io::ErrorKind` variant, so it is matched on the raw errno. const ESTALE: i32 = 116; @@ -6171,31 +6167,9 @@ fn log_stamp(path: &Path, segmented: bool) -> Option { // ---- The session lock -------------------------------------------------------------------------- -/// A held advisory lock on one session. Dropping it closes the descriptor (releasing the lock) and -/// frees the in-process registration. -pub struct SessionLock { - _file: File, - _registration: LockRegistration, -} - -/// "This process already has this session open." Closing *any* descriptor to a POSIX-locked file -/// drops the lock, so a second open in the same process would quietly release the first one's hold — -/// this makes the second attempt report "held" instead. -struct LockRegistration(PathBuf); - -impl Drop for LockRegistration { - fn drop(&mut self) { - held_session_locks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(&self.0); - } -} - -fn held_session_locks() -> &'static Mutex> { - static HELD: OnceLock>> = OnceLock::new(); - HELD.get_or_init(|| Mutex::new(HashSet::new())) -} +/// A held advisory lock on one session ([`crate::file_lock::FileLock`]). Dropping it releases the +/// lock. +pub type SessionLock = crate::file_lock::FileLock; /// Take the advisory lock for the session at `session_path` — the session directory for the segmented /// layout, the `.jsonl` file otherwise. `Ok(None)` means someone else holds it *right now*. @@ -6211,6 +6185,8 @@ fn held_session_locks() -> &'static Mutex> { /// renamed into `.trash/` between the open and the lock is caught rather than silently "locked"; /// - a session is opened **once per process**, because closing *any* descriptor to a POSIX-locked file /// drops the lock — a second open in the same process would quietly release the first one's hold. +/// +/// All three are [`crate::file_lock::try_lock`]'s; this only names the session's lock file. pub fn acquire_session_lock(session_path: &Path) -> std::io::Result> { let lock_path = if session_path.is_dir() { session_path.join("lock") @@ -6219,59 +6195,7 @@ pub fn acquire_session_lock(session_path: &Path) -> std::io::Result {} - Err(std::fs::TryLockError::WouldBlock) => return Ok(None), - Err(std::fs::TryLockError::Error(e)) => return Err(e), - } - if same_file(&file, &lock_path)? { - return Ok(Some(SessionLock { - _file: file, - _registration: registration, - })); - } - // The file we locked is no longer the file at that path — the session directory moved (a - // delete into `.trash/`) between the open and the lock. Drop it and look again. - drop(file); - } - Ok(None) -} - -/// Whether the open descriptor and `path` still name the same inode. -fn same_file(file: &File, path: &Path) -> std::io::Result { - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - let a = file.metadata()?; - let Ok(b) = fs::metadata(path) else { - return Ok(false); - }; - Ok(a.dev() == b.dev() && a.ino() == b.ino()) - } - #[cfg(not(unix))] - { - let _ = (file, path); - Ok(true) - } + crate::file_lock::try_lock(&lock_path) } #[cfg(test)] @@ -11860,28 +11784,4 @@ mod tests { // A directory with no segments has no stamp at all — it is not a session. assert!(log_stamp(&dir.path().join("nothing"), true).is_none()); } - - #[cfg(unix)] - #[test] - fn same_file_catches_a_path_whose_inode_moved() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("lock"); - fs::write(&path, b"").unwrap(); - let held = File::open(&path).unwrap(); - assert!(same_file(&held, &path).unwrap()); - - // Another file takes the path — what a session directory renamed into `.trash/` looks like - // from the lock's point of view. - let other = dir.path().join("other"); - fs::write(&other, b"").unwrap(); - fs::rename(&other, &path).unwrap(); - assert!( - !same_file(&held, &path).unwrap(), - "the descriptor no longer names the file at that path" - ); - - // And a path that is simply gone is not the same file either. - fs::remove_file(&path).unwrap(); - assert!(!same_file(&held, &path).unwrap()); - } } diff --git a/crates/agent/src/tools/mcp_manifest.rs b/crates/agent/src/tools/mcp_manifest.rs index 2ab42b35..c5674ef7 100644 --- a/crates/agent/src/tools/mcp_manifest.rs +++ b/crates/agent/src/tools/mcp_manifest.rs @@ -180,20 +180,23 @@ pub fn load(dir: &ManifestDir, config: &McpServerConfig) -> Option.lock` — a kernel file lock, which +/// The manifest's write lock, waited for up to [`LOCK_TIMEOUT`]: [`crate::file_lock`] on +/// `.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 { +fn lock_store(dir: &ManifestDir) -> Option { let path = dir.file(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok()?; } + let mut lock_path = path.into_os_string(); + lock_path.push(".lock"); + let lock_path = PathBuf::from(lock_path); let deadline = Instant::now() + LOCK_TIMEOUT; loop { - match crate::session_store::acquire_session_lock(&path) { + match crate::file_lock::try_lock(&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, @@ -218,12 +221,21 @@ 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. +/// A write that panics leaves the cache as it was (the rename never happened) — still best-effort, +/// but said at warn, since it is a bug and not a busy lock. async fn update_store_off_runtime( dir: &ManifestDir, change: impl FnOnce(&mut Store) -> bool + Send + 'static, ) { + let path = dir.file(); let dir = dir.clone(); - let _ = tokio::task::spawn_blocking(move || update_store(&dir, change)).await; + if let Err(e) = tokio::task::spawn_blocking(move || update_store(&dir, change)).await { + tracing::warn!( + manifest = %path.display(), + error = %e, + "MCP manifest write failed; the cache is left as it was" + ); + } } /// Drop `config`'s cached manifest, if any — for a server whose answer must not be cached here (a @@ -389,6 +401,63 @@ mod tests { ); } + /// A write that panics is reported at warn — not discarded — and leaves the cache untouched. + #[test] + fn a_write_that_panics_is_logged_and_leaves_the_cache_as_it_was() { + let dir = tempfile::tempdir().unwrap(); + let manifest = ManifestDir::at(dir.path()); + let config = stdio("s", "npx", &["server"]); + block_on(store( + &manifest, + &config, + vec![], + vec![], + vec![], + Some(vec![]), + vec![], + )); + let before = std::fs::read(dir.path().join(FILE)).unwrap(); + let capture = crate::tracing_test::capture(|| { + block_on(update_store_off_runtime(&manifest, |_| { + panic!("a bug in a manifest write") + })); + }); + assert!( + capture + .messages() + .iter() + .any(|m| m.contains("MCP manifest write failed")), + "{:?}", + capture.messages() + ); + assert_eq!(std::fs::read(dir.path().join(FILE)).unwrap(), before); + // And the lock went with the panicking writer. + assert!(lock_store(&manifest).is_some()); + } + + /// The manifest's lock and a session's are one primitive ([`crate::file_lock`]) on different + /// files: holding one never stands in for, or blocks, the other. + #[test] + fn the_manifest_lock_is_its_own_file_lock() { + let dir = tempfile::tempdir().unwrap(); + let manifest = ManifestDir::at(dir.path()); + let held = lock_store(&manifest).unwrap(); + assert!( + crate::file_lock::try_lock(&dir.path().join(format!("{FILE}.lock"))) + .unwrap() + .is_none(), + "it is crate::file_lock on .lock" + ); + let session = dir.path().join(FILE); + assert!( + crate::session_store::acquire_session_lock(&session.with_extension("jsonl")) + .unwrap() + .is_some(), + "a session's lock is a different file" + ); + drop(held); + } + /// 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 { diff --git a/crates/agent/tests/common/mcp_events_fixture.rs b/crates/agent/tests/common/mcp_events_fixture.rs index b50f932a..f307ed51 100644 --- a/crates/agent/tests/common/mcp_events_fixture.rs +++ b/crates/agent/tests/common/mcp_events_fixture.rs @@ -6,11 +6,10 @@ #![allow(dead_code)] -use std::io::{BufRead, BufReader, Read, Write}; +use std::io::{BufRead, Read, Write}; use std::net::TcpStream; use std::path::{Path, PathBuf}; -use std::process::{ChildStdout, Command, Stdio}; -use std::sync::mpsc; +use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; use serde_json::{Value, json}; @@ -77,7 +76,7 @@ pub fn spawn_http_fixture(envs: &[(&str, &str)]) -> (ChildGuard, String, String) } let mut child = cmd.spawn_guarded(); let mut line = String::new(); - BufReader::new(child.stdout.take().unwrap()) + super::child_frames(&mut child) .read_line(&mut line) .unwrap(); let v: Value = serde_json::from_str(line.trim()).expect("fixture banner"); @@ -122,29 +121,18 @@ pub fn fast_knobs(cmd: &mut Command) -> &mut Command { .env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0") } -/// Frames from a `serve` child's stdout, read on a thread so every wait can be bounded. +/// Frames from a `serve` child's stdout ([`super::child_frames`]), each wait bounded by its caller. pub struct Frames { - rx: mpsc::Receiver, + frames: super::Frames, /// Everything received so far, in order. pub seen: Vec, stderr: Option, } impl Frames { - pub fn new(stdout: ChildStdout, stderr: Option) -> Self { - let (tx, rx) = mpsc::channel(); - std::thread::spawn(move || { - for line in BufReader::new(stdout).lines() { - let Ok(line) = line else { break }; - if let Ok(v) = serde_json::from_str::(line.trim()) - && tx.send(v).is_err() - { - break; - } - } - }); + pub fn new(child: &mut std::process::Child, stderr: Option) -> Self { Self { - rx, + frames: super::child_frames(child), seen: Vec::new(), stderr, } @@ -167,7 +155,7 @@ impl Frames { let deadline = Instant::now() + timeout; loop { let left = deadline.saturating_duration_since(Instant::now()); - match self.rx.recv_timeout(left) { + match self.frames.next_frame(left) { Ok(v) => { self.seen.push(v.clone()); if pred(&v) { @@ -188,11 +176,18 @@ impl Frames { if left.is_zero() { return out; } - if let Ok(v) = self.rx.recv_timeout(left) { - self.seen.push(v.clone()); - if pred(&v) { - out.push(v); + match self.frames.next_frame(left) { + Ok(v) => { + self.seen.push(v.clone()); + if pred(&v) { + out.push(v); + } + } + Err(super::NoFrame::Closed) => { + std::thread::sleep(left); + return out; } + Err(super::NoFrame::TimedOut) => {} } } } diff --git a/crates/agent/tests/common/mod.rs b/crates/agent/tests/common/mod.rs index fca9bc41..c0d1b4da 100644 --- a/crates/agent/tests/common/mod.rs +++ b/crates/agent/tests/common/mod.rs @@ -484,7 +484,7 @@ pub fn read_until_response(reader: &mut impl BufRead, command: &str) -> Vec Frames { - frames_with_deadline(out, FRAME_DEADLINE) +/// [`Frames`] over `child`'s stdout (which must be piped, and is taken), with [`FRAME_DEADLINE`]. +/// +/// The **only** way a test reads a child's stdout: `tests/serve_harness_deadlines.rs` fails on any +/// other `.stdout.take()`/`ChildStdout` in the `serve_*` and `mcp_*` suites and `tests/common`, so a +/// reader without a deadline cannot come back. One for a child's whole life — it owns the pipe. +pub fn child_frames(child: &mut std::process::Child) -> Frames { + child_frames_within(child, FRAME_DEADLINE) +} + +/// [`child_frames`] with its own per-line deadline. +pub fn child_frames_within(child: &mut std::process::Child, deadline: Duration) -> Frames { + let out = child + .stdout + .take() + .expect("the child's stdout is piped, and read through one `child_frames`"); + frames_over(out, deadline) } -/// [`Frames`] over `out`, with its own per-line deadline. -pub fn frames_with_deadline(out: impl Read + Send + 'static, deadline: Duration) -> Frames { +fn frames_over(out: impl Read + Send + 'static, deadline: Duration) -> Frames { let (tx, lines) = std::sync::mpsc::channel(); thread::spawn(move || { let mut out = std::io::BufReader::new(out); diff --git a/crates/agent/tests/common/skills_env.rs b/crates/agent/tests/common/skills_env.rs index 73996bf5..e9946954 100644 --- a/crates/agent/tests/common/skills_env.rs +++ b/crates/agent/tests/common/skills_env.rs @@ -208,7 +208,7 @@ impl Serve { pub fn spawn(cmd: &mut Command) -> Self { let mut child = cmd.spawn_guarded(); let stdin = child.stdin.take().unwrap(); - let frames = super::serve_frames(child.stdout.take().unwrap()); + let frames = super::child_frames(&mut child); Self { child, stdin, diff --git a/crates/agent/tests/mcp_client.rs b/crates/agent/tests/mcp_client.rs index f9158813..85df2103 100644 --- a/crates/agent/tests/mcp_client.rs +++ b/crates/agent/tests/mcp_client.rs @@ -14,7 +14,7 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use std::path::Path; use std::process::Command; use std::time::{Duration, Instant}; @@ -788,7 +788,7 @@ fn mcp_stdio_tool_is_discovered_and_callable_through_serve_too() { .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::child_frames(&mut child); writeln!( stdin, diff --git a/crates/agent/tests/mcp_elicitation.rs b/crates/agent/tests/mcp_elicitation.rs index 02254e28..036eec02 100644 --- a/crates/agent/tests/mcp_elicitation.rs +++ b/crates/agent/tests/mcp_elicitation.rs @@ -7,7 +7,7 @@ mod common; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, Write}; use std::path::Path; use std::time::Duration; @@ -58,7 +58,7 @@ fn serve_answers_mcp_elicitation_mid_tool_call() { cmd.env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); 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::child_frames(&mut child); writeln!( stdin, @@ -148,7 +148,7 @@ fn serve_answers_mcp_mrtr_input_required_elicitation() { cmd.env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); 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::child_frames(&mut child); writeln!( stdin, diff --git a/crates/agent/tests/mcp_events_busy.rs b/crates/agent/tests/mcp_events_busy.rs index 620eb41e..717534f1 100644 --- a/crates/agent/tests/mcp_events_busy.rs +++ b/crates/agent/tests/mcp_events_busy.rs @@ -47,7 +47,7 @@ fn start(extra_args: &[&str]) -> Serve { .stderr(Stdio::from(std::fs::File::create(&stderr).unwrap())); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut frames = Frames::new(child.stdout.take().unwrap(), Some(stderr)); + let mut frames = Frames::new(&mut child, Some(stderr)); let fixture = wait_control_file(&control_file); wait_active(&mut stdin, &mut frames, 1); Serve { diff --git a/crates/agent/tests/mcp_events_durability.rs b/crates/agent/tests/mcp_events_durability.rs index e1a4ed55..e91d1e13 100644 --- a/crates/agent/tests/mcp_events_durability.rs +++ b/crates/agent/tests/mcp_events_durability.rs @@ -133,7 +133,7 @@ fn poll_does_not_advance_its_cursor_past_events_it_could_not_accept() { .env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut frames = Frames::new(child.stdout.take().unwrap(), None); + let mut frames = Frames::new(&mut child, None); wait_active(&mut stdin, &mut frames, 1); for n in 1..=3 { @@ -189,7 +189,7 @@ fn the_events_state_follows_the_session_to_a_new_transcript() { .env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut frames = Frames::new(child.stdout.take().unwrap(), None); + let mut frames = Frames::new(&mut child, None); wait_active(&mut stdin, &mut frames, 1); send(&mut stdin, json!({ "type": "new_session", "id": "ns" })); let ns = frames.response("ns"); @@ -262,7 +262,7 @@ fn polling_serve( .env("BEYOND_AI_AGENT_MCP_EVENTS_COALESCE_MS", "100"); let mut child = cmd.spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut frames = Frames::new(child.stdout.take().unwrap(), None); + let mut frames = Frames::new(&mut child, None); wait_active(&mut stdin, &mut frames, 1); (child, stdin, frames, bodies) } diff --git a/crates/agent/tests/mcp_events_lifecycle.rs b/crates/agent/tests/mcp_events_lifecycle.rs index 0936d946..789f4caa 100644 --- a/crates/agent/tests/mcp_events_lifecycle.rs +++ b/crates/agent/tests/mcp_events_lifecycle.rs @@ -277,7 +277,7 @@ fn a_restarted_stdio_serve_resumes_polling_from_its_persisted_cursor() { .env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); let mut child = cmd.spawn_guarded(); let stdin = child.stdin.take().unwrap(); - let frames = Frames::new(child.stdout.take().unwrap(), None); + let frames = Frames::new(&mut child, None); (child, stdin, frames) }; diff --git a/crates/agent/tests/mcp_events_poll.rs b/crates/agent/tests/mcp_events_poll.rs index 21b84202..797efa69 100644 --- a/crates/agent/tests/mcp_events_poll.rs +++ b/crates/agent/tests/mcp_events_poll.rs @@ -34,7 +34,7 @@ fn start(mcp_servers: Value, home: tempfile::TempDir) -> Serve { .stderr(Stdio::from(std::fs::File::create(&stderr).unwrap())); let mut child = cmd.spawn_guarded(); let stdin = child.stdin.take().unwrap(); - let frames = Frames::new(child.stdout.take().unwrap(), Some(stderr)); + let frames = Frames::new(&mut child, Some(stderr)); Serve { _child: child, stdin, diff --git a/crates/agent/tests/mcp_events_push.rs b/crates/agent/tests/mcp_events_push.rs index eee19241..1208ab47 100644 --- a/crates/agent/tests/mcp_events_push.rs +++ b/crates/agent/tests/mcp_events_push.rs @@ -43,7 +43,7 @@ fn start(mcp_servers: Value, extra_env: &[(&str, &str)]) -> Serve { } let mut child = cmd.spawn_guarded(); let stdin = child.stdin.take().unwrap(); - let frames = Frames::new(child.stdout.take().unwrap(), Some(stderr)); + let frames = Frames::new(&mut child, Some(stderr)); Serve { _child: child, stdin, diff --git a/crates/agent/tests/mcp_hotplug.rs b/crates/agent/tests/mcp_hotplug.rs index 475a3a18..aab64e6a 100644 --- a/crates/agent/tests/mcp_hotplug.rs +++ b/crates/agent/tests/mcp_hotplug.rs @@ -6,7 +6,7 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use std::path::Path; use common::{ @@ -85,7 +85,7 @@ fn set_mcp_enabled_changes_advertised_tools_and_model_request() { cmd.env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); 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::child_frames(&mut child); // Default: both servers enabled. writeln!(stdin, "{}", json!({ "id": "g0", "type": "get_mcp" })).unwrap(); @@ -262,7 +262,7 @@ fn disabled_mcp_tool_is_not_callable() { cmd.env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); 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::child_frames(&mut child); writeln!( stdin, @@ -316,7 +316,7 @@ fn new_session_resets_mcp_enablement() { cmd.env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); 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::child_frames(&mut child); writeln!( stdin, diff --git a/crates/agent/tests/mcp_hotplug_subagent.rs b/crates/agent/tests/mcp_hotplug_subagent.rs index 73e0e23d..6655d8af 100644 --- a/crates/agent/tests/mcp_hotplug_subagent.rs +++ b/crates/agent/tests/mcp_hotplug_subagent.rs @@ -8,7 +8,7 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use std::path::Path; use common::{ @@ -88,7 +88,7 @@ fn disabling_a_server_takes_its_tools_away_from_a_subagent_too() { .current_dir(&project); 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::child_frames(&mut child); writeln!( stdin, diff --git a/crates/agent/tests/mcp_oauth.rs b/crates/agent/tests/mcp_oauth.rs index 42fe4aa9..8ad86ef2 100644 --- a/crates/agent/tests/mcp_oauth.rs +++ b/crates/agent/tests/mcp_oauth.rs @@ -327,7 +327,7 @@ fn mcp_login_established_credential_is_honored_by_serve_too_not_just_run() { .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::child_frames(&mut child); writeln!( stdin, "{}", diff --git a/crates/agent/tests/mcp_progress.rs b/crates/agent/tests/mcp_progress.rs index b2d68d05..c1f116e0 100644 --- a/crates/agent/tests/mcp_progress.rs +++ b/crates/agent/tests/mcp_progress.rs @@ -7,7 +7,7 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use std::path::Path; use common::{ @@ -62,7 +62,7 @@ fn serve_streams_mcp_progress_before_tool_end() { cmd.env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); 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::child_frames(&mut child); writeln!( stdin, diff --git a/crates/agent/tests/mcp_skills.rs b/crates/agent/tests/mcp_skills.rs index 40a6690e..e7c318a6 100644 --- a/crates/agent/tests/mcp_skills.rs +++ b/crates/agent/tests/mcp_skills.rs @@ -423,7 +423,7 @@ fn serve_get_commands_and_the_mcp_gate_cover_skills() { /// this is the HTTP half of that regression, the stdio half being every test above. #[test] fn skills_work_over_streamable_http() { - use std::io::{BufRead, BufReader}; + use std::io::BufRead; let env = Env::new(json!({})); let mut server = std::process::Command::new(FIXTURE) .arg("--http") @@ -433,7 +433,7 @@ fn skills_work_over_streamable_http() { .stderr(Stdio::null()) .spawn_guarded(); let mut url = String::new(); - BufReader::new(server.stdout.take().unwrap()) + common::child_frames(&mut server) .read_line(&mut url) .unwrap(); std::fs::write( diff --git a/crates/agent/tests/mcp_stdio_transport.rs b/crates/agent/tests/mcp_stdio_transport.rs index 287201ad..f2fdcc15 100644 --- a/crates/agent/tests/mcp_stdio_transport.rs +++ b/crates/agent/tests/mcp_stdio_transport.rs @@ -32,7 +32,7 @@ fn start( .env("BEYOND_AI_AGENT_MCP_IDLE_SECS", idle_secs); let mut child = cmd.spawn_guarded(); let stdin = child.stdin.take().unwrap(); - let frames = Frames::new(child.stdout.take().unwrap(), None); + let frames = Frames::new(&mut child, None); (child, stdin, frames) } diff --git a/crates/agent/tests/mcp_surfaces.rs b/crates/agent/tests/mcp_surfaces.rs index a8f56980..41844190 100644 --- a/crates/agent/tests/mcp_surfaces.rs +++ b/crates/agent/tests/mcp_surfaces.rs @@ -3,7 +3,7 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use std::path::Path; use common::{ @@ -78,7 +78,7 @@ fn get_mcp_lists_resources_and_prompts_and_tools_call_them() { cmd.env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); 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::child_frames(&mut child); writeln!(stdin, "{}", json!({ "id": "g", "type": "get_mcp" })).unwrap(); let g_frames = read_until_response(&mut stdout, "get_mcp"); @@ -150,7 +150,7 @@ fn mcp_complete_returns_fixture_suggestions() { cmd.env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); 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::child_frames(&mut child); writeln!( stdin, diff --git a/crates/agent/tests/mcp_tasks.rs b/crates/agent/tests/mcp_tasks.rs index 49d26785..869c9d3c 100644 --- a/crates/agent/tests/mcp_tasks.rs +++ b/crates/agent/tests/mcp_tasks.rs @@ -3,7 +3,7 @@ mod common; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, Write}; use std::path::Path; use std::time::{Duration, Instant}; @@ -71,7 +71,7 @@ fn serve_polls_mcp_task_to_completion_with_progress() { cmd.env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); 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::child_frames(&mut child); writeln!( stdin, @@ -156,7 +156,7 @@ fn serve_answers_in_task_elicitation_via_tasks_update() { cmd.env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); 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::child_frames(&mut child); writeln!( stdin, @@ -237,7 +237,7 @@ fn serve_maps_failed_mcp_task_to_tool_error() { cmd.env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); 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::child_frames(&mut child); writeln!( stdin, @@ -287,7 +287,7 @@ fn serve_maps_server_cancelled_mcp_task_to_tool_error() { cmd.env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); 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::child_frames(&mut child); writeln!( stdin, @@ -347,7 +347,7 @@ fn serve_abort_sends_tasks_cancel_for_sticky_task() { cmd.env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); 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::child_frames(&mut child); writeln!( stdin, diff --git a/crates/agent/tests/mcp_tasks_conformance.rs b/crates/agent/tests/mcp_tasks_conformance.rs index 0d592458..76ea1261 100644 --- a/crates/agent/tests/mcp_tasks_conformance.rs +++ b/crates/agent/tests/mcp_tasks_conformance.rs @@ -11,7 +11,7 @@ mod common; -use std::io::{BufRead, BufReader, Read, Write}; +use std::io::{BufRead, Read, Write}; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; @@ -87,7 +87,7 @@ impl Env { .env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); 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::child_frames(&mut child); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "go" })).unwrap(); stdin.flush().unwrap(); let frames = read_until_response(&mut stdout, "prompt"); @@ -151,10 +151,7 @@ fn run_handles_both_shapes_declares_capability_and_honours_latest_poll_interval( .stderr(Stdio::piped()) .spawn_guarded(); let mut stdout = String::new(); - child - .stdout - .take() - .unwrap() + common::child_frames(&mut child) .read_to_string(&mut stdout) .unwrap(); let status = child.wait().unwrap(); @@ -225,7 +222,7 @@ fn serve_deduplicates_input_requests_across_polls() { .env("BEYOND_AI_AGENT_MCP_IDLE_SECS", "0"); 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::child_frames(&mut child); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "go" })).unwrap(); stdin.flush().unwrap(); @@ -481,7 +478,7 @@ fn serve_subagent_polls_a_task_to_completion() { .current_dir(&project); 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::child_frames(&mut child); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "go" })).unwrap(); stdin.flush().unwrap(); read_until_response(&mut stdout, "prompt"); diff --git a/crates/agent/tests/mcp_tasks_durable.rs b/crates/agent/tests/mcp_tasks_durable.rs index 44b75b66..a1bb7c7e 100644 --- a/crates/agent/tests/mcp_tasks_durable.rs +++ b/crates/agent/tests/mcp_tasks_durable.rs @@ -13,7 +13,6 @@ mod common; mod mcp_tasks_env; -use std::io::BufReader; use std::time::Duration; use common::{read_until_response, spawn_model_server, turn_text, turn_tool_use}; @@ -24,7 +23,7 @@ fn single_prompt(env: &Env, turns: Vec) -> Vec { let (base, _bodies) = spawn_model_server(turns); let mut child = env.serve(&base); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); prompt(&mut stdin, "go"); let frames = read_until_response(&mut stdout, "prompt"); drop(stdin); @@ -126,7 +125,7 @@ fn http_dropped_tasks_update_is_resent_not_reasked() { ]); let mut child = env.serve(&base); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); prompt(&mut stdin, "go"); let mut frames = read_until(&mut stdout, "the elicitation", |f| { f["type"] == "elicitation_request" @@ -192,7 +191,7 @@ fn abort_during_redial_cancels_through_the_fresh_connection() { )]); let mut child = env.serve(&base); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); prompt(&mut stdin, "go"); wait_for("the crashing poll", || !env.methods("tasks/get").is_empty()); // Past the first backoff (200 ms), so the redial is under way (the restart takes 1.5 s). @@ -222,7 +221,7 @@ fn serve_in_task_sampling_round_trips_through_the_host() { ]); let mut child = env.serve(&base); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); prompt(&mut stdin, "go"); let mut frames = read_until(&mut stdout, "a sampling_request", |f| { f["type"] == "sampling_request" diff --git a/crates/agent/tests/mcp_tasks_resume.rs b/crates/agent/tests/mcp_tasks_resume.rs index 17c11019..a8fd4a0b 100644 --- a/crates/agent/tests/mcp_tasks_resume.rs +++ b/crates/agent/tests/mcp_tasks_resume.rs @@ -7,8 +7,7 @@ mod common; mod mcp_tasks_env; -use std::io::BufReader; -use std::process::{ChildStdin, ChildStdout}; +use std::process::ChildStdin; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -22,7 +21,7 @@ use serde_json::{Value, json}; struct Serve { child: ChildGuard, stdin: ChildStdin, - stdout: BufReader, + stdout: common::Frames, bodies: Arc>>, } @@ -30,7 +29,7 @@ fn serve(env: &Env, turns: Vec) -> Serve { let (base, bodies) = spawn_model_server(turns); let mut child = env.serve(&base); let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); + let stdout = common::child_frames(&mut child); Serve { child, stdin, @@ -373,7 +372,7 @@ fn serve_repo(env: &Env, turns: Vec, id: &str) -> Serve { .args(["--session-id", id]); let mut child = common::SpawnGuarded::spawn_guarded(&mut cmd); let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); + let stdout = common::child_frames(&mut child); Serve { child, stdin, diff --git a/crates/agent/tests/serve_approval.rs b/crates/agent/tests/serve_approval.rs index 7eb84c20..5303593d 100644 --- a/crates/agent/tests/serve_approval.rs +++ b/crates/agent/tests/serve_approval.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 ffc87837..423a5a52 100644 --- a/crates/agent/tests/serve_auth.rs +++ b/crates/agent/tests/serve_auth.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, diff --git a/crates/agent/tests/serve_block_images.rs b/crates/agent/tests/serve_block_images.rs index 9389fc28..48b44576 100644 --- a/crates/agent/tests/serve_block_images.rs +++ b/crates/agent/tests/serve_block_images.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, "{}", diff --git a/crates/agent/tests/serve_busy_self_abort.rs b/crates/agent/tests/serve_busy_self_abort.rs index 095e36f5..939a0522 100644 --- a/crates/agent/tests/serve_busy_self_abort.rs +++ b/crates/agent/tests/serve_busy_self_abort.rs @@ -30,7 +30,7 @@ fn spawn_and_warm_up( ) -> (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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, "{}", @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 2ae2b3c1..f561751a 100644 --- a/crates/agent/tests/serve_compaction_retry.rs +++ b/crates/agent/tests/serve_compaction_retry.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child2.stdout.take().unwrap()); + let mut stdout2 = common::child_frames(&mut child2); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 551f19a1..419358a6 100644 --- a/crates/agent/tests/serve_exec_endpoint.rs +++ b/crates/agent/tests/serve_exec_endpoint.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut out = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut out = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut out = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut out = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut out = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut out = common::child_frames(&mut child); 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 d380b06b..7585d1d1 100644 --- a/crates/agent/tests/serve_gateway_headers.rs +++ b/crates/agent/tests/serve_gateway_headers.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 fc684210..701146c1 100644 --- a/crates/agent/tests/serve_grant.rs +++ b/crates/agent/tests/serve_grant.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 index 2ccb5d04..10eb48a6 100644 --- a/crates/agent/tests/serve_harness_deadlines.rs +++ b/crates/agent/tests/serve_harness_deadlines.rs @@ -2,7 +2,7 @@ //! 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`. +//! `common::child_frames`, WebSockets through `common::ws_next_frame`, and `skills_env::Serve`. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod common; @@ -19,8 +19,7 @@ fn a_silent_stdout_fails_the_read_at_its_deadline() { .stdout(Stdio::piped()) .stderr(Stdio::null()), ); - let mut frames = - common::frames_with_deadline(child.stdout.take().unwrap(), Duration::from_millis(300)); + let mut frames = common::child_frames_within(&mut child, Duration::from_millis(300)); let started = Instant::now(); let read = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { common::read_until_response(&mut frames, "prompt") @@ -61,41 +60,79 @@ async fn a_silent_websocket_fails_the_read_at_its_deadline() { 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. +/// No test reads a child's stdout except through `common::child_frames`: in the `serve_*` and `mcp_*` +/// suites and `tests/common`, the only code that touches a child's stdout pipe is that one helper. A +/// reader without the deadline is not discouraged but unreachable — there is no `ChildStdout` to wrap. +/// +/// Matched on code with comments dropped and all whitespace removed, so line breaks and formatting +/// cannot hide a use. #[test] -fn every_serve_suite_reads_frames_with_the_shared_deadline() { +fn only_child_frames_touches_a_childs_stdout() { + const FORBIDDEN: &[&str] = &[ + "ChildStdout", + ".stdout.take(", + ".stdout.as_mut(", + ".stdout.as_ref(", + ".stdout.unwrap(", + ".stdout.expect(", + ".stdout=", + ]; 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.starts_with("serve_") || name.starts_with("mcp_")) && name.ends_with(".rs") + // This file names what it looks for. && 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())); + files.extend( + std::fs::read_dir(tests.join("common")) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().is_some_and(|e| e == "rs")), + ); + assert!( + files.len() > 80, + "the scan found the suites: {}", + files.len() + ); + let mut readers = 0; + let mut found = Vec::new(); + for path in &files { + let code: String = std::fs::read_to_string(path) + .unwrap() + .lines() + .map(|line| line.split("//").next().unwrap_or("")) + .collect::() + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + readers += code.matches("child_frames(").count(); + // The helper itself takes the pipe, once. + let allowed = usize::from(path.ends_with("common/mod.rs")); + for pattern in FORBIDDEN { + let n = code.matches(pattern).count(); + let n = if *pattern == ".stdout.take(" { + n.saturating_sub(allowed) + } else { + n + }; + if n > 0 { + found.push(format!("{}: `{pattern}` x{n}", path.display())); } } } assert!( - bare.is_empty(), - "stdout read without a deadline:\n{}", - bare.join("\n") + found.is_empty(), + "a child's stdout read other than through common::child_frames:\n{}", + found.join("\n") ); assert!( - readers > 200, - "the suites read through serve_frames ({readers} readers)" + readers > 250, + "the suites read through child_frames ({readers})" ); } diff --git a/crates/agent/tests/serve_lifecycle.rs b/crates/agent/tests/serve_lifecycle.rs index c0fae364..0671e230 100644 --- a/crates/agent/tests/serve_lifecycle.rs +++ b/crates/agent/tests/serve_lifecycle.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); send( &mut stdin, diff --git a/crates/agent/tests/serve_list_paging.rs b/crates/agent/tests/serve_list_paging.rs index a0ce0fbe..128cc16e 100644 --- a/crates/agent/tests/serve_list_paging.rs +++ b/crates/agent/tests/serve_list_paging.rs @@ -23,7 +23,7 @@ fn three_sessions() -> (tempfile::TempDir, common::ChildGuard, common::Frames) { 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 stdout = common::child_frames(&mut child); { let mut stdin = child.stdin.take().unwrap(); for (i, marker) in ["alpha-one", "beta-two", "gamma-three"].iter().enumerate() { diff --git a/crates/agent/tests/serve_memory.rs b/crates/agent/tests/serve_memory.rs index 2453c8da..b7b8b5bf 100644 --- a/crates/agent/tests/serve_memory.rs +++ b/crates/agent/tests/serve_memory.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); send( &mut stdin, diff --git a/crates/agent/tests/serve_models_thinking.rs b/crates/agent/tests/serve_models_thinking.rs index f4215f5c..bbebc894 100644 --- a/crates/agent/tests/serve_models_thinking.rs +++ b/crates/agent/tests/serve_models_thinking.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // `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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 7c47ed51..8f16907c 100644 --- a/crates/agent/tests/serve_oauth_model_switch.rs +++ b/crates/agent/tests/serve_oauth_model_switch.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 515aa20c..8ba130d3 100644 --- a/crates/agent/tests/serve_prompt_flow.rs +++ b/crates/agent/tests/serve_prompt_flow.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 d0945a65..158e4405 100644 --- a/crates/agent/tests/serve_session_addressing.rs +++ b/crates/agent/tests/serve_session_addressing.rs @@ -58,7 +58,7 @@ impl Serve { fn start(cmd: &mut Command) -> Self { let mut child = cmd.spawn_guarded(); let stdin = child.stdin.take().expect("stdin"); - let stdout = common::serve_frames(child.stdout.take().expect("stdout")); + let stdout = common::child_frames(&mut child); Self { child, stdin, diff --git a/crates/agent/tests/serve_session_lifecycle.rs b/crates/agent/tests/serve_session_lifecycle.rs index f9be1335..c32b4ebd 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child2.stdout.take().unwrap()); + let mut stdout2 = common::child_frames(&mut child2); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child2.stdout.take().unwrap()); + let mut stdout2 = common::child_frames(&mut child2); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 b05442f1..e75f1566 100644 --- a/crates/agent/tests/serve_session_memory.rs +++ b/crates/agent/tests/serve_session_memory.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // --- 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 88d77ffb..35bbb226 100644 --- a/crates/agent/tests/serve_session_title.rs +++ b/crates/agent/tests/serve_session_title.rs @@ -41,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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, @@ -75,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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); for msg in ["first", "second"] { writeln!(stdin, "{}", json!({ "type": "prompt", "message": msg })).unwrap(); @@ -135,7 +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::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 3e6b3aa4..14a2f4cc 100644 --- a/crates/agent/tests/serve_session_tree.rs +++ b/crates/agent/tests/serve_session_tree.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child_a.stdout.take().unwrap()); + let mut stdout_a = common::child_frames(&mut child_a); 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 = common::serve_frames(child_b.stdout.take().unwrap()); + let mut stdout_b = common::child_frames(&mut child_b); 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 = common::serve_frames(child_a.stdout.take().unwrap()); + let mut stdout_a = common::child_frames(&mut child_a); 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 = common::serve_frames(child_b.stdout.take().unwrap()); + let mut stdout_b = common::child_frames(&mut child_b); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child_a.stdout.take().unwrap()); + let mut stdout_a = common::child_frames(&mut child_a); 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 = common::serve_frames(child_b.stdout.take().unwrap()); + let mut stdout_b = common::child_frames(&mut child_b); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 865ced56..81ac5ba9 100644 --- a/crates/agent/tests/serve_state_reporting.rs +++ b/crates/agent/tests/serve_state_reporting.rs @@ -41,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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, @@ -109,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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "go" })).unwrap(); stdin.flush().unwrap(); @@ -170,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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "first" })).unwrap(); stdin.flush().unwrap(); @@ -244,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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "go" })).unwrap(); stdin.flush().unwrap(); @@ -298,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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 @@ -361,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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, @@ -430,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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); let mut ready = String::new(); stdout.read_line(&mut ready).unwrap(); @@ -468,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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); let mut ready = String::new(); stdout.read_line(&mut ready).unwrap(); @@ -512,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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // The freshly (auto-)created active session must not be stale. let mut ready = String::new(); @@ -565,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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); let mut ready = String::new(); stdout.read_line(&mut ready).unwrap(); @@ -612,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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!(stdin, "{}", json!({ "type": "get_session_stats" })).unwrap(); stdin.flush().unwrap(); @@ -654,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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // No name set yet, no assistant reply yet. writeln!(stdin, "{}", json!({ "type": "get_state" })).unwrap(); @@ -741,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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, @@ -809,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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 631b46ad..af9fc3bb 100644 --- a/crates/agent/tests/serve_structured_output.rs +++ b/crates/agent/tests/serve_structured_output.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); let resp = prompt( &mut stdin, diff --git a/crates/agent/tests/serve_subagent.rs b/crates/agent/tests/serve_subagent.rs index 71881d86..972f1a0c 100644 --- a/crates/agent/tests/serve_subagent.rs +++ b/crates/agent/tests/serve_subagent.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // First prompt: no agents defined yet. writeln!( diff --git a/crates/agent/tests/serve_todo.rs b/crates/agent/tests/serve_todo.rs index 421f7977..04fe95bc 100644 --- a/crates/agent/tests/serve_todo.rs +++ b/crates/agent/tests/serve_todo.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 766ce85e..1e0ca63e 100644 --- a/crates/agent/tests/serve_tools_bash.rs +++ b/crates/agent/tests/serve_tools_bash.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // `$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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, diff --git a/crates/agent/tests/serve_trust_prompts.rs b/crates/agent/tests/serve_trust_prompts.rs index 3dad3a64..845afe58 100644 --- a/crates/agent/tests/serve_trust_prompts.rs +++ b/crates/agent/tests/serve_trust_prompts.rs @@ -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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); 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 = common::serve_frames(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); From 1eccec5b2088bd339d3baa00298e85657293551b Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Tue, 6 Oct 2026 23:37:02 -0700 Subject: [PATCH 2/2] agent tests: the guard holds the stdout pipe; lint every route to it; test the inode recheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of 502bce7: - The lint was bypassable (`std::mem::take(&mut child.stdout)` survived, as would `Option::take`, or destructuring a raw `Child`). `ChildGuard` now takes the pipe off the `Child` at spawn, so `child.stdout` is `None` however it is reached; `child_frames` takes it from the guard, and `raw_stdout` exists only for tests about the pipe itself. The lint is a function over source text with tests per bypass form: the `ChildStdout` type, `Option` methods on the field, assignment, `take`/`replace`/`swap(&mut ….stdout)`, `Child { .., stdout, .. }` patterns, unguarded `.spawn()`, and `raw_stdout`; it leaves an `Output`'s bytes and harness fields named `stdout` alone. The suites outside its scope (smoke, settings_store, run_cli_flags) read through `child_frames` too; run_stdout_robustness uses `raw_stdout` for its EPIPE tests. - `file_lock`'s inode recheck is tested through `try_lock`: a test seam replaces the file between open and lock, and `try_lock` must go round again and return the lock on the file now at the path. - The lint scans directory modules (`tests/mcp_tasks_env/mod.rs`). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk --- crates/agent/ARCHITECTURE.md | 2 +- crates/agent/src/file_lock.rs | 55 ++++ .../agent/tests/common/mcp_events_fixture.rs | 2 +- crates/agent/tests/common/mod.rs | 60 ++-- crates/agent/tests/run_cli_flags.rs | 4 +- crates/agent/tests/run_stdout_robustness.rs | 4 +- crates/agent/tests/serve_harness_deadlines.rs | 257 ++++++++++++++---- crates/agent/tests/settings_store.rs | 16 +- crates/agent/tests/smoke.rs | 36 +-- 9 files changed, 335 insertions(+), 101 deletions(-) diff --git a/crates/agent/ARCHITECTURE.md b/crates/agent/ARCHITECTURE.md index e0535baf..911b0ced 100644 --- a/crates/agent/ARCHITECTURE.md +++ b/crates/agent/ARCHITECTURE.md @@ -4130,7 +4130,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`), one frame deadline for every `serve` reader (`FRAME_DEADLINE`, 60s: `child_frames` for stdout — the only code in the `serve_*`/`mcp_*` suites and `tests/common` that touches a child's stdout pipe, which `serve_harness_deadlines` enforces, `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/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: `child_frames` for stdout — the only way to a child's stdout pipe: `ChildGuard` takes the pipe off the `Child` at spawn, so `child.stdout` is empty however it is reached (`raw_stdout` exists for tests about the pipe itself, outside the `serve_*`/`mcp_*` suites), and `serve_harness_deadlines` lints every other route (the type, `Option` methods on the field, `mem::take`/`replace`/`swap` of it, `Child { stdout, .. }` patterns, unguarded spawns) in those suites, their directory modules and `tests/common`, `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/file_lock.rs b/crates/agent/src/file_lock.rs index bc2e17e2..d255d7ca 100644 --- a/crates/agent/src/file_lock.rs +++ b/crates/agent/src/file_lock.rs @@ -71,6 +71,8 @@ pub fn try_lock(lock_path: &Path) -> std::io::Result> { opts.mode(0o600); } let file = opts.open(lock_path)?; + #[cfg(test)] + tests::between_open_and_lock(lock_path); match file.try_lock() { Ok(()) => {} Err(std::fs::TryLockError::WouldBlock) => return Ok(None), @@ -109,6 +111,59 @@ fn same_file(file: &File, path: &Path) -> std::io::Result { #[cfg(test)] mod tests { use super::*; + use std::cell::RefCell; + + /// Run (on this thread) after `try_lock` opens the file and before it locks it. + type Hook = Box; + + thread_local! { + static BETWEEN: RefCell> = const { RefCell::new(None) }; + } + + pub(super) fn between_open_and_lock(path: &Path) { + BETWEEN.with(|hook| { + if let Some(hook) = hook.borrow_mut().as_mut() { + hook(path); + } + }); + } + + /// The file at the path replaced between the open and the lock — a directory holding it renamed + /// away, a crashed holder's file swapped for a fresh one — must not leave the caller "holding" a + /// lock on a file nobody else will ever open. `try_lock` sees the inode moved and starts over, and + /// the lock it returns is on the file now at the path. + #[cfg(unix)] + #[test] + fn a_lock_file_replaced_between_open_and_lock_is_not_the_one_held() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("x.lock"); + let opens = std::rc::Rc::new(std::cell::Cell::new(0)); + let counted = opens.clone(); + let fresh = dir.path().join("fresh"); + BETWEEN.with(|hook| { + *hook.borrow_mut() = Some(Box::new(move |path: &Path| { + counted.set(counted.get() + 1); + if counted.get() == 1 { + std::fs::write(&fresh, b"").unwrap(); + std::fs::rename(&fresh, path).unwrap(); + } + })); + }); + let held = try_lock(&path).unwrap().expect("taken on the second look"); + BETWEEN.with(|hook| *hook.borrow_mut() = None); + assert_eq!(opens.get(), 2, "the moved inode sent it round again"); + // The file at the path is the locked one: another open file description cannot lock it. + let other = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + assert!( + matches!(other.try_lock(), Err(std::fs::TryLockError::WouldBlock)), + "the lock held is on the file now at the path" + ); + drop(held); + } /// One holder at a time within the process (the registry), and the lock is free again once the /// holder drops it. diff --git a/crates/agent/tests/common/mcp_events_fixture.rs b/crates/agent/tests/common/mcp_events_fixture.rs index f307ed51..7b2d1069 100644 --- a/crates/agent/tests/common/mcp_events_fixture.rs +++ b/crates/agent/tests/common/mcp_events_fixture.rs @@ -130,7 +130,7 @@ pub struct Frames { } impl Frames { - pub fn new(child: &mut std::process::Child, stderr: Option) -> Self { + pub fn new(child: &mut ChildGuard, stderr: Option) -> Self { Self { frames: super::child_frames(child), seen: Vec::new(), diff --git a/crates/agent/tests/common/mod.rs b/crates/agent/tests/common/mod.rs index c0d1b4da..4ddf9090 100644 --- a/crates/agent/tests/common/mod.rs +++ b/crates/agent/tests/common/mod.rs @@ -85,40 +85,67 @@ pub const ISOLATED_HOME: &str = "/nonexistent-beyond-ai-agent-test-home"; /// Derefs to [`Child`], so `child.stdin.take()`, `child.kill()`, and `child.wait()` all keep working /// unchanged; an explicit `wait()` before the drop is fine (the drop's `kill` on an already-reaped pid /// fails harmlessly and is ignored). -pub struct ChildGuard(Option); +/// +/// **The child's stdout pipe is not on the `Child`.** The guard takes it at spawn and keeps it, so +/// `child.stdout` is always `None` — through `.take()`, `std::mem::take`, `Option::take`, `as_mut` or +/// anything else — and the pipe comes back only through [`child_frames`] (a reader with +/// [`FRAME_DEADLINE`]) or, for a test about the pipe itself, [`ChildGuard::raw_stdout`]. A test cannot +/// build a frame reader that hangs instead of failing, however it reaches for the pipe. +pub struct ChildGuard { + child: Option, + stdout: Option, +} impl ChildGuard { /// Spawn `cmd` under the guard. Panics with the command's name on failure, matching the /// `.spawn().unwrap()` this replaces. pub fn spawn(cmd: &mut Command) -> Self { - Self(Some(cmd.spawn().unwrap_or_else(|e| { - panic!("failed to spawn {:?}: {e}", cmd.get_program()) - }))) + let mut child = cmd + .spawn() + .unwrap_or_else(|e| panic!("failed to spawn {:?}: {e}", cmd.get_program())); + let stdout = child.stdout.take(); + Self { + child: Some(child), + stdout, + } + } + + /// The raw stdout pipe, for a test about the pipe itself — closing the read end early to see + /// how the child handles `EPIPE`, which a reader thread draining it would defeat. Never a frame + /// reader: [`child_frames`] is, and `tests/serve_harness_deadlines.rs` forbids this in the + /// `serve_*`/`mcp_*` suites and `tests/common`. + pub fn raw_stdout(&mut self) -> std::process::ChildStdout { + self.stdout + .take() + .expect("the child's stdout is piped and not yet taken") } /// [`Child::wait_with_output`], which consumes the child and so can't come through `Deref`. Taking /// the child out disarms the guard — safe precisely because this call reaps the process itself. + /// The held stdout pipe (if no reader took it) is handed back first, so it is captured as usual. pub fn wait_with_output(mut self) -> std::io::Result { - self.0.take().expect("child taken").wait_with_output() + let mut child = self.child.take().expect("child taken"); + child.stdout = self.stdout.take(); + child.wait_with_output() } } impl std::ops::Deref for ChildGuard { type Target = std::process::Child; fn deref(&self) -> &Self::Target { - self.0.as_ref().expect("child taken") + self.child.as_ref().expect("child taken") } } impl std::ops::DerefMut for ChildGuard { fn deref_mut(&mut self) -> &mut Self::Target { - self.0.as_mut().expect("child taken") + self.child.as_mut().expect("child taken") } } impl Drop for ChildGuard { fn drop(&mut self) { - if let Some(mut child) = self.0.take() { + if let Some(mut child) = self.child.take() { let _ = child.kill(); let _ = child.wait(); } @@ -383,7 +410,7 @@ pub fn wait_for_port(port: u16) { /// port 402s every managed request, which is why [`wait_for_allowance_ready`] exists alongside this. struct SharedNats { port: u16, - child: Child, + child: ChildGuard, } impl SharedNats { @@ -403,8 +430,8 @@ impl SharedNats { ]) .stdout(Stdio::null()) .stderr(Stdio::null()) - .spawn() - .expect("spawn nats-server (on PATH? run via mise)"); + // Fails naming `nats-server`: it must be on PATH (run via mise). + .spawn_guarded(); let deadline = Instant::now() + Duration::from_secs(20); while Instant::now() < deadline { if TcpStream::connect(("127.0.0.1", port)).is_ok() { @@ -504,15 +531,16 @@ pub struct Frames { /// [`Frames`] over `child`'s stdout (which must be piped, and is taken), with [`FRAME_DEADLINE`]. /// -/// The **only** way a test reads a child's stdout: `tests/serve_harness_deadlines.rs` fails on any -/// other `.stdout.take()`/`ChildStdout` in the `serve_*` and `mcp_*` suites and `tests/common`, so a -/// reader without a deadline cannot come back. One for a child's whole life — it owns the pipe. -pub fn child_frames(child: &mut std::process::Child) -> Frames { +/// The **only** way a test reads a child's stdout as frames: the pipe lives in the [`ChildGuard`], +/// not on the `Child`, so there is nothing else to wrap; and `tests/serve_harness_deadlines.rs` fails +/// on any `ChildStdout`, unguarded spawn or `raw_stdout` in the `serve_*` and `mcp_*` suites and +/// `tests/common`. One for a child's whole life — it owns the pipe. +pub fn child_frames(child: &mut ChildGuard) -> Frames { child_frames_within(child, FRAME_DEADLINE) } /// [`child_frames`] with its own per-line deadline. -pub fn child_frames_within(child: &mut std::process::Child, deadline: Duration) -> Frames { +pub fn child_frames_within(child: &mut ChildGuard, deadline: Duration) -> Frames { let out = child .stdout .take() diff --git a/crates/agent/tests/run_cli_flags.rs b/crates/agent/tests/run_cli_flags.rs index eec96b3b..92fd3f15 100644 --- a/crates/agent/tests/run_cli_flags.rs +++ b/crates/agent/tests/run_cli_flags.rs @@ -3,7 +3,7 @@ mod common; -use std::io::{BufReader, Write}; +use std::io::Write; use std::process::{Command, Stdio}; use std::time::Duration; @@ -783,7 +783,7 @@ fn serve_binary_reasoning_effort_off_flag_starts_with_reasoning_off_not_the_medi .args(["--reasoning-effort", "off"]) .spawn_guarded(); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!(stdin, "{}", json!({ "type": "get_state" })).unwrap(); stdin.flush().unwrap(); diff --git a/crates/agent/tests/run_stdout_robustness.rs b/crates/agent/tests/run_stdout_robustness.rs index 6750cfb8..7e2593df 100644 --- a/crates/agent/tests/run_stdout_robustness.rs +++ b/crates/agent/tests/run_stdout_robustness.rs @@ -79,7 +79,7 @@ fn run_json_mode_exits_cleanly_instead_of_panicking_when_stdout_is_closed_early( // Read exactly the leading `{"kind":"session",...}` header line — proving the write path up to // (and including) that line still works normally — then close our end, simulating `head -1` // hanging up right after taking its one line. - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = BufReader::new(child.raw_stdout()); let mut header = String::new(); stdout.read_line(&mut header).unwrap(); let parsed: serde_json::Value = serde_json::from_str(header.trim()).unwrap(); @@ -127,7 +127,7 @@ fn run_text_mode_exits_cleanly_instead_of_panicking_when_stdout_is_closed_early( // Text mode has no leading header line — read a small, bounded prefix of the streamed text (well // under the 64KiB pipe buffer) so we know the child has started writing, then close our end. - let mut stdout = child.stdout.take().unwrap(); + let mut stdout = child.raw_stdout(); let mut prefix = [0u8; 64]; stdout.read_exact(&mut prefix).unwrap(); assert!(prefix.iter().all(|&b| b == b'x')); diff --git a/crates/agent/tests/serve_harness_deadlines.rs b/crates/agent/tests/serve_harness_deadlines.rs index 10eb48a6..e2cba5be 100644 --- a/crates/agent/tests/serve_harness_deadlines.rs +++ b/crates/agent/tests/serve_harness_deadlines.rs @@ -60,40 +60,205 @@ async fn a_silent_websocket_fails_the_read_at_its_deadline() { server.abort(); } -/// No test reads a child's stdout except through `common::child_frames`: in the `serve_*` and `mcp_*` -/// suites and `tests/common`, the only code that touches a child's stdout pipe is that one helper. A -/// reader without the deadline is not discouraged but unreachable — there is no `ChildStdout` to wrap. -/// -/// Matched on code with comments dropped and all whitespace removed, so line breaks and formatting -/// cannot hide a use. +/// What may not appear in a scanned file: every way to reach a child's stdout pipe other than +/// `common::child_frames`. Matched on code with comments dropped and whitespace removed, so line +/// breaks and formatting cannot hide a use. `Output`'s `stdout` (captured bytes) and harness fields +/// that happen to be named `stdout` (a `Frames`) are not the pipe and are not flagged. +fn violations(source: &str) -> Vec { + let code: String = source + .lines() + .map(|line| line.split("//").next().unwrap_or("")) + .collect::() + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + let mut found = Vec::new(); + let mut flag = |what: &str, n: usize| { + if n > 0 { + found.push(format!("{what} x{n}")); + } + }; + // The pipe's type, and the guard's escape hatch for tests about the pipe itself. + flag("`ChildStdout`", code.matches("ChildStdout").count()); + flag("`raw_stdout`", code.matches(".raw_stdout(").count()); + // Field access through `Option`'s methods on the child. + for method in [ + "take", "as_mut", "as_ref", "unwrap", "expect", "replace", "insert", + ] { + let pattern = format!(".stdout.{method}("); + flag(&format!("`{pattern}`"), code.matches(&pattern).count()); + } + flag( + "assignment to `.stdout`", + count(&code, ".stdout=", |rest| !rest.starts_with('=')), + ); + // The field taken by a free function: `std::mem::take(&mut child.stdout)`, + // `Option::take(&mut child.stdout)`, `mem::replace(&mut child.stdout, ..)`, `mem::swap(..)`. + for f in ["take(", "replace(", "swap("] { + flag( + &format!("`{f}&mut ….stdout`"), + code.match_indices(&format!("{f}&mut")) + .filter(|(i, m)| { + let arg = &code[i + m.len()..]; + let end = arg.find([',', ')']).unwrap_or(arg.len()); + arg[..end].ends_with(".stdout") + }) + .count(), + ); + } + // A `Child` (or the guard) destructured with its `stdout` field. + for ty in ["Child{", "ChildGuard{"] { + flag( + &format!("`{ty}..stdout..}}` pattern"), + code.match_indices(ty) + .filter(|(i, m)| { + let body = &code[i + m.len()..]; + let end = body.find('}').unwrap_or(body.len()); + body[..end].split([',', ':']).any(|f| f == "stdout") + }) + .count(), + ); + } + // An unguarded spawn: a raw `Child`, whose `stdout` is still on it. + flag("unguarded `.spawn()`", code.matches(".spawn()").count()); + found +} + +fn count(code: &str, pattern: &str, keep: impl Fn(&str) -> bool) -> usize { + code.match_indices(pattern) + .filter(|(i, m)| keep(&code[i + m.len()..])) + .count() +} + +/// `tests/common/mod.rs` with the guard's own implementation — the one place that touches the +/// pipe — cut out: the `ChildGuard` struct and impl, and `child_frames_within`. +fn without_the_guard(source: &str) -> String { + let mut out = source.to_string(); + for start in [ + "pub struct ChildGuard {", + "impl ChildGuard {", + "pub fn child_frames_within(", + ] { + let at = out + .find(start) + .unwrap_or_else(|| panic!("`{start}` is in tests/common/mod.rs")); + let open = at + out[at..].find('{').unwrap(); + let mut depth = 0; + let mut end = open; + for (i, c) in out[open..].char_indices() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + end = open + i + 1; + break; + } + } + _ => {} + } + } + out.replace_range(at..end, ""); + } + out +} + +/// Every bypass form is caught — on one line or spread over several, in any spelling. +#[test] +fn the_lint_catches_every_way_to_the_pipe() { + for bypass in [ + "let out = child.stdout.take().unwrap();", + "let out = child\n .stdout\n .take()\n .unwrap();", + "let out = std::mem::take(&mut child.stdout);", + "let out = mem::take(\n &mut child.stdout,\n);", + "let out = Option::take(&mut child.stdout);", + "let out = std::mem::replace(&mut child.stdout, None);", + "std::mem::swap(&mut child.stdout, &mut mine);", + "let out = child.stdout.as_mut().unwrap();", + "child.stdout = None;", + "let std::process::Child { stdout, .. } = raw;", + "let Child { stdin, stdout: out, .. } = raw;", + "let ChildGuard { stdout, .. } = guard;", + "fn f(out: std::process::ChildStdout) {}", + "let raw = Command::new(\"x\").stdout(Stdio::piped()).spawn().unwrap();", + "let pipe = child.raw_stdout();", + ] { + assert!(!violations(bypass).is_empty(), "missed: {bypass:?}"); + } +} + +/// What is not the pipe stays allowed: an `Output`'s captured bytes, a harness field named `stdout` +/// holding `Frames`, a `Command`'s stdout configuration, and anything in a comment. +#[test] +fn the_lint_leaves_what_is_not_the_pipe_alone() { + for fine in [ + "let text = String::from_utf8_lossy(&output.stdout);", + "assert!(output.stdout.is_empty());", + "read_until(&mut s.stdout, \"x\", |f| true);", + "read_until_response(&mut self.stdout, &name);", + "cmd.stdout(Stdio::piped());", + "let frames = common::child_frames(&mut child);", + "let child = cmd.spawn_guarded();", + "// child.stdout.take() would be wrong here", + "let held = HeldPort::bind(); let child = held.spawn(&cmd, Stdio::null());", + ] { + assert_eq!(violations(fine), Vec::::new(), "{fine:?}"); + } +} + +/// And at run time: the guard holds the pipe, so every form reachable on a guarded child finds +/// nothing — only `child_frames` reads it. +#[test] +fn a_guarded_childs_stdout_field_is_empty_however_it_is_reached() { + let mut child = common::ChildGuard::spawn( + Command::new("sh") + .args(["-c", "echo PIPED"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()), + ); + assert!(child.stdout.is_none()); + assert!(child.stdout.as_mut().is_none()); + assert!(child.stdout.take().is_none()); + assert!(std::mem::take(&mut child.stdout).is_none()); + assert!(Option::take(&mut child.stdout).is_none()); + let mut line = String::new(); + std::io::BufRead::read_line(&mut common::child_frames(&mut child), &mut line).unwrap(); + assert_eq!(line.trim(), "PIPED"); +} + +/// No test reaches a child's stdout pipe except through `common::child_frames`, in the `serve_*` and +/// `mcp_*` suites — top-level files and directory modules alike — and `tests/common`. #[test] fn only_child_frames_touches_a_childs_stdout() { - const FORBIDDEN: &[&str] = &[ - "ChildStdout", - ".stdout.take(", - ".stdout.as_mut(", - ".stdout.as_ref(", - ".stdout.unwrap(", - ".stdout.expect(", - ".stdout=", - ]; let tests = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests"); - 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(); - (name.starts_with("serve_") || name.starts_with("mcp_")) - && name.ends_with(".rs") - // This file names what it looks for. - && name != "serve_harness_deadlines.rs" - }) - .collect(); - files.extend( - std::fs::read_dir(tests.join("common")) - .unwrap() - .map(|e| e.unwrap().path()) - .filter(|p| p.extension().is_some_and(|e| e == "rs")), + fn rust_files(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + rust_files(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + } + let mut files = Vec::new(); + for entry in std::fs::read_dir(&tests).unwrap() { + let path = entry.unwrap().path(); + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + let in_scope = name.starts_with("serve_") || name.starts_with("mcp_") || name == "common"; + if !in_scope || name == "serve_harness_deadlines.rs" { + // This file spells out what it looks for. + continue; + } + if path.is_dir() { + rust_files(&path, &mut files); + } else if name.ends_with(".rs") { + files.push(path); + } + } + assert!( + files.iter().any(|f| f.ends_with("mcp_tasks_env/mod.rs")), + "directory modules are scanned" ); assert!( files.len() > 80, @@ -103,32 +268,18 @@ fn only_child_frames_touches_a_childs_stdout() { let mut readers = 0; let mut found = Vec::new(); for path in &files { - let code: String = std::fs::read_to_string(path) - .unwrap() - .lines() - .map(|line| line.split("//").next().unwrap_or("")) - .collect::() - .chars() - .filter(|c| !c.is_whitespace()) - .collect(); - readers += code.matches("child_frames(").count(); - // The helper itself takes the pipe, once. - let allowed = usize::from(path.ends_with("common/mod.rs")); - for pattern in FORBIDDEN { - let n = code.matches(pattern).count(); - let n = if *pattern == ".stdout.take(" { - n.saturating_sub(allowed) - } else { - n - }; - if n > 0 { - found.push(format!("{}: `{pattern}` x{n}", path.display())); - } + let mut source = std::fs::read_to_string(path).unwrap(); + if path.ends_with("common/mod.rs") { + source = without_the_guard(&source); + } + readers += source.matches("child_frames(").count(); + for v in violations(&source) { + found.push(format!("{}: {v}", path.display())); } } assert!( found.is_empty(), - "a child's stdout read other than through common::child_frames:\n{}", + "a child's stdout reached other than through common::child_frames:\n{}", found.join("\n") ); assert!( diff --git a/crates/agent/tests/settings_store.rs b/crates/agent/tests/settings_store.rs index 1fb595ac..3d0c3f2f 100644 --- a/crates/agent/tests/settings_store.rs +++ b/crates/agent/tests/settings_store.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::{ISOLATED_HOME, SpawnGuarded, run_cmd, spawn_model_server, turn_text}; @@ -223,7 +223,7 @@ fn serve_binary_uses_the_stored_default_model_when_no_flag_or_env_var_is_given() .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::child_frames(&mut child); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -275,7 +275,7 @@ fn serve_binary_stored_session_dir_default_does_not_override_an_explicit_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::child_frames(&mut child); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); @@ -744,7 +744,7 @@ fn serve_binary_explicit_bash_shell_path_flag_wins_over_a_stored_default() { .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::child_frames(&mut child); writeln!(stdin, "{}", json!({ "type": "prompt", "message": "hi" })).unwrap(); stdin.flush().unwrap(); common::read_until_response(&mut stdout, "prompt"); @@ -872,7 +872,7 @@ fn serve_binary_stored_default_models_list_scopes_cycle_model_with_no_explicit_f .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::child_frames(&mut child); writeln!(stdin, "{}", json!({ "type": "cycle_model" })).unwrap(); stdin.flush().unwrap(); @@ -925,7 +925,7 @@ fn serve_binary_explicit_models_flag_wins_over_a_stored_default_models_list() { .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::child_frames(&mut child); writeln!(stdin, "{}", json!({ "type": "cycle_model" })).unwrap(); stdin.flush().unwrap(); @@ -1241,7 +1241,7 @@ fn serve_binary_stored_steering_mode_and_follow_up_mode_seed_a_fresh_processs_in .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::child_frames(&mut child); writeln!( stdin, @@ -1286,7 +1286,7 @@ fn serve_binary_stored_steering_mode_and_follow_up_mode_seed_a_fresh_processs_in .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::child_frames(&mut child); writeln!(stdin, "{}", json!({ "type": "get_state" })).unwrap(); stdin.flush().unwrap(); diff --git a/crates/agent/tests/smoke.rs b/crates/agent/tests/smoke.rs index c6c26570..11629448 100644 --- a/crates/agent/tests/smoke.rs +++ b/crates/agent/tests/smoke.rs @@ -17,7 +17,7 @@ mod common; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, Write}; use std::net::{TcpListener, TcpStream}; use std::path::Path; use std::process::{Command, Output, Stdio}; @@ -411,7 +411,7 @@ fn smoke_prompt_cache_produces_hits() { &["--session-file", session_file.to_str().unwrap()], ); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, @@ -469,7 +469,7 @@ fn smoke_thinking_with_tools_replays_signature() { &["--thinking", "2000"], ); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, @@ -536,7 +536,7 @@ fn smoke_adaptive_thinking_with_tools_replays_signature() { &["--thinking", "2000"], ); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, @@ -614,7 +614,7 @@ fn smoke_openai_responses_reasoning_replays_signature() { &["--reasoning-effort", "low"], ); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, @@ -785,7 +785,7 @@ fn smoke_auto_compaction_fires_live() { ], ); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // Drive several turns; scan every frame across all of them for a `compacted` event. Turn 1 // writes the prefix; a later turn crosses the threshold at its start and compacts. @@ -944,7 +944,7 @@ fn smoke_concurrent_multi_tool_calls_round_trip() { let mut child = serve_child(gw_port, dir.path(), p.model, &[]); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, @@ -1029,7 +1029,7 @@ fn smoke_abort_cancels_a_live_run() { let mut child = serve_child(gw_port, dir.path(), p.model, &[]); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, @@ -1115,7 +1115,7 @@ fn smoke_follow_up_injects_into_a_live_run() { let mut child = serve_child(gw_port, dir.path(), p.model, &[]); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); let follow_up_marker = "LINGONBERRY-5567"; writeln!( @@ -1197,7 +1197,7 @@ fn smoke_branch_summary_generated_live() { &["--session-file", session_file.to_str().unwrap()], ); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); // Build a two-turn linear history (no tools — keep it cheap and deterministic). for msg in [ @@ -1303,7 +1303,7 @@ fn smoke_anthropic_multi_turn_plain_text_does_not_400_live() { let mut child = serve_child(gw_port, dir.path(), "claude-haiku-4-5", &[]); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); let mut responses = Vec::new(); for word in ["ONE", "TWO", "THREE"] { @@ -1368,7 +1368,7 @@ fn smoke_set_model_scrubs_thinking_across_providers() { &["--thinking", "2000"], ); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, @@ -1503,7 +1503,7 @@ fn smoke_session_resumes_across_a_process_restart_live() { ); { let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, "{}", @@ -1535,7 +1535,7 @@ fn smoke_session_resumes_across_a_process_restart_live() { &["--session-file", session_file.to_str().unwrap()], ); let mut stdin2 = child2.stdin.take().unwrap(); - let mut stdout2 = BufReader::new(child2.stdout.take().unwrap()); + let mut stdout2 = common::child_frames(&mut child2); writeln!( stdin2, "{}", @@ -1608,7 +1608,7 @@ fn smoke_fork_creates_an_independent_branch_live() { &["--session-dir", session_dir.to_str().unwrap()], ); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); for msg in [ "Reply with ONLY the word ONE.", @@ -1793,7 +1793,7 @@ fn smoke_manual_compact_honors_custom_instructions_live() { &["--compaction-keep-recent-tokens", "1"], ); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); for msg in [ "Reply with ONLY the word ONE.", @@ -1906,7 +1906,7 @@ fn smoke_split_turn_compaction_fires_mid_turn_live() { ], ); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); writeln!( stdin, @@ -2041,7 +2041,7 @@ fn smoke_whole_run_auto_retry_recovers_from_a_real_dropped_connection_live() { let mut child = serve_child_with_gateway_url(&proxy_url, dir.path(), p.model, &[]); let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut stdout = common::child_frames(&mut child); let started = std::time::Instant::now(); writeln!(