From 43dcb1e7d399a6f55d1ee71d01a6c0f9d0d76876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 05:02:32 -0400 Subject: [PATCH] Q175: onboarding shows what each mode allows and the amounts a company starts with Co-Authored-By: Claude Opus 5.5 --- src/main/services/agent-tools.ts | 4 +- src/main/services/onboarding-service.ts | 3 +- src/main/services/org-governance-service.ts | 9 +++-- src/main/services/organization-seed.ts | 6 ++- src/main/services/policy-service.ts | 3 +- src/renderer/src/app/AutonomySummary.tsx | 27 +++++++++++++ src/renderer/src/app/Onboarding.tsx | 6 ++- src/renderer/src/i18n/zh.ts | 14 ++++++- src/shared/company-presets.ts | 13 ++++++ tests/e2e/desktop.spec.ts | 8 ++++ tests/unit/company-presets.test.ts | 44 +++++++++++++++++++++ 11 files changed, 123 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/app/AutonomySummary.tsx create mode 100644 src/shared/company-presets.ts create mode 100644 tests/unit/company-presets.test.ts diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index 43620cd8..a082c082 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -1,3 +1,4 @@ +import { COMPANY_PRESETS } from "../../shared/company-presets"; import type { DatabaseSync } from "node:sqlite"; import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, WRITING_MODE, type RiskLevel, type WorkLanguage, type WritingMode } from "../../shared/domain"; import { ORGANIZATION_DEPARTMENTS } from "./org-governance-service"; @@ -20,7 +21,6 @@ import { CEO_FIRST_HIRES } from "./org-building"; import type { JsonRecord } from "./provider-http"; import type { ToolCall, ToolDefinition } from "./provider-tool-turn"; -const HIRE_BUDGET_CENTS = 25000; const HEARTBEAT_INTERVAL_SEC = 120; export interface AgentToolContext { @@ -327,7 +327,7 @@ function hireAgent(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord) department, modelProviderId: ctx.agent.modelProviderId, modelId: model, - budgetCents: HIRE_BUDGET_CENTS, + budgetCents: COMPANY_PRESETS.agentHireBudgetCents, managerAgentId: ctx.agent.id, roleLevel: seniority, workspaceId: ctx.workspace?.id ?? null, diff --git a/src/main/services/onboarding-service.ts b/src/main/services/onboarding-service.ts index 14a0c75e..0cb9248c 100644 --- a/src/main/services/onboarding-service.ts +++ b/src/main/services/onboarding-service.ts @@ -1,3 +1,4 @@ +import { COMPANY_PRESETS } from "../../shared/company-presets"; import type { DatabaseSync } from "node:sqlite"; import type { BootstrapOnboardingInput, @@ -60,7 +61,7 @@ export class OnboardingService { department: "Executive", modelProviderId: providerId, modelId: input.providerModel.trim(), - budgetCents: 25000, + budgetCents: COMPANY_PRESETS.ceoBudgetCents, managerAgentId: null, roleLevel: "c_suite", modelStrategy: "explicit", diff --git a/src/main/services/org-governance-service.ts b/src/main/services/org-governance-service.ts index 6a85f9d9..e42ed935 100644 --- a/src/main/services/org-governance-service.ts +++ b/src/main/services/org-governance-service.ts @@ -1,3 +1,4 @@ +import { COMPANY_PRESETS } from "../../shared/company-presets"; import type { DatabaseSync } from "node:sqlite"; import type { CreateAgentInput } from "../../shared/commands"; import { RISK_LEVEL, ROUTE_TO_TYPE, type RiskLevel } from "../../shared/domain"; @@ -127,14 +128,14 @@ export class OrgGovernanceService { private seedDepartmentPolicy(orgId: string, departmentName: string, now: string): void { this.db.prepare(` insert into org_departments(id, org_id, name, lead_agent_id, headcount_limit, monthly_budget_limit_cents, created_at, updated_at) - values (?, ?, ?, null, 12, 2500000, ?, ?) + values (?, ?, ?, null, ?, ?, ?, ?) on conflict(org_id, name) do nothing - `).run(createId("dep"), orgId, departmentName, now, now); + `).run(createId("dep"), orgId, departmentName, COMPANY_PRESETS.departmentHeadcount, COMPANY_PRESETS.departmentBudgetCents, now, now); this.db.prepare(` insert into hiring_policies(id, org_id, department_name, max_headcount, monthly_budget_limit_cents, require_approval_above_cents, created_at, updated_at) - values (?, ?, ?, 12, 2500000, 500000, ?, ?) + values (?, ?, ?, ?, ?, ?, ?, ?) on conflict(org_id, department_name) do nothing - `).run(createId("hpl"), orgId, departmentName, now, now); + `).run(createId("hpl"), orgId, departmentName, COMPANY_PRESETS.departmentHeadcount, COMPANY_PRESETS.departmentBudgetCents, COMPANY_PRESETS.hireReviewAboveCents, now, now); } private resolveRoutingRule(orgId: string, kind: string, risk: RiskLevel): ApprovalRoutingRuleRow { diff --git a/src/main/services/organization-seed.ts b/src/main/services/organization-seed.ts index d4035a7c..ae21359c 100644 --- a/src/main/services/organization-seed.ts +++ b/src/main/services/organization-seed.ts @@ -1,3 +1,4 @@ +import { COMPANY_PRESETS } from "../../shared/company-presets"; import type { DatabaseSync } from "node:sqlite"; import type { CreateOrganizationInput } from "../../shared/commands"; import { PROVIDER_CATALOG } from "../../shared/provider-catalog"; @@ -68,8 +69,8 @@ export function seedOrganization(db: DatabaseSync, input: CreateOrganizationInpu for (const provider of PROVIDER_CATALOG) { db.prepare(` - insert into providers(id, org_id, provider_key, label, auth_type, model, credential_id, enabled, created_at) - values (?, ?, ?, ?, ?, ?, ?, ?, ?) + insert into providers(id, org_id, provider_key, label, auth_type, model, credential_id, enabled, monthly_request_limit, created_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( createId("prv"), orgId, @@ -79,6 +80,7 @@ export function seedOrganization(db: DatabaseSync, input: CreateOrganizationInpu provider.defaultModel, null, 0, + COMPANY_PRESETS.providerMonthlyRequests, now, ); } diff --git a/src/main/services/policy-service.ts b/src/main/services/policy-service.ts index be98d877..e454c58d 100644 --- a/src/main/services/policy-service.ts +++ b/src/main/services/policy-service.ts @@ -1,3 +1,4 @@ +import { COMPANY_PRESETS } from "../../shared/company-presets"; import type { DatabaseSync } from "node:sqlite"; import type { RiskLevel } from "../../shared/domain"; import { RISK_LEVEL } from "../../shared/domain"; @@ -21,7 +22,7 @@ export class PolicyService { this.db.prepare(` insert into policy_rules(id, org_id, subject_type, subject_id, action, effect, risk_ceiling, created_at) values (?, ?, ?, ?, ?, ?, ?, ?) - `).run(createId("pol"), orgId, "organization", orgId, "task_create", "require_approval", RISK_LEVEL.MEDIUM, now); + `).run(createId("pol"), orgId, "organization", orgId, "task_create", "require_approval", COMPANY_PRESETS.taskRiskCeiling, now); } evaluateTaskCreation(orgId: string, risk: RiskLevel): PolicyDecision { diff --git a/src/renderer/src/app/AutonomySummary.tsx b/src/renderer/src/app/AutonomySummary.tsx new file mode 100644 index 00000000..326e66f9 --- /dev/null +++ b/src/renderer/src/app/AutonomySummary.tsx @@ -0,0 +1,27 @@ +import { COMPANY_PRESETS } from "../../../shared/company-presets"; +import { t } from "../i18n/i18n"; + +const dollars = (cents: number) => `$${(cents / 100).toLocaleString("en-US")}`; +const RISK_NAMES = { low: () => t("Low"), medium: () => t("Medium"), high: () => t("High") }; + +/** Everything a mode decides and everything both modes preset, read from the same values the company is created with. */ +export function AutonomySummary({ mode }: { mode: "ai_ceo" | "ai_cofounder" }) { + const gated = { level: RISK_NAMES[COMPANY_PRESETS.taskRiskCeiling](), amount: dollars(COMPANY_PRESETS.hireReviewAboveCents) }; + return ( +
+
    +
  • {mode === "ai_ceo" + ? t("Approved without you: tasks above {level} risk, tasks that run commands in a workspace, and hires over {amount} a month.", gated) + : t("Waiting for you: tasks above {level} risk, tasks that run commands in a workspace, and hires over {amount} a month.", gated)}
  • +
  • {mode === "ai_ceo" + ? t("Deliverables are written without review.") + : t("Deliverables wait for your review before they are written.")}
  • +
  • {t("The CEO starts with a {ceo} monthly budget, and each agent it hires gets {hire}.", { ceo: dollars(COMPANY_PRESETS.ceoBudgetCents), hire: dollars(COMPANY_PRESETS.agentHireBudgetCents) })}
  • +
  • {t("Each department holds up to {count} agents and {amount} a month in agent budgets.", { count: COMPANY_PRESETS.departmentHeadcount, amount: dollars(COMPANY_PRESETS.departmentBudgetCents) })}
  • +
  • {t("Each provider allows {count} model calls a month.", { count: COMPANY_PRESETS.providerMonthlyRequests.toLocaleString("en-US") })}
  • +
  • {t("An approved workspace task runs commands with your user's file access; they are not confined to the workspace folder.")}
  • +
  • {t("Later you can change the mode, hire approval and deliverable review in Settings, General; provider limits in Settings, Providers; and agent budgets on Team.")}
  • +
+
+ ); +} diff --git a/src/renderer/src/app/Onboarding.tsx b/src/renderer/src/app/Onboarding.tsx index 7017907a..f3cfb173 100644 --- a/src/renderer/src/app/Onboarding.tsx +++ b/src/renderer/src/app/Onboarding.tsx @@ -1,3 +1,4 @@ +import { AutonomySummary } from "./AutonomySummary"; import { useMemo, useState, type FormEvent } from "react"; import { PROVIDER_AUTH_TYPE, @@ -307,16 +308,17 @@ function ModeStep({ draft, onChange, onBack, onNext }: StepProps) {

{t("How autonomous should the CEO be?")}

onChange({ executionMode: "ai_ceo" })} /> onChange({ executionMode: "ai_cofounder" })} /> + ); diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index e28ae567..80c82971 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -227,7 +227,6 @@ export const zh: Record = { "AgentCompany sets up your operating loop in six steps. Bring an API key, pick a runtime for the founding CEO, and we'll wire up the first goal, project, and task.": "AgentCompany 用六步搭建你的运营循环:带上 API 密钥,为创始 CEO 选择运行时,我们会接好第一个目标、项目和任务。", "Agents": "智能体", - "Agents auto-approve hires up to the budget envelope and ship work that clears policy.": "智能体在预算范围内自动批准招聘,并交付通过策略检查的工作。", "Agents only": "仅智能体", "All": "全部", "All agents, work, chats, and files are permanently removed.": "所有智能体、工作、聊天和文件将被永久删除。", @@ -384,7 +383,6 @@ export const zh: Record = { "High risk": "高风险", "Hire": "招聘", "Hire agent": "招聘智能体", - "Hires need your stamp; deliverables route through review before shipping.": "招聘需要你签字确认;交付物先经审核再发布。", "Hires require approval": "招聘需审批", "Home": "首页", "How autonomous should the CEO be?": "CEO 的自主程度?", @@ -696,6 +694,18 @@ export const zh: Record = { "Resets {date} (UTC)": "{date} 重置(UTC)", "Unsaved": "未保存", "Working language": "工作语言", + "Runs on its own: approves its risky work and large hires, and writes deliverables without review.": "自主运行:自行批准高风险工作和大额招聘,交付物不经审核直接写入。", + "Checks with you: risky work and large hires wait for you, and deliverables wait for your review.": "与你确认:高风险工作和大额招聘需要你批准,交付物需经你审核。", + "What this mode allows": "此模式允许的范围", + "Approved without you: tasks above {level} risk, tasks that run commands in a workspace, and hires over {amount} a month.": "无需你批准:风险高于“{level}”的任务、在工作区中运行命令的任务,以及月预算超过 {amount} 的招聘。", + "Waiting for you: tasks above {level} risk, tasks that run commands in a workspace, and hires over {amount} a month.": "需要你批准:风险高于“{level}”的任务、在工作区中运行命令的任务,以及月预算超过 {amount} 的招聘。", + "Deliverables are written without review.": "交付物不经审核直接写入。", + "Deliverables wait for your review before they are written.": "交付物在写入前需经你审核。", + "The CEO starts with a {ceo} monthly budget, and each agent it hires gets {hire}.": "CEO 初始月预算为 {ceo},其招聘的每个 Agent 获得 {hire}。", + "Each department holds up to {count} agents and {amount} a month in agent budgets.": "每个部门最多 {count} 个 Agent,Agent 月预算合计不超过 {amount}。", + "Each provider allows {count} model calls a month.": "每个 Provider 每月允许 {count} 次模型调用。", + "An approved workspace task runs commands with your user's file access; they are not confined to the workspace folder.": "获批的工作区任务以你的用户文件权限运行命令,不局限于工作区文件夹。", + "Later you can change the mode, hire approval and deliverable review in Settings, General; provider limits in Settings, Providers; and agent budgets on Team.": "之后可以在“设置 › 通用”中修改模式、招聘审批和交付物审核,在“设置 › 服务商”中修改服务商额度,在“团队”中修改 Agent 预算。", "{language} applies after a restart.": "{language} 将在重启后生效。", "Restart now": "立即重启", "Restart to switch language?": "重启以切换语言?", diff --git a/src/shared/company-presets.ts b/src/shared/company-presets.ts new file mode 100644 index 00000000..f55c79e4 --- /dev/null +++ b/src/shared/company-presets.ts @@ -0,0 +1,13 @@ +/** What a new company starts with; the seeds use these and onboarding shows them, so the two cannot drift. */ +export const COMPANY_PRESETS = { + ceoBudgetCents: 25_000, + /** Monthly budget of each agent an agent hires. */ + agentHireBudgetCents: 25_000, + departmentHeadcount: 12, + departmentBudgetCents: 2_500_000, + /** A hire with a larger monthly budget goes to review. */ + hireReviewAboveCents: 500_000, + providerMonthlyRequests: 1_000, + /** Tasks riskier than this need approval. */ + taskRiskCeiling: "medium", +} as const; diff --git a/tests/e2e/desktop.spec.ts b/tests/e2e/desktop.spec.ts index 7429db5e..85578b87 100644 --- a/tests/e2e/desktop.spec.ts +++ b/tests/e2e/desktop.spec.ts @@ -40,6 +40,14 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.getByLabel("Your name").fill("Dana Lee"); await page.getByLabel("Description").fill("Coordinate agents with auditable approvals."); await page.getByRole("button", { name: "Continue" }).click(); + // The mode step spells out what each mode lets the company do, and the amounts it starts with. + const allows = page.getByRole("region", { name: "What this mode allows" }); + await expect(allows).toContainText("Approved without you: tasks above Medium risk, tasks that run commands in a workspace, and hires over $5,000 a month."); + await expect(allows).toContainText("The CEO starts with a $250 monthly budget, and each agent it hires gets $250."); + await expect(allows).toContainText("Each provider allows 1,000 model calls a month."); + await page.getByRole("button", { name: /AI Co-Founder/ }).click(); + await expect(allows).toContainText("Deliverables wait for your review before they are written."); + await page.getByRole("button", { name: /AI CEO/ }).click(); await page.getByRole("button", { name: "Continue" }).click(); await page.getByLabel("Goal").fill("Ship a verified control plane."); await page.getByLabel("Acceptance criteria").fill("The CEO creates the kickoff operating loop."); diff --git a/tests/unit/company-presets.test.ts b/tests/unit/company-presets.test.ts new file mode 100644 index 00000000..9c315e04 --- /dev/null +++ b/tests/unit/company-presets.test.ts @@ -0,0 +1,44 @@ +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 { OnboardingService } from "../../src/main/services/onboarding-service"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ProviderService } from "../../src/main/services/provider-service"; +import { providerAdapters } from "../../src/main/services/provider-adapters"; +import { ChatService } from "../../src/main/services/chat-service"; +import { dispatchAgentTool } from "../../src/main/services/agent-tools"; +import { COMPANY_PRESETS } from "../../src/shared/company-presets"; + +const vault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") }; + +describe("what onboarding shows is what a company gets", () => { + test("a new company is seeded with exactly the presets onboarding lists", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-presets-${randomUUID()}.db`)); + const db = connection.db; + const providers = new ProviderService(db, providerAdapters, vault); + const onboarding = new OnboardingService({ db, saveProviderCredential: (input) => providers.saveCredential(input) }); + let orgId = ""; + let ceoId = ""; + connection.transaction(() => { + const summary = onboarding.bootstrap({ + companyName: "FlowPilot", companyDescription: "d", employeeName: "Yang", workspacePath: "", executionMode: "ai_cofounder", autoApproveHires: false, + reviewDeliverables: true, goalTitle: "Ship.", goalDescription: "d", providerKey: "openai", providerSecret: "sk-test", providerModel: "gpt-5.4", + }); + orgId = summary.orgId; + ceoId = summary.ceoAgentId; + }); + expect(db.prepare("select budget_cents from agents where id = ?").get(ceoId)).toEqual({ budget_cents: COMPANY_PRESETS.ceoBudgetCents }); + expect(db.prepare("select distinct max_headcount, monthly_budget_limit_cents, require_approval_above_cents from hiring_policies where org_id = ?").all(orgId)) + .toEqual([{ max_headcount: COMPANY_PRESETS.departmentHeadcount, monthly_budget_limit_cents: COMPANY_PRESETS.departmentBudgetCents, require_approval_above_cents: COMPANY_PRESETS.hireReviewAboveCents }]); + expect(db.prepare("select distinct monthly_request_limit from providers where org_id = ?").all(orgId)).toEqual([{ monthly_request_limit: COMPANY_PRESETS.providerMonthlyRequests }]); + expect(db.prepare("select risk_ceiling from policy_rules where org_id = ? and action = 'task_create'").all(orgId)).toEqual([{ risk_ceiling: COMPANY_PRESETS.taskRiskCeiling }]); + + const hired = dispatchAgentTool({ db, organization: new OrganizationService(db), chat: new ChatService(db) }, { + orgId, agent: { id: ceoId, modelProviderId: (db.prepare("select model_provider_id from agents where id = ?").get(ceoId) as { model_provider_id: string }).model_provider_id, modelId: null }, taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null, + }, { id: "call_1", name: "hire_agent", arguments: { name: "Ada", role: "Engineer", title: "Engineer", department: "Engineering", seniority: "staff" } }); + expect(hired).toContain("Ada"); + expect(db.prepare("select budget_cents from agents where org_id = ? and name = 'Ada'").get(orgId)).toEqual({ budget_cents: COMPANY_PRESETS.agentHireBudgetCents }); + }); +});