fix(desktop): preserve failed presence and share surface snapshots

Propagate native query errors; share aggregate presence with cards, menus and lifecycle actions. Keep failed aggregate siblings unknown through live/self updates until successful snapshot retry. Add native command, mounted lifecycle and real-surface request-count regressions.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-02 11:34:01 -04:00
parent c8fd2ddb69
commit fa276f6f3f
16 changed files with 531 additions and 60 deletions

View File

@@ -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<String, (u64, PresenceStatus)> = 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;

View File

@@ -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());
}

View File

@@ -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) };
}

View File

@@ -219,6 +219,7 @@ export function AgentsView() {
/>
<div className="flex flex-col gap-8">
<UnifiedAgentsSection
getAvailability={agents.getAvailability}
defaultModel={inheritedDefaults.model.value}
actionErrorMessage={agents.actionErrorMessage}
actionNoticeMessage={agents.actionNoticeMessage}

View File

@@ -8,7 +8,7 @@ import {
import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModelLabel";
import { effectiveAgentDescription } from "@/features/agents/lib/agentDescription";
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
import { useAgentAvailability } from "@/features/agents/lib/useAgentAvailability";
import type { AgentAvailabilityReader } from "@/features/agents/lib/useAgentAvailability";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
@@ -30,6 +30,7 @@ import { buildUnifiedGroups } from "./unifiedAgentGroups";
type UnifiedAgentsSectionProps = {
defaultModel: string;
getAvailability: AgentAvailabilityReader;
actionErrorMessage: string | null;
actionNoticeMessage: string | null;
agents: ManagedAgent[];
@@ -75,6 +76,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
actionErrorMessage,
actionNoticeMessage,
defaultModel,
getAvailability,
agents,
agentsError,
isActionPending,
@@ -159,6 +161,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
/>
)}
agent={profileAgent}
getAvailability={getAvailability}
defaultModel={defaultModel}
isBestie={profileAgent?.pubkey.toLowerCase() === bestiePubkey}
key={group.persona.id}
@@ -180,6 +183,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
<CollapsibleAgentGroup
agents={unknown}
collapsed={collapsed}
getAvailability={getAvailability}
defaultModel={defaultModel}
groupKey="__unknown__"
bestiePubkey={bestiePubkey}
@@ -196,6 +200,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
<CollapsibleAgentGroup
agents={ungrouped}
collapsed={collapsed}
getAvailability={getAvailability}
defaultModel={defaultModel}
groupKey="__ungrouped__"
bestiePubkey={bestiePubkey}
@@ -234,6 +239,7 @@ function AgentPersonaCard({
agent,
defaultModel,
isBestie,
getAvailability,
persona,
restartingAgentPubkey,
startingAgentPubkey,
@@ -251,6 +257,7 @@ function AgentPersonaCard({
agent: ManagedAgent | undefined;
defaultModel: string;
isBestie: boolean;
getAvailability: AgentAvailabilityReader;
persona: AgentPersona;
restartingAgentPubkey: string | null;
startingAgentPubkey: string | null;
@@ -264,7 +271,7 @@ function AgentPersonaCard({
onStartAgent: (pubkey: string) => 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<string>;
defaultModel: string;
getAvailability: AgentAvailabilityReader;
restartingAgentPubkey: string | null;
startingAgentPubkey: string | null;
onToggle: (key: string) => void;
@@ -522,6 +533,7 @@ function CollapsibleAgentGroup({
{agents.map((agent) => (
<StandaloneAgentCard
agent={agent}
getAvailability={getAvailability}
defaultModel={defaultModel}
isBestie={agent.pubkey.toLowerCase() === bestiePubkey}
key={agent.pubkey}

View File

@@ -41,6 +41,7 @@ let createElement;
let QueryClient;
let QueryClientProvider;
let UnifiedAgentsSection;
let useAgentAvailabilityLookup;
const ipcHandlers = new Map();
@@ -109,16 +110,26 @@ function baseProps(overrides = {}) {
};
}
function Surface(props) {
const { getAvailability } = useAgentAvailabilityLookup(
props.agents.map((a) => 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);
}
});

View File

@@ -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,

View File

@@ -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}

View File

@@ -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}
</div>
@@ -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),

View File

@@ -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);
}

View File

@@ -135,7 +135,10 @@ export function usePresenceSubscription() {
queryClient.setQueriesData<PresenceLookup>(
{
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<PresenceLookup>(
{
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),

View File

@@ -449,6 +449,7 @@ export function UserProfilePanel({
const { handleAgentPrimaryAction, handleAgentRestart } =
useAgentLifecycleActions({
availability: presenceStatus,
channels: channelsQuery.data,
managedAgent,
relayAgents: relayAgentsQuery.data,

View File

@@ -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);

View File

@@ -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<unknown>;
stopManagedAgent: (pubkey: string) => Promise<unknown>;
}) {
const { status: availability } = useAgentAvailability(managedAgent?.pubkey);
const handleAgentPrimaryAction = React.useCallback(async () => {
if (!managedAgent) return;

View File

@@ -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<unknown>;
};
__BUZZ_E2E_QUERY_CLIENT__: {
invalidateQueries: (filter: { queryKey: string[] }) => Promise<void>;
};
};
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/);
}
});
}

View File

@@ -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