import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; import { OrganizationAgentConfiguration } from "../../src/agent/configuration.js"; import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js"; describe("Organization Agent configuration management", () => { let root: string; let configuration: OrganizationAgentConfiguration; beforeEach(async () => { await resetDb(); root = await mkdtemp(join(tmpdir(), "cph-agent-config-")); configuration = new OrganizationAgentConfiguration(prisma, join(root, "store")); }); afterAll(async () => { await prisma.$disconnect(); }); it("installs versioned skills and selects them as part of a dynamic role bundle", async () => { const typst = await makeSkill(root, "typst"); const outline = await makeSkill(root, "outline"); await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "0.15.0" }); await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: outline, version: "1" }); await configuration.upsertRole({ organizationId: DEFAULT_ORG_ID, roleId: "draft", label: "课程草稿", defaultModel: "anthropic/claude-sonnet-5", systemPrompt: "write carefully", tools: ["read_file", "write_file", "cph_build"], sortOrder: 10, }); await prisma.project.create({ data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" }, }); await prisma.agentSession.create({ data: { id: "session-old-role-config", projectId: "project-a", provider: "openrouter", roleId: "draft", model: "anthropic/claude-sonnet-5", metadata: {}, }, }); await configuration.setRoleSkills({ organizationId: DEFAULT_ORG_ID, roleId: "draft", skillNames: ["outline", "typst"], }); const role = await prisma.organizationAgentRole.findUniqueOrThrow({ where: { organizationId_roleId: { organizationId: DEFAULT_ORG_ID, roleId: "draft" } }, include: { skillBindings: { orderBy: { sortOrder: "asc" }, include: { skill: true } } }, }); expect(role).toMatchObject({ label: "课程草稿", systemPrompt: "write carefully" }); expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]); expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]); await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } })) .resolves.toMatchObject({ archivedAt: expect.any(Date), metadata: expect.objectContaining({ userResumable: false }), }); }); it("rejects unknown, disabled and cross-Organization skills", async () => { await seedTestOrganization("org_other", "other"); const typst = await makeSkill(root, "typst"); await configuration.installSkill({ organizationId: "org_other", sourceDir: typst, version: "1" }); await configuration.upsertRole({ organizationId: DEFAULT_ORG_ID, roleId: "draft", label: "Draft", tools: [], }); await expect(configuration.setRoleSkills({ organizationId: DEFAULT_ORG_ID, roleId: "draft", skillNames: ["typst"], })).rejects.toThrow("active skills not found in organization"); }); it("switches the Organization default role atomically", async () => { await configuration.upsertRole({ organizationId: DEFAULT_ORG_ID, roleId: "review", label: "审校", tools: ["read_file"], isDefault: true, }); await expect(prisma.organizationAgentRole.findMany({ where: { organizationId: DEFAULT_ORG_ID, isDefault: true, disabledAt: null }, select: { roleId: true }, })).resolves.toEqual([{ roleId: "review" }]); await expect(configuration.upsertRole({ organizationId: DEFAULT_ORG_ID, roleId: "review", label: "审校", isDefault: false, })).rejects.toThrow("cannot unset the active default role without selecting a replacement"); }); it("rejects a zero-default committed state at the database boundary", async () => { await expect(prisma.organizationAgentRole.updateMany({ where: { organizationId: DEFAULT_ORG_ID, isDefault: true, disabledAt: null }, data: { isDefault: false }, })).rejects.toThrow("must have exactly one active default Agent role"); }); it("rejects creating an Organization without its default role in the same transaction", async () => { await expect(prisma.organization.create({ data: { id: "org_without_role", slug: "without-role", name: "Without Role" }, })).rejects.toThrow("must have exactly one active default Agent role"); }); it("groups roles and skills in the shared folder tree (ADR-0028)", async () => { const teaching = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "教学" }); const lessonPrep = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "备课", parentId: teaching.id, }); expect(lessonPrep.parentId).toBe(teaching.id); const listed = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID }); expect(listed.map((folder) => folder.id).sort()).toEqual([teaching.id, lessonPrep.id].sort()); const typst = await makeSkill(root, "typst"); await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" }); await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: lessonPrep.id }); await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: teaching.id }); const skills = await configuration.listSkills({ organizationId: DEFAULT_ORG_ID }); expect(skills.find((skill) => skill.name === "typst")?.folderId).toBe(lessonPrep.id); const roles = await configuration.listRoles({ organizationId: DEFAULT_ORG_ID }); expect(roles.find((role) => role.roleId === "draft")?.folderId).toBe(teaching.id); const renamed = await configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: teaching.id, name: "教研", }); expect(renamed.name).toBe("教研"); }); it("moves folders within the tree and rejects moves below a descendant", async () => { const a = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "a" }); const b = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "b", parentId: a.id }); const c = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "c" }); const moved = await configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: c.id, parentId: b.id }); expect(moved.parentId).toBe(b.id); await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: c.id })) .rejects.toThrow("folder cannot be moved below its descendant"); await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: a.id })) .rejects.toThrow("folder cannot be its own parent"); }); it("deletes only empty folders", async () => { const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "非空" }); const typst = await makeSkill(root, "typst"); await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" }); await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: folder.id }); await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id })) .rejects.toThrow("still has"); const parent = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "父" }); await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "子", parentId: parent.id }); await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: parent.id })) .rejects.toThrow("child folder"); await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: null }); await configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id }); const remaining = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID }); expect(remaining.map((f) => f.id)).not.toContain(folder.id); const skill = (await configuration.listSkills({ organizationId: DEFAULT_ORG_ID })).find((s) => s.name === "typst"); expect(skill?.folderId).toBeNull(); }); it("rejects cross-Organization folder assignment", async () => { await seedTestOrganization("org_other", "other"); const otherFolder = await configuration.createFolder({ organizationId: "org_other", name: "外部" }); const typst = await makeSkill(root, "typst"); await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" }); await expect(configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: otherFolder.id, })).rejects.toThrow("folder not found in organization"); await expect(configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: otherFolder.id, })).rejects.toThrow("folder not found in organization"); }); it("treats folder assignment as a label-class change (no session archival)", async () => { await prisma.project.create({ data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" }, }); await prisma.agentSession.create({ data: { id: "session-folder-assignment", projectId: "project-a", provider: "openrouter", roleId: "draft", model: "anthropic/claude-sonnet-5", metadata: {}, }, }); const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "分组" }); await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: folder.id }); await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-folder-assignment" } })) .resolves.toMatchObject({ archivedAt: null }); }); async function makeSkill(parent: string, name: string): Promise { const source = join(parent, "sources", name); await mkdir(source, { recursive: true }); await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\ndescription: ${name} skill\n---\n# ${name}\n`); return source; } });