diff --git a/desktop/src/features/agents/lib/agentPresenceStatus.test.mjs b/desktop/src/features/agents/lib/agentPresenceStatus.test.mjs new file mode 100644 index 0000000000..9941571d22 --- /dev/null +++ b/desktop/src/features/agents/lib/agentPresenceStatus.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { mergeAgentPresenceStatus } from "./agentPresenceStatus.ts"; + +test("mergeAgentPresenceStatus — live presence overrides the kind:10100 offline fallback", () => { + // The bug this fixes: `agents_from_events` defaults every relay-discovered + // agent to "offline" because nothing writes `status` into kind:10100, so a + // running agent was reported offline. + const map = mergeAgentPresenceStatus([{ pubkey: "a", status: "offline" }], { + a: "online", + }); + assert.equal(map.a, "online"); +}); + +test("mergeAgentPresenceStatus — presence reporting away is preserved", () => { + const map = mergeAgentPresenceStatus([{ pubkey: "a", status: "offline" }], { + a: "away", + }); + assert.equal(map.a, "away"); +}); + +test("mergeAgentPresenceStatus — agents with no presence entry keep their own status", () => { + // Locally-managed agents derive status from the live process handle, which + // the relay cannot contradict for the machine running them. + const map = mergeAgentPresenceStatus([{ pubkey: "a", status: "online" }], {}); + assert.equal(map.a, "online"); +}); + +test("mergeAgentPresenceStatus — presence for an unrelated pubkey does not leak in", () => { + const map = mergeAgentPresenceStatus([{ pubkey: "a", status: "offline" }], { + b: "online", + }); + assert.deepEqual(map, { a: "offline" }); +}); + +test("mergeAgentPresenceStatus — a presence lookup that has not loaded yet is a no-op", () => { + const map = mergeAgentPresenceStatus( + [ + { pubkey: "a", status: "online" }, + { pubkey: "b", status: "offline" }, + ], + undefined, + ); + assert.deepEqual(map, { a: "online", b: "offline" }); +}); + +test("mergeAgentPresenceStatus — presence can also move an agent to offline", () => { + // A managed agent whose process died still reports "online" from a stale + // handle; relay presence expiring is what corrects it. + const map = mergeAgentPresenceStatus([{ pubkey: "a", status: "online" }], { + a: "offline", + }); + assert.equal(map.a, "offline"); +}); diff --git a/desktop/src/features/agents/lib/agentPresenceStatus.ts b/desktop/src/features/agents/lib/agentPresenceStatus.ts new file mode 100644 index 0000000000..8b7fa7a181 --- /dev/null +++ b/desktop/src/features/agents/lib/agentPresenceStatus.ts @@ -0,0 +1,39 @@ +import type { PresenceLookup, PresenceStatus } from "@/shared/api/types"; + +/** + * The status fields this merge reads off an agent, whatever its source. + * + * Kept structural rather than importing `RelayAgent` so locally-managed agents + * projected into the same shape can be merged without a cast. + */ +export type AgentStatusInput = { + pubkey: string; + status: PresenceStatus; +}; + +/** + * Build the pubkey → status map for an agent roster, preferring live relay + * presence over the agent's self-declared `kind:10100` status. + * + * Why presence wins: the `status` field on a `kind:10100` agent profile has no + * producer in-tree, so `agents_from_events` falls back to `"offline"` for every + * relay-discovered agent. Rendering that directly reports agents as offline + * whether or not they are actually running. Relay presence (kind:20001, backed + * by a 180s Redis TTL) is the only source that reflects the process's real + * state, so it takes precedence wherever it has an entry. + * + * Agents absent from `presence` keep their incoming status: locally-managed + * agents already derive theirs from the live process handle, which is + * authoritative for the machine running them and is not something the relay + * can contradict. + */ +export function mergeAgentPresenceStatus( + agents: readonly AgentStatusInput[], + presence: PresenceLookup | undefined, +): Record { + const map: Record = {}; + for (const agent of agents) { + map[agent.pubkey] = presence?.[agent.pubkey] ?? agent.status; + } + return map; +} diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index 3009076aa8..b6edfdfc8a 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -5,6 +5,8 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; +import { mergeAgentPresenceStatus } from "@/features/agents/lib/agentPresenceStatus"; +import { usePresenceQuery } from "@/features/presence/hooks"; import { useContactListQuery, useUsersBatchQuery, @@ -126,13 +128,14 @@ export function PulseView({ currentPubkey }: PulseViewProps) { () => new Set(agentPubkeys), [agentPubkeys], ); - const agentStatusMap = React.useMemo(() => { - const map: Record = {}; - for (const a of relayAgents) { - map[a.pubkey] = a.status; - } - return map; - }, [relayAgents]); + // Relay presence is the only source that reflects whether an agent process is + // actually up: nothing writes `status` into kind:10100, so every + // relay-discovered agent otherwise renders as offline regardless of state. + const agentPresenceQuery = usePresenceQuery(agentPubkeys); + const agentStatusMap = React.useMemo( + () => mergeAgentPresenceStatus(relayAgents, agentPresenceQuery.data), + [relayAgents, agentPresenceQuery.data], + ); const mentionPubkeys = React.useMemo( () =>