diff --git a/src/bundled/goose/setup.test.ts b/src/bundled/goose/setup.test.ts index 626cddddc8..5a3414b741 100644 --- a/src/bundled/goose/setup.test.ts +++ b/src/bundled/goose/setup.test.ts @@ -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>(); + 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 () => { @@ -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: [ @@ -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: [ diff --git a/src/bundled/goose/setup.ts b/src/bundled/goose/setup.ts index 6f5876576e..127d7a45c1 100644 --- a/src/bundled/goose/setup.ts +++ b/src/bundled/goose/setup.ts @@ -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<{ @@ -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 } @@ -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 { 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 && @@ -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 ?? "", @@ -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) { + 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`. */ diff --git a/src/bundled/goose/tabs.test.tsx b/src/bundled/goose/tabs.test.tsx index 095cc98f46..a4f6e9c6f2 100644 --- a/src/bundled/goose/tabs.test.tsx +++ b/src/bundled/goose/tabs.test.tsx @@ -84,14 +84,23 @@ function created( spawn?: (fake: ReturnType) => GooseSetup; modelAppearance?: "subtle"; config?: Config; + /** Fields goose already holds, beyond Databricks' host. */ + saved?: Readonly>; + defaults?: Readonly<{ providerId: string; modelId: string }>; }> = {}, ) { const saved = new Map>([ ["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: { @@ -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()); @@ -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, @@ -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" ? ( @@ -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(); + 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) => { @@ -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( <> - + , ); diff --git a/src/bundled/goose/tabs.tsx b/src/bundled/goose/tabs.tsx index 81e5c808b7..2cbd6be3db 100644 --- a/src/bundled/goose/tabs.tsx +++ b/src/bundled/goose/tabs.tsx @@ -162,6 +162,8 @@ export function createTabs({ (provider ? setup.models(provider) : Promise.resolve([])), provider, ); + const refresh = () => { + reload(); + reloadModels(); + }; const fallback = (current.provider ? listing.value?.providers.find((item) => item.id === provider) @@ -1081,7 +1087,12 @@ export function createTabs({ { + void setup.check(); + refresh(); + }} disabled={disabled} >