diff --git a/desktop/src/features/onboarding/welcomeGuide.test.mjs b/desktop/src/features/onboarding/welcomeGuide.test.mjs index da14cc8bd9..7f73499650 100644 --- a/desktop/src/features/onboarding/welcomeGuide.test.mjs +++ b/desktop/src/features/onboarding/welcomeGuide.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { activateWelcomeTeamPersonasSequentially, buildWelcomeStarterCreateInput, + findStaleWelcomeTeamMemberPubkeys, LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT, pickWelcomeGuideAgent, pickWelcomeGuideAgentForRelay, @@ -18,6 +19,9 @@ import { const PUB_A = "a".repeat(64); const PUB_B = "b".repeat(64); const PUB_C = "c".repeat(64); +const PUB_D = "d".repeat(64); +const PUB_E = "e".repeat(64); +const PUB_F = "f".repeat(64); const RELAY_A = "ws://localhost:3000"; const RELAY_B = "ws://localhost:3001"; @@ -59,6 +63,106 @@ function makeAgent(overrides = {}) { }; } +function makeMember(pubkey, overrides = {}) { + return { + pubkey, + role: "bot", + isAgent: true, + joinedAt: "2026-07-30T00:00:00.000Z", + displayName: null, + ...overrides, + }; +} + +function makeManagedAgentEvent(pubkey, personaId, overrides = {}) { + return { + id: pubkey, + pubkey: PUB_F, + created_at: 1, + kind: 30177, + tags: [["d", pubkey]], + content: JSON.stringify({ persona_id: personaId }), + sig: "", + ...overrides, + }; +} + +test("stale same-owner Welcome agents are pruned while the current trio is preserved", () => { + const currentAgents = [ + makeAgent({ pubkey: PUB_A, personaId: "builtin:fizz" }), + makeAgent({ pubkey: PUB_B, personaId: "builtin:honey" }), + makeAgent({ pubkey: PUB_C, personaId: "builtin:bumble" }), + ]; + const members = [PUB_A, PUB_B, PUB_C, PUB_D, PUB_E].map((pubkey) => + makeMember(pubkey), + ); + const events = [ + makeManagedAgentEvent(PUB_A, "builtin:fizz"), + makeManagedAgentEvent(PUB_B, "builtin:honey"), + makeManagedAgentEvent(PUB_C, "builtin:bumble"), + makeManagedAgentEvent(PUB_D, "builtin:fizz"), + makeManagedAgentEvent(PUB_E, "builtin:honey"), + ]; + + assert.deepEqual( + findStaleWelcomeTeamMemberPubkeys(members, events, PUB_F, currentAgents), + [PUB_D, PUB_E], + ); +}); + +test("Welcome cleanup ignores foreign, custom, malformed, and uncertain agents", () => { + const currentAgents = [ + makeAgent({ pubkey: PUB_A, personaId: "builtin:fizz" }), + makeAgent({ pubkey: PUB_B, personaId: "builtin:honey" }), + makeAgent({ pubkey: PUB_C, personaId: "builtin:bumble" }), + ]; + const foreignOwner = "1".repeat(64); + const nonMember = "2".repeat(64); + const nonAgent = "3".repeat(64); + const invalidTag = "not-a-pubkey"; + const members = [ + makeMember(PUB_D), + makeMember(PUB_E), + makeMember(nonAgent, { isAgent: false }), + ]; + const events = [ + makeManagedAgentEvent(PUB_D, "builtin:fizz", { + pubkey: foreignOwner, + }), + makeManagedAgentEvent(PUB_E, "custom:helper"), + makeManagedAgentEvent(PUB_E, "builtin:honey", { + content: "{malformed", + }), + makeManagedAgentEvent(nonMember, "builtin:bumble"), + makeManagedAgentEvent(nonAgent, "builtin:fizz"), + makeManagedAgentEvent(invalidTag, "builtin:honey"), + makeManagedAgentEvent(PUB_E, "builtin:honey", { kind: 1 }), + ]; + + assert.deepEqual( + findStaleWelcomeTeamMemberPubkeys(members, events, PUB_F, currentAgents), + [], + ); +}); + +test("Welcome cleanup deduplicates retained records for one stale member", () => { + const stale = makeManagedAgentEvent(PUB_D, "builtin:fizz"); + + assert.deepEqual( + findStaleWelcomeTeamMemberPubkeys( + [makeMember(PUB_D)], + [stale, { ...stale, id: "newer" }], + PUB_F, + [ + makeAgent({ pubkey: PUB_A, personaId: "builtin:fizz" }), + makeAgent({ pubkey: PUB_B, personaId: "builtin:honey" }), + makeAgent({ pubkey: PUB_C, personaId: "builtin:bumble" }), + ], + ), + [PUB_D], + ); +}); + test("pickWelcomeGuideAgent reuses a legacy Kit guide", () => { const legacyKit = makeAgent({ name: "Kit", diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts index aa8deb7c19..6576be3b44 100644 --- a/desktop/src/features/onboarding/welcomeGuide.ts +++ b/desktop/src/features/onboarding/welcomeGuide.ts @@ -8,16 +8,22 @@ import { discoverAcpRuntimes, getChannelMembers, listManagedAgents, + removeChannelMember, updateManagedAgent, } from "@/shared/api/tauri"; +import { relayClient } from "@/shared/api/relayClient"; import { getGlobalAgentConfig } from "@/shared/api/tauriGlobalAgentConfig"; +import { getIdentity } from "@/shared/api/tauriIdentity"; import { listPersonas, setPersonaActive } from "@/shared/api/tauriPersonas"; import type { AcpRuntime, AgentPersona, + ChannelMember, CreateManagedAgentInput, ManagedAgent, + RelayEvent, } from "@/shared/api/types"; +import { KIND_MANAGED_AGENT } from "@/shared/constants/kinds"; import { normalizePubkey } from "@/shared/lib/pubkey"; export const WELCOME_GUIDE_AGENT_NAME = "Fizz"; @@ -206,6 +212,101 @@ async function ensureWelcomeTeamMembership( } } +const PUBKEY_PATTERN = /^[0-9a-f]{64}$/; + +/** + * Find old, same-owner built-in Welcome agents that can be safely removed from + * this channel after the current local trio has joined. + */ +export function findStaleWelcomeTeamMemberPubkeys( + members: readonly ChannelMember[], + events: readonly RelayEvent[], + ownerPubkey: string, + currentAgents: readonly ManagedAgent[], +) { + const normalizedOwner = normalizePubkey(ownerPubkey); + const currentPubkeys = new Set( + currentAgents.map((agent) => normalizePubkey(agent.pubkey)), + ); + const memberPubkeys = new Set( + members + .filter((member) => member.isAgent) + .map((member) => normalizePubkey(member.pubkey)), + ); + const welcomePersonaIds = new Set( + WELCOME_TEAM_STARTERS.map(({ personaId }) => personaId), + ); + const stalePubkeys = new Set(); + + for (const event of events) { + if ( + event.kind !== KIND_MANAGED_AGENT || + normalizePubkey(event.pubkey) !== normalizedOwner + ) { + continue; + } + const pubkey = normalizePubkey( + event.tags.find((tag) => tag[0] === "d")?.[1] ?? "", + ); + if ( + !PUBKEY_PATTERN.test(pubkey) || + !memberPubkeys.has(pubkey) || + currentPubkeys.has(pubkey) + ) { + continue; + } + + try { + const content = JSON.parse(event.content) as { persona_id?: unknown }; + if ( + typeof content.persona_id === "string" && + welcomePersonaIds.has(content.persona_id) + ) { + stalePubkeys.add(pubkey); + } + } catch { + // Malformed public metadata is not enough evidence to remove a member. + } + } + + return [...stalePubkeys]; +} + +async function pruneStaleWelcomeTeamMemberships( + channelId: string, + currentAgents: WelcomeTeamAgents, +) { + const [members, identity] = await Promise.all([ + getChannelMembers(channelId), + getIdentity(), + ]); + const botPubkeys = [ + ...new Set( + members + .filter((member) => member.isAgent) + .map((member) => normalizePubkey(member.pubkey)) + .filter((pubkey) => PUBKEY_PATTERN.test(pubkey)), + ), + ]; + if (botPubkeys.length === 0) return; + + const events = await relayClient.fetchEvents({ + kinds: [KIND_MANAGED_AGENT], + authors: [identity.pubkey], + "#d": botPubkeys, + limit: botPubkeys.length, + }); + const stalePubkeys = findStaleWelcomeTeamMemberPubkeys( + members, + events, + identity.pubkey, + currentAgents, + ); + for (const pubkey of stalePubkeys) { + await removeChannelMember(channelId, pubkey); + } +} + export async function buildWelcomeStarterCreateInput( starter: WelcomeTeamStarterDefinition, persona: AgentPersona, @@ -337,6 +438,14 @@ async function provisionWelcomeTeam( } } await ensureWelcomeTeamMembership(channelId, welcomeAgents); + await pruneStaleWelcomeTeamMemberships(channelId, welcomeAgents).catch( + (error) => { + console.warn( + "[welcomeGuide] Could not prune stale Welcome Team memberships:", + error, + ); + }, + ); return welcomeAgents; }