feat(team-mode): add team registry validator with tests
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { TeamSpecSchema } from "../types"
|
||||
|
||||
import type { Member, TeamSpec } from "../types"
|
||||
import {
|
||||
TeamSpecValidationError,
|
||||
validateDualSupport,
|
||||
validateMemberEligibility,
|
||||
validateSpec,
|
||||
} from "./validator"
|
||||
|
||||
const PROMETHEUS_REJECTION_MESSAGE =
|
||||
"Agent 'prometheus' is plan-mode-only; can only write to .sisyphus/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use category: 'plan' instead."
|
||||
|
||||
function createCategoryMember(name: string): Member {
|
||||
return {
|
||||
kind: "category",
|
||||
name,
|
||||
category: "deep",
|
||||
prompt: `implement the assigned work for ${name}`,
|
||||
backendType: "in-process",
|
||||
isActive: true,
|
||||
}
|
||||
}
|
||||
|
||||
function createBaseTeamSpec(): TeamSpec {
|
||||
return {
|
||||
version: 1,
|
||||
name: "validator-team",
|
||||
createdAt: 1,
|
||||
leadAgentId: "lead",
|
||||
members: [createCategoryMember("lead"), createCategoryMember("reviewer")],
|
||||
}
|
||||
}
|
||||
|
||||
describe("team-registry validator", () => {
|
||||
test("rejects members that specify both category and subagent_type", () => {
|
||||
// given
|
||||
const teamSpec = {
|
||||
...createBaseTeamSpec(),
|
||||
members: [
|
||||
{
|
||||
kind: "category",
|
||||
name: "lead",
|
||||
category: "deep",
|
||||
prompt: "implement the assigned work for lead",
|
||||
subagent_type: "sisyphus",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamSpecSchema.safeParse(teamSpec)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects members that omit the kind discriminator", () => {
|
||||
// given
|
||||
const teamSpec = {
|
||||
...createBaseTeamSpec(),
|
||||
members: [{ name: "lead", category: "deep", prompt: "implement the assigned work for lead" }],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamSpecSchema.safeParse(teamSpec)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects prometheus subagent members with the exact plan message", () => {
|
||||
// given
|
||||
const member: Member = {
|
||||
kind: "subagent_type",
|
||||
name: "planner",
|
||||
subagent_type: "prometheus",
|
||||
backendType: "in-process",
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
// when
|
||||
const act = () => validateMemberEligibility(member)
|
||||
|
||||
// then
|
||||
expect(act).toThrow(PROMETHEUS_REJECTION_MESSAGE)
|
||||
expect(act).toThrow(TeamSpecValidationError)
|
||||
})
|
||||
|
||||
test("accepts hephaestus subagent members after the D-36 eligibility change", () => {
|
||||
// given
|
||||
const member: Member = {
|
||||
kind: "subagent_type",
|
||||
name: "craftsman",
|
||||
subagent_type: "hephaestus",
|
||||
backendType: "in-process",
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
// when
|
||||
const act = () => validateMemberEligibility(member)
|
||||
|
||||
// then
|
||||
expect(act).not.toThrow()
|
||||
})
|
||||
|
||||
test("rejects leadAgentId values that do not match a member name", () => {
|
||||
// given
|
||||
const teamSpec = { ...createBaseTeamSpec(), leadAgentId: "ghost" }
|
||||
|
||||
// when
|
||||
const act = () => validateSpec(teamSpec)
|
||||
|
||||
// then
|
||||
expect(act).toThrow("Team 'validator-team' leadAgentId 'ghost' must match exactly one member.name.")
|
||||
})
|
||||
|
||||
test("rejects duplicate member names within a team", () => {
|
||||
// given
|
||||
const duplicateMember = createCategoryMember("lead")
|
||||
const teamSpec = { ...createBaseTeamSpec(), members: [createCategoryMember("lead"), duplicateMember] }
|
||||
|
||||
// when
|
||||
const act = () => validateSpec(teamSpec)
|
||||
|
||||
// then
|
||||
expect(act).toThrow("Member name 'lead' is duplicated within team 'validator-team'. Member names must be unique.")
|
||||
})
|
||||
|
||||
test("rejects teams that exceed the 8-member cap", () => {
|
||||
// given
|
||||
const teamSpec = {
|
||||
...createBaseTeamSpec(),
|
||||
members: Array.from({ length: 9 }, (_, index) => createCategoryMember(`member-${index}`)),
|
||||
leadAgentId: "member-0",
|
||||
}
|
||||
|
||||
// when
|
||||
const act = () => validateSpec(teamSpec)
|
||||
|
||||
// then
|
||||
expect(act).toThrow("Team 'validator-team' exceeds max 8 members.")
|
||||
})
|
||||
|
||||
test("rejects category prompts that collapse to empty text", () => {
|
||||
// given
|
||||
const member: Member = {
|
||||
kind: "category",
|
||||
name: "lead",
|
||||
category: "deep",
|
||||
prompt: " ",
|
||||
backendType: "in-process",
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
// when
|
||||
const act = () => validateDualSupport(member)
|
||||
|
||||
// then
|
||||
expect(act).toThrow("Member 'lead' prompt must not be empty after trimming whitespace.")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { AGENT_ELIGIBILITY_REGISTRY } from "../types"
|
||||
|
||||
import type { Member, TeamSpec } from "../types"
|
||||
|
||||
const MAX_TEAM_MEMBERS = 8
|
||||
const UNKNOWN_SUBAGENT_MESSAGE =
|
||||
"Unknown subagent_type '<name>'. Available ELIGIBLE agents: sisyphus, atlas, sisyphus-junior, hephaestus (if D-36 applied). Use delegate-task for read-only agents like oracle, librarian, explore, metis, momus, multimodal-looker."
|
||||
|
||||
export class TeamSpecValidationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string,
|
||||
public readonly field?: string,
|
||||
public readonly memberName?: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "TeamSpecValidationError"
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSpec(spec: TeamSpec): void {
|
||||
if (spec.members.length > MAX_TEAM_MEMBERS) {
|
||||
throw new TeamSpecValidationError(
|
||||
`Team '${spec.name}' exceeds max 8 members.`,
|
||||
"TEAM_MEMBER_LIMIT_EXCEEDED",
|
||||
"members",
|
||||
)
|
||||
}
|
||||
|
||||
const seenMemberNames = new Set<string>()
|
||||
let leadMatchCount = 0
|
||||
|
||||
for (const member of spec.members) {
|
||||
if (seenMemberNames.has(member.name)) {
|
||||
throw new TeamSpecValidationError(
|
||||
`Member name '${member.name}' is duplicated within team '${spec.name}'. Member names must be unique.`,
|
||||
"DUPLICATE_MEMBER_NAME",
|
||||
"members",
|
||||
member.name,
|
||||
)
|
||||
}
|
||||
|
||||
seenMemberNames.add(member.name)
|
||||
validateMemberEligibility(member)
|
||||
validateDualSupport(member)
|
||||
|
||||
if (member.name === spec.leadAgentId) {
|
||||
leadMatchCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (leadMatchCount !== 1) {
|
||||
throw new TeamSpecValidationError(
|
||||
`Team '${spec.name}' leadAgentId '${spec.leadAgentId}' must match exactly one member.name.`,
|
||||
"INVALID_LEAD_AGENT_ID",
|
||||
"leadAgentId",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateMemberEligibility(member: Member): void {
|
||||
if (member.kind !== "subagent_type") {
|
||||
return
|
||||
}
|
||||
|
||||
const eligibility = AGENT_ELIGIBILITY_REGISTRY[member.subagent_type]
|
||||
if (!eligibility) {
|
||||
throw new TeamSpecValidationError(
|
||||
UNKNOWN_SUBAGENT_MESSAGE.replace("<name>", member.subagent_type),
|
||||
"UNKNOWN_SUBAGENT_TYPE",
|
||||
"subagent_type",
|
||||
member.name,
|
||||
)
|
||||
}
|
||||
|
||||
if (eligibility.verdict === "hard-reject") {
|
||||
throw new TeamSpecValidationError(
|
||||
eligibility.rejectionMessage ?? `Agent '${member.subagent_type}' is not eligible as a team member.`,
|
||||
"INELIGIBLE_AGENT",
|
||||
"subagent_type",
|
||||
member.name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDualSupport(member: Member): void {
|
||||
const trimmedPrompt = member.prompt?.trim()
|
||||
|
||||
if (trimmedPrompt === "") {
|
||||
throw new TeamSpecValidationError(
|
||||
`Member '${member.name}' prompt must not be empty after trimming whitespace.`,
|
||||
"EMPTY_PROMPT",
|
||||
"prompt",
|
||||
member.name,
|
||||
)
|
||||
}
|
||||
|
||||
if (member.kind === "category" && member.prompt.trim().length < 8) {
|
||||
throw new TeamSpecValidationError(
|
||||
`Member '${member.name}' category prompt must be at least 8 characters long.`,
|
||||
"CATEGORY_PROMPT_TOO_SHORT",
|
||||
"prompt",
|
||||
member.name,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user