diff --git a/crates/buzz-agent/tests/common/mod.rs b/crates/buzz-agent/tests/common/mod.rs index 02bdc3bc0ef..d45fac4985d 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 edee9090d84..fd5042a1167 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 14cab5c63b8..9a6c7266ffe 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -177,6 +177,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 ef67fac5709..da93af673de 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 00000000000..612e453273f --- /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 a4dd4b346d2..274760725f7 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 8a4a6898cce..aaf10075e0d 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 00000000000..894af01fd4b --- /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 00000000000..f9b2df050a4 --- /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"|