mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user