Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions desktop/src/features/agents/lib/agentPresenceStatus.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
39 changes: 39 additions & 0 deletions desktop/src/features/agents/lib/agentPresenceStatus.ts
Original file line number Diff line number Diff line change
@@ -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<string, PresenceStatus> {
const map: Record<string, PresenceStatus> = {};
for (const agent of agents) {
map[agent.pubkey] = presence?.[agent.pubkey] ?? agent.status;
}
return map;
}
17 changes: 10 additions & 7 deletions desktop/src/features/pulse/ui/PulseView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -126,13 +128,14 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
() => new Set(agentPubkeys),
[agentPubkeys],
);
const agentStatusMap = React.useMemo(() => {
const map: Record<string, "online" | "away" | "offline"> = {};
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(
() =>
Expand Down