mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
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:
@@ -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;
|
||||
|
||||
103
desktop/src-tauri/src/commands/profile_presence_tests.rs
Normal file
103
desktop/src-tauri/src/commands/profile_presence_tests.rs
Normal 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());
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -449,6 +449,7 @@ export function UserProfilePanel({
|
||||
|
||||
const { handleAgentPrimaryAction, handleAgentRestart } =
|
||||
useAgentLifecycleActions({
|
||||
availability: presenceStatus,
|
||||
channels: channelsQuery.data,
|
||||
managedAgent,
|
||||
relayAgents: relayAgentsQuery.data,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user