Skip to content
Open
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
17 changes: 17 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,23 @@ available rather than silently discarding it.
that Builderlab server's site, where the account's usage and billing are.
The **Remote agent** hint in a signed-in Builderlab agent's **Identity** tab
also links to **See usage and billing** on that site.
- Some builds offer **AI settings → Automatic (Recommended)** when creating an
agent. Buzz picks the first recommended harness that is set up on this
computer, with its model and thinking level, shows it as **Uses** with the
model and level, and keeps those current in later versions. It never changes
the harness afterwards. If no recommended model is offered when the agent
runs, it uses the harness's default. If nothing recommended is set up on this
computer, a row asks you to set up Goose, Codex or Claude first, with a
**Set up** button, and Create waits. **Manual** opens
Harness, Model and Thinking in place, starting from what Automatic picked. An
agent's **Identity** tab shows the same **AI settings** row; changing the
model, provider or thinking level there turns Automatic off. Automatic is
for local harnesses; a remote agent such as Builderlab is chosen under
Manual's **Harness** and keeps its own model choices. Beside Send, an agent
on Automatic has no model or thinking choice, only its session controls
where they apply; to choose per message, switch it to **Manual**. A pick
saved in a Solo chat before it switched is ignored. Builds without a
recommendation show none of this.
- Desktop's **Harness** menu groups agent types under **Remote** and **Local**.
Creating a remote agent on desktop opens an introduction with its chosen name
and avatar, explaining its online workspace and file access (Builderlab
Expand Down
298 changes: 296 additions & 2 deletions src/bundled/agents/AgentCreateDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import {
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useLayoutEffect } from "react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { useEffect } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type ReactNode, useEffect } from "react";
import { CreateModelChoices } from "../../features/agents/CreateModelChoices";
import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup";
import { AgentInstructions } from "../../features/agents/AgentInstructions";
Expand Down Expand Up @@ -1726,3 +1726,297 @@ it("shows an agent card's pack avatar as that Gloopie", () => {
"true",
);
});

describe("Automatic (Recommended)", () => {
// Synthetic candidates and harnesses; real values come only from a build.
const candidates = [
{ harness: "codex", model: "model-a", effort: "high", label: "Model A" },
{
harness: "goose",
provider: "provider-b",
model: "model-b",
effort: "high",
label: "Model B",
},
];
const harness = (
id: string,
title: string,
ready: (candidate: { model: string }) => boolean | Promise<boolean>,
) =>
({
...notes,
key: `test/${id}`,
id,
title,
recommends: vi.fn(async (candidate: { model: string }) =>
ready(candidate),
),
CreateModel: () => <p>Model picker</p>,
}) as unknown as RegisteredAgentType;
/** A hosted type, as Builderlab: its own configuration group with the
* host's Harness row, as on main. */
const hosted = () =>
({
key: "test/hosted",
pluginId: "test",
revision: "1",
id: "hosted",
title: "Hosted A",
description: "Runs elsewhere.",
remote: {},
defaults: () => ({ config: { model: "" } }),
CreateConfiguration: function HostedConfiguration({
harnessControl,
onReadyChange,
}: {
harnessControl: ReactNode;
onReadyChange?: (ready: boolean) => void;
}) {
useEffect(() => {
onReadyChange?.(true);
}, [onReadyChange]);
return (
<div>
{harnessControl}
<p>Hosted rows</p>
</div>
);
},
}) as unknown as RegisteredAgentType;
let codexReady = true;
const codex = harness("codex", "Codex", () => codexReady);
const goose = harness("goose", "Goose", () => true);
beforeEach(() => {
codexReady = true;
vi.stubEnv("VITE_BUZZ_RECOMMENDED_AGENT", JSON.stringify(candidates));
});
afterEach(() => vi.unstubAllEnvs());
const aiSettings = (dialog: HTMLElement) =>
within(dialog).getByRole("combobox", { name: "AI settings" });
const pickOption = async (
user: ReturnType<typeof userEvent.setup>,
control: HTMLElement,
option: RegExp,
) => {
await user.click(control);
await user.click(await screen.findByRole("option", { name: option }));
};
const chooseManual = (
user: ReturnType<typeof userEvent.setup>,
dialog: HTMLElement,
) => pickOption(user, aiSettings(dialog), /^Manual/);
const harnessSelect = (dialog: HTMLElement) =>
within(dialog).getByRole("combobox", { name: "Harness" });
/** Whether a manual row is folded away, out of reach under Automatic. */
const folded = (element: HTMLElement) => element.closest("[inert]") !== null;

it("starts on Automatic, resolved to the first ready candidate, and creates a pointer", async () => {
const { agents, dialog, user } = setup([echo, goose, codex]);
// "(Recommended)" is said in the menu, not on the trigger.
expect(aiSettings(dialog)).toHaveTextContent(/^Automatic$/);
// Creation waits for the pick.
expect(createButton()).toBeDisabled();
// A quick check never flashes "Checking this computer…".
expect(
within(dialog).queryByText("Checking this computer…"),
).not.toBeInTheDocument();
expect(
await within(dialog).findByText("Model A · High"),
).toBeInTheDocument();
// Automatic owns the harness and model; their rows wait for Manual.
expect(
within(dialog).queryByRole("combobox", { name: "Harness" }),
).not.toBeInTheDocument();
expect(within(dialog).queryByText("Model picker")).not.toBeInTheDocument();
// There is no choice of where it runs.
expect(
within(dialog).queryByRole("combobox", { name: "Runs on" }),
).not.toBeInTheDocument();
await user.type(within(dialog).getByLabelText("Name"), "Agent A");
await user.click(createButton());
expect(agents.create).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
type: "test/codex",
name: "Agent A",
// The values are not copied: runs read the current candidate.
config: {
notes: "start",
instructions: "Be brief.",
preset: "recommended",
},
}),
);
});

