From c1c262b6c50b2ee0b77988b75d3b38b7a330e8e9 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 19:23:07 -0700 Subject: [PATCH] Handle hidden runtimes during Welcome kickoff Signed-off-by: kenny lopez --- .../onboarding/welcomeKickoff.test.mjs | 21 ++++++ .../src/features/onboarding/welcomeKickoff.ts | 33 ++++++++++ desktop/src/testing/e2eBridge.ts | 4 ++ desktop/tests/e2e/onboarding.spec.ts | 66 +++++++++++++++++++ 4 files changed, 124 insertions(+) diff --git a/desktop/src/features/onboarding/welcomeKickoff.test.mjs b/desktop/src/features/onboarding/welcomeKickoff.test.mjs index 08d1287dd..be09d82ad 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.test.mjs +++ b/desktop/src/features/onboarding/welcomeKickoff.test.mjs @@ -8,6 +8,7 @@ import { buildWelcomeKickoffOpenerSendInput, classifyWelcomeKickoffResolution, createWelcomeKickoffCoordinator, + hasEnabledWelcomeRuntime, mergeKickoffEvents, resolveWelcomeAgentReadiness, resolveWelcomeAgentSet, @@ -56,6 +57,26 @@ test("welcome readiness ignores a hidden logged-in runtime", () => { ); }); +test("welcome provisioning requires an enabled available runtime", () => { + const runtimes = [ + { + id: "goose", + label: "Goose", + availability: "available", + authStatus: { status: "not_applicable" }, + }, + { + id: "claude", + label: "Claude", + availability: "adapter_missing", + authStatus: { status: "unknown" }, + }, + ]; + + assert.equal(hasEnabledWelcomeRuntime(runtimes, ["goose"]), false); + assert.equal(hasEnabledWelcomeRuntime(runtimes, []), true); +}); + test("resolveWelcomeAgentSet orders agents by stable persona identity", () => { assert.deepEqual(resolveWelcomeAgentSet([bumble, fizz, honey]), { lead: fizz, diff --git a/desktop/src/features/onboarding/welcomeKickoff.ts b/desktop/src/features/onboarding/welcomeKickoff.ts index d89e7247d..bba48bf0d 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.ts +++ b/desktop/src/features/onboarding/welcomeKickoff.ts @@ -67,6 +67,15 @@ export function resolveWelcomeAgentReadiness( ); } +export function hasEnabledWelcomeRuntime( + runtimes: readonly AcpRuntimeCatalogEntry[], + disabledRuntimeIds: readonly string[], +) { + return filterEnabledAcpRuntimes(runtimes, disabledRuntimeIds).some( + (runtime) => runtime.availability === "available", + ); +} + function formatAgentNames(agents: readonly ManagedAgent[]) { if (agents.length === 0) return ""; if (agents.length === 1) return agents[0]?.name ?? ""; @@ -579,11 +588,17 @@ export function useWelcomeKickoff( ), [disabledRuntimeIds, globalConfig, runtimesQuery.data], ); + const canProvisionWelcomeTeam = React.useMemo( + () => + hasEnabledWelcomeRuntime(runtimesQuery.data ?? [], disabledRuntimeIds), + [disabledRuntimeIds, runtimesQuery.data], + ); React.useEffect(() => { if ( !channelId || !isActiveWelcome || configLoading || + managedAgentsQuery.isPending || runtimesQuery.isPending ) { return; @@ -596,6 +611,21 @@ export function useWelcomeKickoff( focusedWelcomeChannelRef.current !== channelId; void (async () => { try { + if (await markerExists(channelId, closerMarker)) { + return; + } + if (!canProvisionWelcomeTeam) { + if (!agentSet) return; + await sendManagedAgentChannelMessage({ + agentPubkey: agentSet.lead.pubkey, + channelId, + content: WELCOME_KICKOFF_PROVIDER_MESSAGE, + marker: providerMarker, + markerScope: "channel", + }); + return; + } + const welcomeTeam = await ensureWelcomeTeam( channelId, activeCommunity?.relayUrl, @@ -715,9 +745,12 @@ export function useWelcomeKickoff( })(); }, [ activeCommunity?.relayUrl, + agentSet, + canProvisionWelcomeTeam, channelId, configLoading, isActiveWelcome, + managedAgentsQuery.isPending, onKickoffOpenerPosted, queryClient, readiness, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 493908c87..7f1210066 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -75,6 +75,7 @@ type MockManagedAgentSeed = { name: string; avatarUrl?: string | null; personaId?: string | null; + teamId?: string | null; /** Harness/runtime id pin; `null` = inherit from persona (native default). */ runtime?: string | null; status?: RawManagedAgent["status"]; @@ -714,6 +715,7 @@ type RawManagedAgent = { pubkey: string; name: string; persona_id: string | null; + team_id?: string | null; /** Record-level harness/runtime pin (`null` when inheriting from the persona). */ runtime: string | null; relay_url: string; @@ -1467,6 +1469,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { pubkey: agent.pubkey, name: agent.name, persona_id: agent.persona_id, + team_id: agent.team_id ?? null, runtime: agent.runtime ?? null, relay_url: agent.relay_url, acp_command: agent.acp_command, @@ -2001,6 +2004,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { pubkey: seed.pubkey, name: seed.name, persona_id: seed.personaId ?? null, + team_id: seed.teamId ?? null, // Native serde always emits this key (`null` when unpinned) — the bridge // must mirror the wire shape, not omit the key. runtime: seed.runtime ?? null, diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index bfbfc6f06..3a940160b 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -2,6 +2,7 @@ import { hexToBytes } from "@noble/hashes/utils.js"; import { expect, test, type Page } from "@playwright/test"; import { nsecEncode } from "nostr-tools/nip19"; +import { ACP_RUNTIME_VISIBILITY_STORAGE_KEY } from "@/features/agents/lib/runtimeVisibilityPreference"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { installFakeCamera } from "../helpers/fakeCamera"; import { @@ -2726,6 +2727,71 @@ test("first-run onboarding lands before Welcome team bootstrap completes", async expect(await commandCount(page, "create_managed_agent")).toBe(3); }); +test("first-run onboarding reuses the Welcome team when every harness is hidden", async ({ + page, +}) => { + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + await page.addInitScript((storageKey) => { + window.localStorage.setItem(storageKey, JSON.stringify(["goose"])); + }, ACP_RUNTIME_VISIBILITY_STORAGE_KEY); + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + { + id: "goose", + label: "Goose", + avatar_url: "", + availability: "available", + command: "goose", + binary_path: "/usr/local/bin/goose", + default_args: ["acp"], + mcp_command: null, + install_hint: "Install Goose", + install_instructions_url: "https://block.github.io/goose/", + can_auto_install: true, + requires_external_cli: true, + underlying_cli_path: null, + node_required: false, + auth_status: { status: "not_applicable" }, + login_hint: null, + }, + ], + managedAgents: [ + { + pubkey: "f".repeat(64), + name: "Fizz", + personaId: "builtin:fizz", + teamId: "builtin-team:welcome", + }, + { + pubkey: "e".repeat(64), + name: "Honey", + personaId: "builtin:honey", + teamId: "builtin-team:welcome", + }, + { + pubkey: "d".repeat(64), + name: "Bumble", + personaId: "builtin:bumble", + teamId: "builtin-team:welcome", + }, + ], + }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + + await page.getByTestId("onboarding-display-name").fill("Morty QA"); + await completeProfileOnboarding(page); + + await expectPrivateWelcomeLanding(page); + await expect(page.getByTestId("message-timeline")).toContainText( + "connect to an AI provider in Settings", + ); + expect(await commandCount(page, "create_managed_agent")).toBe(0); +}); + test("existing relay profile with display name auto-skips onboarding without localStorage", async ({ page, }) => {