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
49 changes: 49 additions & 0 deletions desktop/src/features/agents/lib/managedAgentRelayScope.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";

import { managedAgentsForRelay } from "./managedAgentRelayScope.ts";

const agent = (name, relayUrl) => ({ name, pubkey: name, relayUrl });

const FIZZ_A = agent("fizz-a", "ws://relay-a:3000");
const FIZZ_B = agent("fizz-b", "ws://relay-b:3000");
const FIZZ_LOCAL = agent("fizz-local", "ws://localhost:3000");

test("keeps only the agents minted against the active relay", () => {
assert.deepEqual(
managedAgentsForRelay([FIZZ_A, FIZZ_B], "ws://relay-b:3000"),
[FIZZ_B],
);
});

test("matches canonically across localhost, port, and trailing slash", () => {
assert.deepEqual(
managedAgentsForRelay([FIZZ_LOCAL, FIZZ_B], "ws://127.0.0.1:3000/"),
[FIZZ_LOCAL],
);
});

test("an unknown or unparsable active relay leaves the list untouched", () => {
// Better to show every agent than to show none because the caller could not
// say which relay it is on.
assert.deepEqual(managedAgentsForRelay([FIZZ_A, FIZZ_B], null), [
FIZZ_A,
FIZZ_B,
]);
assert.deepEqual(managedAgentsForRelay([FIZZ_A, FIZZ_B], "not a url"), [
FIZZ_A,
FIZZ_B,
]);
});

test("a record with an unreadable relay url is kept", () => {
const broken = agent("broken", "");
assert.deepEqual(
managedAgentsForRelay([broken, FIZZ_B], "ws://relay-a:3000"),
[broken],
);
});

test("an absent list is empty, not undefined", () => {
assert.deepEqual(managedAgentsForRelay(undefined, "ws://relay-a:3000"), []);
});
33 changes: 33 additions & 0 deletions desktop/src/features/agents/lib/managedAgentRelayScope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { canonicalRelayUrl } from "@/features/agents/managedAgentRuntimeStatus";

/**
* Scope a managed-agent list to one community's relay.
*
* `managed-agents.json` is one file per install and every record keeps the
* relay URL its keypair was minted against. Changing a community's relay URL
* mints new keypairs and appends them; the old records stay, so surfaces that
* read the whole file see one entry per historical relay — the same agent name
* repeated under keys that are not reachable on the relay the user is
* currently connected to.
*
* Comparison is canonical (`canonicalRelayUrl`), so `localhost` vs `127.0.0.1`,
* a default port, or a trailing slash still match the same relay.
*
* Both arguments are treated as unknown rather than empty when absent: a
* `null`/unparsable relay URL returns the list untouched. A surface that
* cannot say which relay it is on must not silently show nothing.
*/
export function managedAgentsForRelay<T extends { relayUrl: string }>(
agents: readonly T[] | undefined,
relayUrl: string | null | undefined,
): readonly T[] {
if (!agents) return [];
const canonical = relayUrl ? canonicalRelayUrl(relayUrl) : null;
if (canonical === null) return agents;
return agents.filter((agent) => {
const agentCanonical = canonicalRelayUrl(agent.relayUrl);
// An unparsable record is kept: dropping it would hide a real agent on the
// strength of a URL we failed to read.
return agentCanonical === null || agentCanonical === canonical;
});
}
40 changes: 23 additions & 17 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
useChannelMembersQuery,
useChannelsQuery,
} from "@/features/channels/hooks";
import { managedAgentsForRelay } from "@/features/agents/lib/managedAgentRelayScope";
import { useCommunities } from "@/features/communities/useCommunities";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
import {
Expand Down Expand Up @@ -108,6 +110,15 @@ export function useMentions(
const members = externalMembers ?? membersQuery.data;
const isArchivedDiscovery = useIsArchivedPredicate();
const managedAgentsQuery = useManagedAgentsQuery();
// Records outlive the relay URL they were minted against, so the raw list
// carries one entry per historical relay. Only the ones reachable on the
// community we are connected to belong in the picker.
const { activeCommunity } = useCommunities();
const managedAgents = React.useMemo(
() =>
managedAgentsForRelay(managedAgentsQuery.data, activeCommunity?.relayUrl),
[activeCommunity?.relayUrl, managedAgentsQuery.data],
);
const relayAgentsQuery = useRelayAgentsQuery();
const channelsQuery = useChannelsQuery();
const personasQuery = usePersonasQuery();
Expand Down Expand Up @@ -136,42 +147,37 @@ export function useMentions(
const managedAgentNamesByPubkey = React.useMemo(
() =>
new Map(
(managedAgentsQuery.data ?? []).map((agent) => [
managedAgents.map((agent) => [
normalizePubkey(agent.pubkey),
agent.name,
]),
),
[managedAgentsQuery.data],
[managedAgents],
);
const managedAgentPersonaIdsByPubkey = React.useMemo(
() =>
new Map(
(managedAgentsQuery.data ?? [])
managedAgents
.filter((agent) => Boolean(agent.personaId))
.map((agent) => [
normalizePubkey(agent.pubkey),
agent.personaId as string,
]),
),
[managedAgentsQuery.data],
[managedAgents],
);
const managedAgentPersonaIds = React.useMemo(
() =>
new Set(
(managedAgentsQuery.data ?? [])
managedAgents
.map((agent) => agent.personaId)
.filter((personaId): personaId is string => Boolean(personaId)),
),
[managedAgentsQuery.data],
[managedAgents],
);
const managedAgentPubkeys = React.useMemo(
() =>
new Set(
(managedAgentsQuery.data ?? []).map((agent) =>
normalizePubkey(agent.pubkey),
),
),
[managedAgentsQuery.data],
() => new Set(managedAgents.map((agent) => normalizePubkey(agent.pubkey))),
[managedAgents],
);
const relayAgentNamesByPubkey = React.useMemo(
() =>
Expand Down Expand Up @@ -219,7 +225,7 @@ export function useMentions(
],
);
const personaNameByPubkey = React.useMemo(() => {
const agents = managedAgentsQuery.data ?? [];
const agents = managedAgents;
const personas = personasQuery.data ?? [];
const personaById = new Map(personas.map((p) => [p.id, p.displayName]));
const lookup = new Map<string, string>();
Expand All @@ -230,7 +236,7 @@ export function useMentions(
}
}
return lookup;
}, [managedAgentsQuery.data, personasQuery.data]);
}, [managedAgents, personasQuery.data]);
const knownAgentPubkeys = mentionableAgentPubkeys;
const activePersonas = React.useMemo(
() => (personasQuery.data ?? []).filter((persona) => persona.isActive),
Expand Down Expand Up @@ -357,7 +363,7 @@ export function useMentions(
});
}

for (const agent of managedAgentsQuery.data ?? []) {
for (const agent of managedAgents) {
addCandidate({
kind: "identity",
pubkey: agent.pubkey,
Expand Down Expand Up @@ -431,7 +437,7 @@ export function useMentions(
managedAgentNamesByPubkey,
managedAgentPersonaIds,
managedAgentPersonaIdsByPubkey,
managedAgentsQuery.data,
managedAgents,
memberPubkeys,
members,
mentionableAgentPubkeys,
Expand Down