forked from EduCraft/curriculum-project-hub
854c6189bb
Add a shared transparent OrganizationAgentConfigFolder tree for grouping agent roles and skills in the admin UI without affecting identity, bindings, run loading, or slash commands.
227 lines
10 KiB
TypeScript
227 lines
10 KiB
TypeScript
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<string> {
|
|
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;
|
|
}
|
|
});
|