From d5a73b9f35feaa81ef9afb13d1ec8943e8ec6ca9 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 2 Sep 2026 21:53:15 -0400 Subject: [PATCH] fix(desktop): derive agent availability from relay presence (#7127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Requested rebase published — ef40744b Rebased 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](https://github.com/block/buzz/blob/f4bb2ed44e5a989d93c5f51e93c0bbd2dca941be/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. ![Offline presence does not remove the deployment lifecycle control](https://raw.githubusercontent.com/block/buzz/ed68b1b4597dde47118b7591b1e8ebf3702447ca/pr-7127--offline-deployment.png) **Online presence:** green dot, same lifecycle control. ![Authored Online presence changes availability without changing the deployment](https://raw.githubusercontent.com/block/buzz/ed68b1b4597dde47118b7591b1e8ebf3702447ca/pr-7127--online-deployment.png) [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 Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- crates/buzz-agent/tests/common/mod.rs | 49 +- crates/buzz-agent/tests/regressions.rs | 63 +- desktop/playwright.config.ts | 1 + desktop/src-tauri/src/commands/profile.rs | 11 +- .../src/commands/profile_presence_tests.rs | 103 +++ desktop/src/features/agents/AGENTS.md | 11 + .../agents/lib/managedAgentControlActions.ts | 29 +- .../managedAgentDeletionAvailability.test.mjs | 469 +++++++++++ .../agents/lib/useAgentAvailability.test.mjs | 119 +++ .../agents/lib/useAgentAvailability.ts | 76 ++ .../agents/ui/AgentRuntimeAvatarControl.tsx | 39 +- desktop/src/features/agents/ui/AgentsView.tsx | 1 + .../agents/ui/UnifiedAgentsSection.tsx | 17 + .../UnifiedAgentsSectionCardTarget.test.mjs | 315 +++++++- .../agents/ui/useManagedAgentActions.ts | 21 +- .../features/channels/ui/MembersSidebar.tsx | 9 +- .../channels/ui/MembersSidebarMemberCard.tsx | 18 +- .../channels/ui/useMembersSidebarActions.ts | 19 + desktop/src/features/presence/hooks.ts | 8 +- .../ui/UserProfileAgentManagementRows.tsx | 6 +- .../features/profile/ui/UserProfilePanel.tsx | 11 +- .../profile/ui/UserProfilePanelDeletion.ts | 13 +- .../profile/ui/UserProfilePanelSections.tsx | 18 +- .../profile/ui/UserProfilePopover.tsx | 7 +- .../profile/ui/UserProfilePrimaryActions.tsx | 8 +- .../profile/ui/useAgentLifecycleActions.ts | 20 +- desktop/src/testing/e2eBridge.ts | 14 +- desktop/tests/e2e/agent-availability.spec.ts | 744 ++++++++++++++++++ .../e2e/agent-error-state-screenshots.spec.ts | 77 +- desktop/tests/e2e/agents.spec.ts | 28 + desktop/tests/e2e/channels.spec.ts | 14 +- desktop/tests/e2e/onboarding.spec.ts | 60 +- desktop/tests/e2e/profile.spec.ts | 60 +- desktop/tests/helpers/welcomeTeam.ts | 87 ++ docs/agent-availability.md | 122 +++ 35 files changed, 2540 insertions(+), 127 deletions(-) create mode 100644 desktop/src-tauri/src/commands/profile_presence_tests.rs create mode 100644 desktop/src/features/agents/lib/managedAgentDeletionAvailability.test.mjs create mode 100644 desktop/src/features/agents/lib/useAgentAvailability.test.mjs create mode 100644 desktop/src/features/agents/lib/useAgentAvailability.ts create mode 100644 desktop/tests/e2e/agent-availability.spec.ts create mode 100644 desktop/tests/helpers/welcomeTeam.ts create mode 100644 docs/agent-availability.md diff --git a/crates/buzz-agent/tests/common/mod.rs b/crates/buzz-agent/tests/common/mod.rs index 02bdc3bc0e..d45fac4985 100644 --- a/crates/buzz-agent/tests/common/mod.rs +++ b/crates/buzz-agent/tests/common/mod.rs @@ -19,7 +19,7 @@ use std::time::Duration; use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; -use tokio::sync::Mutex; +use tokio::sync::{oneshot, Mutex, Notify}; pub struct CapturingLlm { pub url: String, @@ -107,11 +107,22 @@ pub struct Harness { stdin: tokio::process::ChildStdin, stdout: BufReader, stderr: Arc>, + stderr_changed: Arc, 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>, + ) -> Self { let bin = env!("CARGO_BIN_EXE_buzz-agent"); let mut cmd = tokio::process::Command::new(bin); cmd.env("BUZZ_AGENT_PROVIDER", "openai") @@ -135,7 +146,14 @@ impl Harness { 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 { @@ -150,6 +168,7 @@ impl Harness { if let Ok(mut out) = stderr_out.lock() { out.push_str(&line); } + changed.notify_waiters(); } }); Self { @@ -157,6 +176,7 @@ impl Harness { stdin, stdout, stderr: stderr_buf, + stderr_changed, next_id: 1, } } @@ -228,9 +248,36 @@ impl Harness { 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 { diff --git a/crates/buzz-agent/tests/regressions.rs b/crates/buzz-agent/tests/regressions.rs index edee9090d8..fd5042a116 100644 --- a/crates/buzz-agent/tests/regressions.rs +++ b/crates/buzz-agent/tests/regressions.rs @@ -2706,6 +2706,30 @@ async fn ordinary_400_stays_terminal_and_triggers_no_recovery() { /// part of the assertion. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn context_recovery_budget_exhaustion_surfaces_the_error() { + assert_context_recovery_budget_exhaustion(false).await; +} + +/// The same real provider/ACP scenario with stderr collection held until after +/// the stdout response. The old immediate snapshot cannot observe the budget. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn context_recovery_budget_exhaustion_waits_for_delayed_stderr() { + assert_context_recovery_budget_exhaustion(true).await; +} + +#[tokio::test] +#[should_panic(expected = "timed out waiting for stderr diagnostic")] +async fn stderr_diagnostic_wait_is_bounded_when_absent() { + let llm = spawn_capturing_llm(vec![]).await; + let h = Harness::spawn(&llm.url).await; + h.wait_for_stderr( + "diagnostic that is never emitted", + Duration::from_millis(20), + ) + .await; +} + +async fn assert_context_recovery_budget_exhaustion(delay_stderr: bool) { + let (release_stderr, stderr_gate) = tokio::sync::oneshot::channel(); // Enough canned 400s that the queue is never the thing that stops the loop; // the fallback response is also a 400-shaped body under this helper only if // queued, so keep the queue generously long. @@ -2713,7 +2737,7 @@ async fn context_recovery_budget_exhaustion_surfaces_the_error() { .map(|_| (400, openai_context_length_error())) .collect(); let llm = spawn_capturing_llm_with_status(responses).await; - let mut h = Harness::spawn_with_env( + let mut h = Harness::spawn_with_stderr_gate( &llm.url, &[ ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), @@ -2723,6 +2747,7 @@ async fn context_recovery_budget_exhaustion_surfaces_the_error() { ), ("BUZZ_AGENT_MAX_HANDOFFS", "0"), ], + delay_stderr.then_some(stderr_gate), ) .await; let sid = init_session(&mut h, json!([])).await; @@ -2751,8 +2776,27 @@ async fn context_recovery_budget_exhaustion_surfaces_the_error() { // floor produce a surfaced error, so the assertion above passes either way // — and the floor can fire on the first rung without the budget ever being // consumed, which would make this test silently exercise a different - // mechanism than its name claims. Pin the budget explicitly. - let stderr = h.stderr_text(); + // mechanism than its name claims. Pin the budget explicitly. Stdout is not + // a barrier for the independent stderr collector. + let stderr = { + let wait = h.wait_for_stderr("context recovery budget spent", Duration::from_secs(5)); + tokio::pin!(wait); + if delay_stderr { + assert!( + !h.stderr_text().contains("context recovery budget spent"), + "the old immediate snapshot must miss the held diagnostic" + ); + // Prove the actual wait stays pending before releasing the collector, + // without a sleep or depending on how quickly either task runs. + std::future::poll_fn(|cx| { + assert!(std::future::Future::poll(wait.as_mut(), cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + release_stderr.send(()).expect("release stderr collection"); + } + wait.await + }; assert!( stderr.contains("context recovery budget spent"), "the per-run recovery BUDGET must be what stops the loop here, not the prompt floor; \ @@ -2767,6 +2811,15 @@ async fn context_recovery_budget_exhaustion_surfaces_the_error() { "expected all 3 recovery rungs to be attempted before giving up, saw {rungs} — \ stderr={stderr}" ); + assert!( + !stderr.contains("context recovery would shrink"), + "the prompt floor must not stop this fixture: {stderr}" + ); + assert_eq!( + llm.captured.lock().await.len(), + 4, + "expected the rejected completion plus exactly three failed summaries" + ); h.shutdown().await; } @@ -2816,7 +2869,9 @@ async fn small_history_context_400_refuses_rescue_at_the_prompt_floor() { r0.get("error").is_some(), "a context 400 with no shrinkable history must surface the error, got: {r0}" ); - let stderr = h.stderr_text(); + let stderr = h + .wait_for_stderr("context recovery would shrink", Duration::from_secs(5)) + .await; assert!( stderr.contains("below the") && stderr.contains("floor"), "the prompt-budget FLOOR must be what stops this, not the recovery budget; got: {stderr}" diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 8636102e16..c571472a36 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -178,6 +178,7 @@ export default defineConfig({ name: "integration", testMatch: [ "**/agents.spec.ts", + "**/agent-availability.spec.ts", "**/agent-snapshot-recipient.spec.ts", "**/onboarding.spec.ts", "**/stream.spec.ts", diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index ef67fac570..da93af673d 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -344,8 +344,8 @@ pub async fn get_presence( } // Presence is published as kind:20001 ephemeral events. Query the most - // recent per author. Some relays don't retain ephemeral events — we - // best-effort and return what we get. + // recent per author. Only a successful empty snapshot establishes absence; + // transport/auth/storage failures must reject so consumers remain unknown. let events = query_relay( &state, &[serde_json::json!({ @@ -353,8 +353,7 @@ pub async fn get_presence( "authors": pubkeys, })], ) - .await - .unwrap_or_default(); + .await?; let mut latest: HashMap = HashMap::new(); for ev in &events { @@ -482,3 +481,7 @@ mod tests { assert_eq!(filter["page"], serde_json::json!(1)); } } + +#[cfg(test)] +#[path = "profile_presence_tests.rs"] +mod presence_tests; diff --git a/desktop/src-tauri/src/commands/profile_presence_tests.rs b/desktop/src-tauri/src/commands/profile_presence_tests.rs new file mode 100644 index 0000000000..612e453273 --- /dev/null +++ b/desktop/src-tauri/src/commands/profile_presence_tests.rs @@ -0,0 +1,103 @@ +//! Drive the actual get_presence command through its authenticated HTTP query. +//! In particular, an error must not become a successful empty IPC snapshot. +use super::get_presence; +use crate::app_state::build_app_state; +use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL}; +use tauri::Manager; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[tokio::test] +async fn presence_command_preserves_query_failure_and_successful_absence() { + let _serial = TEST_SERIAL.lock().await; + reset_rate_limit_gate(); + for (status, body) in [ + ("200 OK", "[]"), + ("401 Unauthorized", r#"{"error":"unauthorized"}"#), + ("429 Too Many Requests", r#"{"error":"retry in 1s"}"#), + ( + "500 Internal Server Error", + r#"{"error":"storage unavailable"}"#, + ), + ("200 OK", "not json"), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut buf = [0; 4096]; + let count = stream.read(&mut buf).await.unwrap(); + assert!(count > 0); + request.extend_from_slice(&buf[..count]); + assert!(request.len() < 16384); + if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..end]).to_lowercase(); + let length: usize = headers + .lines() + .find_map(|line| { + line.strip_prefix("content-length:") + .map(|v| v.trim().parse().unwrap()) + }) + .unwrap(); + if request.len() >= end + 4 + length { + break; + } + } + } + let request = String::from_utf8(request).unwrap(); + assert!(request.starts_with("POST /query ")); + assert!(request.to_lowercase().contains("authorization: nostr ")); + assert!(request.contains("20001")); + let response = format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + stream.write_all(response.as_bytes()).await.unwrap(); + }); + let state = build_app_state(); + *state.relay_url_override.lock().unwrap() = Some(format!("ws://{addr}")); + let app = tauri::test::mock_builder() + .manage(state) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let result = tokio::time::timeout( + std::time::Duration::from_secs(5), + get_presence(vec!["a".repeat(64)], app.state()), + ) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), server) + .await + .unwrap() + .unwrap(); + if status == "200 OK" && body == "[]" { + assert_eq!( + serde_json::to_value(result.unwrap()).unwrap(), + serde_json::json!({}) + ); + } else { + assert!( + result.is_err(), + "{status} / {body} must reject, not return Offline: {result:?}" + ); + } + reset_rate_limit_gate(); + } +} + +#[tokio::test] +async fn presence_command_transport_failure_is_not_offline() { + let _serial = TEST_SERIAL.lock().await; + reset_rate_limit_gate(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + let state = build_app_state(); + *state.relay_url_override.lock().unwrap() = Some(format!("ws://{addr}")); + let app = tauri::test::mock_builder() + .manage(state) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let result = get_presence(vec!["a".repeat(64)], app.state()).await; + assert!(result.is_err(), "transport failure must reject: {result:?}"); + // Empty input does not require a relay and remains a genuine empty result. + assert!(get_presence(vec![], app.state()).await.unwrap().is_empty()); +} diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index a4dd4b346d..274760725f 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -205,6 +205,17 @@ with a TypeScript lookup table or an id comparison in a component. select a representative or offer persona Start; a relay persona link cannot borrow a local sibling's management controls. See [the identity contract](../../../../docs/agent-profile-identity.md). + Availability dots read relay presence, never a saved deployment + receipt or runtime status. Failed/disconnected reads are unknown; lifecycle + actions retain their separate routing. Current exact-key Online/Away presence + suppresses Start for an inactive local record without granting Stop authority; + list/profile/member startup guards must not interpret Offline as proof of safe + startup. Deletion also consumes that same exact-key availability reader: + unknown requests shutdown when a channel exists, request failure retains the + record, and only established Offline keeps the intentional no-request path. + Unqueried persona siblings are unknown. No presence state grants deletion or + Stop authority; native local stop-before-remove remains independent. See + [the availability contract](../../../../docs/agent-availability.md). 14. **Thinking effort has two surfaces: a local-only WRITE control and a read-only two-facts DISPLAY.** The write control is `EffortPickerField` (`ui/EffortPickerField.tsx`), a self-contained section component mounted in diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index 8a4a6898cc..aaf10075e0 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -1,10 +1,6 @@ import { sendChannelMessage } from "@/shared/api/tauri"; -import type { - Channel, - ManagedAgent, - PresenceLookup, - RelayAgent, -} from "@/shared/api/types"; +import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types"; +import type { AgentAvailabilityReader } from "./useAgentAvailability"; import { normalizePubkey } from "@/shared/lib/pubkey"; type DeleteManagedAgentInput = { @@ -23,7 +19,7 @@ type ManagedAgentChannelContext = { }; type ManagedAgentActionContext = ManagedAgentChannelContext & { - presenceLookup?: PresenceLookup | null; + getAvailability: AgentAvailabilityReader; }; export type ManagedAgentActionResult = { @@ -31,6 +27,7 @@ export type ManagedAgentActionResult = { noticeMessage?: string; }; +/** Lifecycle action routing only; deployed is a retained receipt, not presence. */ export function isManagedAgentActive(agent: Pick) { return agent.status === "running" || agent.status === "deployed"; } @@ -133,7 +130,8 @@ export async function stopManagedAgentWithRules({ agent.pubkey, ]); return { - noticeMessage: "Shutdown command sent. Agent will stop shortly.", + noticeMessage: + "Shutdown requested. This does not confirm the agent has stopped.", }; } @@ -146,7 +144,7 @@ export async function deleteManagedAgentWithRules({ channels, deleteManagedAgent, preferredChannelId, - presenceLookup, + getAvailability, relayAgents, skipRemoteDeleteConfirm = false, }: { @@ -155,7 +153,7 @@ export async function deleteManagedAgentWithRules({ skipRemoteDeleteConfirm?: boolean; } & ManagedAgentActionContext): Promise { if (agent.backend.type === "provider" && agent.backendAgentId) { - const presence = presenceLookup?.[normalizePubkey(agent.pubkey)]; + const availability = getAvailability(agent.pubkey); const channelId = resolveManagedAgentChannelId(agent, { channels, preferredChannelId, @@ -163,14 +161,19 @@ export async function deleteManagedAgentWithRules({ }); if (channelId) { - if (presence === "online" || presence === "away") { + // Only established Offline preserves the intentional no-request path. + // Unknown is not evidence that shutdown can safely be skipped. + if (availability !== "offline") { await sendChannelMessage(channelId, "!shutdown", undefined, undefined, [ agent.pubkey, ]); if (!skipRemoteDeleteConfirm) { const confirmed = window.confirm( - "Shutdown command sent, but the agent may still be running. " + + (availability === undefined + ? "This agent’s availability is unknown. " + : "") + + "Shutdown requested, but the agent may still be running. " + "Deleting now removes the local record — the remote deployment " + "will be orphaned if shutdown hasn't completed. Continue?", ); @@ -193,7 +196,7 @@ export async function deleteManagedAgentWithRules({ if (!skipRemoteDeleteConfirm) { const confirmed = window.confirm( "This agent is deployed but not in any channel. " + - "Deleting will orphan the remote deployment (it will keep running). Continue?", + "Deleting removes the local management record; the remote deployment may still be running. Continue?", ); if (!confirmed) { return { cancelled: true }; diff --git a/desktop/src/features/agents/lib/managedAgentDeletionAvailability.test.mjs b/desktop/src/features/agents/lib/managedAgentDeletionAvailability.test.mjs new file mode 100644 index 0000000000..894af01fd4 --- /dev/null +++ b/desktop/src/features/agents/lib/managedAgentDeletionAvailability.test.mjs @@ -0,0 +1,469 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +const PK = "a".repeat(64); +const SIBLING = "b".repeat(64); +const agent = { + pubkey: PK, + name: "Remote", + personaId: "persona", + status: "deployed", + backend: { type: "provider", id: "fixture", config: {} }, + backendAgentId: "receipt", +}; +const channel = { id: "channel", name: "agents", memberPubkeys: [PK] }; +const directory = [ + { pubkey: PK, channels: ["agents"], channelIds: ["channel"] }, +]; +let act, + render, + cleanup, + waitFor, + createElement, + QueryClient, + QueryClientProvider; +let useAgentAvailabilityLookup, + useManagedAgentActions, + useProfileAgentDeletion, + CommunitiesProvider; +let deleteManagedAgentWithRules, deleteManagedAgent, relayClient, originals; +let connection, listeners, handlers, commands, confirms, clients; + +before(async () => { + Object.assign(globalThis, { + window: dom.window, + localStorage: dom.window.localStorage, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + }); + dom.window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + commands.push([command, args]); + if (handlers.has(command)) return handlers.get(command)(args); + throw new Error(`Unexpected IPC: ${command}`); + }, + transformCallback: () => 1, + }; + ({ act, render, cleanup, waitFor } = await import("@testing-library/react")); + ({ createElement } = await import("react")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ CommunitiesProvider } = await import( + "../../communities/useCommunities.tsx" + )); + ({ useAgentAvailabilityLookup } = await import("./useAgentAvailability.ts")); + ({ useManagedAgentActions } = await import( + "../ui/useManagedAgentActions.ts" + )); + ({ useProfileAgentDeletion } = await import( + "../../profile/ui/UserProfilePanelDeletion.ts" + )); + ({ deleteManagedAgentWithRules } = await import( + "./managedAgentControlActions.ts" + )); + ({ deleteManagedAgent } = await import("../../../shared/api/tauri.ts")); + ({ relayClient } = await import("../../../shared/api/relayClient.ts")); + originals = { + getConnectionState: relayClient.getConnectionState, + subscribeToConnectionState: relayClient.subscribeToConnectionState, + }; + relayClient.getConnectionState = () => connection; + relayClient.subscribeToConnectionState = (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }; +}); + +afterEach(() => { + cleanup(); + for (const client of clients ?? []) { + client.cancelQueries(); + client.clear(); + } +}); +after(() => { + Object.assign(relayClient, originals); + dom.window.close(); +}); + +function setup() { + clients = []; + commands = []; + confirms = []; + connection = "connected"; + listeners = new Set(); + handlers = new Map([ + ["get_presence", () => ({ [PK]: "online" })], + ["delete_managed_agent", () => null], + ["remove_channel_member", () => null], + ["send_channel_message", () => ({ event_id: "event", created_at: 0 })], + ["list_managed_agents", () => []], + ["get_relay_agents", () => []], + ["list_available_acp_runtimes", () => []], + ["get_channels", () => []], + ["plugin:event|listen", () => 1], + ["plugin:event|unlisten", () => null], + ]); + dom.window.confirm = (copy) => { + confirms.push(copy); + return true; + }; +} + +function mount( + owner, + { agents = [agent], keys = [PK], seedChannels = true } = {}, +) { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + mutations: { retry: false, gcTime: 0 }, + }, + }); + clients.push(client); + client.setQueryData(["managed-agents"], agents); + client.setQueryData(["relay-agents"], directory); + if (seedChannels) client.setQueryData(["channels"], [channel]); + client.setQueryData(["globalAgentConfig"], { env_vars: {} }); + let current; + function AgentsSurface() { + current = useManagedAgentActions(); + return null; + } + function ProfileSurface() { + const availability = useAgentAvailabilityLookup(keys); + const deletion = useProfileAgentDeletion({ + channels: [channel], + managedAgents: agents, + managedAgent: agents[0], + relayAgents: agents.map((row) => ({ + ...directory[0], + pubkey: row.pubkey, + })), + getAvailability: availability.getAvailability, + deleteManagedAgent: ({ pubkey, forceRemoteDelete }) => + deleteManagedAgent(pubkey, forceRemoteDelete), + }); + current = { ...availability, ...deletion }; + return null; + } + const Surface = owner === "agents" ? AgentsSurface : ProfileSurface; + render( + createElement( + QueryClientProvider, + { client }, + createElement(CommunitiesProvider, null, createElement(Surface)), + ), + ); + return { client, current: () => current }; +} + +function effects() { + return commands.filter(([name]) => + [ + "send_channel_message", + "delete_managed_agent", + "remove_channel_member", + ].includes(name), + ); +} + +for (const owner of ["agents", "profile"]) { + for (const scenario of [ + "online", + "away", + "offline", + "missing", + "pending", + "failed-online", + "failed-offline", + "disconnected-online", + "disconnected-offline", + ]) { + test(`${owner} deletion uses resolved ${scenario} at the production hook/IPC boundary`, async () => { + setup(); + const warm = scenario.endsWith("-offline") ? "offline" : "online"; + handlers.set("get_presence", () => { + if (scenario === "pending") return new Promise(() => {}); + if (scenario === "missing") return {}; + return { [PK]: scenario.includes("-") ? warm : scenario }; + }); + const surface = mount(owner); + const key = ["presence", PK]; + if (scenario !== "pending") { + await waitFor(() => + assert.equal(surface.client.getQueryState(key)?.status, "success"), + ); + } + if (scenario.startsWith("failed")) { + handlers.set("get_presence", () => + Promise.reject("relay unreachable: request timed out"), + ); + await act(() => + surface.client.invalidateQueries({ queryKey: key, exact: true }), + ); + assert.equal(surface.client.getQueryState(key).status, "error"); + assert.deepEqual(surface.client.getQueryData(key), { [PK]: warm }); + } + if (scenario.startsWith("disconnected")) { + await act(async () => { + connection = "disconnected"; + for (const listener of listeners) listener(connection); + }); + assert.deepEqual(surface.client.getQueryData(key), { [PK]: warm }); + } + const unknown = scenario.includes("-") || scenario === "pending"; + await waitFor(() => + assert.equal( + surface.current().getAvailability(PK), + unknown ? undefined : scenario === "missing" ? "offline" : scenario, + ), + ); + commands.length = 0; + await act(async () => { + if (owner === "agents") await surface.current().handleDelete(PK); + else await surface.current().deleteManagedAgentRecord(agent); + }); + const shouldShutdown = scenario !== "offline" && scenario !== "missing"; + assert.deepEqual( + effects().map(([name]) => name), + [ + ...(shouldShutdown ? ["send_channel_message"] : []), + "delete_managed_agent", + "remove_channel_member", + ], + ); + if (shouldShutdown) { + assert.equal(effects()[0][1].content, "!shutdown"); + assert.deepEqual(effects()[0][1].mentionPubkeys, [PK]); + } + assert.deepEqual( + effects().find(([name]) => name === "delete_managed_agent")[1], + { + pubkey: PK, + forceRemoteDelete: true, + }, + ); + if (owner === "agents") { + assert.equal(confirms.length, 1); + if (unknown) { + assert.match(confirms[0], /availability is unknown/); + assert.doesNotMatch(confirms[0], /offline/i); + } else if (!shouldShutdown) assert.match(confirms[0], /is offline/); + } else + assert.deepEqual(confirms, [], "profile already obtained confirmation"); + }); + } +} + +test("reader retained across an await sees errors/disconnect, not cached success; unqueried siblings stay unknown", async () => { + setup(); + const surface = mount("profile"); + await waitFor(() => + assert.equal(surface.current().getAvailability(PK), "online"), + ); + const retainedReader = surface.current().getAvailability; + assert.equal(retainedReader(SIBLING), undefined); + handlers.set("get_presence", () => Promise.reject("failed")); + await act(() => + surface.client.invalidateQueries({ queryKey: ["presence", PK] }), + ); + assert.equal(retainedReader(PK), undefined); + await act(async () => + surface.client.setQueryData(["presence", PK], { [PK]: "online" }), + ); + connection = "reconnecting"; // even before the next React connection render + assert.equal(retainedReader(PK), undefined); +}); + +for (const owner of ["agents", "profile"]) { + test(`${owner} unknown shutdown failure preserves record and channel membership`, async () => { + setup(); + handlers.set("get_presence", () => Promise.reject("failed")); + handlers.set("send_channel_message", () => + Promise.reject(new Error("shutdown refused")), + ); + const surface = mount(owner); + await waitFor(() => + assert.equal( + surface.client.getQueryState(["presence", PK])?.status, + "error", + ), + ); + await act(async () => { + if (owner === "agents") await surface.current().handleDelete(PK); + else + await assert.rejects( + surface.current().deleteManagedAgentRecord(agent), + /shutdown refused/, + ); + }); + assert.deepEqual( + effects().map(([name]) => name), + ["send_channel_message"], + ); + assert.deepEqual(confirms, []); + if (owner === "agents") + assert.equal(surface.current().actionErrorMessage, "shutdown refused"); + }); +} + +test("unknown waits for shutdown before confirmation/delete; cancellation retains record", async () => { + setup(); + let release; + handlers.set( + "send_channel_message", + () => + new Promise((resolve) => { + release = resolve; + }), + ); + dom.window.confirm = (copy) => { + confirms.push(copy); + return false; + }; + const operation = deleteManagedAgentWithRules({ + agent, + channels: [channel], + relayAgents: directory, + getAvailability: () => undefined, + deleteManagedAgent: ({ pubkey, forceRemoteDelete }) => + deleteManagedAgent(pubkey, forceRemoteDelete), + }); + await waitFor(() => assert.equal(typeof release, "function")); + assert.deepEqual(confirms, []); + assert.equal(effects().length, 1); + release({ event_id: "event" }); + assert.deepEqual(await operation, { cancelled: true }); + assert.match(confirms[0], /availability is unknown/); + assert.equal(effects().length, 1); +}); + +test("no channel warns without claiming process state; local deletion ignores presence", async () => { + setup(); + const remove = ({ pubkey, forceRemoteDelete }) => + deleteManagedAgent(pubkey, forceRemoteDelete); + await deleteManagedAgentWithRules({ + agent, + channels: [], + relayAgents: [], + getAvailability: () => undefined, + deleteManagedAgent: remove, + }); + assert.match(confirms[0], /may still be running/); + assert.doesNotMatch(confirms[0], /will keep running|offline/i); + assert.deepEqual( + effects().map(([name]) => name), + ["delete_managed_agent"], + ); + commands.length = 0; + confirms.length = 0; + await deleteManagedAgentWithRules({ + agent: { ...agent, backend: { type: "local" } }, + channels: [], + relayAgents: [], + getAvailability: () => { + throw new Error("must not consult presence"); + }, + deleteManagedAgent: remove, + }); + assert.deepEqual(effects(), [ + ["delete_managed_agent", { pubkey: PK, forceRemoteDelete: null }], + ]); + assert.deepEqual(confirms, []); +}); + +test("Agents deletion rechecks availability after channel discovery, not the click-time snapshot", async () => { + setup(); + let releaseChannels; + handlers.set( + "get_channels", + () => + new Promise((resolve) => { + releaseChannels = resolve; + }), + ); + handlers.set("get_presence", () => ({ [PK]: "offline" })); + const surface = mount("agents", { seedChannels: false }); + await waitFor(() => + assert.equal(surface.current().getAvailability(PK), "offline"), + ); + let operation; + await act(async () => { + operation = surface.current().handleDelete(PK); + }); + await waitFor(() => assert.equal(typeof releaseChannels, "function")); + handlers.set("get_presence", () => Promise.reject("failed")); + await act(() => + surface.client.invalidateQueries({ queryKey: ["presence", PK] }), + ); + assert.deepEqual(effects(), []); + await act(async () => { + releaseChannels({ hash: "empty", channels: [], last_messages: {} }); + await operation; + }); + assert.deepEqual( + effects().map(([name]) => name), + ["send_channel_message", "delete_managed_agent", "remove_channel_member"], + ); + assert.match(confirms[0], /availability is unknown/); +}); + +test("profile persona deletion cannot infer Offline for an unqueried sibling", async () => { + setup(); + handlers.set("get_presence", () => ({})); + const surface = mount("profile", { + agents: [agent, { ...agent, pubkey: SIBLING }], + keys: [PK], + }); + await waitFor(() => + assert.equal(surface.current().getAvailability(PK), "offline"), + ); + assert.equal(surface.current().getAvailability(SIBLING), undefined); + await act(() => + surface.current().deleteManagedAgentsForPersona({ id: "persona" }), + ); + const requests = effects().filter( + ([name]) => name === "send_channel_message", + ); + assert.equal(requests.length, 1); + assert.deepEqual(requests[0][1].mentionPubkeys, [SIBLING]); + assert.match(confirms[0], /is offline/); + assert.match(confirms[1], /availability is unknown/); +}); + +test("successful cached snapshot remains authoritative during refetch; only settled error revokes it", async () => { + setup(); + const surface = mount("profile"); + await waitFor(() => + assert.equal(surface.current().getAvailability(PK), "online"), + ); + let rejectRead; + handlers.set( + "get_presence", + () => + new Promise((_, reject) => { + rejectRead = reject; + }), + ); + let refresh; + await act(async () => { + refresh = surface.client.invalidateQueries({ queryKey: ["presence", PK] }); + }); + assert.equal(surface.current().getAvailability(PK), "online"); + await act(async () => { + rejectRead("failed"); + await refresh; + }); + assert.equal(surface.current().getAvailability(PK), undefined); +}); diff --git a/desktop/src/features/agents/lib/useAgentAvailability.test.mjs b/desktop/src/features/agents/lib/useAgentAvailability.test.mjs new file mode 100644 index 0000000000..f9b2df050a --- /dev/null +++ b/desktop/src/features/agents/lib/useAgentAvailability.test.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { resolveAgentAvailability } from "./useAgentAvailability.ts"; +import { + getManagedAgentPrimaryActionLabel, + isManagedAgentActive, +} from "./managedAgentControlActions.ts"; +import { AgentRuntimeAvatarControl } from "../ui/AgentRuntimeAvatarControl.tsx"; + +const deployed = { + status: "deployed", + backend: { type: "provider", id: "fixture" }, + backendAgentId: "retained-receipt", +}; + +for (const presence of ["online", "away", "offline", undefined]) { + test(`retained deployment receipt does not supply availability (${presence})`, () => { + const availability = resolveAgentAvailability(presence, true, true); + assert.equal(availability, presence ?? "offline"); + // Controls retain their existing routing. Offline is not permission to + // spawn a second body, nor proof that a shutdown message succeeded. + assert.equal(isManagedAgentActive(deployed), true); + assert.equal(getManagedAgentPrimaryActionLabel(deployed), "Shutdown"); + const html = renderToStaticMarkup( + createElement(AgentRuntimeAvatarControl, { + activeTestId: "active", + isActive: true, + availability, + isStarting: false, + label: "Agent", + startTestId: "start", + onStart() {}, + }), + ); + assert.doesNotMatch(html, /is running/); + assert.match( + html, + new RegExp( + `Agent: ${availability[0].toUpperCase()}${availability.slice(1)}`, + ), + ); + assert.equal(html.includes("bg-emerald-500"), availability === "online"); + assert.doesNotMatch(html, /data-testid="start"/); + }); +} + +for (const [loaded, connected] of [ + [false, true], + [true, false], + [false, false], +]) { + test(`unavailable presence is unknown, not cached online (${loaded}, ${connected})`, () => { + const availability = resolveAgentAvailability("online", loaded, connected); + assert.equal(availability, undefined); + const html = renderToStaticMarkup( + createElement(AgentRuntimeAvatarControl, { + activeTestId: "active", + isActive: true, + availability, + isStarting: false, + label: "Agent", + startTestId: "start", + onStart() {}, + }), + ); + assert.match(html, /Availability unknown/); + assert.doesNotMatch(html, /bg-emerald-500|is running/); + }); +} + +for (const lifecycle of ["running", "stopped"]) { + test(`local ${lifecycle} controls remain independent of online presence`, () => { + const agent = { status: lifecycle, backend: { type: "local" } }; + const isActive = isManagedAgentActive(agent); + assert.equal( + getManagedAgentPrimaryActionLabel(agent), + isActive ? "Stop" : "Start agent", + ); + const html = renderToStaticMarkup( + createElement(AgentRuntimeAvatarControl, { + activeTestId: "active", + isActive, + availability: "online", + isStarting: false, + label: "Local Agent", + startTestId: "start", + onStart() {}, + }), + ); + assert.equal(html.includes('data-testid="start"'), false); + assert.equal(html.includes('data-testid="active"'), true); + }); +} + +for (const availability of ["online", "away"]) { + test(`stale restart and runtime error cannot hide stopped ${availability} presence`, () => { + const html = renderToStaticMarkup( + createElement(AgentRuntimeAvatarControl, { + activeTestId: "active", + startTestId: "start", + errorTestId: "error", + isActive: false, + isStarting: false, + requiresRestart: true, + errorLabel: "Previous startup failed", + availability, + label: "Agent", + onStart() {}, + }), + ); + assert.match(html, /data-testid="active"/); + assert.doesNotMatch( + html, + /data-testid="start"|data-testid="error"|