Q170: agent model suggestions and choices follow the picked provider

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 04:08:17 -04:00
1 parent a8fbc08df6
commit 7c291cf7a7
5 files changed
+60 -13

No files matched your search

@@ -1,3 +1,4 @@
import { assertModelHasProvider } from "../../shared/agent-model";
import { setAutonomousPaused, updateUiPreferences } from "./app-state";
import { updateOrganizationProfile } from "./organization-profile";
import { setGoalDueDate, validDueAt } from "./goal-due-date";
@@ -88,6 +89,7 @@ export class OrganizationService {
createAgent(input: CreateAgentInput): string {
assertUsableMentionName(input.name);
this.assertAgentNameAvailable(input.orgId, input.name, null);
assertModelHasProvider(input.modelProviderId, input.modelId);
this.governance.assertRank(input.orgId, null, input.roleLevel ?? "staff");
const hiringPolicy = this.governance.enforceHiringPolicy(input);
const id = createId("agt");
@@ -173,6 +175,7 @@ export class OrganizationService {
assertUsableMentionName(name);
this.assertAgentNameAvailable(input.orgId, name, input.agentId);
}
assertModelHasProvider(input.modelProviderId, input.modelId);
if (input.modelProviderId) {
const provider = this.db.prepare("select id from providers where id = ? and org_id = ?")
.get(input.modelProviderId, input.orgId) as { id: string } | undefined;
+20 -13
View File
@@ -5,7 +5,7 @@ import { AvatarField } from "../shared/AvatarField";
import { TopbarActions } from "../shared/TopbarActions";
import { Dialog } from "../shared/Dialog";
import { OrgChartTree } from "./team/OrgChartTree";
import type { AgentRecord, EmployeeRecord, ProviderRecord } from "../../../shared/domain";
import type { AgentRecord, EmployeeRecord, ProviderCatalogEntry, ProviderRecord } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId, money } from "./types";
import { t } from "../i18n/i18n";
@@ -148,8 +148,9 @@ export function TeamPage({ snapshot, actions }: PageProps) {
</select>
</Field>
<Field label={t("Model")}>
<input className="input" name="modelId" list="agent-model-suggestions" value={hireModelId} placeholder={providers.find((provider) => provider.id === hireProviderId)?.model ?? ""} onChange={(event) => setHireModelId(event.currentTarget.value)} />
<input className="input" name="modelId" list="hire-model-suggestions" value={hireModelId} disabled={!hireProviderId} placeholder={providers.find((provider) => provider.id === hireProviderId)?.model ?? ""} onChange={(event) => setHireModelId(event.currentTarget.value)} />
</Field>
<ModelSuggestions id="hire-model-suggestions" providerId={hireProviderId} providers={providers} catalog={snapshot.providerCatalog} />
<p style={{ fontSize: 12, color: "var(--text-3)", margin: 0 }}>{t("Leave the model blank to use the provider's default. A changed model applies from the agent's next session.")}</p>
<Field label={t("Manager")}>
<select className="input" name="managerAgentId" defaultValue="">
@@ -175,7 +176,7 @@ export function TeamPage({ snapshot, actions }: PageProps) {
<Field label={t("Title")}><input className="input" name="title" defaultValue={editingAgent.title} /></Field>
<Field label={t("Department")}><input className="input" name="department" defaultValue={editingAgent.department} required /></Field>
<Field label={t("Monthly budget")}><input className="input" name="budget" type="number" min="0" step="1" defaultValue={editingAgent.budgetCents / 100} /></Field>
<EditModelFields agent={editingAgent} providers={providers} />
<EditModelFields agent={editingAgent} providers={providers} catalog={snapshot.providerCatalog} />
<p style={{ fontSize: 12, color: "var(--text-3)", margin: 0 }}>{t("Leave the model blank to use the provider's default. A changed model applies from the agent's next session.")}</p>
<Field label={t("Seniority")}>
{/* The CEO's rank carries company-wide powers, so it is shown, never picked. */}
@@ -195,13 +196,6 @@ export function TeamPage({ snapshot, actions }: PageProps) {
</Dialog>
)}
<datalist id="agent-model-suggestions">
{snapshot.providerCatalog.flatMap((entry) =>
entry.models.map((model) => (
<option key={`${entry.key}-${model.id}`} value={model.id}>{entry.label} · {model.name}</option>
)),
)}
</datalist>
</div>
);
}
@@ -263,23 +257,36 @@ function buildRoster(
}
/** The placeholder names the default of the provider picked now, which is the model a blank field will follow. */
function EditModelFields({ agent, providers }: { agent: AgentRecord; providers: ProviderRecord[] }) {
function EditModelFields({ agent, providers, catalog }: { agent: AgentRecord; providers: ProviderRecord[]; catalog: ProviderCatalogEntry[] }) {
const [providerId, setProviderId] = useState(agent.modelProviderId ?? "");
const [modelId, setModelId] = useState(agent.modelId ?? "");
return (
<>
<Field label={t("Provider")}>
<select className="input" name="modelProviderId" value={providerId} onChange={(event) => setProviderId(event.currentTarget.value)}>
<select className="input" name="modelProviderId" value={providerId} onChange={(event) => { setProviderId(event.currentTarget.value); setModelId(""); }}>
<option value="">{t("Unassigned")}</option>
{providers.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}
</select>
</Field>
<Field label={t("Model")}>
<input className="input" name="modelId" defaultValue={agent.modelId ?? ""} placeholder={providers.find((provider) => provider.id === providerId)?.model ?? ""} list="agent-model-suggestions" />
<input className="input" name="modelId" value={modelId} onChange={(event) => setModelId(event.currentTarget.value)} disabled={!providerId} placeholder={providers.find((provider) => provider.id === providerId)?.model ?? ""} list="edit-model-suggestions" />
</Field>
<ModelSuggestions id="edit-model-suggestions" providerId={providerId} providers={providers} catalog={catalog} />
</>
);
}
/** Suggests only the picked provider's models; a model from another provider would mean nothing here. */
function ModelSuggestions({ id, providerId, providers, catalog }: { id: string; providerId: string; providers: ProviderRecord[]; catalog: ProviderCatalogEntry[] }) {
const providerKey = providers.find((provider) => provider.id === providerId)?.providerKey;
const models = catalog.find((entry) => entry.key === providerKey)?.models ?? [];
return (
<datalist id={id}>
{models.map((model) => <option key={model.id} value={model.id}>{model.name}</option>)}
</datalist>
);
}
function RosterRow({ row, last, onEdit }: { row: RosterEntry; last: boolean; onEdit: (() => void) | null }) {
return (
<div
+5
View File
@@ -9,6 +9,11 @@ export function agentModel(agentModelId: string | null, providerModel: string |
return { model: null, source: "none" };
}
/** A model names one of its provider's models, so it means nothing without that provider. */
export function assertModelHasProvider(modelProviderId: string | null, modelId: string | null): void {
if (modelId?.trim() && !modelProviderId) throw new Error("Pick the provider before its model.");
}
/** Agents a change to this provider's default model reaches, and those that keep their own model. */
export function defaultModelReach(agents: Array<{ modelProviderId: string | null; modelId: string | null }>, providerId: string): { following: number; ownModel: number } {
const bound = agents.filter((agent) => agent.modelProviderId === providerId);
+6
View File
@@ -105,8 +105,14 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
await expect(editModel).toHaveAttribute("placeholder", "gpt-5.4");
const editProvider = page.locator(".modal select[name=modelProviderId]");
const savedProvider = await editProvider.inputValue();
// Suggestions come from the picked provider only, and switching provider clears the model.
await expect(page.locator("#edit-model-suggestions option").first()).toHaveAttribute("value", /.+/);
await expect(page.locator("#edit-model-suggestions option[value^=claude]")).toHaveCount(0);
await editModel.fill("gpt-5.4");
await editProvider.selectOption("");
await expect(editModel).toHaveAttribute("placeholder", "");
await expect(editModel).toHaveValue("");
await expect(editModel).toBeDisabled();
await editProvider.selectOption(savedProvider);
await page.locator(".modal").getByRole("button", { name: "Save" }).click();
await expect(page.getByText("Alex Turing").first()).toBeVisible();
+26
View File
@@ -0,0 +1,26 @@
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { DatabaseConnection } from "../../src/main/database/connection";
import { OrganizationService } from "../../src/main/services/organization-service";
describe("an agent's model belongs to its provider", () => {
test("a model without a provider is refused on hire and on edit", () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-agent-model-${randomUUID()}.db`));
const organization = new OrganizationService(connection.db);
let orgId = "";
let agentId = "";
connection.transaction(() => {
orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" });
});
const hire = (modelId: string | null) => organization.createAgent({
orgId, name: `Ada ${randomUUID().slice(0, 4)}`, role: "Engineer", department: "Engineering", modelProviderId: null, modelId, budgetCents: 1000, managerAgentId: null,
});
expect(() => connection.transaction(() => hire("gpt-5.4"))).toThrow("Pick the provider before its model.");
connection.transaction(() => { agentId = hire(null); });
expect(() => connection.transaction(() => organization.updateAgent({
orgId, agentId, name: "Ada", role: "Engineer", title: "Engineer", roleLevel: "staff", department: "Engineering", budgetCents: 1000, modelProviderId: null, modelId: "gpt-5.4",
}))).toThrow("Pick the provider before its model.");
});
});