Files
dev-puppeteer/packages/core/src/binding.test.ts
2026-05-10 00:31:18 +09:00

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 }),
});
}