Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 96 additions & 6 deletions src/bundled/goose/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,89 @@ describe("GooseSetup", () => {
{ id: "qwen3", label: "qwen3" },
{ id: "llama4", label: "llama4" },
]);
expect((await goose.models("anthropic")).map((item) => item.id)).toEqual([
"claude-opus-5-5",
"claude-haiku-5-5",
expect(await goose.models("hidden")).toEqual([]);
});

it("offers no catalog models for a provider that is not set up, and says what it needs", async () => {
const saved = new Map<string, Set<string>>();
const { setup: goose } = setup({
saved,
supported: ["databricks-gpt-6-luna"],
});
await expect(goose.models("databricks")).rejects.toThrow(
"Databricks isn't set up in goose. Add Host (DATABRICKS_HOST) in Edit configuration, or run goose configure.",
);
// `goose configure` sets it up meanwhile; a fresh read finds its models.
saved.set("databricks", new Set(["DATABRICKS_HOST"]));
await goose.check();
expect(await goose.models("databricks")).toEqual([
{ id: "databricks-gpt-6-luna", label: "databricks-gpt-6-luna" },
]);
});

it("offers no models for a provider that signs in until it has", async () => {
const { setup: goose } = setup({
providers: [
{
providerId: "copilot",
providerName: "GitHub Copilot",
configured: false,
visibleInSetup: true,
configKeys: [
{
name: "GITHUB_COPILOT_TOKEN",
required: true,
secret: true,
deviceCodeFlow: true,
},
],
models: [{ id: "gpt-6" }, { id: "claude-opus-5-5" }],
},
],
});
await expect(goose.models("copilot")).rejects.toThrow(
"GitHub Copilot isn't set up in goose. Run goose configure to sign in.",
);
await expect(
goose.picks({ provider: "copilot", model: "gpt-6" }),
).resolves.toEqual({
current: "gpt-6",
models: [{ id: "gpt-6", label: "gpt-6", efforts: [] }],
});
});

it("offers the models of a provider that runs on its fields' defaults", async () => {
// goose lists Ollama as not configured without `OLLAMA_HOST`, though it
// runs on localhost's.
const { setup: goose } = setup({
providers: [
{
providerId: "ollama",
providerName: "Ollama",
configured: false,
visibleInSetup: true,
configKeys: [
{
name: "OLLAMA_HOST",
required: true,
secret: false,
default: "localhost",
},
],
models: [{ id: "qwen3" }, { id: "llama4" }],
},
],
});
expect((await goose.models("ollama")).map(({ id }) => id)).toEqual([
"qwen3",
"llama4",
]);
expect(await goose.models("databricks")).toEqual([]);
await expect(
goose.picks({ provider: "ollama", model: "qwen3" }),
).resolves.toMatchObject({
current: "qwen3",
models: [{ id: "qwen3" }, { id: "llama4" }],
});
});

