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
parent 68e670e001
commit c19e0ce2d4
26 changed files with 1072 additions and 60 deletions

View File

@@ -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");

View File

@@ -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 = {

View File

@@ -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] ??

View File

@@ -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(
() =>

View File

@@ -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);

View File

@@ -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. */

View File

@@ -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"),

View File

@@ -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);

View File

@@ -18,6 +18,7 @@ const EMPTY_CONFIG: GlobalAgentConfig = {
env_vars: {},
provider: null,
model: null,
preferred_runtime: null,
};
export const globalAgentConfigQueryKey = ["globalAgentConfig"] as const;