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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user