mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
🤖 ## Requested rebase published —ef40744bRebased onto fetched main **47d068e2109d077414cbf2f4f1c927f6d051037a**, published **ef40744b3aeb4baaf8c81416e1a644fb5b315f91** with the exact expected-old `df7fad6a` force-with-lease. No merge. Manual conflicts were additive: preserve main's exact-key identity documentation alongside the availability contract, and retain both Bestie props and the shared availability reader in `UnifiedAgentsSection`. Range-diff confirms unchanged lifecycle policy: exact-key action-time authority, Unknown versus Offline, rejected shutdown retains record/memberships, and separate local/provider/owner gates. Main's exact-key profile routing survives. Both test-only CI synchronization repairs (natural toast expiry and bounded stderr wait) are byte-identical to the prior head. All ten original authors/messages/DCO/material coauthor trailers are preserved; configured signing policy was not changed. Fresh checks on the rebased candidate: - TypeScript, Biome on 26 changed TypeScript files, differential file-size gate, and diff whitespace: pass. - Focused production-hook/card/profile units: **58/58**. - Fresh E2E build, availability/deletion browser: **11/11**, no retries. - Main exact-key profile cases plus failed-DM send/startup retries: **6/6**, no retries. Previously reviewed full Desktop/buzz-agent package and mutation evidence is reused for unchanged behavior; no ceremonial full suite, new native/provider test, or `just ci` pass is claimed. Local configs, dependency links, and historical artifacts are preserved. Hosted observation: **MERGEABLE**, **BLOCKED / REVIEW_REQUIRED**, no new-head formal review. [CI 33699735990](https://github.com/block/buzz/actions/runs/33699735990) is running (including Rust and Desktop lanes), not a completed success. DCO and required Security aggregate passed at the observation; the separate Codex advisory review was skipped. No completed failing check or new inline feedback observed. Historical approvals are not new-head approvals. No reviewer/security authorization or merge action was performed. --- ## Feature summary and retained pre-rebase evidence ## Summary In Buzz Desktop, an agent could look online just because it had been started or deployed, even when there was no current sign it was connected. Cards and profiles now show availability from the agent's relay presence rather than a saved launch record, so you can distinguish an online agent from one that was merely deployed. Agents cards and profiles use presence reported through the shared server (the relay). A successful presence read with no online agent shows Offline; failed/disconnected evidence shows unknown, rather than retaining a misleading cached Online state. Lifecycle actions remain separate. An offline agent may still have a Shutdown action because the deployment record exists. Shutdown reports a **request**, not proof the process stopped. Offline does not imply that starting a duplicate agent is safe, and Online does not promise a response. ### Related issue Independent base: `main`; no stack parent or child among the replacements. Extracted from [#7114](https://github.com/block/buzz/pull/7114), retained as historical source (`98fe33ec`). [Behavior contract](f4bb2ed44e/docs/agent-availability.md). Originating [Buzz discussion](buzz://message?channel=f7a9536a-1738-4bad-a888-b3ea25010ef1&id=7aa1f0ab23dce514bd8a0221441cf005bf428914621171472b79747c50820848) · channel `f7a9536a-1738-4bad-a888-b3ea25010ef1`. ### Testing The same saved provider-backed agent, with only authored presence changing. These are mock-browser states, not a before/after deployment or live relay transport test; production UI is unchanged by the later fixture repairs. **No online presence:** gray dot, existing Shutdown control retained.  **Online presence:** green dot, same lifecycle control.  [Capture details](https://github.com/block/buzz/pull/7127#issuecomment-5482264824). To check manually, compare runtime-only transitions with presence updates, then disconnect/fail the presence read and verify it does not stay Online. A Shutdown request should not immediately claim confirmed termination. #### Historical pre-rebase evidence and limitations (df7fad6a) Lifecycle production source remains **`f4bb2ed44e5a989d93c5f51e93c0bbd2dca941be`**. Current published head is **`df7fad6ae65dda78508317186a95522d1bb22ed9`**: the prior browser synchronization at `b78d093e` plus an additive two-file Rust test-harness synchronization described below. No production bytes, dependency/configuration files, or prior commits were changed; no rebase. Current live main `0dbd036f5bff33e7ade75e7639f3218d424a6e73` has identical failing-test/toaster/send-flow source; the causal browser comparison used latest successfully tested main `04babf02655440b4dfd37f2e2df605ead0a030d8`. **Lifecycle/deletion correction:** both Agents and actual profile deletion now pass the shared exact-key availability reader, not raw cached data. It reads the canonical query state and connection at action time, including after awaited channel discovery. Failed/disconnected/pending evidence and unqueried persona siblings are unknown; successful missing means Offline only for a requested key. Successful background refetch cache remains usable; settled failure revokes it. No second cache or per-row polling was added. Provider record + channel + Online/Away/**unknown** awaits shutdown submission before local removal; rejection preserves record/membership for retry. Established Offline preserves intentional no-request removal. No route preserves warned local removal. Local agents retain native stop-before-remove, independent of presence. Profile consent now describes a shutdown **request**, not remote deletion or guaranteed termination. Existing ownership and force gates are unchanged. **Verified, reused exact-candidate validation:** the independently approved eleven-file patch (SHA-256 `2f69fe12ef0420e62dea1fd8db28cfa22cde5eecaf8080e656310a3e60d0cf86`) was committed without byte changes. Desktop **5,921 passed, 0 failed/skipped**, including **26 new mounted production hook/IPC regressions**; rebuilt availability browser suite **11/11 passed, no retries**, including four actual profile Delete journeys. Desktop check (existing 4 warnings/5 infos), typecheck, production/protected-feature artifact matrix, differential file-size/policy and diff checks passed. No blanket rerun or new full-repository `just ci` is claimed for this frontend correction. Production regressions cover cached Online **and Offline** failure/disconnection, genuine missing/Offline, pending, successful inflight refetch versus settled error, retained reader, error during awaited channel discovery, unqueried persona sibling, shutdown rejection/order/cancel, no route and local authority. Browser fixtures use safe mock IPC and a retained provider receipt, not a real deployment. Three restored mutation controls fail: unknown → skip shutdown (**15** regressions), Agents raw-cache reader (**6**), actual profile raw-cache caller (**1 browser journey**, false removal on failed cached Offline). Independent review approved the exact frozen bytes and added **4/4 cached-empty failure/disconnection probes** across both callers. This is local independent approval, not formal GitHub/A Team clearance. The prior native propagation/poll-count defects remain closed ([earlier response](https://github.com/block/buzz/pull/7127#issuecomment-5512278755)). The prior hover-popover correction at `b55423f6` remains covered by the full 11-journey browser run: pending/failed/disconnected means no badge or accessible status, genuine missing/Offline retains an Offline badge. Its earlier fallback-restoration mutation failed as expected (badge count 1 rather than 0); that historical witness is reused, not rerun. **Reused unchanged native/system boundary:** local `just ci` at `c59067d8` passed workspace/Tauri fmt/clippy, static/policy checks, Rust unit recipe, native workspace **3,159 passed / 20 ignored**, Web build and **2,019 mobile tests**. No native implementation changed in this lifecycle correction. These are historical boundary results, not new-head native/live certification. [Parent CI](https://github.com/block/buzz/actions/runs/33650549130) passed with **14 retry-recovered browser flakes**, not retry-free. Old-head CI/reviews are not current-head clearance. **Hosted gates:** [CI33662151103](https://github.com/block/buzz/actions/runs/33662151103) on `f4bb2ed4` **FAILED**: smoke shard1 had 322 pass, one failure, one retry-recovered flaky, two skipped. The failed first-DM retry test timed out on all three attempts because the error toast intercepted Send. That failure is preserved, not waived; the scoped test repair below is published as `b78d093e`. [CI33668171165](https://github.com/block/buzz/actions/runs/33668171165) on `b78d093e` subsequently **FAILED** the Rust unit budget regression described below. Both original failures remain visible; neither was retried to green. Exact `f4bb2ed4` and `b78d093e` APPROVED reviews cover unchanged reviewed bytes, not formal approval of the new head. Fresh exact-head CI and the established automated technical rereview are the next gates for `df7fad6a`. Historical deletion responses remain ([5092381800](https://github.com/block/buzz/pull/7127#issuecomment-5513759316), [5092391193](https://github.com/block/buzz/pull/7127#issuecomment-5513763497)). No formal review dismissed. The [security notice](https://github.com/block/buzz/pull/7127#issuecomment-5482231278) and latest-push maintainer/codeowner policy remain separate actionable gates: eligible Block organization members own current-range authorization. No merge/security authority exercised. **CI causal repair (`b78d093e`, test only):** the error `Message failed to send: Mock first DM send failed.` is deliberately injected by the existing fixture. CI screenshot and retry trace show the bottom-right Sonner notification over the actual enabled Send button. `fill()` leaves the pointer parked there; Sonner pauses its 4-second lifetime while hovered. A fast run can click before animation settles (unchanged local test passed in 2.7s; two actual tested-main CI cases passed first attempt in 3.3s), which does not disprove the failure. Independent controlled browser runs on `f4bb2ed4` and tested main `04babf` both reproduced the same toast hit-test at Send `(1203,627,32,32)`, persistent hover beyond 4s, and intercepted ordinary click with no second send. Moving the real pointer to the editor allows natural expiry and successful ordinary retry, preserving all original DM-channel/recipient assertions. This same synchronization already exists in the neighboring agent-startup-failure test. The one-file correction keeps the visible error assertion, scopes its toast locator, moves the pointer back to the editor and observes normal toast removal (bounded 10s) before retry. No forced click, direct toast dismissal, mocked clock, CSS override, skipped test, production behavior change, or new backend mock. Six focused browser executions pass (first-send/startup-failure, three repeats each, no retries); the held-toast control fails on original bytes at the Send click while the exact repaired test passes. Biome and diff checks pass. Reuse unchanged 5,921 Desktop / 11 availability browser / four independent probes above; no semantic production change warrants repeating those suites. Original failed CI attempt/retries, local fast pass, deliberate failing control and all traces remain in `WORK_LOGS/AVAILABILITY_CI_B9210A40`. Browser evidence is mock-IPC Chromium, not native/live-relay certification. The UI still temporarily overlays Send while a notification is hovered; the test exercises its real move-away/expiry recovery, not immediate click-through. **Rust CI causal repair (`df7fad6a`, test only):** [original Rust / Unit Tests failure, job100375291370](https://github.com/block/buzz/actions/runs/33668171165/job/100375291370) tested GitHub merge `223dee91a396d8cb4ebf18b9b8559e5a54951235`. `context_recovery_budget_exhaustion_surfaces_the_error` failed at `regressions.rs:2756` in **0.091s** because its immediate stderr snapshot lacked `context recovery budget spent`. ACP context-error assertions had already passed. The captured prefix shows all three budgets **32768 → 16384 → 8192 bytes**, above the 4096-byte floor, and ends during the third attempt. This is **not evidence of floor exhaustion**. The collector is an independent Tokio task; a stdout response is not a stderr barrier. Recovery, harness and test blobs were identical across the compared base/head/merge parents; no production regression was implicated. The shared test Harness now provides a bounded event/condition wait, registering for collector notifications before reading the buffer to avoid lost wakeups. The budget and adjacent terminal floor assertions wait for their own diagnostic and retain the matching snapshot. The budget test still requires the provider's ACP context error and exactly three recovery rungs, now corroborated by **exactly four provider calls** and no floor diagnostic. Timeout remains a real failure with captured stderr. No fixed sleep, weaker assertion, skip, provider-limit/logging change, dependency/config edit, or production change. **Deterministic causal control:** the same real agent/HTTP-provider/ACP scenario holds only stderr collection behind a one-shot gate until after stdout responds. The old immediate snapshot fails the original budget assertion (intentional exit101); the repaired wait explicitly remains Pending while held, then passes after release. No scheduler-speed assumption or fixed sleep. This reproduces the observation race under controlled delay, **not the exact historical CI schedule**. A missing-diagnostic test proves the wait actually times out. Original failure and deliberate failing-control logs/patch remain in `WORK_LOGS/RUST_TRIAGE_06E32D2D` and `WORK_LOGS/RUST_SYNC_BC5758B9`. **Final candidate validation:** focused recovery **9/9**, floor **1/1**, absent-diagnostic timeout **1/1** pass. One full touched-package run, `cargo test --locked -p buzz-agent`: **695 passed, 0 failed, 1 existing ignored**, including all **54 regressions**. Local nextest was unavailable, so this uses the repository-supported cargo-test fallback, not a claim of nextest reproduction. `cargo fmt --check`, package-scoped Clippy all-targets with `-D warnings`, differential file-size/policy and diff checks pass. Previously reviewed availability production and the Desktop/browser evidence above are unchanged and reused; no all-native blanket rerun. The test-only delta was self-reviewed against collector ordering, timeout and falsification evidence. Existing production approval remains valid for those bytes; exact-new-head technical/CI clearance is not assumed. The required **Security aggregate** is distinct from optional Codex advisory feedback; no security authorization, human review contact, or merge was requested. **Remaining policy limits:** shutdown submission is not harness acceptance or process termination; confirmed Offline/no-route local removal may leave a remote process; route discovery is best effort, membership cleanup uses `Promise.allSettled`, and multi-instance deletion is sequential/non-atomic. No distributed singleton, provider-health, tenant-switch cancellation, live relay TTL or packaged WebView/VoiceOver certification is claimed. The pre-existing DM-header raw-presence fallback (`ChannelScreenHeader`/`useActiveChannelHeader`) remains outside this repair and uncertified. Screenshots above remain historical mock-browser illustrations, not new deletion or native transport evidence. --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
327 lines
12 KiB
Rust
327 lines
12 KiB
Rust
//! Shared subprocess test harness for the buzz-agent ACP integration suites.
|
|
//!
|
|
//! Every integration test file is its own crate, so this module is included
|
|
//! with `mod common;` in each and compiles once per binary — each binary uses
|
|
//! only the subset it needs, hence the module-wide `dead_code` allow.
|
|
//!
|
|
//! It drives a real `buzz-agent` child over the ACP wire against a fake LLM
|
|
//! (`CapturingLlm`, which records each request body) and answers the
|
|
//! `session/request_permission` surface (`approve_permission`, selecting the
|
|
//! offered `allow_once` option by `kind`, never a hardcoded `optionId`).
|
|
|
|
#![allow(dead_code)]
|
|
|
|
use std::collections::VecDeque;
|
|
use std::process::Stdio;
|
|
use std::sync::{Arc, Mutex as StdMutex};
|
|
use std::time::Duration;
|
|
|
|
use serde_json::{json, Value};
|
|
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
|
use tokio::net::TcpListener;
|
|
use tokio::sync::{oneshot, Mutex, Notify};
|
|
|
|
pub struct CapturingLlm {
|
|
pub url: String,
|
|
pub captured: Arc<Mutex<Vec<Value>>>,
|
|
}
|
|
|
|
pub async fn spawn_capturing_llm(responses: Vec<Value>) -> CapturingLlm {
|
|
spawn_capturing_llm_with_status(responses.into_iter().map(|v| (200u16, v)).collect()).await
|
|
}
|
|
|
|
/// Like `spawn_capturing_llm` but each canned response carries its own HTTP
|
|
/// status, so a test can serve a real provider rejection (e.g. a context-window
|
|
/// 400) instead of only success bodies.
|
|
pub async fn spawn_capturing_llm_with_status(responses: Vec<(u16, Value)>) -> CapturingLlm {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = format!("http://{}", listener.local_addr().unwrap());
|
|
let queue = Arc::new(Mutex::new(VecDeque::from(responses)));
|
|
let captured: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let cap2 = captured.clone();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let (mut sock, _) = match listener.accept().await {
|
|
Ok(p) => p,
|
|
Err(_) => return,
|
|
};
|
|
let queue = queue.clone();
|
|
let captured = cap2.clone();
|
|
tokio::spawn(async move {
|
|
let mut buf = Vec::new();
|
|
let mut tmp = [0u8; 8192];
|
|
// Read until headers complete.
|
|
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
|
match sock.read(&mut tmp).await {
|
|
Ok(0) | Err(_) => return,
|
|
Ok(n) => buf.extend_from_slice(&tmp[..n]),
|
|
}
|
|
if buf.len() > 4_000_000 {
|
|
return;
|
|
}
|
|
}
|
|
// Parse Content-Length and read body.
|
|
let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
|
|
let headers = &buf[..header_end];
|
|
let mut body_len = 0usize;
|
|
for line in headers.split(|b| *b == b'\n') {
|
|
let line = std::str::from_utf8(line).unwrap_or("");
|
|
if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-length:") {
|
|
body_len = rest.trim().trim_end_matches('\r').parse().unwrap_or(0);
|
|
}
|
|
}
|
|
while buf.len() < header_end + body_len {
|
|
match sock.read(&mut tmp).await {
|
|
Ok(0) | Err(_) => return,
|
|
Ok(n) => buf.extend_from_slice(&tmp[..n]),
|
|
}
|
|
}
|
|
if let Ok(req) = serde_json::from_slice::<Value>(&buf[header_end..]) {
|
|
captured.lock().await.push(req);
|
|
}
|
|
let (status, body) = queue
|
|
.lock()
|
|
.await
|
|
.pop_front()
|
|
.unwrap_or_else(|| (200, json!({ "error": "no canned response" })));
|
|
let body_s = serde_json::to_string(&body).unwrap();
|
|
let reason = match status {
|
|
200 => "OK",
|
|
400 => "Bad Request",
|
|
_ => "Error",
|
|
};
|
|
let resp = format!(
|
|
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
body_s.len(), body_s,
|
|
);
|
|
let _ = sock.write_all(resp.as_bytes()).await;
|
|
let _ = sock.shutdown().await;
|
|
});
|
|
}
|
|
});
|
|
CapturingLlm { url, captured }
|
|
}
|
|
|
|
pub struct Harness {
|
|
child: tokio::process::Child,
|
|
stdin: tokio::process::ChildStdin,
|
|
stdout: BufReader<tokio::process::ChildStdout>,
|
|
stderr: Arc<StdMutex<String>>,
|
|
stderr_changed: Arc<Notify>,
|
|
next_id: i64,
|
|
}
|
|
|
|
impl Harness {
|
|
pub async fn spawn_with_env(base_url: &str, extra: &[(&str, &str)]) -> Self {
|
|
Self::spawn_with_stderr_gate(base_url, extra, None).await
|
|
}
|
|
|
|
/// Delay stderr collection until released, to exercise stdout/stderr ordering
|
|
/// without changing the child or relying on scheduler timing.
|
|
pub async fn spawn_with_stderr_gate(
|
|
base_url: &str,
|
|
extra: &[(&str, &str)],
|
|
stderr_gate: Option<oneshot::Receiver<()>>,
|
|
) -> Self {
|
|
let bin = env!("CARGO_BIN_EXE_buzz-agent");
|
|
let mut cmd = tokio::process::Command::new(bin);
|
|
cmd.env("BUZZ_AGENT_PROVIDER", "openai")
|
|
.env("OPENAI_COMPAT_API_KEY", "test")
|
|
.env("OPENAI_COMPAT_MODEL", "fake-model")
|
|
.env("OPENAI_COMPAT_BASE_URL", base_url)
|
|
.env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5")
|
|
.env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5")
|
|
.env("BUZZ_AGENT_MAX_ROUNDS", "8")
|
|
.env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2");
|
|
for (k, v) in extra {
|
|
cmd.env(k, v);
|
|
}
|
|
cmd.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.kill_on_drop(true);
|
|
let mut child = cmd.spawn().expect("spawn buzz-agent");
|
|
let stdin = child.stdin.take().unwrap();
|
|
let stdout = BufReader::new(child.stdout.take().unwrap());
|
|
let stderr = child.stderr.take().unwrap();
|
|
let stderr_buf = Arc::new(StdMutex::new(String::new()));
|
|
let stderr_out = Arc::clone(&stderr_buf);
|
|
let stderr_changed = Arc::new(Notify::new());
|
|
let changed = Arc::clone(&stderr_changed);
|
|
tokio::spawn(async move {
|
|
if let Some(gate) = stderr_gate {
|
|
// Dropping the sender (e.g. on assertion failure) also unblocks
|
|
// collection, rather than leaving a detached reader waiting.
|
|
let _ = gate.await;
|
|
}
|
|
let mut reader = BufReader::new(stderr);
|
|
let mut line = String::new();
|
|
loop {
|
|
line.clear();
|
|
let n = match reader.read_line(&mut line).await {
|
|
Ok(n) => n,
|
|
Err(_) => break,
|
|
};
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
if let Ok(mut out) = stderr_out.lock() {
|
|
out.push_str(&line);
|
|
}
|
|
changed.notify_waiters();
|
|
}
|
|
});
|
|
Self {
|
|
child,
|
|
stdin,
|
|
stdout,
|
|
stderr: stderr_buf,
|
|
stderr_changed,
|
|
next_id: 1,
|
|
}
|
|
}
|
|
|
|
pub async fn spawn(base_url: &str) -> Self {
|
|
Self::spawn_with_env(base_url, &[]).await
|
|
}
|
|
|
|
pub async fn send(&mut self, method: &str, params: Value) -> i64 {
|
|
let id = self.next_id;
|
|
self.next_id += 1;
|
|
self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }))
|
|
.await;
|
|
id
|
|
}
|
|
|
|
pub async fn notify(&mut self, method: &str, params: Value) {
|
|
self.write(json!({ "jsonrpc": "2.0", "method": method, "params": params }))
|
|
.await;
|
|
}
|
|
|
|
pub async fn write(&mut self, msg: Value) {
|
|
let mut s = serde_json::to_string(&msg).unwrap();
|
|
s.push('\n');
|
|
self.stdin.write_all(s.as_bytes()).await.unwrap();
|
|
self.stdin.flush().await.unwrap();
|
|
}
|
|
|
|
pub async fn recv(&mut self) -> Value {
|
|
let mut line = String::new();
|
|
let n = tokio::time::timeout(Duration::from_secs(15), self.stdout.read_line(&mut line))
|
|
.await
|
|
.expect("recv timeout")
|
|
.expect("read line");
|
|
assert!(n > 0, "agent EOF; stderr={}", self.stderr_text());
|
|
serde_json::from_str(&line).expect("non-JSON line")
|
|
}
|
|
|
|
pub async fn recv_until<F: FnMut(&Value) -> bool>(&mut self, mut pred: F) -> Value {
|
|
loop {
|
|
let v = self.recv().await;
|
|
if pred(&v) {
|
|
return v;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Like `recv_until`, but auto-approves any `session/request_permission`
|
|
/// seen while waiting. Tests that exercise tool execution, not the
|
|
/// permission boundary (that lives in `permission_boundary.rs`), must
|
|
/// approve a model-issued tool call so it reaches the server.
|
|
pub async fn recv_until_approving<F: FnMut(&Value) -> bool>(&mut self, mut pred: F) -> Value {
|
|
loop {
|
|
let v = self.recv().await;
|
|
if v.get("method") == Some(&json!("session/request_permission")) {
|
|
let resp = approve_permission(&v);
|
|
self.write(resp).await;
|
|
continue;
|
|
}
|
|
if pred(&v) {
|
|
return v;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn shutdown(mut self) {
|
|
drop(self.stdin);
|
|
let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await;
|
|
let _ = self.child.start_kill();
|
|
}
|
|
|
|
/// Snapshot only: receiving a response on stdout does not drain stderr.
|
|
pub fn stderr_text(&self) -> String {
|
|
self.stderr.lock().map(|s| s.clone()).unwrap_or_default()
|
|
}
|
|
|
|
/// Wait for a diagnostic in the independently collected stderr stream.
|
|
/// Returns the matching snapshot so subsequent assertions see its prefix.
|
|
pub async fn wait_for_stderr(&self, needle: &str, timeout: Duration) -> String {
|
|
tokio::time::timeout(timeout, async {
|
|
loop {
|
|
let changed = self.stderr_changed.notified();
|
|
tokio::pin!(changed);
|
|
// Register before inspecting the buffer: a line collected between
|
|
// the snapshot and await must not become a lost wakeup.
|
|
changed.as_mut().enable();
|
|
let stderr = self.stderr_text();
|
|
if stderr.contains(needle) {
|
|
return stderr;
|
|
}
|
|
changed.await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap_or_else(|_| {
|
|
panic!(
|
|
"timed out waiting for stderr diagnostic {needle:?}; stderr={}",
|
|
self.stderr_text()
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
pub fn openai_text(content: &str) -> Value {
|
|
json!({
|
|
"id": "cc-1", "object": "chat.completion", "model": "fake-model",
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": { "role": "assistant", "content": content },
|
|
"finish_reason": "stop",
|
|
}],
|
|
})
|
|
}
|
|
|
|
pub fn openai_tool_call(id: &str, name: &str, args: Value) -> Value {
|
|
json!({
|
|
"id": "cc-2", "object": "chat.completion", "model": "fake-model",
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": {
|
|
"role": "assistant", "content": null,
|
|
"tool_calls": [{
|
|
"id": id, "type": "function",
|
|
"function": { "name": name, "arguments": args.to_string() },
|
|
}],
|
|
},
|
|
"finish_reason": "tool_calls",
|
|
}],
|
|
})
|
|
}
|
|
|
|
/// Select the offered option whose `kind == "allow_once"` and return the
|
|
/// `session/request_permission` response. Mirrors buzz-acp's answering side,
|
|
/// which selects by `kind`, never by a hardcoded `optionId`. Centralizing this
|
|
/// means a future option-id rename can't silently turn allow into a denial.
|
|
pub fn approve_permission(request: &Value) -> Value {
|
|
let option_id = request["params"]["options"]
|
|
.as_array()
|
|
.and_then(|opts| opts.iter().find(|o| o["kind"] == "allow_once"))
|
|
.and_then(|o| o["optionId"].as_str())
|
|
.expect("request must offer an allow_once option");
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"id": request["id"],
|
|
"result": { "outcome": { "outcome": "selected", "optionId": option_id } },
|
|
})
|
|
}
|