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/lib/useAgentAvailability.ts b/desktop/src/features/agents/lib/useAgentAvailability.ts index 044bab60a2..6cf238ed56 100644 --- a/desktop/src/features/agents/lib/useAgentAvailability.ts +++ b/desktop/src/features/agents/lib/useAgentAvailability.ts @@ -1,3 +1,4 @@ +import * as React from "react"; import { usePresenceQuery } from "@/features/presence/hooks"; import type { PresenceStatus } from "@/shared/api/types"; import { useRelayConnection } from "@/shared/api/useRelayConnection"; @@ -27,14 +28,36 @@ export function agentPresenceStartBlockReason( : undefined; } -/** Share the existing presence query/subscription; no separate status cache. */ -export function useAgentAvailability(pubkey: string | null | undefined) { - const query = usePresenceQuery(pubkey ? [pubkey] : []); +/** Read availability from the surface-owned snapshot, not per-row polling. */ +export type AgentAvailabilityReader = ( + pubkey: string | null | undefined, +) => PresenceStatus | undefined; + +/** One query/connection observer for a surface's cards and lifecycle actions. */ +export function useAgentAvailabilityLookup( + pubkeys: string[], + options?: { enabled?: boolean }, +) { + const query = usePresenceQuery(pubkeys, options); const connection = useRelayConnection(); - const status = resolveAgentAvailability( - pubkey ? query.data?.[normalizePubkey(pubkey)] : undefined, - query.isSuccess, - connection === "connected", + const getAvailability: AgentAvailabilityReader = React.useCallback( + (pubkey) => + pubkey + ? resolveAgentAvailability( + query.data?.[normalizePubkey(pubkey)], + query.isSuccess, + connection === "connected", + ) + : undefined, + [query.data, query.isSuccess, connection], ); - return { query, status }; + return { query, getAvailability }; +} + +/** Single-identity surfaces use the same authority as aggregate surfaces. */ +export function useAgentAvailability(pubkey: string | null | undefined) { + const { query, getAvailability } = useAgentAvailabilityLookup( + pubkey ? [pubkey] : [], + ); + return { query, status: getAvailability(pubkey) }; } diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 22b5326223..a31fec44c4 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -219,6 +219,7 @@ export function AgentsView() { />
)} agent={profileAgent} + getAvailability={getAvailability} defaultModel={defaultModel} isBestie={profileAgent?.pubkey.toLowerCase() === bestiePubkey} key={group.persona.id} @@ -180,6 +183,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { void; onStartPersona: (persona: AgentPersona) => void; }) { - const { status: availability } = useAgentAvailability(agent?.pubkey); + const availability = getAvailability(agent?.pubkey); const title = persona.displayName; // Card face second line: the authored description when one exists; // otherwise fall back to the model label as before. @@ -364,6 +371,7 @@ function StandaloneAgentCard({ agent, isBestie, defaultModel, + getAvailability, restartingAgentPubkey, startingAgentPubkey, onOpenAgentProfile, @@ -373,6 +381,7 @@ function StandaloneAgentCard({ agent: ManagedAgent; isBestie: boolean; defaultModel: string; + getAvailability: AgentAvailabilityReader; restartingAgentPubkey: string | null; startingAgentPubkey: string | null; onOpenAgentProfile: ( @@ -382,7 +391,7 @@ function StandaloneAgentCard({ onRestartAgent: (pubkey: string) => void; onStartAgent: (pubkey: string) => void; }) { - const { status: availability } = useAgentAvailability(agent.pubkey); + const availability = getAvailability(agent.pubkey); const title = agent.name; const profileQuery = useUserProfileQuery(agent.pubkey); const friendlyError = friendlyAgentLastError( @@ -478,6 +487,7 @@ function CollapsibleAgentGroup({ bestiePubkey, collapsed, defaultModel, + getAvailability, restartingAgentPubkey, startingAgentPubkey, onToggle, @@ -491,6 +501,7 @@ function CollapsibleAgentGroup({ bestiePubkey: string | null; collapsed: ReadonlySet; defaultModel: string; + getAvailability: AgentAvailabilityReader; restartingAgentPubkey: string | null; startingAgentPubkey: string | null; onToggle: (key: string) => void; @@ -522,6 +533,7 @@ function CollapsibleAgentGroup({ {agents.map((agent) => ( a.pubkey), + ); + return createElement(UnifiedAgentsSection, { ...props, getAvailability }); +} + function renderSection(props) { const client = new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0 } }, + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { gcTime: 0 }, + }, }); clients.push(client); return render( createElement( QueryClientProvider, { client }, - createElement(UnifiedAgentsSection, props), + createElement(Surface, props), ), ); } @@ -157,6 +168,9 @@ before(async () => { "@tanstack/react-query" )); ({ UnifiedAgentsSection } = await import("./UnifiedAgentsSection.tsx")); + ({ useAgentAvailabilityLookup } = await import( + "../lib/useAgentAvailability.ts" + )); }); afterEach(() => { @@ -379,3 +393,214 @@ for (const kind of ["persona", "custom", "unknown"]) { } }); } + +test("N cards share a snapshot, one poll, failure recovery and live subscription lifecycle", async (t) => { + installFailOpenIpc(); + const { relayClient } = await import("../../../shared/api/relayClient.ts"); + const { usePresenceSubscription, useSetPresenceMutation } = await import( + "../../presence/hooks.ts" + ); + const original = { + getConnectionState: relayClient.getConnectionState, + subscribeToConnectionState: relayClient.subscribeToConnectionState, + subscribeToReconnects: relayClient.subscribeToReconnects, + subscribeLive: relayClient.subscribeLive, + sendPresence: relayClient.sendPresence, + }; + const subscriptions = []; + const requests = []; + let fail = false; + let finishSnapshot; + ipcHandlers.set("get_presence", ({ pubkeys }) => { + requests.push(pubkeys); + if (fail) return Promise.reject("relay unreachable: request timed out"); + return new Promise((resolve) => { + finishSnapshot = resolve; + }); + }); + relayClient.sendPresence = async () => {}; + relayClient.getConnectionState = () => "connected"; + relayClient.subscribeToConnectionState = () => () => {}; + relayClient.subscribeToReconnects = () => () => {}; + relayClient.subscribeLive = async (filter, onEvent, onReady) => { + const sub = { filter, onEvent, closed: false }; + subscriptions.push(sub); + onReady("eose"); + return async () => { + sub.closed = true; + }; + }; + Object.defineProperty(dom.window.document, "visibilityState", { + configurable: true, + value: "visible", + }); + const originalFocus = dom.window.document.hasFocus; + dom.window.document.hasFocus = () => true; + t.mock.timers.enable({ apis: ["setInterval"] }); + let setPresence; + function SubscribedSurface(props) { + setPresence = useSetPresenceMutation(SELF_PK); + usePresenceSubscription(); + return createElement(Surface, props); + } + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { gcTime: 0 }, + }, + }); + clients.push(client); + const props = baseProps({ + agents: [ + agent({ pubkey: LIVE_PK, status: "running" }), + agent({ pubkey: ARCHIVED_PK, personaId: null, status: "running" }), + agent({ pubkey: SELF_PK, personaId: "missing", status: "running" }), + ], + personas: [persona()], + }); + const tree = (next) => + createElement( + QueryClientProvider, + { client }, + createElement(SubscribedSurface, next), + ); + const settle = async () => + act(async () => { + await new Promise((r) => setTimeout(r, 120)); + }); + try { + let view; + await act(async () => { + view = render(tree(props)); + }); + assert.equal( + requests.length, + 1, + "one in-flight request for persona/custom/unknown rows", + ); + await act(async () => { + view.rerender(tree({ ...props, agents: [...props.agents].reverse() })); + }); + assert.equal( + requests.length, + 1, + "reordering while the snapshot is pending does not refetch", + ); + await act(async () => { + finishSnapshot({}); + }); + await settle(); + assert.deepEqual(requests[0], [ARCHIVED_PK, LIVE_PK, SELF_PK]); + assert.equal(subscriptions.length, 1); + assert.deepEqual(subscriptions[0].filter.authors, requests[0]); + assert.equal( + client + .getQueryCache() + .find({ queryKey: ["presence", ...requests[0]] }) + .getObserversCount(), + 1, + ); + await act(async () => { + subscriptions[0].onEvent({ pubkey: LIVE_PK, content: "away" }); + }); + await settle(); + assert.match( + screen + .getByTestId(`agent-runtime-active-${LIVE_PK}`) + .getAttribute("aria-label"), + /Away$/, + ); + assert.match( + screen + .getByTestId(`agent-runtime-active-${ARCHIVED_PK}`) + .getAttribute("aria-label"), + /Offline$/, + ); + assert.equal( + requests.length, + 1, + "live exact-key update makes no snapshot requests", + ); + fail = true; + await act(async () => { + t.mock.timers.tick(60000); + }); + await settle(); + assert.equal(requests.length, 2, "one backstop poll, not one per card"); + for (const { pubkey } of props.agents) { + assert.match( + screen + .getByTestId(`agent-runtime-active-${pubkey}`) + .getAttribute("aria-label"), + /Availability unknown$/, + ); + } + await act(async () => { + subscriptions[0].onEvent({ pubkey: ARCHIVED_PK, content: "online" }); + }); + await settle(); + for (const { pubkey } of props.agents) { + assert.match( + screen + .getByTestId(`agent-runtime-active-${pubkey}`) + .getAttribute("aria-label"), + /Availability unknown$/, + "one live author must not resurrect a failed aggregate's cached siblings", + ); + } + await act(async () => { + await setPresence.mutateAsync("online"); + }); + await settle(); + for (const { pubkey } of props.agents) { + assert.match( + screen + .getByTestId(`agent-runtime-active-${pubkey}`) + .getAttribute("aria-label"), + /Availability unknown$/, + "a successful self heartbeat must not heal a failed aggregate snapshot", + ); + } + fail = false; + await act(async () => { + t.mock.timers.tick(60000); + }); + await act(async () => { + finishSnapshot({}); + }); + await settle(); + assert.equal(requests.length, 3); + assert.match( + screen + .getByTestId(`agent-runtime-active-${LIVE_PK}`) + .getAttribute("aria-label"), + /Offline$/, + ); + await act(async () => { + view.rerender(tree({ ...props, agents: [props.agents[0]] })); + }); + await act(async () => { + finishSnapshot({}); + }); + await settle(); + assert.deepEqual(subscriptions[1].filter.authors, [LIVE_PK]); + assert.equal(subscriptions[0].closed, true); + await act(async () => { + view.unmount(); + }); + assert.equal( + subscriptions[1].closed, + true, + "last surface removes its live subscription", + ); + const count = requests.length; + await act(async () => { + t.mock.timers.tick(120000); + }); + assert.equal(requests.length, count, "unmounted surfaces do not poll"); + } finally { + t.mock.timers.reset(); + dom.window.document.hasFocus = originalFocus; + Object.assign(relayClient, original); + } +}); diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index b9c680b6aa..c34cfbea4a 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -16,13 +16,11 @@ import { } from "@/features/agents/hooks"; import { agentPresenceStartBlockReason, - resolveAgentAvailability, + useAgentAvailabilityLookup, } from "../lib/useAgentAvailability"; -import { useRelayConnection } from "@/shared/api/useRelayConnection"; import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { useChannelsQuery } from "@/features/channels/hooks"; import { invalidateChannelMembersRosters } from "@/features/channels/rosterFreshness"; -import { usePresenceQuery } from "@/features/presence/hooks"; import type { AgentPersona, Channel, ManagedAgent } from "@/shared/api/types"; import { removeChannelMember } from "@/shared/api/tauri"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -106,17 +104,13 @@ export function useManagedAgentActions() { [managedAgents], ); - const managedPresenceQuery = usePresenceQuery(managedPubkeyList); - const relayConnection = useRelayConnection(); + const { query: managedPresenceQuery, getAvailability } = + useAgentAvailabilityLookup(managedPubkeyList); function assertStartNotBlockedByPresence(agent: ManagedAgent) { const reason = agentPresenceStartBlockReason( isManagedAgentActive(agent), - resolveAgentAvailability( - managedPresenceQuery.data?.[normalizePubkey(agent.pubkey)], - managedPresenceQuery.isSuccess, - relayConnection === "connected", - ), + getAvailability(agent.pubkey), ); if (reason) throw new Error(reason); } @@ -449,6 +443,7 @@ export function useManagedAgentActions() { managedAgentsQuery, managedAgentLogQuery, managedPresenceQuery, + getAvailability, managedAgents, managedPubkeys, channelIdToName, diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index e6121c25dd..4ff9493bb7 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -31,7 +31,7 @@ import { } from "@/features/profile/hooks"; import { formatOwnerLabel } from "@/features/profile/lib/identity"; import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch"; -import { usePresenceQuery } from "@/features/presence/hooks"; +import { useAgentAvailabilityLookup } from "@/features/agents/lib/useAgentAvailability"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; import { useIdentityQuery } from "@/shared/api/hooks"; import { changeChannelMemberRole } from "@/shared/api/tauri"; @@ -198,7 +198,7 @@ export function MembersSidebar({ () => rawMembers.map((member) => member.pubkey), [rawMembers], ); - const memberPresenceQuery = usePresenceQuery(allMemberPubkeys, { + const { getAvailability } = useAgentAvailabilityLookup(allMemberPubkeys, { enabled: open && rawMembers.length > 0, }); const memberProfilesQuery = useUsersBatchQuery(allMemberPubkeys, { @@ -515,6 +515,7 @@ export function MembersSidebar({ handleRemoveMember, isActionPending, } = useMembersSidebarActions({ + getAvailability, channelId, controllableManagedBots, removableManagedBots, @@ -682,9 +683,7 @@ export function MembersSidebar({ : undefined } pairAction={pairAction} - presenceStatus={ - memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null - } + presenceStatus={getAvailability(member.pubkey)} profileAvatarUrl={memberProfile?.avatarUrl ?? null} showOtherSetupMarker={showOtherSetupMarker} viewerIsOwner={viewerIsOwner} diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 00c9ee9a37..1a1ceb8172 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -1,7 +1,4 @@ -import { - agentPresenceStartBlockReason, - useAgentAvailability, -} from "@/features/agents/lib/useAgentAvailability"; +import { agentPresenceStartBlockReason } from "@/features/agents/lib/useAgentAvailability"; import { Activity, Ban, @@ -297,6 +294,7 @@ export function MembersSidebarMemberCard({ onUntimeout={onUntimeout} onViewActivity={onViewActivity} pairAction={pairAction} + availability={presenceStatus ?? undefined} /> ) : null}
@@ -306,6 +304,7 @@ export function MembersSidebarMemberCard({ const PEOPLE_ROLES = ["admin", "member", "guest"] as const; function MemberActionsMenu({ + availability, canChangeRole, canModerateMember, canRemoveMember, @@ -327,6 +326,7 @@ function MemberActionsMenu({ pairAction, }: { canChangeRole: boolean; + availability: PresenceStatus | undefined; canModerateMember: boolean; canRemoveMember: boolean; canViewActivity: boolean; @@ -351,7 +351,6 @@ function MemberActionsMenu({ const isBanned = moderationState?.banned ?? false; const isTimedOut = moderationState?.timedOut ?? false; - const { status: availability } = useAgentAvailability(managedAgent?.pubkey); const startBlockReason = managedAgent ? agentPresenceStartBlockReason( pairAction ? pairAction === "stop" : isManagedAgentActive(managedAgent), diff --git a/desktop/src/features/channels/ui/useMembersSidebarActions.ts b/desktop/src/features/channels/ui/useMembersSidebarActions.ts index 4841232b2b..ced4836d6b 100644 --- a/desktop/src/features/channels/ui/useMembersSidebarActions.ts +++ b/desktop/src/features/channels/ui/useMembersSidebarActions.ts @@ -1,10 +1,7 @@ import { agentPresenceStartBlockReason, - resolveAgentAvailability, + type AgentAvailabilityReader, } from "@/features/agents/lib/useAgentAvailability"; -import { usePresenceQuery } from "@/features/presence/hooks"; -import { useRelayConnection } from "@/shared/api/useRelayConnection"; -import { normalizePubkey } from "@/shared/lib/pubkey"; import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; @@ -36,6 +33,7 @@ import type { type UseMembersSidebarActionsOptions = { channelId: string | null; + getAvailability: AgentAvailabilityReader; controllableManagedBots: readonly ManagedAgent[]; removableManagedBots: readonly ManagedAgent[]; currentPubkey?: string; @@ -56,6 +54,7 @@ const EMPTY_AGENT_CONTEXT = { export function useMembersSidebarActions({ channelId, + getAvailability, controllableManagedBots, removableManagedBots, currentPubkey, @@ -63,21 +62,13 @@ export function useMembersSidebarActions({ relayUrl, }: UseMembersSidebarActionsOptions) { const queryClient = useQueryClient(); - const presenceQuery = usePresenceQuery( - controllableManagedBots.map((agent) => agent.pubkey), - ); - const connection = useRelayConnection(); function assertStartNotBlockedByPresence( agent: ManagedAgent, lifecycleActive: boolean, ) { const reason = agentPresenceStartBlockReason( lifecycleActive, - resolveAgentAvailability( - presenceQuery.data?.[normalizePubkey(agent.pubkey)], - presenceQuery.isSuccess, - connection === "connected", - ), + getAvailability(agent.pubkey), ); if (reason) throw new Error(reason); } diff --git a/desktop/src/features/presence/hooks.ts b/desktop/src/features/presence/hooks.ts index f6718a6a5c..63d5902b27 100644 --- a/desktop/src/features/presence/hooks.ts +++ b/desktop/src/features/presence/hooks.ts @@ -135,7 +135,10 @@ export function usePresenceSubscription() { queryClient.setQueriesData( { queryKey: ["presence"], + // A single live author cannot heal a failed aggregate snapshot: + // setQueriesData would mark every cached sibling successful again. predicate: (query) => + query.state.status === "success" && presenceQueryWantsPubkey(query.queryKey, pubkey), }, (old) => mergePresenceUpdate(old, pubkey, status), @@ -206,7 +209,9 @@ export function useSetPresenceMutation(pubkey?: string) { queryClient.setQueriesData( { queryKey: ["presence"], + // A successful self heartbeat is not a fresh roster snapshot. predicate: (query) => + query.state.status === "success" && presenceQueryWantsPubkey(query.queryKey, normalizedPubkey), }, (old) => mergePresenceUpdate(old, normalizedPubkey, status), diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index e7764c1569..e6dc20e414 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -449,6 +449,7 @@ export function UserProfilePanel({ const { handleAgentPrimaryAction, handleAgentRestart } = useAgentLifecycleActions({ + availability: presenceStatus, channels: channelsQuery.data, managedAgent, relayAgents: relayAgentsQuery.data, diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index d19a5fa19d..40d6bc3507 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -18,7 +18,7 @@ import { ownsAuthorAgent, } from "@/features/profile/lib/identity"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; -import { usePresenceQuery } from "@/features/presence/hooks"; +import { useAgentAvailability } from "@/features/agents/lib/useAgentAvailability"; import { useUserStatusQuery } from "@/features/user-status/hooks"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { ProfileAvatarWithStatus } from "@/features/profile/ui/ProfileAvatarWithStatus"; @@ -262,7 +262,7 @@ function UserProfilePopoverBody({ const usersBatchQuery = useUsersBatchQuery([pubkey]); const relayAgentsQuery = useRelayAgentsQuery(); const managedAgentsQuery = useManagedAgentsQuery(); - const presenceQuery = usePresenceQuery([pubkey]); + const { status: presenceStatus } = useAgentAvailability(pubkey); const userStatusQuery = useUserStatusQuery([pubkey]); const { canOpenAgentActivity, openAgentActivity } = useOpenAgentActivity(); @@ -331,7 +331,6 @@ function UserProfilePopoverBody({ showHumanProfileActions || showMessageAction || showHuddleAction; const canViewActivity = isBotProfile && viewerIsOwner && canOpenAgentActivity(pubkey); - const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()]; const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()]; const userStatusText = userStatus?.text.trim() ?? ""; const hasUserStatus = Boolean(userStatusText || userStatus?.emoji); diff --git a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts index 78705584a3..745df71768 100644 --- a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts +++ b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts @@ -7,27 +7,30 @@ import { startManagedAgentWithRules, stopManagedAgentWithRules, } from "@/features/agents/lib/managedAgentControlActions"; -import { - agentPresenceStartBlockReason, - useAgentAvailability, -} from "@/features/agents/lib/useAgentAvailability"; +import { agentPresenceStartBlockReason } from "@/features/agents/lib/useAgentAvailability"; import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks"; -import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types"; +import type { + Channel, + ManagedAgent, + RelayAgent, + PresenceStatus, +} from "@/shared/api/types"; export function useAgentLifecycleActions({ + availability, channels, managedAgent, relayAgents, startManagedAgent, stopManagedAgent, }: { + availability: PresenceStatus | undefined; channels: readonly Channel[] | undefined; managedAgent: ManagedAgent | undefined; relayAgents: readonly RelayAgent[] | undefined; startManagedAgent: (pubkey: string) => Promise; stopManagedAgent: (pubkey: string) => Promise; }) { - const { status: availability } = useAgentAvailability(managedAgent?.pubkey); const handleAgentPrimaryAction = React.useCallback(async () => { if (!managedAgent) return; diff --git a/desktop/tests/e2e/agent-availability.spec.ts b/desktop/tests/e2e/agent-availability.spec.ts index 4dec31c344..aa3a3b6ae9 100644 --- a/desktop/tests/e2e/agent-availability.spec.ts +++ b/desktop/tests/e2e/agent-availability.spec.ts @@ -146,7 +146,7 @@ test("missing snapshot is offline but failed reads cannot reuse cached online", w.__TAURI_INTERNALS__.invoke = async (command, payload, options) => { if (command === "get_presence") { if (w.__AVAILABILITY_RESPONSE__ === "error") - throw new Error("Presence fixture: relay read failed"); + throw "relay unreachable: request timed out"; // Native Result::Err IPC shape if (w.__AVAILABILITY_RESPONSE__ === "missing") return {}; if (w.__AVAILABILITY_RESPONSE__ === "online") { return Object.fromEntries( @@ -348,3 +348,98 @@ test("member menu cannot start a present stopped local runtime", async ({ ) .toBe(1); }); + +for (const surface of ["agents", "members"] as const) { + test(`${surface}: three rows and their actions share one presence request`, async ({ + page, + }) => { + const keys = [LOCAL, "e".repeat(64), "f".repeat(64)]; + await installMockBridge(page, { + managedAgents: keys.map((pubkey, index) => ({ + pubkey, + name: `Shared snapshot ${index}`, + status: "running" as const, + channelNames: ["agents"], + })), + }); + await page.goto(surface === "agents" ? "/#/agents" : "/"); + if (surface === "members") { + await page.getByTestId("channel-agents").click(); + await page.getByTestId("channel-members-trigger").click(); + } + for (const pubkey of keys) { + await expect( + page.getByTestId( + surface === "agents" + ? `managed-agent-${pubkey}` + : `sidebar-member-${pubkey}`, + ), + ).toBeVisible(); + } + // Count requests at the actual IPC boundary. Refresh every active presence + // observer so a hidden per-row/action query cannot escape the assertion. + const calls = await page.evaluate(async (keys) => { + const w = window as typeof window & { + __TAURI_INTERNALS__: { + invoke: ( + command: string, + payload: unknown, + options: unknown, + ) => Promise; + }; + __BUZZ_E2E_QUERY_CLIENT__: { + invalidateQueries: (filter: { queryKey: string[] }) => Promise; + }; + }; + const calls: string[][] = []; + const original = w.__TAURI_INTERNALS__.invoke; + w.__TAURI_INTERNALS__.invoke = (command, payload, options) => { + if (command === "get_presence") { + const pubkeys = (payload as { pubkeys: string[] }).pubkeys; + if (pubkeys.some((key) => keys.includes(key))) calls.push(pubkeys); + } + return original(command, payload, options); + }; + try { + await w.__BUZZ_E2E_QUERY_CLIENT__.invalidateQueries({ + queryKey: ["presence"], + }); + } finally { + w.__TAURI_INTERNALS__.invoke = original; + } + return calls; + }, keys); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual(expect.arrayContaining(keys)); + await expect + .poll(() => + page.evaluate(() => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "agents", + kind: 20001, + }), + ), + ) + .toBe(true); + await page.evaluate( + (pubkey) => + window.__BUZZ_E2E_EMIT_MOCK_PRESENCE__?.({ pubkey, status: "away" }), + LOCAL, + ); + if (surface === "agents") { + await expect( + page.getByTestId(`agent-runtime-active-${LOCAL}`), + ).toHaveAttribute("aria-label", "Shared snapshot 0: Away"); + await expect( + page.getByTestId(`agent-runtime-active-${keys[1]}`), + ).toHaveAttribute("aria-label", "Shared snapshot 1: Offline"); + } else { + await expect( + page.getByTestId(`sidebar-member-presence-${LOCAL}`).locator("span"), + ).toHaveClass(/bg-amber-500/); + await expect( + page.getByTestId(`sidebar-member-presence-${keys[1]}`).locator("span"), + ).toHaveClass(/bg-muted-foreground/); + } + }); +} diff --git a/docs/agent-availability.md b/docs/agent-availability.md index 134fe7afc5..b24e1c54d7 100644 --- a/docs/agent-availability.md +++ b/docs/agent-availability.md @@ -7,6 +7,14 @@ Offline. A pending/failed read or disconnected relay means unknown, including when the cache previously contained Online. Online and Away require presence. Agents cards and profiles share the existing presence query and live subscription; +the Agents action owner queries its full managed set once and passes its reader +to persona/custom/unknown cards. Members owns one full-roster query shared by +rows, menus and bulk actions. The profile's actions use its existing snapshot, +and its popover also preserves failed/disconnected reads as unknown. Native +query errors reject the IPC call rather than fabricating an empty snapshot. +A live update or self heartbeat cannot heal a failed aggregate snapshot by +marking cached siblings successful; those reads remain unknown until a +successful snapshot retry. No per-row polling is needed, and there is no second availability cache or substrate poller. Lifecycle controls remain separate: a deployed provider agent still offers Shutdown while offline. Shutdown sends a request, not a confirmed termination, and absence of presence @@ -28,6 +36,15 @@ is introduced. Runtime details remain accessible through the card/profile. ## Regression evidence +- `desktop/src-tauri/src/commands/profile_presence_tests.rs`: actual command + against loopback HTTP; empty success versus auth/rate-limit/storage/malformed + response and transport failure, with authenticated kind-20001 requests. +- `UnifiedAgentsSectionCardTarget.test.mjs`: three card types use one request + and one polling observer, reordered while the snapshot is in flight; exact-key + live updates, failed warm-cache poll, retry, changed author set and unmount. +- `agent-availability.spec.ts`: real Agents/Members surfaces request-count check; + failure uses the native string rejection shape, not a fabricated success. + - `desktop/src/features/agents/lib/useAgentAvailability.test.mjs`: successful, missing, unavailable, and disconnected presence; lifecycle routing and badges. - `desktop/tests/e2e/agent-availability.spec.ts`: deployed/offline profile and