forked from EduCraft/curriculum-project-hub
feat(hub): org-scoped agent role/skill folder tree (ADR-0028)
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.
This commit is contained in:
@@ -118,6 +118,105 @@ describe("Organization Agent configuration management", () => {
|
||||
})).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 });
|
||||
|
||||
Reference in New Issue
Block a user