430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { z } from "zod";
|
|
|
|
import { type BindingOverride, bindTemplatePersonas } from "./binding.js";
|
|
import type { BackendConfig } from "./config.js";
|
|
import { DevflowError } from "./errors.js";
|
|
import { hash } from "./hash.js";
|
|
import { Persona } from "./persona.js";
|
|
import { personaHash } from "./persona.js";
|
|
import { Template } from "./template.js";
|
|
|
|
const enabledBackends: BackendConfig[] = [
|
|
{ id: "fake", enabled: true },
|
|
{ id: "claude", enabled: true, binaryPath: process.execPath },
|
|
{ id: "codex", enabled: true, binaryPath: process.execPath },
|
|
];
|
|
|
|
describe("binding algorithm", () => {
|
|
it("auto-selects deterministically by preferred backend, version, name, then hash", () => {
|
|
const result = bindTemplatePersonas({
|
|
runId: "run-1",
|
|
template: template({
|
|
roles: [
|
|
{
|
|
id: "implementer",
|
|
requiredCapabilities: ["code_edit"],
|
|
preferredBackends: ["claude", "fake"],
|
|
},
|
|
],
|
|
}),
|
|
personas: [
|
|
persona({ name: "fake_v9", version: 9, backend: "fake", capabilities: ["code_edit"] }),
|
|
persona({ name: "claude_v1", version: 1, backend: "claude", capabilities: ["code_edit"] }),
|
|
persona({
|
|
name: "claude_v2_b",
|
|
version: 2,
|
|
backend: "claude",
|
|
capabilities: ["code_edit"],
|
|
}),
|
|
persona({
|
|
name: "claude_v2_a",
|
|
version: 2,
|
|
backend: "claude",
|
|
capabilities: ["code_edit"],
|
|
}),
|
|
],
|
|
templateHash: "template-hash",
|
|
availableBackends: enabledBackends,
|
|
});
|
|
|
|
expect(result.bindings).toHaveLength(1);
|
|
expect(result.bindings[0]?.roleId).toBe("implementer");
|
|
expect(result.bindings[0]?.persona.name).toBe("claude_v2_a");
|
|
});
|
|
|
|
it("falls back to non-preferred personas only when preferred personas fail eligibility", () => {
|
|
const result = bindTemplatePersonas({
|
|
runId: "run-1",
|
|
template: template({
|
|
roles: [
|
|
{
|
|
id: "implementer",
|
|
requiredCapabilities: ["code_edit"],
|
|
preferredBackends: ["claude"],
|
|
},
|
|
],
|
|
}),
|
|
personas: [
|
|
persona({ name: "claude_reader", backend: "claude", capabilities: ["code_review"] }),
|
|
persona({ name: "fake_implementer", backend: "fake", capabilities: ["code_edit"] }),
|
|
],
|
|
templateHash: "template-hash",
|
|
availableBackends: enabledBackends,
|
|
});
|
|
|
|
expect(result.bindings[0]?.persona.name).toBe("fake_implementer");
|
|
});
|
|
|
|
it("does not fall back when preferred personas only fail allowed role checks", () => {
|
|
expect(() =>
|
|
bindTemplatePersonas({
|
|
runId: "run-1",
|
|
template: template({
|
|
roles: [
|
|
{
|
|
id: "implementer",
|
|
requiredCapabilities: ["code_edit"],
|
|
preferredBackends: ["claude"],
|
|
},
|
|
],
|
|
}),
|
|
personas: [
|
|
persona({
|
|
name: "claude_restricted",
|
|
backend: "claude",
|
|
capabilities: ["code_edit"],
|
|
allowedRoles: ["reviewer"],
|
|
}),
|
|
persona({ name: "fake_implementer", backend: "fake", capabilities: ["code_edit"] }),
|
|
],
|
|
templateHash: "template-hash",
|
|
availableBackends: enabledBackends,
|
|
}),
|
|
).toThrow(/no_eligible_persona/);
|
|
});
|
|
|
|
it("fails instead of falling back when the selected preferred backend is unavailable", () => {
|
|
expect(() =>
|
|
bindTemplatePersonas({
|
|
runId: "run-1",
|
|
template: template({
|
|
roles: [
|
|
{
|
|
id: "implementer",
|
|
requiredCapabilities: ["code_edit"],
|
|
preferredBackends: ["codex"],
|
|
},
|
|
],
|
|
}),
|
|
personas: [
|
|
persona({ name: "codex_implementer", backend: "codex", capabilities: ["code_edit"] }),
|
|
persona({ name: "fake_implementer", backend: "fake", capabilities: ["code_edit"] }),
|
|
],
|
|
templateHash: "template-hash",
|
|
availableBackends: [{ id: "fake", enabled: true }],
|
|
}),
|
|
).toThrow(/backend_unavailable/);
|
|
});
|
|
|
|
it("classifies binding failures as human-required DevflowError instances", () => {
|
|
try {
|
|
bindTemplatePersonas({
|
|
runId: "run-1",
|
|
template: template({
|
|
roles: [
|
|
{
|
|
id: "implementer",
|
|
requiredCapabilities: ["code_edit"],
|
|
preferredBackends: ["codex"],
|
|
},
|
|
],
|
|
}),
|
|
personas: [persona({ name: "codex_implementer", backend: "codex" })],
|
|
templateHash: "template-hash",
|
|
availableBackends: [{ id: "codex", enabled: true }],
|
|
});
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(DevflowError);
|
|
expect((error as DevflowError).class).toBe("human_required");
|
|
expect((error as DevflowError).code).toBe("backend_unavailable");
|
|
return;
|
|
}
|
|
|
|
throw new Error("expected binding to fail");
|
|
});
|
|
|
|
it("treats absolute backend paths as process-start resolved registry entries", () => {
|
|
const result = bindTemplatePersonas({
|
|
runId: "run-1",
|
|
template: template({
|
|
roles: [
|
|
{
|
|
id: "implementer",
|
|
requiredCapabilities: ["code_edit"],
|
|
preferredBackends: ["codex"],
|
|
},
|
|
],
|
|
}),
|
|
personas: [persona({ name: "codex_implementer", backend: "codex" })],
|
|
templateHash: "template-hash",
|
|
availableBackends: [{ id: "codex", enabled: true, binaryPath: "/process/start/codex" }],
|
|
});
|
|
|
|
expect(result.bindings[0]?.backend).toBe("codex");
|
|
});
|
|
|
|
it("supports persona overrides and backend-constraining overrides", () => {
|
|
const result = bindTemplatePersonas({
|
|
runId: "run-1",
|
|
template: template({
|
|
roles: [{ id: "reviewer", requiredCapabilities: ["code_review"] }],
|
|
}),
|
|
personas: [
|
|
persona({ name: "fake_reviewer", backend: "fake", capabilities: ["code_review"] }),
|
|
persona({ name: "codex_reviewer", backend: "codex", capabilities: ["code_review"] }),
|
|
],
|
|
overrides: { roles: { reviewer: { backend: "codex" } } },
|
|
templateHash: "template-hash",
|
|
availableBackends: enabledBackends,
|
|
});
|
|
|
|
expect(result.bindings[0]?.persona.name).toBe("codex_reviewer");
|
|
|
|
const swappedPersona = bindTemplatePersonas({
|
|
runId: "run-1",
|
|
template: template({
|
|
roles: [{ id: "reviewer", requiredCapabilities: ["code_review"] }],
|
|
}),
|
|
personas: [
|
|
persona({ name: "alpha_reviewer", backend: "fake", capabilities: ["code_review"] }),
|
|
persona({ name: "beta_reviewer", backend: "fake", capabilities: ["code_review"] }),
|
|
],
|
|
overrides: { roles: { reviewer: { persona: "beta_reviewer" } } },
|
|
templateHash: "template-hash",
|
|
availableBackends: enabledBackends,
|
|
});
|
|
|
|
expect(swappedPersona.bindings[0]?.persona.name).toBe("beta_reviewer");
|
|
});
|
|
|
|
it("rejects typo override keys and normalizes explicit undefined override fields", () => {
|
|
const base = {
|
|
runId: "run-1",
|
|
template: template(),
|
|
personas: [persona({ name: "fake_implementer", backend: "fake" })],
|
|
templateHash: "template-hash",
|
|
availableBackends: enabledBackends,
|
|
};
|
|
|
|
expect(() =>
|
|
bindTemplatePersonas({
|
|
...base,
|
|
overrides: {
|
|
roles: {
|
|
implementer: { backned: "codex" } as unknown as BindingOverride,
|
|
},
|
|
},
|
|
}),
|
|
).toThrow(/Unrecognized key/);
|
|
|
|
expect(
|
|
bindTemplatePersonas({
|
|
...base,
|
|
overrides: {
|
|
roles: {
|
|
implementer: {
|
|
persona: undefined,
|
|
backend: undefined,
|
|
} as unknown as BindingOverride,
|
|
},
|
|
},
|
|
}).bindings[0]?.persona.name,
|
|
).toBe("fake_implementer");
|
|
|
|
expect(() =>
|
|
bindTemplatePersonas({
|
|
...base,
|
|
overrides: {
|
|
roles: {
|
|
implmenter: { backend: "fake" },
|
|
},
|
|
},
|
|
}),
|
|
).toThrow(/unknown override role/);
|
|
});
|
|
|
|
it("computes binding hashes from the locked hash subject", () => {
|
|
const override = { backend: "fake" as const };
|
|
const result = bindTemplatePersonas({
|
|
runId: "run-1",
|
|
template: template({
|
|
roles: [
|
|
{
|
|
id: "reviewer",
|
|
requiredCapabilities: ["code_review"],
|
|
count: 2,
|
|
},
|
|
],
|
|
}),
|
|
personas: [
|
|
persona({ name: "fake_reviewer", backend: "fake", capabilities: ["code_review"] }),
|
|
],
|
|
overrides: { roles: { "reviewer#1": override } },
|
|
templateHash: "template-hash",
|
|
availableBackends: enabledBackends,
|
|
});
|
|
const binding = result.bindings.find((candidate) => candidate.roleId === "reviewer#1");
|
|
const selectedPersonaHash = personaHash(binding?.persona);
|
|
|
|
expect(binding?.bindingHash).toBe(
|
|
hash({
|
|
runId: "run-1",
|
|
roleId: "reviewer#1",
|
|
templateHash: "template-hash",
|
|
personaHash: selectedPersonaHash,
|
|
backend: "fake",
|
|
override,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("expands counted roles and enforces backend diversity after overrides", () => {
|
|
const result = bindTemplatePersonas({
|
|
runId: "run-1",
|
|
template: template({
|
|
roles: [
|
|
{
|
|
id: "reviewer",
|
|
requiredCapabilities: ["code_review"],
|
|
count: 2,
|
|
diversity: { requireDifferentBackends: true },
|
|
},
|
|
],
|
|
}),
|
|
personas: [
|
|
persona({ name: "fake_reviewer", backend: "fake", capabilities: ["code_review"] }),
|
|
persona({ name: "codex_reviewer", backend: "codex", capabilities: ["code_review"] }),
|
|
],
|
|
templateHash: "template-hash",
|
|
availableBackends: enabledBackends,
|
|
});
|
|
|
|
expect(result.bindings.map((binding) => binding.roleId)).toEqual(["reviewer#0", "reviewer#1"]);
|
|
expect(new Set(result.bindings.map((binding) => binding.backend)).size).toBe(2);
|
|
|
|
const constrainedSecondInstance = bindTemplatePersonas({
|
|
runId: "run-1",
|
|
template: template({
|
|
roles: [
|
|
{
|
|
id: "reviewer",
|
|
requiredCapabilities: ["code_review"],
|
|
count: 2,
|
|
diversity: { requireDifferentBackends: true },
|
|
},
|
|
],
|
|
}),
|
|
personas: [
|
|
persona({ name: "fake_reviewer", backend: "fake", capabilities: ["code_review"] }),
|
|
persona({ name: "codex_reviewer", backend: "codex", capabilities: ["code_review"] }),
|
|
],
|
|
overrides: { roles: { "reviewer#1": { backend: "fake" } } },
|
|
templateHash: "template-hash",
|
|
availableBackends: enabledBackends,
|
|
});
|
|
|
|
expect(constrainedSecondInstance.bindings.map((binding) => binding.backend)).toEqual([
|
|
"codex",
|
|
"fake",
|
|
]);
|
|
});
|
|
|
|
it("rejects unavailable backends, missing capabilities, role restrictions, and risk overflow", () => {
|
|
const base = {
|
|
runId: "run-1",
|
|
templateHash: "template-hash",
|
|
availableBackends: [{ id: "fake", enabled: true }] satisfies BackendConfig[],
|
|
};
|
|
|
|
expect(() =>
|
|
bindTemplatePersonas({
|
|
...base,
|
|
template: template({ roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }] }),
|
|
personas: [persona({ name: "disabled", backend: "codex", capabilities: ["code_edit"] })],
|
|
}),
|
|
).toThrow(/backend_unavailable/);
|
|
|
|
expect(() =>
|
|
bindTemplatePersonas({
|
|
...base,
|
|
template: template({ roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }] }),
|
|
personas: [persona({ name: "reviewer", backend: "fake", capabilities: ["code_review"] })],
|
|
}),
|
|
).toThrow(/no_eligible_persona/);
|
|
|
|
expect(() =>
|
|
bindTemplatePersonas({
|
|
...base,
|
|
template: template({ roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }] }),
|
|
personas: [
|
|
persona({
|
|
name: "restricted",
|
|
backend: "fake",
|
|
capabilities: ["code_edit"],
|
|
allowedRoles: ["reviewer"],
|
|
}),
|
|
],
|
|
}),
|
|
).toThrow(/no_eligible_persona/);
|
|
|
|
expect(() =>
|
|
bindTemplatePersonas({
|
|
...base,
|
|
template: template({
|
|
roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }],
|
|
phases: [{ key: "danger", title: "Danger", risk: "high", roles: ["implementer"] }],
|
|
}),
|
|
personas: [
|
|
persona({
|
|
name: "medium_only",
|
|
backend: "fake",
|
|
capabilities: ["code_edit"],
|
|
maxRiskLevel: "medium",
|
|
}),
|
|
],
|
|
}),
|
|
).toThrow(/no_eligible_persona/);
|
|
});
|
|
});
|
|
|
|
function template(input: Partial<z.input<typeof Template>> = {}): Template {
|
|
const roles = input.roles ?? [{ id: "implementer", requiredCapabilities: ["code_edit"] }];
|
|
const phases = input.phases ?? [
|
|
{ key: "spec", title: "Spec", risk: "low", roles: [roles[0]?.id ?? "implementer"] },
|
|
];
|
|
|
|
return Template.parse({
|
|
name: input.name ?? "development",
|
|
version: input.version ?? 1,
|
|
roles,
|
|
phases,
|
|
defaultGates: input.defaultGates ?? [],
|
|
});
|
|
}
|
|
|
|
function persona(
|
|
input: Partial<z.input<typeof Persona>> & Pick<z.input<typeof Persona>, "name">,
|
|
): Persona {
|
|
return Persona.parse({
|
|
name: input.name,
|
|
version: input.version ?? 1,
|
|
backend: input.backend ?? "fake",
|
|
capabilities: input.capabilities ?? ["code_edit"],
|
|
maxRiskLevel: input.maxRiskLevel ?? "high",
|
|
promptConfig: input.promptConfig ?? {},
|
|
modelConfig: input.modelConfig ?? {},
|
|
...(input.allowedRoles === undefined ? {} : { allowedRoles: input.allowedRoles }),
|
|
});
|
|
}
|