Add preferred runtime onboarding (#2040)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-17 14:19:29 -06:00
committed by GitHub
co-authored by Pinky
parent 68e670e001
commit c19e0ce2d4
26 changed files with 1072 additions and 60 deletions
@@ -120,6 +120,23 @@ test("row 3: plain avatar URLs pass through; base64 data URIs upload via the inj
assert.equal(uploads.length, 1, "upload must go through the injected fn");
});
test("row 3: failed persona avatar upload never substitutes the runtime avatar", async () => {
const input = await buildInstanceInputForDefinition(
persona({
id: "builtin:fizz",
displayName: "Fizz",
avatarUrl: "data:image/png;base64,aGk=",
}),
claudeRuntime,
async () => {
throw new Error("upload failed");
},
);
assert.equal(input.avatarUrl, undefined);
assert.notEqual(input.avatarUrl, claudeRuntime.avatarUrl);
});
test("mapping carries the runtime and definition fields", async () => {
const input = await buildInstanceInputForDefinition(persona(), gooseRuntime);
assert.equal(input.name, "Test Agent");
@@ -45,11 +45,12 @@ export async function availableRuntimesForStart(
export function resolveStartRuntimeForDefinition(
persona: AgentPersona,
runtimes: readonly AcpRuntime[],
preferredRuntimeId?: string | null,
): { runtime: AcpRuntime; warnings: string[] } {
// Use the buzz-agent-first preference (buzz-agent → goose → first available)
// so a freshly installed goose never beats the bundled buzz-agent sidecar
// for runtime-less personas (item 13 regression guard).
const defaultRuntime = getDefaultPersonaRuntime(runtimes);
const defaultRuntime = getDefaultPersonaRuntime(runtimes, preferredRuntimeId);
const { runtime, warnings, isOverridden }: ResolvePersonaRuntimeResult =
resolvePersonaRuntime(persona.runtime, runtimes, defaultRuntime);
@@ -115,7 +116,6 @@ export async function buildInstanceInputForDefinition(
const avatarUrl = await resolveManagedAgentAvatarUrl(
persona.avatarUrl,
upload,
runtime.avatarUrl,
);
const base = {
@@ -12,11 +12,13 @@ import type { AcpRuntime, AcpRuntimeCatalogEntry } from "@/shared/api/types";
*/
export function getDefaultPersonaRuntime<T extends AcpRuntimeCatalogEntry>(
runtimes: readonly T[],
preferredRuntimeId?: string | null,
): T | null {
const available = runtimes.filter(
(runtime) => runtime.availability === "available",
);
return (
available.find((runtime) => runtime.id === preferredRuntimeId) ??
available.find((runtime) => runtime.id === "buzz-agent") ??
available.find((runtime) => runtime.id === "goose") ??
available[0] ??
@@ -5,6 +5,7 @@ import {
useAvailableAcpRuntimes,
useCreateChannelManagedAgentsMutation,
} from "@/features/agents/hooks";
import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents";
import {
emptyResolvedTeamPersonas,
@@ -50,6 +51,7 @@ export function AddTeamToChannelDialog({
onOpenChange,
onDeployed,
}: AddTeamToChannelDialogProps) {
const { globalConfig } = useGlobalAgentConfig();
const channelsQuery = useChannelsQuery();
const providersQuery = useAvailableAcpRuntimes();
const [channelId, setChannelId] = React.useState("");
@@ -69,7 +71,10 @@ export function AddTeamToChannelDialog({
const runtimes = providersQuery.data ?? [];
// Use the buzz-agent-first preference so the team-deploy fallback mirrors the
// single-agent start path (buzz-agent → goose → first available).
const defaultProvider = getDefaultPersonaRuntime(runtimes);
const defaultProvider = getDefaultPersonaRuntime(
runtimes,
globalConfig.preferred_runtime,
);
const teamPersonaResolution = React.useMemo(
() =>
@@ -156,9 +156,17 @@ export function AgentDefinitionDialog({
const [showAdvancedFields, setShowAdvancedFields] = React.useState(false);
const [isAvatarUploadPending, setIsAvatarUploadPending] =
React.useState(false);
const {
globalConfig,
inheritedDefaults: {
provider: inheritedProviderDefault,
model: inheritedModelDefault,
},
inheritedEnvVars: inheritedEnvVarsForAdvanced,
} = useAgentDialogDefaults({ open });
const defaultRuntime = React.useMemo(
() => getDefaultPersonaRuntime(runtimes),
[runtimes],
() => getDefaultPersonaRuntime(runtimes, globalConfig.preferred_runtime),
[globalConfig.preferred_runtime, runtimes],
);
const shouldReduceMotion = useReducedMotion();
const initialModelProviderEditableWithoutRuntime = Boolean(
@@ -331,14 +339,6 @@ export function AgentDefinitionDialog({
// Used to silence requirements already satisfied there.
const { data: runtimeFileConfig, isLoading: fileConfigLoading } =
useRuntimeFileConfigQuery(runtime, { enabled: open });
const {
globalConfig,
inheritedDefaults: {
provider: inheritedProviderDefault,
model: inheritedModelDefault,
},
inheritedEnvVars: inheritedEnvVarsForAdvanced,
} = useAgentDialogDefaults({ open });
function handleAiConfigurationModeChange(nextMode: AgentAiConfigurationMode) {
setAiConfigurationMode(nextMode);
setIsCustomProviderEditing(false);
@@ -45,6 +45,7 @@ export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = {
env_vars: {},
provider: null,
model: null,
preferred_runtime: null,
};
/** Baked env keys that route to structured controls, not the generic env editor. */
@@ -75,6 +75,23 @@ test("getPersonaProviderOptions appends (current) tail for an unknown saved prov
// ── getDefaultPersonaRuntime — buzz-agent first ───────────────────────────────
test("getDefaultPersonaRuntime honors an available global preference", () => {
const runtimes = [
makeRuntime("buzz-agent"),
makeRuntime("goose"),
makeRuntime("claude"),
];
assert.equal(getDefaultPersonaRuntime(runtimes, "claude")?.id, "claude");
});
test("getDefaultPersonaRuntime ignores an unavailable global preference", () => {
const runtimes = [
makeRuntime("buzz-agent"),
makeRuntime("claude", "not_installed"),
];
assert.equal(getDefaultPersonaRuntime(runtimes, "claude")?.id, "buzz-agent");
});
test("getDefaultPersonaRuntime returns buzz-agent over goose when both are available", () => {
const runtimes = [
makeRuntime("goose"),
@@ -12,6 +12,7 @@ import {
useStopManagedAgentMutation,
useDeleteManagedAgentMutation,
} from "@/features/agents/hooks";
import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
import { useChannelsQuery } from "@/features/channels/hooks";
import { usePresenceQuery } from "@/features/presence/hooks";
import type {
@@ -35,6 +36,7 @@ import {
} from "../lib/instanceInputForDefinition";
export function useManagedAgentActions() {
const { globalConfig } = useGlobalAgentConfig();
const relayAgentsQuery = useRelayAgentsQuery();
const managedAgentsQuery = useManagedAgentsQuery();
const [shouldLoadChannels, setShouldLoadChannels] = React.useState(false);
@@ -193,6 +195,7 @@ export function useManagedAgentActions() {
const { runtime, warnings } = resolveStartRuntimeForDefinition(
persona,
runtimes,
globalConfig.preferred_runtime,
);
const input = await buildInstanceInputForDefinition(persona, runtime);
@@ -18,6 +18,7 @@ const EMPTY_CONFIG: GlobalAgentConfig = {
env_vars: {},
provider: null,
model: null,
preferred_runtime: null,
};
export const globalAgentConfigQueryKey = ["globalAgentConfig"] as const;
@@ -1,6 +1,10 @@
import * as React from "react";
import type { QueryClient } from "@tanstack/react-query";
import {
getGlobalAgentConfig,
setGlobalAgentConfig,
} from "@/shared/api/tauriGlobalAgentConfig";
import {
getIdentity,
importIdentity,
@@ -40,6 +44,53 @@ export function MachineOnboardingFlow({
const [selectedPubkey, setSelectedPubkey] = React.useState<string | null>(
null,
);
const [selectedRuntimeId, setSelectedRuntimeId] = React.useState<
string | null
>(null);
const [pendingRuntimeId, setPendingRuntimeId] = React.useState<string | null>(
null,
);
const [isRuntimeSelectionSaving, setIsRuntimeSelectionSaving] =
React.useState(false);
const [runtimeSelectionError, setRuntimeSelectionError] = React.useState<
string | null
>(null);
const runtimeSaveSequence = React.useRef(0);
const persistPreferredRuntime = React.useCallback(
async (runtimeId: string) => {
const sequence = runtimeSaveSequence.current + 1;
runtimeSaveSequence.current = sequence;
setPendingRuntimeId(runtimeId);
setRuntimeSelectionError(null);
setIsRuntimeSelectionSaving(true);
try {
const current = await getGlobalAgentConfig();
const result = await setGlobalAgentConfig({
...current,
preferred_runtime: runtimeId,
});
if (runtimeSaveSequence.current === sequence) {
setSelectedRuntimeId(result.config.preferred_runtime);
setPendingRuntimeId(null);
}
} catch (cause) {
if (runtimeSaveSequence.current === sequence) {
setPendingRuntimeId(null);
setRuntimeSelectionError(
cause instanceof Error
? cause.message
: "Couldn’t save your preferred runtime.",
);
}
} finally {
if (runtimeSaveSequence.current === sequence) {
setIsRuntimeSelectionSaving(false);
}
}
},
[],
);
const loadFreshIdentity = React.useCallback(async () => {
setIsPending(true);
@@ -188,9 +239,25 @@ export function MachineOnboardingFlow({
actions={{
back: () =>
setPage(identityWasImported ? "key-import" : "backup"),
next: () => setPage("config"),
next: () => {
if (!selectedRuntimeId) return;
if (
selectedRuntimeId === "claude" ||
selectedRuntimeId === "codex"
) {
complete(selectedPubkey ?? undefined);
return;
}
setPage("config");
},
}}
direction="forward"
isSelectionSaving={isRuntimeSelectionSaving}
onSelectedRuntimeChange={(runtimeId) => {
void persistPreferredRuntime(runtimeId);
}}
selectionError={runtimeSelectionError}
selectedRuntimeId={pendingRuntimeId ?? selectedRuntimeId}
/>
) : (
<DefaultConfigStep
+224 -10
View File
@@ -10,7 +10,9 @@ import {
} from "lucide-react";
import {
useAcpAuthMethodsQuery,
useAcpRuntimesQuery,
useConnectAcpRuntimeMutation,
useInstallAcpRuntimeMutation,
useGitBashPrerequisiteQuery,
} from "@/features/agents/hooks";
@@ -23,6 +25,7 @@ import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Spinner } from "@/shared/ui/spinner";
import { runtimeCanBeSelected } from "./onboardingRuntimeSelection";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
import {
@@ -34,11 +37,19 @@ import type { SetupStepActions, SetupStepState } from "./types";
type SetupStepProps = {
actions: SetupStepActions;
direction: OnboardingTransitionDirection;
isSelectionSaving: boolean;
onSelectedRuntimeChange: (runtimeId: string) => void;
selectionError: string | null;
selectedRuntimeId: string | null;
};
type SetupStepContentProps = {
actions: SetupStepActions;
direction: OnboardingTransitionDirection;
isSelectionSaving: boolean;
onSelectedRuntimeChange: (runtimeId: string) => void;
selectionError: string | null;
selectedRuntimeId: string | null;
state: SetupStepState;
};
@@ -93,13 +104,11 @@ function RuntimeIcon({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
function RuntimeStatus({
installError,
installSuccess,
isInstalling,
onInstall,
runtime,
}: {
installError: string | null;
installSuccess: boolean;
isInstalling: boolean;
onInstall: () => void;
runtime: AcpRuntimeCatalogEntry;
@@ -124,7 +133,7 @@ function RuntimeStatus({
);
}
if (runtime.availability === "available" || installSuccess) {
if (runtime.availability === "available" && runtimeCanBeSelected(runtime)) {
return (
<div
aria-label={`${runtime.label} available`}
@@ -280,36 +289,182 @@ function runtimeDetailText(runtime: AcpRuntimeCatalogEntry): string {
return "Not installed yet.";
}
function isSupportedOnboardingAuthMethod(
runtime: AcpRuntimeCatalogEntry,
method: { id: string; name: string },
) {
if (runtime.id !== "codex") return true;
return !/api[-_ ]?key/i.test(`${method.id} ${method.name}`);
}
function onboardingAuthMethodLabel(
runtime: AcpRuntimeCatalogEntry,
method: { name: string },
) {
if (runtime.id === "codex") return "Log in";
return method.name || "Sign in";
}
function RuntimeAuthActions({
onAuthenticated,
runtime,
}: {
onAuthenticated: () => void;
runtime: AcpRuntimeCatalogEntry;
}) {
const runtimesQuery = useAcpRuntimesQuery();
const methodsQuery = useAcpAuthMethodsQuery(runtime.id, {
enabled:
runtime.availability === "available" &&
runtime.authStatus.status === "logged_out",
});
const connectMutation = useConnectAcpRuntimeMutation();
if (runtime.authStatus.status === "config_invalid") {
return (
<p className="mt-2 text-2xs leading-4 text-destructive">
{runtime.authStatus.diagnostic}
</p>
);
}
if (runtime.authStatus.status === "unknown") {
return (
<div className="mt-2 flex flex-col items-center gap-1.5">
<span className="text-2xs text-muted-foreground">
Couldn’t verify authentication.
</span>
<Button
disabled={runtimesQuery.isFetching}
onClick={(event) => {
event.stopPropagation();
void runtimesQuery.refetch();
}}
size="sm"
type="button"
variant="ghost"
>
{runtimesQuery.isFetching ? "Checking…" : "Check again"}
</Button>
</div>
);
}
if (runtime.authStatus.status !== "logged_out") return null;
const methods = (methodsQuery.data?.methods ?? []).filter((method) =>
isSupportedOnboardingAuthMethod(runtime, method),
);
return (
<div className="mt-2 flex flex-col items-center gap-1.5">
{methodsQuery.isLoading ? (
<span className="text-2xs text-muted-foreground">Loading sign-in…</span>
) : methods.length > 0 ? (
methods.map((method) => (
<Button
disabled={connectMutation.isPending}
key={method.id}
onClick={(event) => {
event.stopPropagation();
connectMutation.mutate(
{
methodId: method.id,
runtimeId: runtime.id,
},
{
onSuccess: () => {
if (runtime.id === "claude" || runtime.id === "codex") {
onAuthenticated();
}
},
},
);
}}
size="sm"
type="button"
variant="outline"
>
{connectMutation.isPending
? "Opening…"
: onboardingAuthMethodLabel(runtime, method)}
</Button>
))
) : (
<span className="text-2xs text-muted-foreground">
{methodsQuery.error instanceof Error
? "Couldn’t load sign-in options."
: runtime.loginHint || "Sign in from the CLI."}
</span>
)}
{connectMutation.error instanceof Error ? (
<span className="text-2xs text-destructive">
{connectMutation.error.message}
</span>
) : null}
<Button
disabled={runtimesQuery.isFetching}
onClick={(event) => {
event.stopPropagation();
void Promise.all([runtimesQuery.refetch(), methodsQuery.refetch()]);
}}
size="sm"
type="button"
variant="ghost"
>
{runtimesQuery.isFetching ? "Checking…" : "Check again"}
</Button>
</div>
);
}
function RuntimeCard({
installError,
installSuccess,
isInstalling,
onInstall,
onSelect,
runtime,
selectionDisabled,
selected,
}: {
installError: string | null;
installSuccess: boolean;
isInstalling: boolean;
onInstall: () => void;
onSelect: () => void;
runtime: AcpRuntimeCatalogEntry;
selectionDisabled: boolean;
selected: boolean;
}) {
const isAvailable = runtime.availability === "available" || installSuccess;
const canSelect = runtimeCanBeSelected(runtime) && !selectionDisabled;
return (
// biome-ignore lint/a11y/useSemanticElements: Cannot use <input> because this card contains nested setup and details buttons, which require interactive content
<div
aria-checked={selected}
aria-disabled={!canSelect}
className={cn(
"relative flex min-h-40 w-40 flex-col items-center justify-center gap-3 rounded-2xl bg-white/85 p-4 text-center",
isAvailable
? "shadow-[0_0_55px_25px_rgba(255,255,255,0.85)]"
: "shadow-[0_0_45px_18px_rgba(255,255,255,0.55)] opacity-90",
installError && "ring-1 ring-destructive/40",
selected && "ring-2 ring-primary",
canSelect && "cursor-pointer hover:bg-white",
)}
data-testid={`onboarding-runtime-${runtime.id}`}
onClick={canSelect ? onSelect : undefined}
onKeyDown={(event) => {
if (canSelect && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
onSelect();
}
}}
role="radio"
tabIndex={canSelect ? 0 : -1}
>
<div className="absolute right-2 top-2">
<RuntimeStatus
installError={installError}
installSuccess={installSuccess}
isInstalling={isInstalling}
onInstall={onInstall}
runtime={runtime}
@@ -355,6 +510,10 @@ function RuntimeCard({
{installSuccess && runtime.availability !== "available" ? (
<p className="mt-1 text-2xs leading-4 text-primary">Installed</p>
) : null}
{selected ? (
<p className="mt-1 text-2xs font-medium text-primary">Preferred</p>
) : null}
<RuntimeAuthActions onAuthenticated={onSelect} runtime={runtime} />
</div>
</div>
);
@@ -419,11 +578,26 @@ function GitBashPrerequisiteCard() {
}
function RuntimeProvidersSection({
isSelectionSaving,
onSelectedRuntimeChange,
runtimeProviders,
selectedRuntimeId,
}: {
isSelectionSaving: boolean;
onSelectedRuntimeChange: (runtimeId: string) => void;
runtimeProviders: SetupStepState["runtimeProviders"];
selectedRuntimeId: string | null;
}) {
const { errorMessage, isChecking, items } = runtimeProviders;
const runtimeOrder = ["claude", "codex", "goose", "buzz-agent"];
const orderedItems = [...items].sort((left, right) => {
const leftIndex = runtimeOrder.indexOf(left.id);
const rightIndex = runtimeOrder.indexOf(right.id);
return (
(leftIndex === -1 ? runtimeOrder.length : leftIndex) -
(rightIndex === -1 ? runtimeOrder.length : rightIndex)
);
});
const installMutation = useInstallAcpRuntimeMutation();
const [installResults, setInstallResults] = React.useState<
Record<string, InstallResultState>
@@ -471,8 +645,12 @@ function RuntimeProvidersSection({
<GitBashPrerequisiteCard />
{items.length > 0 ? (
<div className="flex flex-wrap items-stretch justify-center gap-4">
{items.map((runtime) => (
<div
aria-label="Preferred agent harness"
className="flex flex-wrap items-stretch justify-center gap-4"
role="radiogroup"
>
{orderedItems.map((runtime) => (
<RuntimeCard
installError={installResults[runtime.id]?.error ?? null}
installSuccess={installResults[runtime.id]?.success ?? false}
@@ -482,7 +660,10 @@ function RuntimeProvidersSection({
}
key={runtime.id}
onInstall={() => handleInstall(runtime.id)}
onSelect={() => onSelectedRuntimeChange(runtime.id)}
runtime={runtime}
selectionDisabled={isSelectionSaving}
selected={selectedRuntimeId === runtime.id}
/>
))}
</div>
@@ -512,6 +693,10 @@ function RuntimeProvidersSection({
function SetupStepContent({
actions,
direction,
isSelectionSaving,
onSelectedRuntimeChange,
selectionError,
selectedRuntimeId,
state,
}: SetupStepContentProps) {
const { runtimeProviders } = state;
@@ -525,16 +710,30 @@ function SetupStepContent({
direction={direction}
transitionKey={`setup-${direction}`}
>
<RuntimeProvidersSection runtimeProviders={runtimeProviders} />
<RuntimeProvidersSection
isSelectionSaving={isSelectionSaving}
onSelectedRuntimeChange={onSelectedRuntimeChange}
runtimeProviders={runtimeProviders}
selectedRuntimeId={selectedRuntimeId}
/>
<OnboardingFooter>
{selectionError ? (
<p
className="max-w-sm text-center text-xs text-destructive"
role="alert"
>
{selectionError}
</p>
) : null}
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-setup-next"
disabled={!selectedRuntimeId || isSelectionSaving}
onClick={actions.next}
type="button"
>
Next
{isSelectionSaving ? "Saving…" : "Next"}
</Button>
<Button
@@ -551,10 +750,25 @@ function SetupStepContent({
);
}
export function SetupStep({ actions, direction }: SetupStepProps) {
export function SetupStep({
actions,
direction,
isSelectionSaving,
onSelectedRuntimeChange,
selectionError,
selectedRuntimeId,
}: SetupStepProps) {
const state = useSetupStepState();
return (
<SetupStepContent actions={actions} direction={direction} state={state} />
<SetupStepContent
actions={actions}
direction={direction}
isSelectionSaving={isSelectionSaving}
onSelectedRuntimeChange={onSelectedRuntimeChange}
selectionError={selectionError}
selectedRuntimeId={selectedRuntimeId}
state={state}
/>
);
}
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import { runtimeCanBeSelected } from "./onboardingRuntimeSelection.ts";
function runtime(id, availability, status) {
return { id, availability, authStatus: { status } };
}
test("Claude and Codex require available authenticated CLIs", () => {
for (const id of ["claude", "codex"]) {
assert.equal(
runtimeCanBeSelected(runtime(id, "available", "logged_in")),
true,
);
assert.equal(
runtimeCanBeSelected(runtime(id, "available", "not_applicable")),
true,
);
assert.equal(
runtimeCanBeSelected(runtime(id, "available", "logged_out")),
false,
);
assert.equal(
runtimeCanBeSelected(runtime(id, "available", "config_invalid")),
false,
);
assert.equal(
runtimeCanBeSelected(runtime(id, "available", "unknown")),
false,
);
assert.equal(
runtimeCanBeSelected(runtime(id, "not_installed", "logged_in")),
false,
);
}
});
test("Buzz Agent and Goose remain selectable when available", () => {
for (const id of ["buzz-agent", "goose"]) {
assert.equal(
runtimeCanBeSelected(runtime(id, "available", "not_applicable")),
true,
);
assert.equal(
runtimeCanBeSelected(runtime(id, "not_installed", "not_applicable")),
false,
);
}
});
test("unknown runtimes are not onboarding choices", () => {
assert.equal(
runtimeCanBeSelected(runtime("custom", "available", "logged_in")),
false,
);
});
@@ -0,0 +1,12 @@
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
export function runtimeCanBeSelected(runtime: AcpRuntimeCatalogEntry) {
if (runtime.availability !== "available") return false;
if (runtime.id === "claude" || runtime.id === "codex") {
return (
runtime.authStatus.status === "logged_in" ||
runtime.authStatus.status === "not_applicable"
);
}
return runtime.id === "buzz-agent" || runtime.id === "goose";
}
@@ -3,6 +3,7 @@ import test from "node:test";
import {
activateWelcomeTeamPersonasSequentially,
buildWelcomeStarterCreateInput,
LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT,
pickWelcomeGuideAgent,
pickWelcomeGuideAgentForRelay,
@@ -152,6 +153,58 @@ test("starter persona activation is serialized to protect the shared store", asy
assert.deepEqual(calls, ["builtin:fizz", "builtin:honey", "builtin:bumble"]);
});
test("all Welcome starters use the onboarding runtime preference", async () => {
const claude = {
id: "claude",
label: "Claude",
avatarUrl: "https://runtime/claude.png",
availability: "available",
command: "claude-code-acp",
binaryPath: "/bin/claude-code-acp",
defaultArgs: [],
mcpCommand: null,
installHint: "",
installInstructionsUrl: "",
canAutoInstall: false,
underlyingCliPath: "/bin/claude",
};
const buzzAgent = {
...claude,
id: "buzz-agent",
label: "Buzz Agent",
command: "buzz-agent",
};
for (const starter of WELCOME_TEAM_STARTERS) {
const input = await buildWelcomeStarterCreateInput(
starter,
{
id: starter.personaId,
displayName: starter.name,
systemPrompt: `${starter.name} prompt`,
model: null,
provider: null,
runtime: null,
avatarUrl: null,
envVars: {},
isBuiltIn: true,
isActive: true,
},
[buzzAgent, claude],
"claude",
RELAY_A,
);
assert.equal(input.agentCommand, "claude-code-acp");
assert.equal(input.harnessOverride, true);
assert.equal(input.personaId, starter.personaId);
assert.equal(input.teamId, WELCOME_TEAM_ID);
assert.equal(input.relayUrl, RELAY_A);
assert.equal(input.spawnAfterCreate, false);
assert.equal(input.startOnAppLaunch, false);
}
});
test("welcome team starter definitions and role identities are stable", () => {
assert.equal(WELCOME_TEAM_ID, "builtin-team:welcome");
assert.deepEqual(WELCOME_TEAM_STARTERS, [
+59 -10
View File
@@ -1,12 +1,23 @@
import {
buildInstanceInputForDefinition,
resolveStartRuntimeForDefinition,
} from "@/features/agents/lib/instanceInputForDefinition";
import {
addChannelMembers,
createManagedAgent,
discoverAcpRuntimes,
getChannelMembers,
listManagedAgents,
updateManagedAgent,
} from "@/shared/api/tauri";
import { getGlobalAgentConfig } from "@/shared/api/tauriGlobalAgentConfig";
import { listPersonas, setPersonaActive } from "@/shared/api/tauriPersonas";
import type { ManagedAgent } from "@/shared/api/types";
import type {
AcpRuntime,
AgentPersona,
CreateManagedAgentInput,
ManagedAgent,
} from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
export const WELCOME_GUIDE_AGENT_NAME = "Fizz";
@@ -195,6 +206,29 @@ async function ensureWelcomeTeamMembership(
}
}
export async function buildWelcomeStarterCreateInput(
starter: WelcomeTeamStarterDefinition,
persona: AgentPersona,
runtimes: readonly AcpRuntime[],
preferredRuntimeId: string | null,
relayUrl?: string | null,
): Promise<CreateManagedAgentInput> {
const { runtime } = resolveStartRuntimeForDefinition(
persona,
runtimes,
preferredRuntimeId,
);
return {
...(await buildInstanceInputForDefinition(persona, runtime)),
name: starter.name,
teamId: WELCOME_TEAM_ID,
relayUrl: relayUrl ?? undefined,
spawnAfterCreate: false,
startOnAppLaunch: false,
respondTo: "owner-only",
};
}
/**
* Ensure the complete built-in Welcome Team is ready for kickoff.
* The team itself is Rust-seeded; this only activates personas, creates any
@@ -206,6 +240,17 @@ async function provisionWelcomeTeam(
): Promise<WelcomeTeamAgents> {
const existingAgents = await listManagedAgents();
await ensureWelcomeTeamPersonasActive();
const [personas, runtimeCatalog, globalConfig] = await Promise.all([
listPersonas(),
discoverAcpRuntimes(),
getGlobalAgentConfig(),
]);
const personasById = new Map(
personas.map((persona) => [persona.id, persona]),
);
const runtimes = runtimeCatalog.filter(
(runtime): runtime is AcpRuntime => runtime.availability === "available",
);
const agents: ManagedAgent[] = [];
for (const starter of WELCOME_TEAM_STARTERS) {
@@ -219,15 +264,19 @@ async function provisionWelcomeTeam(
continue;
}
const created = await createManagedAgent({
name: starter.name,
personaId: starter.personaId,
teamId: WELCOME_TEAM_ID,
relayUrl: relayUrl ?? undefined,
spawnAfterCreate: false,
startOnAppLaunch: false,
respondTo: "owner-only",
});
const persona = personasById.get(starter.personaId);
if (!persona) {
throw new Error(`${starter.name} agent not found.`);
}
const created = await createManagedAgent(
await buildWelcomeStarterCreateInput(
starter,
persona,
runtimes,
globalConfig.preferred_runtime,
relayUrl,
),
);
agents.push(created.agent);
}
const [lead, honey, bumble] = agents;
@@ -25,6 +25,7 @@ import {
useUpdateManagedAgentMutation,
useUpdatePersonaMutation,
} from "@/features/agents/hooks";
import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
import { AddAgentToChannelDialog } from "@/features/agents/ui/AddAgentToChannelDialog";
import {
availableRuntimesForStart,
@@ -125,6 +126,7 @@ export function UserProfilePanel({
widthPx,
transparentChrome = false,
}: UserProfilePanelProps) {
const { globalConfig } = useGlobalAgentConfig();
const isOverlay = useIsThreadPanelOverlay();
const isSplitLayout = layout === "split";
useEscapeKey(onClose, isOverlay || isSinglePanelView);
@@ -417,6 +419,7 @@ export function UserProfilePanel({
const { runtime, warnings } = resolveStartRuntimeForDefinition(
personaToStart,
runtimes,
globalConfig.preferred_runtime,
);
for (const warning of warnings) {
@@ -436,6 +439,7 @@ export function UserProfilePanel({
[
availableRuntimesQuery,
createAgentMutation.mutateAsync,
globalConfig.preferred_runtime,
managedAgentsQuery.refetch,
relayAgentsQuery.refetch,
],
+2
View File
@@ -984,6 +984,8 @@ export type GlobalAgentConfig = {
provider: string | null;
/** Global fallback model identifier. Null = no global default. */
model: string | null;
/** Preferred ACP runtime for agents without a persona-specific runtime. */
preferred_runtime: string | null;
};
/**
+15 -3
View File
@@ -290,6 +290,7 @@ type E2eConfig = {
env_vars: Record<string, string>;
provider: string | null;
model: string | null;
preferred_runtime?: string | null;
};
/** Baked build env returned by the display and key-name Tauri commands. */
bakedBuildEnv?: Array<{
@@ -6696,6 +6697,12 @@ async function handleConnectAcpRuntime(
// re-evaluated via addInitScript, so the counter starts at 0 for every test.
let installCallCount = 0;
let addChannelMembersCallCount = 0;
let mockGlobalAgentConfig: {
env_vars: Record<string, string>;
provider: string | null;
model: string | null;
preferred_runtime?: string | null;
} | null = null;
// Per-page get_nsec call counter for sequenced error testing.
let nsecCallCount = 0;
@@ -8444,6 +8451,9 @@ export function maybeInstallE2eTauriMocks() {
return;
}
mockGlobalAgentConfig = config.mock?.globalAgentConfig
? { ...config.mock.globalAgentConfig }
: null;
resetMockRelayMembers(config);
resetMockRelayAgents(config);
resetMockManagedAgents(config);
@@ -9456,13 +9466,13 @@ export function maybeInstallE2eTauriMocks() {
return null;
}
case "get_global_agent_config": {
// Return the mock global agent config if provided; otherwise return
// an empty config (no global provider, model, or env vars).
// Return the mutable persisted mock value, seeded from the test config.
return (
config?.mock?.globalAgentConfig ?? {
mockGlobalAgentConfig ?? {
env_vars: {},
provider: null,
model: null,
preferred_runtime: null,
}
);
}
@@ -9476,6 +9486,7 @@ export function maybeInstallE2eTauriMocks() {
env_vars: Record<string, string>;
provider: string | null;
model: string | null;
preferred_runtime: string | null;
};
}
).config;
@@ -9490,6 +9501,7 @@ export function maybeInstallE2eTauriMocks() {
if (saveDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, saveDelayMs));
}
mockGlobalAgentConfig = savedConfig;
// In the E2E environment there are no running agents to restart, so
// the counts default to 0 unless a spec drives them explicitly.
return {