forked from EduCraft/curriculum-project-hub
d7bbffb9c6
Rebased feat/agent-config-folder-tree onto current main, keeping cursor invalidation (not session archive) and requireFolder helpers. Regenerated package-lock so npm ci finds @emnapi/*; tighten session-cursor test assert for missing claudeSessionId key.
228 lines
11 KiB
TypeScript
228 lines
11 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: { claudeSessionId: "sdk-session-old", userResumable: true },
|
|
},
|
|
});
|
|
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"]);
|
|
// Execution-surface change invalidates the provider session cursor but
|
|
// keeps the Hub session alive so its transcript stays reachable.
|
|
const invalidated = await prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } });
|
|
expect(invalidated.archivedAt).toBeNull();
|
|
expect(invalidated.metadata).toEqual(expect.objectContaining({ userResumable: false }));
|
|
expect(invalidated.metadata).not.toHaveProperty("claudeSessionId");
|
|
});
|
|
|
|
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;
|
|
}
|
|
});
|