diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index 08ac6215..3cd99051 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -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; diff --git a/src/renderer/src/pages/TeamPage.tsx b/src/renderer/src/pages/TeamPage.tsx index 97c805c5..88b392db 100644 --- a/src/renderer/src/pages/TeamPage.tsx +++ b/src/renderer/src/pages/TeamPage.tsx @@ -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) { - provider.id === hireProviderId)?.model ?? ""} onChange={(event) => setHireModelId(event.currentTarget.value)} /> + provider.id === hireProviderId)?.model ?? ""} onChange={(event) => setHireModelId(event.currentTarget.value)} /> +

{t("Leave the model blank to use the provider's default. A changed model applies from the agent's next session.")}

- +

{t("Leave the model blank to use the provider's default. A changed model applies from the agent's next session.")}

{/* The CEO's rank carries company-wide powers, so it is shown, never picked. */} @@ -195,13 +196,6 @@ export function TeamPage({ snapshot, actions }: PageProps) { )} - - {snapshot.providerCatalog.flatMap((entry) => - entry.models.map((model) => ( - - )), - )} - ); } @@ -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 ( <> - { setProviderId(event.currentTarget.value); setModelId(""); }}> {providers.map((provider) => )} - provider.id === providerId)?.model ?? ""} list="agent-model-suggestions" /> + setModelId(event.currentTarget.value)} disabled={!providerId} placeholder={providers.find((provider) => provider.id === providerId)?.model ?? ""} list="edit-model-suggestions" /> + ); } +/** 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 ( + + {models.map((model) => )} + + ); +} + function RosterRow({ row, last, onEdit }: { row: RosterEntry; last: boolean; onEdit: (() => void) | null }) { return (
, providerId: string): { following: number; ownModel: number } { const bound = agents.filter((agent) => agent.modelProviderId === providerId); diff --git a/tests/e2e/desktop.spec.ts b/tests/e2e/desktop.spec.ts index 05325f38..0eb4ba25 100644 --- a/tests/e2e/desktop.spec.ts +++ b/tests/e2e/desktop.spec.ts @@ -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(); diff --git a/tests/unit/agent-model-provider.test.ts b/tests/unit/agent-model-provider.test.ts new file mode 100644 index 00000000..6efbf662 --- /dev/null +++ b/tests/unit/agent-model-provider.test.ts @@ -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."); + }); +});