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> = {}): 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> & Pick, "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 }), }); }