fix(team-mode): accept legacy inline specs

This commit is contained in:
YeonGyu-Kim
2026-05-16 15:43:08 +09:00
parent a20540579e
commit cf7bf9d02d
5 changed files with 163 additions and 2 deletions
@@ -89,6 +89,25 @@ describe("normalizeTeamSpecInput", () => {
expect(result).toThrow("Caller agent explore is not eligible as team lead; specify leadAgentId explicitly")
})
test("still requires an eligible caller or explicit lead for 8 inline members", () => {
// given
const rawSpec = {
name: "eight-member-team",
members: Array.from({ length: 8 }, () => ({
category: "quick",
prompt: "Complete one validation task.",
})),
}
// when
const result = () => normalizeTeamSpecInput(rawSpec, {
callerTeamLead: resolveCallerTeamLead("explore"),
})
// then
expect(result).toThrow("Caller agent explore is not eligible as team lead; specify leadAgentId explicitly")
})
test("normalizes natural inline names to schema-safe names", () => {
// given
const rawSpec = {
@@ -141,4 +160,35 @@ describe("normalizeTeamSpecInput", () => {
],
})
})
test("uses the first generated member as lead when 8 inline members leave no room for implicit lead injection", () => {
// given
const rawSpec = {
name: "eight-member-team",
members: Array.from({ length: 8 }, () => ({
category: "quick",
prompt: "Complete one validation task.",
})),
}
// when
const normalizedSpec = normalizeTeamSpecInput(rawSpec, {
callerTeamLead: resolveCallerTeamLead("Sisyphus - Ultraworker"),
})
// then
expect(normalizedSpec).toMatchObject({
leadAgentId: "quick-1",
members: [
{ name: "quick-1", kind: "category" },
{ name: "quick-2", kind: "category" },
{ name: "quick-3", kind: "category" },
{ name: "quick-4", kind: "category" },
{ name: "quick-5", kind: "category" },
{ name: "quick-6", kind: "category" },
{ name: "quick-7", kind: "category" },
{ name: "quick-8", kind: "category" },
],
})
})
})
@@ -133,6 +133,7 @@ function normalizeInlineMember(member: JsonRecord, options?: NormalizeTeamSpecIn
description: _description,
loadSkills: _loadSkills,
load_skills: _loadSkillsSnakeCase,
permission: _permission,
responsibilities: _responsibilities,
role: _role,
systemPrompt: _systemPrompt,
@@ -191,6 +192,10 @@ export function normalizeTeamSpecInput(raw: unknown, options?: NormalizeTeamSpec
if (Array.isArray(rawMembers)) {
let normalizedMembers = rawMembers.map((member) => isJsonRecord(member) ? normalizeInlineMember(member, options) : member)
const callerTeamLead = options?.callerTeamLead
const shouldUseFirstMemberAsLead = !hasExplicitLead
&& normalizedMembers.length >= 8
&& callerTeamLead?.isEligibleForTeamLead === true
if (isJsonRecord(rawLead)) {
const leadMember = normalizeInlineMember(rawLead, options)
@@ -209,8 +214,9 @@ export function normalizeTeamSpecInput(raw: unknown, options?: NormalizeTeamSpec
}
}
if (!hasExplicitLead) {
const callerTeamLead = options?.callerTeamLead
if (shouldUseFirstMemberAsLead) {
leadAgentId = getMemberName(normalizedMembers[0])
} else if (!hasExplicitLead) {
if (callerTeamLead?.isEligibleForTeamLead && callerTeamLead.agentTypeId !== undefined) {
normalizedMembers = [createCallerLeadMember(callerTeamLead.agentTypeId), ...normalizedMembers]
leadAgentId = "lead"
@@ -221,6 +227,10 @@ export function normalizeTeamSpecInput(raw: unknown, options?: NormalizeTeamSpec
normalizedMembers = assignGeneratedMemberNames(normalizedMembers)
if (leadAgentId === undefined && shouldUseFirstMemberAsLead) {
leadAgentId = getMemberName(normalizedMembers[0])
}
normalizedMembers = normalizedMembers.map((member) => {
const memberName = getMemberName(member)
const isLead = isJsonRecord(member) && member.isLead === true
@@ -157,6 +157,21 @@ describe("team-registry validator", () => {
expect(act).toThrow("Team 'validator-team' exceeds max 8 members.")
})
test("accepts teams with exactly 8 members", () => {
// given
const teamSpec = {
...createBaseTeamSpec(),
members: Array.from({ length: 8 }, (_, index) => createCategoryMember(`member-${index}`)),
leadAgentId: "member-0",
}
// when
const act = () => validateSpec(teamSpec)
// then
expect(act).not.toThrow()
})
test("rejects hyperplan teams that omit required adversarial categories", () => {
// given
const teamSpec: TeamSpec = {
@@ -271,6 +271,79 @@ describe("createTeamCreateTool inline_spec normalization", () => {
})
})
test("accepts legacy member permission fields in inline_spec", async () => {
// given
const createTeamCreateTool = await loadCreateTeamCreateTool()
const config = createConfig()
const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config)
const inlineSpec = {
name: "permission-compat-team",
members: [
{
name: "docs-validator",
category: "quick",
prompt: "Check docs against code and report mismatches.",
permission: "read",
},
{
name: "code-validator",
subagent_type: "atlas",
permission: { write: false },
},
],
}
// when
await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session", "Sisyphus"))
const spec = createTeamRunMock.mock.calls[0]?.[0]
// then
expect(spec).toMatchObject({
name: "permission-compat-team",
members: [
{ name: "lead", kind: "subagent_type" },
{ name: "docs-validator", kind: "category", category: "quick" },
{ name: "code-validator", kind: "subagent_type", subagent_type: "atlas" },
],
})
expect(JSON.stringify(spec?.members)).not.toContain("permission")
})
test("accepts exactly 8 inline members when no explicit lead is provided", async () => {
// given
const createTeamCreateTool = await loadCreateTeamCreateTool()
const config = createConfig()
const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config)
const inlineSpec = {
name: "eight-member-team",
members: Array.from({ length: 8 }, (_, index) => ({
name: `member-${index + 1}`,
category: "quick",
prompt: `Complete validation scenario ${index + 1}.`,
})),
}
// when
await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session", "Sisyphus"))
const spec = createTeamRunMock.mock.calls[0]?.[0]
// then
expect(spec?.members).toHaveLength(8)
expect(spec).toMatchObject({
leadAgentId: "member-1",
members: [
{ name: "member-1", kind: "category", category: "quick" },
{ name: "member-2", kind: "category", category: "quick" },
{ name: "member-3", kind: "category", category: "quick" },
{ name: "member-4", kind: "category", category: "quick" },
{ name: "member-5", kind: "category", category: "quick" },
{ name: "member-6", kind: "category", category: "quick" },
{ name: "member-7", kind: "category", category: "quick" },
{ name: "member-8", kind: "category", category: "quick" },
],
})
})
test("accepts role and capabilities style members with the configured fallback category", async () => {
// given
const createTeamCreateTool = await loadCreateTeamCreateTool()
+13
View File
@@ -255,6 +255,19 @@ describe("createTeamToolGating", () => {
await expect(result).rejects.toThrow("team-mode tool team_send_message denied: not a participant of team 11111111-1111-4111-8111-111111111111")
})
test("rejects team_status when the session is not in the registry and not in runtime state", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
temporaryDirectories.push(baseDir)
await seedTeams(baseDir, createRuntimeState())
// when
const result = runHook("team_status", "unknown-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir)
// then
await expect(result).rejects.toThrow("team-mode tool team_status denied: not a participant of team 11111111-1111-4111-8111-111111111111")
})
test("rejects team_send_message when the registry only has the caller for a different team than the requested teamRunId", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))