it("falls back to goose and says why when Codex can't run a candidate", async () => {
codexReady = false;
const { agents, dialog, user } = setup([echo, goose, codex]);
expect(
await within(dialog).findByText("Model B · High"),
).toBeInTheDocument();
// No visible note: an (i) after the chip explains, on click too.
expect(within(dialog).queryByText(/isn’t set up/)).not.toBeInTheDocument();
await user.click(
within(dialog).getByRole("button", { name: "Why this model" }),
);
expect(
await screen.findByText(
"Buzz’s usual pick, Codex, isn’t set up on this computer, so this agent uses Model B.",
),
).toBeInTheDocument();
await user.type(within(dialog).getByLabelText("Name"), "Agent A");
await user.click(createButton());
expect(agents.create).toHaveBeenCalledWith(
expect.objectContaining({
type: "test/goose",
config: expect.objectContaining({ preset: "recommended" }),
}),
);
});

it("uses goose without a note where Codex isn't offered, as on Windows", async () => {
const { dialog } = setup([echo, goose]);
expect(
await within(dialog).findByText("Model B · High"),
).toBeInTheDocument();
expect(within(dialog).queryByText(/isn’t set up/)).not.toBeInTheDocument();
});

it("takes a harness's next candidate when its first model isn't offered", async () => {
vi.stubEnv(
"VITE_BUZZ_RECOMMENDED_AGENT",
JSON.stringify([
candidates[0],
{ ...candidates[0], model: "model-c", label: "Model C" },
candidates[1],
]),
);
const picky = harness("codex", "Codex", ({ model }) => model === "model-c");
const { dialog } = setup([echo, goose, picky]);
expect(
await within(dialog).findByText("Model C · High"),
).toBeInTheDocument();
// Codex is the pick, so it isn't named as skipped.
expect(within(dialog).queryByText(/isn’t set up/)).not.toBeInTheDocument();
});

