diff --git a/src/main/services/task-run-preparation.ts b/src/main/services/task-run-preparation.ts index 5e318f94..1f5e4387 100644 --- a/src/main/services/task-run-preparation.ts +++ b/src/main/services/task-run-preparation.ts @@ -1,3 +1,4 @@ +import { agentModel } from "../../shared/agent-model"; import { TASK_APPROVAL_KINDS_SQL, TASK_SNAPSHOT_SQL } from "./task-approval"; import type { DatabaseSync } from "node:sqlite"; import { APPROVAL_STATE, RISK_LEVEL } from "../../shared/domain"; @@ -73,7 +74,7 @@ export function assertModelSelectable(providerKey: string, agentModelId: string /** The model a run calls: the agent's own choice, or else its provider's default. */ export function effectiveModel(agentModelId: string | null, providerModel: string | null): string | null { - return agentModelId?.trim() || providerModel; + return agentModel(agentModelId, providerModel).model; } export function requireAgentProvider(db: DatabaseSync, orgId: string, agent: TaskExecutionAgentRow): TaskExecutionProviderRow { diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index c5cd82b7..30d50660 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -678,4 +678,8 @@ export const zh: Record = { "{passed}/{total} passed": "{passed}/{total} 通过", "{pending} pending · {approved} approved this week": "{pending} 待处理 · 本周已批准 {approved}", "{percent}% of cap": "上限的 {percent}%", + "Leave the model blank to use the provider's default. A changed model applies from the agent's next session.": "模型留空即使用 Provider 的默认模型。修改后的模型从该 Agent 的下一次会话起生效。", + "{model} (provider default)": "{model}(Provider 默认)", + "Chosen when the session starts": "会话开始时确定", + "The default model reaches {following} agents without their own model; {own} keep their own. Sessions already started keep the model they began with.": "默认模型作用于 {following} 个未单独设置模型的 Agent;{own} 个保留自己的模型。已开始的会话继续使用开始时的模型。", }; diff --git a/src/renderer/src/pages/RunsPage.tsx b/src/renderer/src/pages/RunsPage.tsx index b0dd1aa7..cecb5e52 100644 --- a/src/renderer/src/pages/RunsPage.tsx +++ b/src/renderer/src/pages/RunsPage.tsx @@ -204,7 +204,8 @@ function RunDetail({ run, tasks, agents, providers, actions, onOpenTask }: {
{t("Agent")}
{agent?.name ?? "—"} ({agent?.role ?? "—"})
- {t("Provider")}
{provider?.label ?? "—"} · {provider?.model ?? "—"}
+ {t("Provider")}
{provider?.label ?? "—"}
+ {t("Model")}
{run.runModel ?? t("Chosen when the session starts")}
{t("Trigger")}
{run.trigger}
{t("Created")}
{formatRelative(run.createdAt)}
{run.startedAt && (<>{t("Started")}
{formatRelative(run.startedAt)}
)} diff --git a/src/renderer/src/pages/TeamPage.tsx b/src/renderer/src/pages/TeamPage.tsx index 8515ea81..97c805c5 100644 --- a/src/renderer/src/pages/TeamPage.tsx +++ b/src/renderer/src/pages/TeamPage.tsx @@ -1,3 +1,4 @@ +import { agentModel } from "../../../shared/agent-model"; import { useMemo, useState, type FormEvent } from "react"; import { Avatar, Button, Field, Icon, IconButton } from "../shared/ui"; import { AvatarField } from "../shared/AvatarField"; @@ -40,21 +41,20 @@ export function TeamPage({ snapshot, actions }: PageProps) { const openHireDialog = () => { const provider = providers[0] ?? null; setHireProviderId(provider?.id ?? ""); - setHireModelId(provider?.model ?? ""); + setHireModelId(""); setHireOpen(true); }; const updateHireProvider = (providerId: string) => { - const provider = providers.find((item) => item.id === providerId) ?? null; setHireProviderId(providerId); - setHireModelId(provider?.model ?? ""); + setHireModelId(""); }; const submit = (event: FormEvent) => { event.preventDefault(); const form = new FormData(event.currentTarget); const selectedProvider = providers.find((provider) => provider.id === hireProviderId) ?? null; - const modelId = selectedProvider ? hireModelId.trim() || selectedProvider.model : null; + const modelId = selectedProvider ? hireModelId.trim() || null : null; void actions.run(() => window.agentCompany.createAgent({ orgId, name: text(form, "name"), @@ -148,8 +148,9 @@ export function TeamPage({ snapshot, actions }: PageProps) { - 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. */} {editingAgent.roleLevel === "c_suite" ? ( @@ -251,12 +245,13 @@ function buildRoster( const cap = agents.reduce((max, agent) => (agent.budgetCents > max ? agent.budgetCents : max), 0); for (const agent of agents) { const provider = agent.modelProviderId ? providers.find((entry) => entry.id === agent.modelProviderId) : null; + const inEffect = agentModel(agent.modelId, provider?.model ?? null); rows.push({ id: agent.id, name: agent.name, role: agent.role, dept: agent.department || "—", - model: agent.modelId ?? (provider?.model ?? "—"), + model: inEffect.source === "provider_default" ? t("{model} (provider default)", { model: inEffect.model ?? "" }) : inEffect.model ?? "—", providerLabel: provider?.label ?? "—", budgetCents: agent.budgetCents, budgetCapCents: cap > 0 ? cap : null, @@ -267,6 +262,24 @@ function buildRoster( return rows; } +/** 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[] }) { + const [providerId, setProviderId] = useState(agent.modelProviderId ?? ""); + return ( + <> + + + + + provider.id === providerId)?.model ?? ""} list="agent-model-suggestions" /> + + + ); +} + function RosterRow({ row, last, onEdit }: { row: RosterEntry; last: boolean; onEdit: (() => void) | null }) { return (
) )} + {active && ( +

+ {t("The default model reaches {following} agents without their own model; {own} keep their own. Sessions already started keep the model they began with.", { following: defaultModelReach(agents, active.id).following, own: defaultModelReach(agents, active.id).ownModel })} +

+ )}
); diff --git a/src/shared/agent-model.ts b/src/shared/agent-model.ts new file mode 100644 index 00000000..fd8fd17e --- /dev/null +++ b/src/shared/agent-model.ts @@ -0,0 +1,17 @@ +/** Where an agent's model comes from: its own setting, else its provider's default. */ +export type ModelSource = "agent" | "provider_default" | "none"; + +/** The model a new session of the agent starts with; a running session keeps the one it pinned. */ +export function agentModel(agentModelId: string | null, providerModel: string | null): { model: string | null; source: ModelSource } { + const own = agentModelId?.trim(); + if (own) return { model: own, source: "agent" }; + if (providerModel) return { model: providerModel, source: "provider_default" }; + return { model: null, source: "none" }; +} + +/** 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); + const ownModel = bound.filter((agent) => agent.modelId?.trim()).length; + return { following: bound.length - ownModel, ownModel }; +} diff --git a/src/shared/domain.ts b/src/shared/domain.ts index 3c5a217f..007dd73f 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -211,6 +211,8 @@ export interface TaskRunRecord { contextJson: string | null; /** Capabilities the run could not load, one per line, so the user sees what it worked without. */ capabilityWarnings: string | null; + /** The model the session pinned when it started; null until then. */ + runModel: string | null; } export interface ApprovalRecord { diff --git a/tests/e2e/desktop.spec.ts b/tests/e2e/desktop.spec.ts index 1848390d..26bc7182 100644 --- a/tests/e2e/desktop.spec.ts +++ b/tests/e2e/desktop.spec.ts @@ -89,7 +89,9 @@ test("onboards, hires, and creates work in the Electron shell", async () => { }; }); expect(hiredAgent.modelProviderId).toBeTruthy(); - expect(hiredAgent.modelId).toBe("gpt-5.4"); + // A hire without its own model follows the provider default, and the roster says so. + expect(hiredAgent.modelId).toBeNull(); + await expect(page.getByText("gpt-5.4 (provider default)").first()).toBeVisible(); expect(hiredAgent.workspaceId).toBe(hiredAgent.primaryWorkspaceId); expect(hiredAgent.heartbeatEnabled).toBe(true); expect(hiredAgent.heartbeatIntervalSec).toBe(120); @@ -98,6 +100,14 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.getByRole("button", { name: "Edit Alex Chen" }).click(); await page.locator(".modal").getByLabel("Name", { exact: true }).fill("Alex Turing"); await page.getByLabel("Avatar emoji").fill("🚀"); + // The blank model field names the default of whichever provider is picked now. + const editModel = page.locator(".modal").getByLabel("Model", { exact: true }); + await expect(editModel).toHaveAttribute("placeholder", "gpt-5.4"); + const editProvider = page.locator(".modal select[name=modelProviderId]"); + const savedProvider = await editProvider.inputValue(); + await editProvider.selectOption(""); + await expect(editModel).toHaveAttribute("placeholder", ""); + await editProvider.selectOption(savedProvider); await page.locator(".modal").getByRole("button", { name: "Save" }).click(); await expect(page.getByText("Alex Turing").first()).toBeVisible(); await expect(page.locator(".table-row", { hasText: "Alex Turing" }).locator(".avatar", { hasText: "🚀" })).toBeVisible(); diff --git a/tests/unit/agent-model.test.ts b/tests/unit/agent-model.test.ts new file mode 100644 index 00000000..c148d07d --- /dev/null +++ b/tests/unit/agent-model.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { agentModel, defaultModelReach } from "../../src/shared/agent-model"; +import { effectiveModel } from "../../src/main/services/task-run-preparation"; + +describe("agent model", () => { + it("names the model in effect and where it comes from", () => { + expect(agentModel("gpt-6", "gpt-5")).toEqual({ model: "gpt-6", source: "agent" }); + expect(agentModel(" ", "gpt-5")).toEqual({ model: "gpt-5", source: "provider_default" }); + expect(agentModel(null, null)).toEqual({ model: null, source: "none" }); + // Runs start with exactly the model the UI shows. + expect(effectiveModel(" gpt-6 ", "gpt-5")).toBe("gpt-6"); + expect(effectiveModel(null, "gpt-5")).toBe("gpt-5"); + }); + + it("counts the agents a default-model change reaches", () => { + const agents = [ + { modelProviderId: "p1", modelId: null }, + { modelProviderId: "p1", modelId: "" }, + { modelProviderId: "p1", modelId: "own" }, + { modelProviderId: "p2", modelId: null }, + ]; + expect(defaultModelReach(agents, "p1")).toEqual({ following: 2, ownModel: 1 }); + }); +}); diff --git a/tests/unit/message-readers.test.ts b/tests/unit/message-readers.test.ts index 80fc912a..4fe2d311 100644 --- a/tests/unit/message-readers.test.ts +++ b/tests/unit/message-readers.test.ts @@ -6,7 +6,7 @@ function run(agentId: string | null, contextJson: string | null): TaskRunRecord return { id: `run_${Math.random()}`, orgId: "org", taskId: "tsk", agentId, providerId: null, providerRequestId: null, executionContextId: null, serviceIdentityId: null, workspaceMountJson: "[]", secretScopeJson: "{}", - trigger: "chat_mention", state: "completed", createdAt: "", startedAt: null, completedAt: null, errorMessage: null, contextJson, capabilityWarnings: null, + trigger: "chat_mention", state: "completed", createdAt: "", startedAt: null, completedAt: null, errorMessage: null, contextJson, capabilityWarnings: null, runModel: null, }; }