Skip to content
Draft
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
104 changes: 104 additions & 0 deletions desktop/src/features/onboarding/welcomeGuide.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import {
activateWelcomeTeamPersonasSequentially,
buildWelcomeStarterCreateInput,
findStaleWelcomeTeamMemberPubkeys,
LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT,
pickWelcomeGuideAgent,
pickWelcomeGuideAgentForRelay,
Expand All @@ -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";

Expand Down Expand Up @@ -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",
Expand Down
109 changes: 109 additions & 0 deletions desktop/src/features/onboarding/welcomeGuide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>(
WELCOME_TEAM_STARTERS.map(({ personaId }) => personaId),
);
const stalePubkeys = new Set<string>();

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,
Expand Down Expand Up @@ -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;
}

Expand Down
Loading