it("lists only the workspace's and goose's own Databricks endpoints", async () => {
Expand Down Expand Up @@ -189,8 +267,11 @@ describe("GooseSetup", () => {

it("offers an agent its provider's models, or those of goose's config", async () => {
const { setup: goose } = setup({ supported: ["qwen3", "llama4"] });
const { setup: configured } = setup({
saved: new Map([["anthropic", new Set(["ANTHROPIC_API_KEY"])]]),
});
await expect(
goose.picks({ provider: "anthropic", model: "" }),
configured.picks({ provider: "anthropic", model: "" }),
).resolves.toEqual({
current: "claude-sonnet-5-5",
models: [
Expand All @@ -200,11 +281,20 @@ describe("GooseSetup", () => {
],
});
await expect(
goose.picks({ provider: "anthropic", model: "claude-haiku-5-5" }),
configured.picks({ provider: "anthropic", model: "claude-haiku-5-5" }),
).resolves.toMatchObject({
current: "claude-haiku-5-5",
models: [{ id: "claude-opus-5-5" }, { id: "claude-haiku-5-5" }],
});
// A provider that is not set up offers only the model the agent names.
await expect(
goose.picks({ provider: "anthropic", model: "claude-haiku-5-5" }),
).resolves.toEqual({
current: "claude-haiku-5-5",
models: [
{ id: "claude-haiku-5-5", label: "claude-haiku-5-5", efforts: [] },
],
});
await expect(goose.picks({ provider: "", model: "" })).resolves.toEqual({
current: "qwen3",
models: [
Expand Down
39 changes: 29 additions & 10 deletions src/bundled/goose/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export type Provider = Readonly<{
signsIn: boolean;
steps: readonly string[];
models: readonly ModelChoice[];
/** Why it isn't set up to run, or "" when it is. */
needs: string;
}>;

export type Providers = Readonly<{
Expand Down Expand Up @@ -206,17 +208,15 @@ export class GooseSetup {
const provider = choice.provider.trim();
const { providers, defaults } = await this.providers();
const id = provider || defaults.provider;
const listed = providers.find((item) => item.id === id);
const current =
choice.model.trim() ||
(provider
? providers.find((item) => item.id === id)?.defaultModel
: defaults.model) ||
(provider ? listed?.defaultModel : defaults.model) ||
"";
const models = (id ? await this.models(id) : []).map(({ id, label }) => ({
id,
label,
efforts: [],
}));
// A provider not set up offers only the model the agent names.
const models = (id && !listed?.needs ? await this.models(id) : []).map(
({ id, label }) => ({ id, label, efforts: [] }),
);
// A model its provider does not list is still the one it runs on.
return !current || models.some((model) => model.id === current)
? { current, models }
Expand Down Expand Up @@ -257,11 +257,13 @@ export class GooseSetup {
}

/** Databricks catalogs can lag its endpoints and AI Gateway services;
* configured providers use live discovery for both Databricks versions. */
* configured providers use live discovery for both Databricks versions.
* A provider not set up offers none: its catalog's would not run. */
async models(id: string): Promise<readonly ModelChoice[]> {
const listed = (await this.providers()).providers.find(
(item) => item.id === id,
);
if (listed?.needs) throw new Error(listed.needs);
const prefixes = PREFIXES[id];
const models =
listed?.configured &&
Expand Down Expand Up @@ -438,7 +440,7 @@ function provider(entry: Entry): Provider {
const models = [...(entry.models ?? [])].sort(
(a, b) => Number(Boolean(b.recommended)) - Number(Boolean(a.recommended)),
);
return {
const listed = {
id: entry.providerId,
name: entry.providerName,
description: entry.description ?? "",
Expand All @@ -457,6 +459,23 @@ function provider(entry: Entry): Provider {
// The exact ID: a catalog name can stand for several models.
models: models.map((model) => ({ id: model.id, label: model.id })),
};
return { ...listed, needs: needs(listed) };
}

/** What `provider` still needs before it runs, or "" for nothing: a required
* field without a default, or signing in. goose lists Ollama as not configured
* without `OLLAMA_HOST`, though it runs on the host's default. */
function needs(provider: Omit<Provider, "needs">) {
if (provider.configured) return "";
const fields = provider.keys
.filter((key) => key.required && !key.default)
.map((key) => `${keyLabel(key.name)} (${key.name})`);
const step = fields.length
? `Add ${fields.join(", ")} in Edit configuration, or run goose configure.`
: provider.signsIn
? "Run goose configure to sign in."
: "";
return step && `${provider.name} isn't set up in goose. ${step}`;
}

/** `ANTHROPIC_API_KEY` reads as `API key`, `OPENAI_HOST` as `Host`. */
Expand Down
121 changes: 94 additions & 27 deletions src/bundled/goose/tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,23 @@ function created(
spawn?: (fake: ReturnType<typeof fakeSpawn>) => GooseSetup;
modelAppearance?: "subtle";
config?: Config;
/** Fields goose already holds, beyond Databricks' host. */
saved?: Readonly<Record<string, readonly string[]>>;
defaults?: Readonly<{ providerId: string; modelId: string }>;
}> = {},
) {
const saved = new Map<string, Set<string>>([
["databricks", new Set(["DATABRICKS_HOST"])],
...Object.entries(options.saved ?? {}).map(
([id, keys]) => [id, new Set(keys)] as const,
),
]);
const fake = fakeSpawn({
providers: PROVIDERS,
defaults: { providerId: "databricks", modelId: "goose-claude" },
defaults: options.defaults ?? {
providerId: "databricks",
modelId: "goose-claude",
},
saved,
reply: options.reply ?? "OK",
thinking: {
Expand Down Expand Up @@ -151,6 +160,35 @@ async function choose(label: string, option: string) {
/** The model field, labeled with goose's default or the agent's choice. */
const modelButton = () => screen.getByRole("button", { name: /^Goose model:/ });

/** Settings' Harness defaults, offering Goose on `setup`. */
function gooseDefaults(setup: GooseSetup) {
const { DefaultControl, ProviderDialogBody } = createTabs({
setup,
...unused,
});
const types = [
{
defaults: () => ({ config: DEFAULT_CONFIG }),
key: "buzz.goose/goose",
pluginId: "buzz.goose",
revision: "test",
id: "goose",
title: "Goose",
provider: {
subscribe: setup.subscribe,
snapshot: setup.snapshot,
check: setup.check,
DefaultControl,
DialogBody: ProviderDialogBody,
},
},
] as RegisteredAgentType[];
return {
types: () => types,
subscribe: () => () => {},
} as unknown as Agents;
}

/** Opens the provider dialog from the model field. */
async function openChoices() {
await userEvent.setup().click(modelButton());
Expand Down Expand Up @@ -236,8 +274,36 @@ it("keeps provider fields visible while saving its key and selecting a model", a
expect(changes.at(-1)).toMatchObject({ model: "", effort: "" });
});

it("says a provider is not set up instead of offering its catalog's models", async () => {
const { fake, saved } = created({
defaults: { providerId: "anthropic", modelId: "claude-sonnet-5-5" },
});
expect(
await screen.findByText(
"Anthropic isn't set up in goose. Add API key (ANTHROPIC_API_KEY) in Edit configuration, or run goose configure.",
),
).toHaveAttribute("role", "alert");
const user = userEvent.setup();
await user.click(modelButton());
expect(await screen.findByRole("menu")).toBeVisible();
expect(screen.queryByRole("menuitem", { name: "Claude" })).toBeNull();
await user.keyboard("{Escape}");
// `goose configure` sets it up meanwhile, on another default model.
saved.set("anthropic", new Set(["ANTHROPIC_API_KEY"]));
await new GooseSetup(fake).saveDefaults({
provider: "anthropic",
model: "claude-opus-5-5",
});
await user.click(screen.getByRole("button", { name: "Try again" }));
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
await waitFor(() =>
expect(modelButton()).toHaveTextContent("claude-opus-5-5"),
);
});

it("keeps a card's model chip to the model, while its menu shows the level", async () => {
created({
saved: { anthropic: ["ANTHROPIC_API_KEY"] },
modelAppearance: "subtle",
config: {
...DEFAULT_CONFIG,
Expand Down Expand Up @@ -959,6 +1025,8 @@ for (const host of ["defaults", "create"] as const) {
fail = reject;
}),
);
// A recheck that never settles: retry feedback must not wait on it.
vi.spyOn(setup, "check").mockReturnValue(new Promise(() => {}));
const { DefaultControl, CreateModel } = createTabs({ setup, ...unused });
render(
host === "defaults" ? (
Expand Down Expand Up @@ -993,6 +1061,30 @@ for (const host of ["defaults", "create"] as const) {
});
}

it("keeps Goose default recovery focused where a recheck would need attention", async () => {
const setup = new GooseSetup(
fakeSpawn({
providers: PROVIDERS,
defaults: { providerId: "databricks", modelId: "goose-claude" },
}),
);
vi.spyOn(setup, "models")
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValue([{ id: "goose-claude", label: "Claude" }]);
render(<AgentDefaults agents={gooseDefaults(setup)} />);
const retry = await screen.findByRole("button", { name: "Try again" });
// A recheck would now settle on attention and replace the control.
vi.spyOn(setup, "providers").mockRejectedValue(new Error("unreachable"));
retry.focus();
await userEvent.setup().keyboard("{Enter}");
await waitFor(() =>
expect(
screen.getByRole("button", { name: /^Goose default model:/ }),
).toHaveFocus(),
);
expect(setup.snapshot().readiness).toBe("ready");
});

it.each([false, true])(
"preserves return focus through pending discovery (move focus: %s)",
async (moveFocus) => {
Expand All @@ -1003,34 +1095,9 @@ it.each([false, true])(
supported,
});
const setup = new GooseSetup(fake);
const { DefaultControl, ProviderDialogBody } = createTabs({
setup,
...unused,
});
const types = [
{
defaults: () => ({ config: DEFAULT_CONFIG }),
key: "buzz.goose/goose",
pluginId: "buzz.goose",
revision: "test",
id: "goose",
title: "Goose",
provider: {
subscribe: setup.subscribe,
snapshot: setup.snapshot,
check: setup.check,
DefaultControl,
DialogBody: ProviderDialogBody,
},
},
] as RegisteredAgentType[];
const agents = {
types: () => types,
subscribe: () => () => {},
} as unknown as Agents;
render(
<>
<AgentDefaults agents={agents} />
<AgentDefaults agents={gooseDefaults(setup)} />
<button type="button">Another setting</button>
</>,
);
Expand Down
Loading
Loading