it("opens the harness's rows under Manual, starting from what Automatic picked", async () => {
const { agents, dialog, user } = setup([echo, goose, codex]);
await within(dialog).findByText("Model A · High");
await chooseManual(user, dialog);
expect(aiSettings(dialog)).toHaveTextContent("Manual");
expect(
within(dialog).getByText(
"You choose the harness, model and thinking level.",
),
).toBeInTheDocument();
// The resolved harness stays selected, with its own rows in reach.
expect(harnessSelect(dialog)).toHaveTextContent("Codex");
expect(folded(harnessSelect(dialog))).toBe(false);
expect(folded(within(dialog).getByText("Model picker"))).toBe(false);
await user.type(within(dialog).getByLabelText("Name"), "Agent A");
await user.click(createButton());
// A manual agent keeps the candidate's settings as its own, unmarked.
expect(agents.create).toHaveBeenCalledWith(
expect.objectContaining({
type: "test/codex",
config: {
notes: "start",
instructions: "Be brief.",
model: "model-a",
effort: "high",
},
}),
);
});

it("lets the owner choose manually while the pick is still running, even with one harness", async () => {
let finish = (_ready: boolean) => {};
const slow = harness(
"codex",
"Codex",
() =>
new Promise<boolean>((resolve) => {
finish = resolve;
}),
);
try {
const { agents, dialog, user } = setup([slow]);
await user.type(within(dialog).getByLabelText("Name"), "Agent A");
expect(createButton()).toBeDisabled();
await chooseManual(user, dialog);
expect(createButton()).toBeEnabled();
// The pick finishing later leaves the owner's choice alone.
await act(async () => finish(true));
expect(aiSettings(dialog)).toHaveTextContent("Manual");
await user.click(createButton());
expect(agents.create).toHaveBeenCalledWith(
expect.objectContaining({
type: "test/codex",
config: { notes: "start", instructions: "Be brief." },
}),
);
} finally {
finish(false);
}
});

it("asks for one thing to set up first when nothing recommended is set up", async () => {
codexReady = false;
const unready = harness("goose", "Goose", () => false);
const { agents, dialog, user } = setup([echo, unready, codex]);
expect(
await within(dialog).findByText("One thing to set up first"),
).toBeInTheDocument();
expect(
within(dialog).getByText(
"Buzz needs Goose, Codex or Claude on this computer to run agents.",
),
).toBeInTheDocument();
expect(unready.recommends).toHaveBeenCalled();
expect(aiSettings(dialog)).toBeDisabled();
await user.type(within(dialog).getByLabelText("Name"), "Agent A");
// No harness can run an Automatic agent here, so there is none to create;
// the button stays focusable so its reason can be read.
expect(createButton()).toHaveAttribute("aria-disabled", "true");
await user.click(createButton());
expect(agents.create).not.toHaveBeenCalled();
});

it("offers a hosted type only under Manual's Harness, with its own controls as on main", async () => {
// Automatic starts on a local harness, even when a hosted type is first.
const { dialog, user } = setup([hosted(), echo, goose, codex]);
await within(dialog).findByText("Model A · High");
expect(
within(dialog).queryByRole("combobox", { name: "Runs on" }),
).not.toBeInTheDocument();
expect(within(dialog).queryByText("Hosted rows")).not.toBeInTheDocument();
await chooseManual(user, dialog);
await pickOption(user, harnessSelect(dialog), /^Hosted A/);
// Main's own group, without AI settings, and Harness to go back.
expect(within(dialog).getByText("Hosted rows")).toBeInTheDocument();
expect(
within(dialog).queryByRole("combobox", { name: "AI settings" }),
).not.toBeInTheDocument();
await pickOption(user, harnessSelect(dialog), /^Codex/);
expect(aiSettings(dialog)).toHaveTextContent("Manual");
expect(within(dialog).queryByText("Hosted rows")).not.toBeInTheDocument();
});

it("is absent in builds without a recommendation", async () => {
vi.stubEnv("VITE_BUZZ_RECOMMENDED_AGENT", "");
const { agents, dialog, user } = setup([echo, goose, codex, hosted()]);
expect(
within(dialog).queryByRole("combobox", { name: "AI settings" }),
).not.toBeInTheDocument();
expect(harnessSelect(dialog)).toHaveTextContent("Echo");
await user.type(within(dialog).getByLabelText("Name"), "Agent A");
expect(createButton()).toBeEnabled();
expect(goose.recommends).not.toHaveBeenCalled();
await user.click(createButton());
expect(agents.create).toHaveBeenCalledWith(
expect.objectContaining({ type: "test/echo", config: { reply: "" } }),
);
});
});
Loading
Loading