diff --git a/index.ts b/index.ts index a310ea1..224fae5 100644 --- a/index.ts +++ b/index.ts @@ -71,10 +71,12 @@ import { registerSpawnTool } from "./spawn/index.js"; import { registerModelGroupsCommand } from "./model-groups/command.js"; import { resolveSpawnModelRoute, SpawnRouteError } from "./model-groups/router.js"; import { registerModelGroupAutocomplete } from "./model-groups/autocomplete.js"; -import { getEffectiveModelGroupNames } from "./model-groups/router.js"; +import { getEffectiveModelGroups, getEffectiveModelGroupNames } from "./model-groups/router.js"; +import { MODEL_GROUP_MODALITIES, type ResolvedModelGroup, type ModelGroupsAccess } from "./model-groups/types.js"; import { loadModelGroups, summarizeBootValidation, validateModelGroups } from "./model-groups/store.js"; import { escapeDisplayLabel } from "./model-groups/display.js"; -import type { ModelGroupsAccess } from "./model-groups/types.js"; +import { presentConstraintPrompt } from "./model-groups/constraints/presentation.js"; +import { productionConstraintRegistry } from "./model-groups/constraints/registry.js"; import { cacheLookupCommand, cacheLookupCommandExplicitModel, @@ -100,6 +102,9 @@ import { updateIndicators, } from "./tui.js"; import { applyReadonlyBashGuard } from "./readonly-bash.js"; + +const MODEL_GROUP_MODALITY_PROSE = MODEL_GROUP_MODALITIES.join(", ").replace(/, ([^,]+)$/, ", or $1"); + // ── Helpers ──────────────────────────────────────────────────────────── /** @@ -461,13 +466,13 @@ function refreshModelGroupsState(state: AgenticodingState, ctx: ExtensionContext return state.modelGroups.validation; } -function modelGroupsPromptSection(names: string[]): string | undefined { - if (names.length === 0) return undefined; +function modelGroupsPromptSection(groups: ResolvedModelGroup[]): string | undefined { + if (groups.length === 0) return undefined; + const labels = groups.map((group) => `${escapeDisplayLabel(group.name)} (${(group.evaluations ? presentConstraintPrompt(group.evaluations, productionConstraintRegistry).filter(Boolean).join(", ") : group.modalities?.effective.join(", ")) || "no common modalities"})`); return `\n## Model Groups for spawn\n` + - `Available Model Groups: ${names.join(", ")}\n` + - `When the operator asks to spawn with one of these groups, or mentions #group-name, call spawn with group set to the exact group name only when the mapping is known and confident. ` + - `If no known/confident group is requested, omit group and inherit the parent model/thinking. ` + - `The group list is names-only; do not assume provider/model membership, thinking levels, auth status, validation details, or storage paths from it.`; + `Available Model Groups: ${labels.join(", ")}\n` + + `When the operator asks to spawn with one of these groups, or mentions #group-name, call spawn with group set to the exact group name only when the mapping is known and confident. If a delegated task requires ${MODEL_GROUP_MODALITY_PROSE} capability, pass those requirements as constraints. If no known/confident group is requested, omit group and inherit the parent model/thinking. ` + + `The group list exposes only names and effective modalities; do not assume provider/model membership, thinking levels, auth status, validation details, or storage paths from it.`; } export default function (pi: ExtensionAPI): void { @@ -756,7 +761,7 @@ export default function (pi: ExtensionAPI): void { ); } - const modelGroupSection = modelGroupsPromptSection(getEffectiveModelGroupNames(state.modelGroups.groups)); + const modelGroupSection = modelGroupsPromptSection(getEffectiveModelGroups(state.modelGroups.groups)); if (modelGroupSection) { parts.push(modelGroupSection); } @@ -920,9 +925,9 @@ export default function (pi: ExtensionAPI): void { const backupNote = issue.backupFailed ? `; backup failed${backupPath ? ` (${backupPath})` : ""}, original file left untouched` : ""; ctx.ui.notify(`Model Groups config ${issue.kind} in ${issue.scope} scope (${sourcePath}); using empty config for that scope${backupNote}; ${detail}`, "warning"); } - const { unavailableCount, overrideCount } = summarizeBootValidation(validation.groups); - if (unavailableCount > 0 || overrideCount > 0) { - ctx.ui.notify(`Model Groups boot validation: ${unavailableCount} unavailable model references · ${overrideCount} project overrides`, "warning"); + const { unavailableCount, overrideCount, emptyModalityCount, staleModalityOverrideCount } = summarizeBootValidation(validation.groups); + if (unavailableCount > 0 || overrideCount > 0 || emptyModalityCount > 0 || staleModalityOverrideCount > 0) { + ctx.ui.notify(`Model Groups boot validation: ${unavailableCount} unavailable model references · ${overrideCount} project overrides · ${emptyModalityCount} groups with no common modalities · ${staleModalityOverrideCount} stale modality overrides`, "warning"); } } diff --git a/model-groups/constraints/engine.ts b/model-groups/constraints/engine.ts new file mode 100644 index 0000000..00d26ab --- /dev/null +++ b/model-groups/constraints/engine.ts @@ -0,0 +1,45 @@ +import type { ConstraintRegistry } from "./registry.js"; +import type { AnyConstraintDescriptor, ConstraintEvaluation, ConstraintMemberResolution, ConstraintViolation, ErasedConstraintEvaluation } from "./types.js"; + +function evaluateDescriptor(descriptor: AnyConstraintDescriptor, resolution: ConstraintMemberResolution, override: unknown): ErasedConstraintEvaluation { + const members = resolution.members.map(({ ref, model }) => ({ ref, ...(model ? { fact: descriptor.modelFact(model) } : {}) })); + const aggregate = descriptor.aggregate({ members }); + const reconciled = descriptor.reconcile({ aggregate, override }); + return { key: descriptor.key, aggregate, effective: reconciled.effective, diagnostics: reconciled.diagnostics }; +} + +/** Pure evaluator: resolution is supplied by the host and no registry APIs are reachable here. */ +export function evaluateConstraints( + resolution: ConstraintMemberResolution, + overrides: Readonly>, + registry: ConstraintRegistry, +): readonly ErasedConstraintEvaluation[] { + return registry.descriptors.map((descriptor) => evaluateDescriptor(descriptor, resolution, overrides[descriptor.key])); +} + +export function evaluateConstraint( + descriptor: AnyConstraintDescriptor, + resolution: ConstraintMemberResolution, + override: unknown, +): ConstraintEvaluation { + return evaluateDescriptor(descriptor, resolution, override) as ConstraintEvaluation; +} + +export function evaluateGroupRequirement( + descriptor: AnyConstraintDescriptor, + evaluation: ErasedConstraintEvaluation, + requirement: unknown, +): ConstraintViolation | undefined { + const satisfaction = descriptor.groupSatisfies({ aggregate: evaluation.aggregate, effective: evaluation.effective, requirement }); + return satisfaction.satisfied ? undefined : { key: descriptor.key, scope: "group", satisfaction }; +} + +export function evaluateModelRequirement( + descriptor: AnyConstraintDescriptor, + model: ConstraintMemberResolution["members"][number]["model"], + requirement: unknown, +): ConstraintViolation | undefined { + if (!model) return { key: descriptor.key, scope: "model", satisfaction: { satisfied: false, missing: "unresolved" } }; + const satisfaction = descriptor.modelSatisfies({ fact: descriptor.modelFact(model), requirement }); + return satisfaction.satisfied ? undefined : { key: descriptor.key, scope: "model", satisfaction }; +} diff --git a/model-groups/constraints/modalities.ts b/model-groups/constraints/modalities.ts new file mode 100644 index 0000000..1957fa5 --- /dev/null +++ b/model-groups/constraints/modalities.ts @@ -0,0 +1,92 @@ +import { Type } from "typebox"; +import type { Api, Model } from "@earendil-works/pi-ai"; +import { MODEL_GROUP_MODALITIES, type ModelGroupModalities, type ModelGroupModality } from "../types.js"; +import type { ConstraintCodec, ConstraintDescriptor, ConstraintDiagnostic, ConstraintEvaluation, ConstraintSatisfaction, ConstraintViolation } from "./types.js"; + +function ordered(values: Iterable): ModelGroupModality[] { + const set = new Set(values); + return MODEL_GROUP_MODALITIES.filter((value) => set.has(value)); +} + +function modalityCodec(): ConstraintCodec { + return { + decode(value, path) { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !MODEL_GROUP_MODALITIES.includes(item as ModelGroupModality)) || new Set(value).size !== value.length) return { ok: false, message: `${path} must be a unique modality vocabulary array` }; + return { ok: true, value: ordered(value as ModelGroupModality[]) }; + }, + encode: (value) => [...value], + equals: (left, right) => left.length === right.length && left.every((value, index) => value === right[index]), + schema: Type.Array(Type.Union(MODEL_GROUP_MODALITIES.map((value) => Type.Literal(value))), { uniqueItems: true }), + }; +} + +function satisfaction(missing: ModelGroupModality[]): ConstraintSatisfaction { + return missing.length ? { satisfied: false, missing } : { satisfied: true }; +} + +export function getModalitiesModelFact(model: Model): ModelGroupModality[] { + return ordered([...(Array.isArray(model.input) ? model.input as ModelGroupModality[] : []), ...(model.reasoning === true ? ["reasoning" as const] : [])]); +} + +export const modalitiesConstraint: ConstraintDescriptor<"modalities", ModelGroupModality[], ModelGroupModalities, ModelGroupModality[], ModelGroupModality[], ModelGroupModality[]> = { + key: "modalities", + order: 0, + modelFact: getModalitiesModelFact, + aggregate({ members }) { + const sets = members.map(({ fact }) => new Set(fact ?? [])); + const supported = ordered(sets.flatMap((set) => [...set])); + const common = members.length === 0 || members.some(({ fact }) => fact === undefined) + ? [] + : ordered(MODEL_GROUP_MODALITIES.filter((modality) => sets.every((set) => set.has(modality)))); + return { common, supported, effective: common }; + }, + reconcile({ aggregate, override }) { + const effective = override === undefined ? aggregate.common : ordered(override.filter((modality) => aggregate.supported.includes(modality))); + const missing = override === undefined ? [] : ordered(override.filter((modality) => !aggregate.supported.includes(modality))); + const diagnostics: ConstraintDiagnostic[] = [ + ...(aggregate.common.length === 0 ? [{ key: "modalities", code: "empty-common" }] : []), + ...(missing.length ? [{ key: "modalities", code: "unsupported-override", details: missing }] : []), + ]; + return { effective, diagnostics }; + }, + groupSatisfies({ effective, requirement }) { return satisfaction(ordered(requirement.filter((modality) => !effective.includes(modality)))); }, + modelSatisfies({ fact, requirement }) { return satisfaction(ordered(requirement.filter((modality) => !fact.includes(modality)))); }, + persistence: { override: modalityCodec(), clone: (value) => [...value] }, + requirement: { + decode(value, path) { + if (!value || typeof value !== "object" || Array.isArray(value) || !("required" in value)) return { ok: false, message: `${path} must be an object with required modalities` }; + return modalityCodec().decode((value as { required: unknown }).required, `${path}.required`); + }, + encode: (value) => ({ required: [...value] }), + equals: (left, right) => modalityCodec().equals(left, right), + schema: Type.Object({ required: modalityCodec().schema }), + }, + editor: { kind: "multi-select", label: "Modalities", choices: (evaluation) => evaluation.aggregate.supported, automatic: (evaluation) => `Automatic (common: ${evaluation.aggregate.common.join(", ") || "none"})`, format: (value) => `Override: ${value.join(", ") || "none"}`, allowAutomatic: true }, + present: { + group: (evaluation) => evaluation.effective.join(", "), + prompt: (evaluation) => evaluation.effective.join(", "), + diagnostic: (diagnostic) => diagnostic.code === "empty-common" + ? "⚠ no common modalities" + : `⚠ stale modality override: ${((diagnostic.details as ModelGroupModality[] | undefined) ?? []).join(", ")}`, + violation: (violation: ConstraintViolation) => violation.key, + }, +}; + +export function deriveModalitiesEvaluation( + members: readonly { ref: { provider: string; modelId: string }; model?: Model }[], + override: ModelGroupModality[] | undefined, +): ConstraintEvaluation { + const aggregate = modalitiesConstraint.aggregate({ members: members.map(({ ref, model }) => ({ ref, ...(model ? { fact: modalitiesConstraint.modelFact(model) } : {}) })) }); + const reconciled = modalitiesConstraint.reconcile({ aggregate, override }); + return { key: modalitiesConstraint.key, aggregate, effective: reconciled.effective, diagnostics: reconciled.diagnostics }; +} + +export function assertModalitiesOverrideSupported(evaluation: ConstraintEvaluation, override: ModelGroupModality[] | undefined): void { + if (override === undefined) return; + const missing = evaluation.diagnostics.find((diagnostic) => diagnostic.code === "unsupported-override")?.details as ModelGroupModality[] | undefined; + if (missing?.length) throw new Error(`Model group modality override includes unsupported modalities: ${missing.join(", ")}.`); +} + +export function getMissingModalitiesFromModel(model: Model, required: readonly ModelGroupModality[]): ModelGroupModality[] { + return ordered(required.filter((modality) => !getModalitiesModelFact(model).includes(modality))); +} diff --git a/model-groups/constraints/presentation.ts b/model-groups/constraints/presentation.ts new file mode 100644 index 0000000..2b1f58e --- /dev/null +++ b/model-groups/constraints/presentation.ts @@ -0,0 +1,64 @@ +import type { ConstraintRegistry } from "./registry.js"; +import type { AnyConstraintDescriptor, ConstraintDiagnostic, ConstraintEditorSpec, ConstraintEvaluation, ConstraintViolation, ErasedConstraintEvaluation } from "./types.js"; + +export interface ConstraintDiagnosticRecord extends ConstraintDiagnostic { + text: string; +} + +export type ConstraintEditorRow = + | { kind: "automatic"; label: string } + | { kind: "choice"; label: string; value: readonly string[] } + | { kind: "number"; label: string; value: number | null; unit: string; min: number; step: number }; + +export function presentConstraintGroups(evaluations: readonly ErasedConstraintEvaluation[], registry: ConstraintRegistry): string[] { + return evaluations.flatMap((evaluation) => { + const descriptor = registry.get(evaluation.key); + return descriptor ? [descriptor.present.group(evaluation)] : []; + }); +} + +export function presentConstraintPrompt(evaluations: readonly ErasedConstraintEvaluation[], registry: ConstraintRegistry): string[] { + return evaluations.flatMap((evaluation) => { + const descriptor = registry.get(evaluation.key); + return descriptor ? [descriptor.present.prompt(evaluation)] : []; + }); +} + +export function presentConstraintDiagnosticRecords(evaluations: readonly ErasedConstraintEvaluation[], registry: ConstraintRegistry): ConstraintDiagnosticRecord[] { + return evaluations.flatMap((evaluation) => evaluation.diagnostics.flatMap((diagnostic) => { + const descriptor = registry.get(diagnostic.key); + return descriptor ? [{ ...diagnostic, text: descriptor.present.diagnostic(diagnostic) }] : []; + })); +} + +export function presentConstraintDiagnostics(diagnostics: readonly ConstraintDiagnostic[], registry: ConstraintRegistry): string[] { + return diagnostics.flatMap((diagnostic) => { + const descriptor = registry.get(diagnostic.key); + return descriptor ? [descriptor.present.diagnostic(diagnostic)] : []; + }); +} + +export function constraintEditorRows( + descriptor: AnyConstraintDescriptor, + evaluation: ErasedConstraintEvaluation, + override?: unknown, +): readonly ConstraintEditorRow[] { + const editor = descriptor.editor as ConstraintEditorSpec; + if (editor.kind === "multi-select") { + const choices = [...new Set([...editor.choices(evaluation as ConstraintEvaluation), ...(Array.isArray(override) ? override.filter((value): value is string => typeof value === "string") : [])])]; + const rows: ConstraintEditorRow[] = [{ kind: "automatic", label: editor.automatic(evaluation as ConstraintEvaluation) }]; + for (let mask = 0; mask < 2 ** choices.length; mask++) { + const value = choices.filter((_, index) => (mask & (1 << index)) !== 0); + rows.push({ kind: "choice", label: editor.format(value), value }); + } + return rows; + } + return [ + { kind: "automatic", label: editor.automatic(evaluation as ConstraintEvaluation) }, + { kind: "number", label: editor.label, value: editor.value(evaluation as ConstraintEvaluation), unit: editor.unit, min: editor.min, step: editor.step }, + ]; +} + +export function presentConstraintViolation(violation: ConstraintViolation, registry: ConstraintRegistry): string | undefined { + return registry.get(violation.key)?.present.violation(violation); +} diff --git a/model-groups/constraints/registry.ts b/model-groups/constraints/registry.ts new file mode 100644 index 0000000..a2adb8b --- /dev/null +++ b/model-groups/constraints/registry.ts @@ -0,0 +1,21 @@ +import { modalitiesConstraint } from "./modalities.js"; +import type { AnyConstraintDescriptor } from "./types.js"; + +export interface ConstraintRegistry { + readonly descriptors: readonly AnyConstraintDescriptor[]; + get(key: string): AnyConstraintDescriptor | undefined; +} + +export function createConstraintRegistry(descriptors: readonly AnyConstraintDescriptor[]): ConstraintRegistry { + const ordered = [...descriptors].sort((left, right) => left.order - right.order || left.key.localeCompare(right.key)); + const keys = new Set(); + for (const descriptor of ordered) { + if (keys.has(descriptor.key)) throw new Error(`Duplicate model-group constraint key: ${descriptor.key}.`); + keys.add(descriptor.key); + } + return { descriptors: ordered, get: (key) => ordered.find((descriptor) => descriptor.key === key) }; +} + +// Internal, fixed production catalog. Tests inject a registry with createConstraintRegistry. +const productionDescriptors = [modalitiesConstraint] as const; +export const productionConstraintRegistry = createConstraintRegistry(productionDescriptors); diff --git a/model-groups/constraints/resolution.ts b/model-groups/constraints/resolution.ts new file mode 100644 index 0000000..0e56c3a --- /dev/null +++ b/model-groups/constraints/resolution.ts @@ -0,0 +1,12 @@ +import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { Api, Model } from "@earendil-works/pi-ai"; +import type { ModelGroupModel } from "../types.js"; +import type { ConstraintMemberResolution } from "./types.js"; + +/** Host adapter: group algebra deliberately receives this snapshot, not a registry. */ +export function resolveConstraintMembers( + members: readonly ModelGroupModel[], + modelRegistry: Pick, +): ConstraintMemberResolution { + return { members: members.map((ref) => ({ ref, model: modelRegistry.find(ref.provider, ref.modelId) as Model | undefined })) }; +} diff --git a/model-groups/constraints/types.ts b/model-groups/constraints/types.ts new file mode 100644 index 0000000..82ca613 --- /dev/null +++ b/model-groups/constraints/types.ts @@ -0,0 +1,67 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; +import type { TSchema } from "typebox"; +import type { ModelGroupModel } from "../types.js"; + +export type DecodeResult = { ok: true; value: T } | { ok: false; message: string }; + +export interface ConstraintMemberResolution { + members: readonly { ref: ModelGroupModel; model?: Model }[]; +} + +export interface ConstraintCodec { + decode(value: unknown, path: string): DecodeResult; + encode(value: T): unknown; + equals(left: T, right: T): boolean; + schema: TSchema; +} + +export interface ConstraintDiagnostic { + key: string; + code: string; + details?: unknown; +} + +export interface ConstraintSatisfaction { + satisfied: boolean; + missing?: unknown; + unsatisfied?: unknown; +} + +export interface ConstraintViolation { + key: string; + scope: "group" | "model"; + satisfaction: ConstraintSatisfaction; +} + +export type ConstraintEditorSpec = + | { kind: "multi-select"; label: string; choices(evaluation: ConstraintEvaluation): readonly string[]; automatic(evaluation: ConstraintEvaluation): string; format(value: readonly string[]): string; allowAutomatic: true } + | { kind: "number"; label: string; unit: string; min: number; step: number; automatic(evaluation: ConstraintEvaluation): string; value(evaluation: ConstraintEvaluation): number | null; allowAutomatic: true }; + +export interface ConstraintEvaluation { + key: string; + aggregate: Aggregate; + effective: Effective; + diagnostics: readonly ConstraintDiagnostic[]; +} + +export interface ConstraintDescriptor { + readonly key: K; + readonly order: number; + modelFact(model: Model): Fact; + aggregate(input: { members: readonly { ref: ModelGroupModel; fact?: Fact }[] }): Aggregate; + reconcile(input: { aggregate: Aggregate; override: Override | undefined }): { effective: Effective; diagnostics: ConstraintDiagnostic[] }; + groupSatisfies(input: { aggregate: Aggregate; effective: Effective; requirement: Requirement }): ConstraintSatisfaction; + modelSatisfies(input: { fact: Fact; requirement: Requirement }): ConstraintSatisfaction; + persistence: { override: ConstraintCodec; clone(value: Override): Override }; + requirement: ConstraintCodec; + editor: ConstraintEditorSpec; + present: { + group(evaluation: ConstraintEvaluation): string; + prompt(evaluation: ConstraintEvaluation): string; + diagnostic(diagnostic: ConstraintDiagnostic): string; + violation(violation: ConstraintViolation): string; + }; +} + +export type AnyConstraintDescriptor = ConstraintDescriptor; +export type ErasedConstraintEvaluation = ConstraintEvaluation; diff --git a/model-groups/modalities.ts b/model-groups/modalities.ts new file mode 100644 index 0000000..f9c1ab9 --- /dev/null +++ b/model-groups/modalities.ts @@ -0,0 +1,32 @@ +import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { Api, Model } from "@earendil-works/pi-ai"; +import { assertModalitiesOverrideSupported, deriveModalitiesEvaluation, getMissingModalitiesFromModel, getModalitiesModelFact } from "./constraints/modalities.js"; +import { resolveConstraintMembers } from "./constraints/resolution.js"; +import type { ModelGroupDef, ModelGroupModalities, ModelGroupModality } from "./types.js"; + +/** Compatibility façade for the production modalities descriptor. */ +export function getModelModalities(model: Model): ModelGroupModality[] { + return getModalitiesModelFact(model); +} + +export function deriveModelGroupModalities( + group: Pick, + modelRegistry: Pick, +): ModelGroupModalities { + const override = group.constraints?.modalities as ModelGroupModality[] | undefined; + const evaluation = deriveModalitiesEvaluation(resolveConstraintMembers(group.models, modelRegistry).members, override); + return { common: evaluation.aggregate.common, supported: evaluation.aggregate.supported, effective: evaluation.effective }; +} + +export function assertModalityOverrideSupported( + group: Pick, + modelRegistry: Pick, +): void { + const override = group.constraints?.modalities as ModelGroupModality[] | undefined; + const evaluation = deriveModalitiesEvaluation(resolveConstraintMembers(group.models, modelRegistry).members, override); + assertModalitiesOverrideSupported(evaluation, override); +} + +export function getMissingModelModalities(model: Model, required: readonly ModelGroupModality[]): ModelGroupModality[] { + return getMissingModalitiesFromModel(model, required); +} diff --git a/model-groups/router.ts b/model-groups/router.ts index 73a9db3..e0b5312 100644 --- a/model-groups/router.ts +++ b/model-groups/router.ts @@ -1,106 +1,62 @@ import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import { clampThinkingLevel, type Api, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai"; -import type { ResolvedModelGroup } from "./types.js"; +import { evaluateConstraint, evaluateGroupRequirement, evaluateModelRequirement } from "./constraints/engine.js"; +import { productionConstraintRegistry, type ConstraintRegistry } from "./constraints/registry.js"; +import { resolveConstraintMembers } from "./constraints/resolution.js"; +import type { ConstraintViolation } from "./constraints/types.js"; +import { type ModelGroupModality, type ResolvedModelGroup } from "./types.js"; export type SpawnRouteStatus = "inherited" | "routed" | "unknown-fallback"; - -export interface SpawnModelRoute { - status: SpawnRouteStatus; - requestedGroup?: string; - groupName?: string; - model: Model; - provider: string; - modelId: string; - thinking: ModelThinkingLevel; -} - -export type SpawnRouteErrorReason = "empty" | "no-usable-models"; - +export interface SpawnModelRoute { status: SpawnRouteStatus; requestedGroup?: string; groupName?: string; model: Model; provider: string; modelId: string; thinking: ModelThinkingLevel } +export type SpawnRouteErrorReason = "empty" | "no-usable-models" | "missing-modality" | "constraint-unsatisfied"; export class SpawnRouteError extends Error { - readonly kind = "unusable-group" as const; - readonly group: string; - readonly reason: SpawnRouteErrorReason; - - constructor(group: string, reason: SpawnRouteErrorReason) { - const detail = reason === "empty" - ? "has no model entries" - : "has no configured/authenticated usable models"; - super(`Model Group '${group}' ${detail}.`); - this.name = "SpawnRouteError"; - this.group = group; - this.reason = reason; + readonly kind = "unusable-group" as const; readonly group: string; readonly reason: SpawnRouteErrorReason; readonly missingModalities: ModelGroupModality[]; readonly missingFromGroup: ModelGroupModality[]; readonly missingFromModel: ModelGroupModality[]; readonly constraintUnsatisfied?: readonly ConstraintViolation[]; + constructor(group: string, reason: SpawnRouteErrorReason, details: { missingModalities?: ModelGroupModality[]; missingFromGroup?: ModelGroupModality[]; missingFromModel?: ModelGroupModality[]; constraintUnsatisfied?: readonly ConstraintViolation[]; provider?: string; modelId?: string; knownGroup?: boolean } = {}) { + const missingModalities = details.missingModalities ?? [], missingFromGroup = details.missingFromGroup ?? [], missingFromModel = details.missingFromModel ?? []; + const message = reason === "empty" ? `Model Group '${group}' has no model entries.` : reason === "no-usable-models" ? `Model Group '${group}' has no configured/authenticated usable models.` : reason === "missing-modality" ? details.knownGroup ? `Model Group '${group}' cannot satisfy required modalities: ${missingModalities.join(", ")}. Effective group modalities missing: ${missingFromGroup.join(", ") || "none"}. Routed model '${details.provider}/${details.modelId}' missing: ${missingFromModel.join(", ") || "none"}.` : `Spawn model '${details.provider}/${details.modelId}' cannot satisfy required modalities: ${missingModalities.join(", ")}.` : `Spawn route '${group}' cannot satisfy constraint requirements.`; + super(message); this.name = "SpawnRouteError"; this.group = group; this.reason = reason; this.missingModalities = missingModalities; this.missingFromGroup = missingFromGroup; this.missingFromModel = missingFromModel; if (details.constraintUnsatisfied) this.constraintUnsatisfied = details.constraintUnsatisfied; } } - -function parentProvider(model: Model): string { - return typeof model.provider === "string" ? model.provider : ""; -} - -function effectiveGroupMap(groups: ResolvedModelGroup[]): Map { - const byName = new Map(); - for (const group of groups) { - if (group.validation?.shadowedByProject) continue; - const existing = byName.get(group.name); - if (!existing || group.scope === "project") byName.set(group.name, group); +function parentProvider(model: Model): string { return typeof model.provider === "string" ? model.provider : ""; } +function effectiveGroupMap(groups: ResolvedModelGroup[]): Map { const map = new Map(); for (const group of groups) { if (group.validation?.shadowedByProject) continue; const current = map.get(group.name); if (!current || group.scope === "project") map.set(group.name, group); } return map; } +export function getEffectiveModelGroups(groups: ResolvedModelGroup[]): ResolvedModelGroup[] { return [...effectiveGroupMap(groups).values()].sort((a, b) => a.name.localeCompare(b.name)); } +export function getEffectiveModelGroupNames(groups: ResolvedModelGroup[]): string[] { return getEffectiveModelGroups(groups).map((group) => group.name); } + +/** Route selection remains auth-aware; constraint evaluation receives its explicit member snapshot. */ +export function resolveSpawnModelRoute(options: { requestedGroup?: string; constraints?: Readonly>; groups: ResolvedModelGroup[]; parentModel: Model; parentThinking: ModelThinkingLevel; modelRegistry: Pick; constraintRegistry?: ConstraintRegistry; rng?: () => number }): SpawnModelRoute { + const requestedGroup = options.requestedGroup?.trim(); const requirements = options.constraints ?? {}; const registry = options.constraintRegistry ?? productionConstraintRegistry; + const inherited = (status: "inherited" | "unknown-fallback"): SpawnModelRoute => ({ status, ...(status === "unknown-fallback" && requestedGroup ? { requestedGroup } : {}), model: options.parentModel, provider: parentProvider(options.parentModel), modelId: options.parentModel.id, thinking: options.parentThinking }); + let route: SpawnModelRoute; let group: ResolvedModelGroup | undefined; + if (!requestedGroup) route = inherited("inherited"); else { group = effectiveGroupMap(options.groups).get(requestedGroup); if (!group) route = inherited("unknown-fallback"); else { if (group.models.length === 0) throw new SpawnRouteError(group.name, "empty"); const usable = group.models.map((entry) => { const model = options.modelRegistry.find(entry.provider, entry.modelId) as Model | undefined; return model && options.modelRegistry.hasConfiguredAuth(model) ? { entry, model } : undefined; }).filter((entry): entry is { entry: ResolvedModelGroup["models"][number]; model: Model } => Boolean(entry)); if (!usable.length) throw new SpawnRouteError(group.name, "no-usable-models"); const selected = usable[Math.min(usable.length - 1, Math.max(0, Math.floor((options.rng ?? Math.random)() * usable.length)))]; route = { status: "routed", requestedGroup, groupName: group.name, model: selected.model, provider: selected.entry.provider, modelId: selected.entry.modelId, thinking: clampThinkingLevel(selected.model, selected.entry.thinkingLevel ?? options.parentThinking) }; } } + if (!Object.keys(requirements).length) return route; + const resolution = group ? resolveConstraintMembers(group.models, options.modelRegistry) : { members: [] }; + const violations: ConstraintViolation[] = []; + for (const [key, rawRequirement] of Object.entries(requirements)) { + const descriptor = registry.get(key); + if (!descriptor) throw new Error(`Unknown spawn constraint requirement '${key}'.`); + const decoded = Array.isArray(rawRequirement) ? { ok: true as const, value: rawRequirement } : descriptor.requirement.decode(rawRequirement, `constraints.${key}`); + if (!decoded.ok) throw new Error(decoded.message); + const requirement = decoded.value; + if (group) { + const override = group.constraints?.[key]; + const evaluation = evaluateConstraint(descriptor, resolution, override); + const violation = evaluateGroupRequirement(descriptor, evaluation, requirement); + if (violation) violations.push(violation); + } + const violation = evaluateModelRequirement(descriptor, route.model, requirement); + if (violation) violations.push(violation); } - return byName; -} - -export function getEffectiveModelGroups(groups: ResolvedModelGroup[]): ResolvedModelGroup[] { - return [...effectiveGroupMap(groups).values()].sort((a, b) => a.name.localeCompare(b.name)); -} - -export function getEffectiveModelGroupNames(groups: ResolvedModelGroup[]): string[] { - return getEffectiveModelGroups(groups).map((group) => group.name); -} - -export function resolveSpawnModelRoute(options: { - requestedGroup?: string; - groups: ResolvedModelGroup[]; - parentModel: Model; - parentThinking: ModelThinkingLevel; - modelRegistry: Pick; - rng?: () => number; -}): SpawnModelRoute { - const requestedGroup = options.requestedGroup?.trim(); - const inherited = (status: "inherited" | "unknown-fallback"): SpawnModelRoute => ({ - status, - ...(status === "unknown-fallback" && requestedGroup ? { requestedGroup } : {}), - model: options.parentModel, - provider: parentProvider(options.parentModel), - modelId: options.parentModel.id, - thinking: options.parentThinking, - }); - - if (!requestedGroup) return inherited("inherited"); - - const group = effectiveGroupMap(options.groups).get(requestedGroup); - if (!group) return inherited("unknown-fallback"); - if (group.models.length === 0) throw new SpawnRouteError(group.name, "empty"); - - const usable = group.models - .map((entry) => { - const model = options.modelRegistry.find(entry.provider, entry.modelId) as Model | undefined; - return model && options.modelRegistry.hasConfiguredAuth(model) - ? { entry, model } - : undefined; - }) - .filter((entry): entry is { entry: typeof group.models[number]; model: Model } => Boolean(entry)); - - if (usable.length === 0) throw new SpawnRouteError(group.name, "no-usable-models"); - - const rng = options.rng ?? Math.random; - const index = Math.min(usable.length - 1, Math.max(0, Math.floor(rng() * usable.length))); - const selected = usable[index]; - const requestedThinking = selected.entry.thinkingLevel ?? options.parentThinking; - const thinking = clampThinkingLevel(selected.model, requestedThinking); - return { - status: "routed", - requestedGroup, - groupName: group.name, - model: selected.model, - provider: selected.entry.provider, - modelId: selected.entry.modelId, - thinking, - }; + const modalityViolations = violations.filter((violation) => violation.key === "modalities"); + if (modalityViolations.length) { + const missingFromGroup = modalityViolations.filter((violation) => violation.scope === "group").flatMap((violation) => violation.satisfaction.missing as ModelGroupModality[] ?? []); + const missingFromModel = modalityViolations.filter((violation) => violation.scope === "model").flatMap((violation) => violation.satisfaction.missing as ModelGroupModality[] ?? []); + const codec = registry.get("modalities")!.requirement; + const ordered = (values: readonly ModelGroupModality[]) => { + const decoded = codec.decode({ required: values }, "modalities"); + return decoded.ok ? decoded.value as ModelGroupModality[] : [...values]; + }; + throw new SpawnRouteError(group?.name ?? (requestedGroup || ""), "missing-modality", { missingModalities: ordered([...missingFromGroup, ...missingFromModel]), missingFromGroup: ordered(missingFromGroup), missingFromModel: ordered(missingFromModel), provider: route.provider, modelId: route.modelId, knownGroup: Boolean(group) }); + } + if (violations.length) throw new SpawnRouteError(group?.name ?? (requestedGroup || ""), "constraint-unsatisfied", { constraintUnsatisfied: violations, provider: route.provider, modelId: route.modelId, knownGroup: Boolean(group) }); + return route; } diff --git a/model-groups/store.ts b/model-groups/store.ts index ecc2d91..008df7b 100644 --- a/model-groups/store.ts +++ b/model-groups/store.ts @@ -3,209 +3,102 @@ import path from "node:path"; import * as fs from "node:fs"; import { CONFIG_DIR_NAME, type ModelRegistry } from "@earendil-works/pi-coding-agent"; import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; +import { assertModalityOverrideSupported } from "./modalities.js"; +import { modalitiesConstraint } from "./constraints/modalities.js"; +import { evaluateConstraints } from "./constraints/engine.js"; +import { presentConstraintDiagnosticRecords } from "./constraints/presentation.js"; +import { productionConstraintRegistry } from "./constraints/registry.js"; +import { resolveConstraintMembers } from "./constraints/resolution.js"; import { canonicalizeModelGroupName } from "./names.js"; -import { - ModelGroupsPersistenceError, - type ModelGroupDef, - type ModelGroupModel, - type ModelGroupScope, - type ModelGroupsAccess, - type ModelGroupsBootValidation, - type ModelGroupsConfig, - type ModelGroupsLoadedGroup, - type ModelGroupsLoadIssue, - type ModelGroupsLoadResult, - type ResolvedModelGroup, -} from "./types.js"; +import { ModelGroupsPersistenceError, type ModelGroupDef, type ModelGroupModalities, type ModelGroupModality, type ModelGroupModel, type ModelGroupScope, type ModelGroupsAccess, type ModelGroupsBootValidation, type ModelGroupsConfig, type ModelGroupsLoadedGroup, type ModelGroupsLoadIssue, type ModelGroupsLoadResult, type ResolvedModelGroup } from "./types.js"; -const CURRENT_VERSION = 1; +const CURRENT_VERSION = 2; const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); type FsOps = Pick; let fsOps: FsOps = fs; - export function __setModelGroupsFsForTests(next: Partial | null): void { fsOps = next ? { ...fs, ...next } : fs; } -export function modelGroupsPath(scope: ModelGroupScope, cwd: string, projectConfigDirName = CONFIG_DIR_NAME): string { - return scope === "global" - ? path.join(homedir(), ".pi", "agent", "pi-agenticoding", "model-groups.json") - : path.join(cwd, projectConfigDirName, "pi-agenticoding", "model-groups.json"); -} - +export function modelGroupsPath(scope: ModelGroupScope, cwd: string, projectConfigDirName = CONFIG_DIR_NAME): string { return scope === "global" ? path.join(homedir(), ".pi", "agent", "pi-agenticoding", "model-groups.json") : path.join(cwd, projectConfigDirName, "pi-agenticoding", "model-groups.json"); } function ownGroups(): Record { return Object.create(null) as Record; } -function defineGroup(groups: Record, name: string, def: ModelGroupDef): void { - Object.defineProperty(groups, name, { value: cloneDef(def), enumerable: true, writable: true, configurable: true }); +function cloneDef(def: ModelGroupDef): ModelGroupDef { + const constraints = def.constraints === undefined ? undefined : { ...def.constraints, ...(Array.isArray(def.constraints.modalities) ? { modalities: [...def.constraints.modalities] } : {}) }; + return { ...def, models: def.models.map((model) => ({ ...model })), ...(constraints === undefined ? {} : { constraints }) }; } +function defineGroup(groups: Record, name: string, def: ModelGroupDef): void { Object.defineProperty(groups, name, { value: cloneDef(def), enumerable: true, writable: true, configurable: true }); } function hasOwnGroup(groups: Record, name: string): boolean { return Object.hasOwn(groups, name); } function emptyConfig(): ModelGroupsConfig { return { version: CURRENT_VERSION, groups: ownGroups() }; } -function cloneDef(def: ModelGroupDef): ModelGroupDef { return { models: def.models.map((model) => ({ ...model })) }; } function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function assertScopeAllowed(scope: ModelGroupScope, access: ModelGroupsAccess): void { - if (scope === "project" && access.policy === "global-only") throw new Error("Project Model Groups are unavailable in global-only mode"); -} -function persistenceError(details: ConstructorParameters[0]): ModelGroupsPersistenceError { - return new ModelGroupsPersistenceError(details); -} +function assertScopeAllowed(scope: ModelGroupScope, access: ModelGroupsAccess): void { if (scope === "project" && access.policy === "global-only") throw new Error("Project Model Groups are unavailable in global-only mode"); } +function persistenceError(details: ConstructorParameters[0]): ModelGroupsPersistenceError { return new ModelGroupsPersistenceError(details); } function validateModelEntry(value: unknown, at: string): { ok: true; model: ModelGroupModel } | { ok: false; message: string } { if (!isPlainRecord(value)) return { ok: false, message: `${at} must be an object` }; - if (typeof value.provider !== "string" || value.provider.length === 0) return { ok: false, message: `${at}.provider must be a non-empty string` }; - if (typeof value.modelId !== "string" || value.modelId.length === 0) return { ok: false, message: `${at}.modelId must be a non-empty string` }; + if (typeof value.provider !== "string" || !value.provider) return { ok: false, message: `${at}.provider must be a non-empty string` }; + if (typeof value.modelId !== "string" || !value.modelId) return { ok: false, message: `${at}.modelId must be a non-empty string` }; if (value.thinkingLevel !== undefined && !VALID_THINKING.has(value.thinkingLevel as ModelThinkingLevel)) return { ok: false, message: `${at}.thinkingLevel is invalid` }; - const model: ModelGroupModel = { provider: value.provider, modelId: value.modelId }; - if (value.thinkingLevel !== undefined) model.thinkingLevel = value.thinkingLevel as ModelThinkingLevel; + const model = { ...value, provider: value.provider, modelId: value.modelId } as ModelGroupModel; + if (value.thinkingLevel === undefined) delete (model as any).thinkingLevel; return { ok: true, model }; } -function normalizeGroups(rawGroups: Record): { ok: true; groups: Record } | { ok: false; message: string } { - const groups = ownGroups(); - for (const rawName of Object.keys(rawGroups)) { - const name = canonicalizeModelGroupName(rawName); - if (!name) return { ok: false, message: "group name must not be empty after trimming" }; - if (hasOwnGroup(groups, name)) return { ok: false, message: `group keys collide after trimming at '${name}'` }; - const rawDef = rawGroups[rawName]; - if (!isPlainRecord(rawDef)) return { ok: false, message: `group ${rawName} must be an object` }; - if (!Array.isArray(rawDef.models)) return { ok: false, message: `group ${rawName}.models must be an array` }; - const models: ModelGroupModel[] = []; - for (let index = 0; index < rawDef.models.length; index++) { - const result = validateModelEntry(rawDef.models[index], `group ${rawName}.models[${index}]`); - if (!result.ok) return result; - models.push(result.model); - } - defineGroup(groups, name, { models }); - } - return { ok: true, groups }; -} -function validateConfig(raw: unknown): { ok: true; config: ModelGroupsConfig } | { ok: false; message: string } { - if (!isPlainRecord(raw)) return { ok: false, message: "config root must be an object" }; - const version = raw.version === undefined || raw.version === 0 ? CURRENT_VERSION : raw.version; - if (typeof version !== "number" || !Number.isInteger(version) || version < 1) return { ok: false, message: "version must be a non-negative supported integer (only missing/0 normalize)" }; - if (version > CURRENT_VERSION) return { ok: false, message: `unsupported version ${version}` }; - if (!isPlainRecord(raw.groups)) return { ok: false, message: "groups must be an object" }; - const normalized = normalizeGroups(raw.groups); - return normalized.ok ? { ok: true, config: { version: CURRENT_VERSION, groups: normalized.groups } } : normalized; +function validateOverride(value: unknown, at: string): { ok: true; value?: ModelGroupModality[] } | { ok: false; message: string } { + if (value === undefined) return { ok: true }; + const decoded = modalitiesConstraint.persistence.override.decode(value, at); + return decoded.ok ? { ok: true, value: decoded.value } : decoded; } -function backupAndIssue(scope: ModelGroupScope, sourcePath: string, kind: ModelGroupsLoadIssue["kind"], message: string, version?: number): ModelGroupsLoadIssue { - const backupPath = `${sourcePath}.bak`; - const issue: ModelGroupsLoadIssue = { scope, sourcePath, kind, message, backupPath, version }; - if (kind === "unsupported-version") return issue; - try { fsOps.copyFileSync(sourcePath, backupPath); } - catch (cause) { - issue.backupFailed = true; - issue.message = `${message}; backup failed: ${cause instanceof Error ? cause.message : String(cause)}`; +function normalizeOverrideEnvelope(rawDef: Record, sourceVersion: number, rawName: string): { ok: true; constraints?: Record } | { ok: false; message: string } { + if (sourceVersion < 2) { + if (Object.hasOwn(rawDef, "constraints")) return { ok: false, message: `group ${rawName}.constraints is unsupported in legacy config` }; + return { ok: true }; } - return issue; -} -function loadScope(scope: ModelGroupScope, access: ModelGroupsAccess): { config: ModelGroupsConfig; issue?: ModelGroupsLoadIssue } { - assertScopeAllowed(scope, access); - const sourcePath = modelGroupsPath(scope, access.cwd); - if (!fsOps.existsSync(sourcePath)) return { config: emptyConfig() }; - let parsed: unknown; - try { parsed = JSON.parse(String(fsOps.readFileSync(sourcePath, "utf8"))); } - catch (cause) { return { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "corrupt-json", cause instanceof Error ? cause.message : String(cause)) }; } - if (isPlainRecord(parsed) && typeof parsed.version === "number" && Number.isInteger(parsed.version) && parsed.version > CURRENT_VERSION) { - return { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "unsupported-version", `unsupported version ${parsed.version}`, parsed.version) }; + if (rawDef.constraints !== undefined && !isPlainRecord(rawDef.constraints)) return { ok: false, message: `group ${rawName}.constraints must be an object` }; + const constraints = rawDef.constraints === undefined ? undefined : { ...rawDef.constraints as Record }; + if (constraints && Object.hasOwn(constraints, "modalities")) { + const override = validateOverride(constraints.modalities, `group ${rawName}.constraints.modalities`); + if (!override.ok) return override; + constraints.modalities = modalitiesConstraint.persistence.override.encode(override.value!); } - const validated = validateConfig(parsed); - if (!validated.ok) return { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "schema-invalid", validated.message) }; - return { config: validated.config }; -} -function mergeLoaded(configs: Record, access: ModelGroupsAccess): ModelGroupsLoadedGroup[] { - const names = new Set([...Object.keys(configs.global.groups), ...Object.keys(configs.project.groups)]); - const merged: ModelGroupsLoadedGroup[] = []; - for (const name of [...names].sort()) { - if (hasOwnGroup(configs.global.groups, name)) merged.push({ name, scope: "global", sourcePath: modelGroupsPath("global", access.cwd), ...cloneDef(configs.global.groups[name]) }); - if (access.policy === "global-project" && hasOwnGroup(configs.project.groups, name)) merged.push({ name, scope: "project", sourcePath: modelGroupsPath("project", access.cwd), ...cloneDef(configs.project.groups[name]) }); - } - return merged; -} -export function loadModelGroups(access: ModelGroupsAccess): ModelGroupsLoadResult { - const global = loadScope("global", access); - const project = access.policy === "global-project" ? loadScope("project", access) : { config: emptyConfig() }; - const configs = { global: global.config, project: project.config }; - return { configs, merged: mergeLoaded(configs, access), issues: [global.issue, project.issue].filter((issue): issue is ModelGroupsLoadIssue => Boolean(issue)) }; + return { ok: true, ...(constraints && Object.keys(constraints).length ? { constraints } : {}) }; } -function normalizeSaveConfig(scope: ModelGroupScope, sourcePath: string, config: ModelGroupsConfig): ModelGroupsConfig { - const normalized = normalizeGroups(config.groups as unknown as Record); - if (!normalized.ok) throw persistenceError({ operation: "save", scope, sourcePath, phase: "config-validation", message: normalized.message }); - return { version: CURRENT_VERSION, groups: normalized.groups }; -} -export function saveModelGroups(scope: ModelGroupScope, access: ModelGroupsAccess, config: ModelGroupsConfig): void { - assertScopeAllowed(scope, access); - const sourcePath = modelGroupsPath(scope, access.cwd); - const normalized = normalizeSaveConfig(scope, sourcePath, config); - const dir = path.dirname(sourcePath); - const tempPath = `${sourcePath}.${process.pid}.${Date.now()}.tmp`; - let raw: Record = {}; - if (fsOps.existsSync(sourcePath)) { - try { const parsed = JSON.parse(String(fsOps.readFileSync(sourcePath, "utf8"))); if (isPlainRecord(parsed)) raw = parsed; } - catch { /* load recovery owns malformed content */ } - } - const body = JSON.stringify({ ...raw, version: CURRENT_VERSION, groups: normalized.groups }, null, 2) + "\n"; - try { fsOps.mkdirSync(dir, { recursive: true }); fsOps.writeFileSync(tempPath, body, "utf8"); } - catch (cause) { throw persistenceError({ operation: "save", scope, sourcePath, targetPath: tempPath, phase: "temp-write", message: `Failed to write temp model-groups file for ${scope}: ${cause instanceof Error ? cause.message : String(cause)}`, cause }); } - try { fsOps.renameSync(tempPath, sourcePath); } - catch (cause) { - let cleanupDetail = ""; - try { fsOps.unlinkSync(tempPath); } - catch (cleanupCause) { cleanupDetail = `; temp cleanup failed: ${cleanupCause instanceof Error ? cleanupCause.message : String(cleanupCause)}`; } - throw persistenceError({ operation: "save", scope, sourcePath, targetPath: tempPath, phase: "rename", message: `Failed to commit model-groups file for ${scope}: ${cause instanceof Error ? cause.message : String(cause)}${cleanupDetail}`, cause }); +function normalizeGroups(rawGroups: Record, sourceVersion: number): { ok: true; groups: Record } | { ok: false; message: string } { + const groups = ownGroups(); + for (const rawName of Object.keys(rawGroups)) { + const name = canonicalizeModelGroupName(rawName); if (!name) return { ok: false, message: "group name must not be empty after trimming" }; if (hasOwnGroup(groups, name)) return { ok: false, message: `group keys collide after trimming at '${name}'` }; + const rawDef = rawGroups[rawName]; if (!isPlainRecord(rawDef) || !Array.isArray(rawDef.models)) return { ok: false, message: `group ${rawName}${isPlainRecord(rawDef) ? ".models must be an array" : " must be an object"}` }; + const models: ModelGroupModel[] = []; for (let i = 0; i < rawDef.models.length; i++) { const result = validateModelEntry(rawDef.models[i], `group ${rawName}.models[${i}]`); if (!result.ok) return result; models.push(result.model); } + const envelope = normalizeOverrideEnvelope(rawDef, sourceVersion, rawName); + if (!envelope.ok) return envelope; + // Strip runtime-derived fields while retaining opaque config keys and the v2 envelope. + const { name: _name, scope: _scope, sourcePath: _sourcePath, modalities: _modalities, validation: _validation, models: _rawModels, constraints: _constraints, modalityOverride: _modalityOverride, ...configDef } = rawDef; + const { ok: _ok, ...normalizedEnvelope } = envelope; + defineGroup(groups, name, { ...configDef, models, ...normalizedEnvelope, ...(sourceVersion < 2 && Object.hasOwn(rawDef, "modalityOverride") ? { modalityOverride: rawDef.modalityOverride } : {}) }); } + return { ok: true, groups }; } -function loadScopeConfig(scope: ModelGroupScope, access: ModelGroupsAccess): ModelGroupsConfig { - const loaded = loadScope(scope, access); - if (loaded.issue?.backupFailed) throw persistenceError({ operation: "save", scope, sourcePath: loaded.issue.sourcePath, targetPath: loaded.issue.backupPath, phase: "load-recovery", message: `Refusing to overwrite ${scope} model-groups config after ${loaded.issue.kind} recovery because backup failed: ${loaded.issue.message}`, cause: loaded.issue }); - return loaded.config; -} +function validateConfig(raw: unknown): { ok: true; config: ModelGroupsConfig } | { ok: false; message: string } { + if (!isPlainRecord(raw) || !isPlainRecord(raw.groups)) return { ok: false, message: !isPlainRecord(raw) ? "config root must be an object" : "groups must be an object" }; + const sourceVersion = raw.version === undefined || raw.version === 0 ? 1 : raw.version; + if (typeof sourceVersion !== "number" || !Number.isInteger(sourceVersion) || sourceVersion < 1) return { ok: false, message: "version must be a non-negative supported integer (only missing/0 normalize)" }; + if (sourceVersion > CURRENT_VERSION) return { ok: false, message: `unsupported version ${sourceVersion}` }; + const normalized = normalizeGroups(raw.groups, sourceVersion); return normalized.ok ? { ok: true, config: { version: CURRENT_VERSION, groups: normalized.groups } } : normalized; +} +function backupAndIssue(scope: ModelGroupScope, sourcePath: string, kind: ModelGroupsLoadIssue["kind"], message: string, version?: number): ModelGroupsLoadIssue { const issue: ModelGroupsLoadIssue = { scope, sourcePath, kind, message, backupPath: `${sourcePath}.bak`, version }; if (kind === "unsupported-version") return issue; try { fsOps.copyFileSync(sourcePath, issue.backupPath!); } catch (cause) { issue.backupFailed = true; issue.message = `${message}; backup failed: ${cause instanceof Error ? cause.message : String(cause)}`; } return issue; } +function loadScope(scope: ModelGroupScope, access: ModelGroupsAccess): { config: ModelGroupsConfig; issue?: ModelGroupsLoadIssue } { assertScopeAllowed(scope, access); const sourcePath = modelGroupsPath(scope, access.cwd); if (!fsOps.existsSync(sourcePath)) return { config: emptyConfig() }; let parsed: unknown; try { parsed = JSON.parse(String(fsOps.readFileSync(sourcePath, "utf8"))); } catch (cause) { return { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "corrupt-json", cause instanceof Error ? cause.message : String(cause)) }; } if (isPlainRecord(parsed) && typeof parsed.version === "number" && Number.isInteger(parsed.version) && parsed.version > CURRENT_VERSION) return { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "unsupported-version", `unsupported version ${parsed.version}`, parsed.version) }; const validated = validateConfig(parsed); return validated.ok ? { config: validated.config } : { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "schema-invalid", validated.message) }; } +function mergeLoaded(configs: Record, access: ModelGroupsAccess): ModelGroupsLoadedGroup[] { const names = new Set([...Object.keys(configs.global.groups), ...Object.keys(configs.project.groups)]); const out: ModelGroupsLoadedGroup[] = []; for (const name of [...names].sort()) { if (hasOwnGroup(configs.global.groups, name)) out.push({ name, scope: "global", sourcePath: modelGroupsPath("global", access.cwd), ...cloneDef(configs.global.groups[name]) }); if (access.policy === "global-project" && hasOwnGroup(configs.project.groups, name)) out.push({ name, scope: "project", sourcePath: modelGroupsPath("project", access.cwd), ...cloneDef(configs.project.groups[name]) }); } return out; } +export function loadModelGroups(access: ModelGroupsAccess): ModelGroupsLoadResult { const global = loadScope("global", access); const project = access.policy === "global-project" ? loadScope("project", access) : { config: emptyConfig() }; return { configs: { global: global.config, project: project.config }, merged: mergeLoaded({ global: global.config, project: project.config }, access), issues: [global.issue, project.issue].filter((i): i is ModelGroupsLoadIssue => Boolean(i)) }; } +function normalizeSaveConfig(scope: ModelGroupScope, sourcePath: string, config: ModelGroupsConfig): ModelGroupsConfig { const normalized = normalizeGroups(config.groups as any, 2); if (!normalized.ok) throw persistenceError({ operation: "save", scope, sourcePath, phase: "config-validation", message: normalized.message }); return { version: CURRENT_VERSION, groups: normalized.groups }; } +/** Low-level persistence: unlike createGroup/updateGroup, this does not enforce the modality union-cap invariant; cap enforcement is CRUD-only, so rename/delete/move are out of scope. */ +export function saveModelGroups(scope: ModelGroupScope, access: ModelGroupsAccess, config: ModelGroupsConfig): void { assertScopeAllowed(scope, access); const sourcePath = modelGroupsPath(scope, access.cwd); const normalized = normalizeSaveConfig(scope, sourcePath, config); let raw: Record = {}; if (fsOps.existsSync(sourcePath)) { try { const parsed = JSON.parse(String(fsOps.readFileSync(sourcePath, "utf8"))); if (isPlainRecord(parsed)) { if (typeof parsed.version === "number" && Number.isInteger(parsed.version) && parsed.version > CURRENT_VERSION) throw persistenceError({ operation: "save", scope, sourcePath, phase: "config-validation", message: `unsupported version ${parsed.version}` }); raw = parsed; } } catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw cause; } } const tempPath = `${sourcePath}.${process.pid}.${Date.now()}.tmp`; try { fsOps.mkdirSync(path.dirname(sourcePath), { recursive: true }); fsOps.writeFileSync(tempPath, JSON.stringify({ ...raw, version: CURRENT_VERSION, groups: normalized.groups }, null, 2) + "\n", "utf8"); } catch (cause) { throw persistenceError({ operation: "save", scope, sourcePath, targetPath: tempPath, phase: "temp-write", message: `Failed to write temp model-groups file for ${scope}: ${cause instanceof Error ? cause.message : String(cause)}`, cause }); } try { fsOps.renameSync(tempPath, sourcePath); } catch (cause) { let detail = ""; try { fsOps.unlinkSync(tempPath); } catch (cleanup) { detail = `; temp cleanup failed: ${cleanup instanceof Error ? cleanup.message : String(cleanup)}`; } throw persistenceError({ operation: "save", scope, sourcePath, targetPath: tempPath, phase: "rename", message: `Failed to commit model-groups file for ${scope}: ${cause instanceof Error ? cause.message : String(cause)}${detail}`, cause }); } } +function loadScopeConfig(scope: ModelGroupScope, access: ModelGroupsAccess): ModelGroupsConfig { const loaded = loadScope(scope, access); if (loaded.issue?.backupFailed || loaded.issue?.kind === "unsupported-version") throw persistenceError({ operation: "save", scope, sourcePath: loaded.issue!.sourcePath, targetPath: loaded.issue!.backupPath, phase: loaded.issue?.kind === "unsupported-version" ? "config-validation" : "load-recovery", message: `Refusing to overwrite ${scope} model-groups config after ${loaded.issue!.kind} recovery because ${loaded.issue!.message}`, cause: loaded.issue }); return loaded.config; } function canonicalName(raw: string): string { const name = canonicalizeModelGroupName(raw); if (!name) throw new Error("Model group name is required"); return name; } -export function createGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawName: string, def: ModelGroupDef): void { - assertScopeAllowed(scope, access); const name = canonicalName(rawName); const config = loadScopeConfig(scope, access); - if (hasOwnGroup(config.groups, name)) throw new Error(`Model group '${name}' already exists in ${scope} scope`); - defineGroup(config.groups, name, def); saveModelGroups(scope, access, config); -} -export function updateGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawName: string, def: ModelGroupDef): void { - assertScopeAllowed(scope, access); const name = canonicalName(rawName); const config = loadScopeConfig(scope, access); - if (!hasOwnGroup(config.groups, name)) throw new Error(`Model group '${name}' does not exist in ${scope} scope`); - defineGroup(config.groups, name, def); saveModelGroups(scope, access, config); -} -export function renameGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawOldName: string, rawNewName: string): void { - assertScopeAllowed(scope, access); const oldName = canonicalName(rawOldName); const newName = canonicalName(rawNewName); if (oldName === newName) return; - const config = loadScopeConfig(scope, access); - if (!hasOwnGroup(config.groups, oldName)) throw new Error(`Model group '${oldName}' does not exist in ${scope} scope`); - if (hasOwnGroup(config.groups, newName)) throw new Error(`Model group '${newName}' already exists in ${scope} scope`); - const existing = config.groups[oldName]; delete config.groups[oldName]; defineGroup(config.groups, newName, existing); saveModelGroups(scope, access, config); -} -export function deleteGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawName: string): { otherScopeHasOverride: boolean } { - assertScopeAllowed(scope, access); const name = canonicalName(rawName); const config = loadScopeConfig(scope, access); - if (!hasOwnGroup(config.groups, name)) throw new Error(`Model group '${name}' does not exist in ${scope} scope`); - delete config.groups[name]; - const other = access.policy === "global-only" ? emptyConfig() : loadScopeConfig(scope === "global" ? "project" : "global", access); - try { saveModelGroups(scope, access, config); } - catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw new ModelGroupsPersistenceError({ operation: "delete", scope: cause.scope, sourcePath: cause.sourcePath, targetPath: cause.targetPath, phase: cause.phase, message: cause.message, cause }); throw cause; } - return { otherScopeHasOverride: hasOwnGroup(other.groups, name) }; -} -export function moveGroup(access: ModelGroupsAccess, rawName: string, newScope: ModelGroupScope): void { - const name = canonicalName(rawName); const oldScope: ModelGroupScope = newScope === "project" ? "global" : "project"; - assertScopeAllowed(oldScope, access); assertScopeAllowed(newScope, access); - const source = loadScopeConfig(oldScope, access); const target = loadScopeConfig(newScope, access); - if (!hasOwnGroup(source.groups, name)) throw new Error(`Model group '${name}' does not exist in ${oldScope} scope`); - if (hasOwnGroup(target.groups, name)) throw new Error(`Model group '${name}' already exists in ${newScope} scope`); - defineGroup(target.groups, name, source.groups[name]); - try { saveModelGroups(newScope, access, target); } - catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw new ModelGroupsPersistenceError({ operation: "move", scope: newScope, sourcePath: modelGroupsPath(oldScope, access.cwd), targetPath: modelGroupsPath(newScope, access.cwd), phase: cause.phase, message: `Model group '${name}' was not written to ${newScope}: ${cause.message}`, cause }); throw cause; } - delete source.groups[name]; - try { saveModelGroups(oldScope, access, source); } - catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw new ModelGroupsPersistenceError({ operation: "move", scope: oldScope, sourcePath: modelGroupsPath(oldScope, access.cwd), targetPath: modelGroupsPath(newScope, access.cwd), phase: "source-remove", partialMove: "target-written-source-retained", message: `Model group '${name}' was written to ${newScope} but retained in ${oldScope}: ${cause.message}`, cause }); throw cause; } -} -export function validateModelGroups(loadResult: ModelGroupsLoadResult, modelRegistry: ModelRegistry): ResolvedModelGroup[] { - const projectNames = new Set(Object.keys(loadResult.configs.project.groups)); - return loadResult.merged.map((group) => { - const unavailableRefs: Array<{ provider: string; modelId: string }> = []; - for (const ref of group.models) { const model = modelRegistry.find(ref.provider, ref.modelId); if (!model || !modelRegistry.hasConfiguredAuth(model)) unavailableRefs.push({ provider: ref.provider, modelId: ref.modelId }); } - return { ...group, validation: { unavailableRefs, shadowedByProject: group.scope === "global" && projectNames.has(group.name), degraded: unavailableRefs.length > 0 && unavailableRefs.length < group.models.length } }; - }); -} -export function listResolvedModelGroups(access: ModelGroupsAccess, modelRegistry: ModelRegistry): ModelGroupsBootValidation { - const loaded = loadModelGroups(access); return { groups: validateModelGroups(loaded, modelRegistry), loadIssues: loaded.issues }; -} -export function summarizeBootValidation(groups: ResolvedModelGroup[]): { unavailableCount: number; overrideCount: number } { - return { unavailableCount: groups.reduce((sum, group) => sum + group.validation.unavailableRefs.length, 0), overrideCount: groups.filter((group) => group.validation.shadowedByProject).length }; -} -export const EMPTY_MODEL_GROUPS_CONFIG: ModelGroupsConfig = emptyConfig(); -export { CURRENT_VERSION as MODEL_GROUPS_CONFIG_VERSION, hasOwnGroup }; +function normalizeMutationDef(def: ModelGroupDef): ModelGroupDef { + const normalized = normalizeGroups({ group: def }, CURRENT_VERSION); + if (!normalized.ok) throw persistenceError({ operation: "save", phase: "config-validation", message: normalized.message }); + return normalized.groups.group; +} +export function createGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawName: string, def: ModelGroupDef, modelRegistry: Pick): void { assertScopeAllowed(scope, access); const name = canonicalName(rawName); const config = loadScopeConfig(scope, access); if (hasOwnGroup(config.groups, name)) throw new Error(`Model group '${name}' already exists in ${scope} scope`); const normalizedDef = normalizeMutationDef(def); assertModalityOverrideSupported(normalizedDef, modelRegistry); defineGroup(config.groups, name, normalizedDef); saveModelGroups(scope, access, config); } +export function updateGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawName: string, def: ModelGroupDef, modelRegistry: Pick): void { assertScopeAllowed(scope, access); const name = canonicalName(rawName); const config = loadScopeConfig(scope, access); if (!hasOwnGroup(config.groups, name)) throw new Error(`Model group '${name}' does not exist in ${scope} scope`); const normalizedDef = normalizeMutationDef(def); assertModalityOverrideSupported(normalizedDef, modelRegistry); defineGroup(config.groups, name, normalizedDef); saveModelGroups(scope, access, config); } +export function renameGroup(scope: ModelGroupScope, access: ModelGroupsAccess, old: string, next: string): void { const config = loadScopeConfig(scope, access); const a = canonicalName(old), b = canonicalName(next); if (a === b) return; if (!hasOwnGroup(config.groups, a)) throw new Error(`Model group '${a}' does not exist in ${scope} scope`); if (hasOwnGroup(config.groups, b)) throw new Error(`Model group '${b}' already exists in ${scope} scope`); const def = config.groups[a]; delete config.groups[a]; defineGroup(config.groups, b, def); saveModelGroups(scope, access, config); } +export function deleteGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawName: string): { otherScopeHasOverride: boolean } { const config = loadScopeConfig(scope, access); const name = canonicalName(rawName); if (!hasOwnGroup(config.groups, name)) throw new Error(`Model group '${name}' does not exist in ${scope} scope`); delete config.groups[name]; const other = access.policy === "global-only" ? emptyConfig() : loadScopeConfig(scope === "global" ? "project" : "global", access); try { saveModelGroups(scope, access, config); } catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw new ModelGroupsPersistenceError({ operation: "delete", scope: cause.scope, sourcePath: cause.sourcePath, targetPath: cause.targetPath, phase: cause.phase, message: cause.message, cause }); throw cause; } return { otherScopeHasOverride: hasOwnGroup(other.groups, name) }; } +export function moveGroup(access: ModelGroupsAccess, rawName: string, newScope: ModelGroupScope): void { const name = canonicalName(rawName), oldScope: ModelGroupScope = newScope === "project" ? "global" : "project"; const source = loadScopeConfig(oldScope, access), target = loadScopeConfig(newScope, access); if (!hasOwnGroup(source.groups, name)) throw new Error(`Model group '${name}' does not exist in ${oldScope} scope`); if (hasOwnGroup(target.groups, name)) throw new Error(`Model group '${name}' already exists in ${newScope} scope`); defineGroup(target.groups, name, source.groups[name]); try { saveModelGroups(newScope, access, target); } catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw new ModelGroupsPersistenceError({ operation: "move", scope: newScope, sourcePath: modelGroupsPath(oldScope, access.cwd), targetPath: cause.targetPath, phase: cause.phase, message: cause.message, cause }); throw cause; } delete source.groups[name]; try { saveModelGroups(oldScope, access, source); } catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw new ModelGroupsPersistenceError({ operation: "move", scope: oldScope, sourcePath: modelGroupsPath(oldScope, access.cwd), targetPath: modelGroupsPath(newScope, access.cwd), phase: "source-remove", partialMove: "target-written-source-retained", message: cause.message, cause }); throw cause; } } +export function validateModelGroups(loadResult: ModelGroupsLoadResult, modelRegistry: ModelRegistry): ResolvedModelGroup[] { const projectNames = new Set(Object.keys(loadResult.configs.project.groups)); return loadResult.merged.map((group) => { const unavailableRefs = group.models.filter((ref) => { const model = modelRegistry.find(ref.provider, ref.modelId); return !model || !modelRegistry.hasConfiguredAuth(model); }).map(({ provider, modelId }) => ({ provider, modelId })); const evaluations = evaluateConstraints(resolveConstraintMembers(group.models, modelRegistry), group.constraints ?? {}, productionConstraintRegistry); const modalityEvaluation = evaluations.find((evaluation) => evaluation.key === modalitiesConstraint.key)!; const modalities = modalityEvaluation.aggregate as ModelGroupModalities; const diagnostics = presentConstraintDiagnosticRecords(evaluations, productionConstraintRegistry); const unsupportedOverrideModalities = (diagnostics.find((diagnostic) => diagnostic.key === "modalities" && diagnostic.code === "unsupported-override")?.details as ModelGroupModality[] | undefined) ?? []; return { ...group, modalities: { ...modalities, effective: modalityEvaluation.effective as ModelGroupModality[] }, evaluations, validation: { unavailableRefs, shadowedByProject: group.scope === "global" && projectNames.has(group.name), degraded: unavailableRefs.length > 0 && unavailableRefs.length < group.models.length, emptyCommonModalities: diagnostics.some((diagnostic) => diagnostic.key === "modalities" && diagnostic.code === "empty-common"), unsupportedOverrideModalities } }; }); } +export function listResolvedModelGroups(access: ModelGroupsAccess, registry: ModelRegistry): ModelGroupsBootValidation { const loaded = loadModelGroups(access); return { groups: validateModelGroups(loaded, registry), loadIssues: loaded.issues }; } +export function summarizeBootValidation(groups: ResolvedModelGroup[]): { unavailableCount: number; overrideCount: number; emptyModalityCount: number; staleModalityOverrideCount: number } { const diagnostics = groups.flatMap((group) => group.evaluations ? presentConstraintDiagnosticRecords(group.evaluations, productionConstraintRegistry) : []); return { unavailableCount: groups.reduce((sum, group) => sum + group.validation.unavailableRefs.length, 0), overrideCount: groups.filter((g) => g.validation.shadowedByProject).length, emptyModalityCount: diagnostics.filter((diagnostic) => diagnostic.key === "modalities" && diagnostic.code === "empty-common").length, staleModalityOverrideCount: diagnostics.filter((diagnostic) => diagnostic.key === "modalities" && diagnostic.code === "unsupported-override").length }; } +export const EMPTY_MODEL_GROUPS_CONFIG: ModelGroupsConfig = emptyConfig(); export { CURRENT_VERSION as MODEL_GROUPS_CONFIG_VERSION, hasOwnGroup }; diff --git a/model-groups/tui.ts b/model-groups/tui.ts index 96fc2db..e1bd582 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -11,11 +11,14 @@ import { summarizeBootValidation, updateGroup, } from "./store.js"; -import { ModelGroupsPersistenceError, type ModelGroupDef, type ModelGroupScope, type ModelGroupsAccess, type ModelGroupsBootValidation, type ResolvedModelGroup } from "./types.js"; +import { ModelGroupsPersistenceError, type ModelGroupDef, type ModelGroupModality, type ModelGroupScope, type ModelGroupsAccess, type ModelGroupsBootValidation, type ResolvedModelGroup } from "./types.js"; import { canonicalizeModelGroupName } from "./names.js"; import { decodeDisplayLabel, escapeDisplayLabel } from "./display.js"; +import { constraintEditorRows, presentConstraintDiagnosticRecords, type ConstraintEditorRow } from "./constraints/presentation.js"; +import { productionConstraintRegistry } from "./constraints/registry.js"; +import type { AnyConstraintDescriptor, ErasedConstraintEvaluation } from "./constraints/types.js"; -export type ModelGroupsScreen = "LIST" | "EDITOR" | "MODEL_EDIT" | "WIZARD_PROVIDER" | "WIZARD_MODEL" | "WIZARD_THINKING" | "DELETE_CONFIRM"; +export type ModelGroupsScreen = "LIST" | "EDITOR" | "MODALITIES" | "MODEL_EDIT" | "WIZARD_PROVIDER" | "WIZARD_MODEL" | "WIZARD_THINKING" | "DELETE_CONFIRM"; export interface ModelGroupsStoreOps { listResolvedModelGroups: typeof listResolvedModelGroups; @@ -44,7 +47,8 @@ function isBackspace(data: string): boolean { return matchesKey(data, Key.backsp function isDeleteChord(data: string): boolean { return data === "D" || matchesKey(data, Key.delete); } function cloneDef(def: ModelGroupDef): ModelGroupDef { - return { models: def.models.map((model) => ({ ...model })) }; + const constraints = def.constraints === undefined ? undefined : { ...def.constraints, ...(Array.isArray(def.constraints.modalities) ? { modalities: [...def.constraints.modalities] } : {}) }; + return { models: def.models.map((model) => ({ ...model })), ...(constraints === undefined ? {} : { constraints }) }; } function groupKey(group: Pick): string { @@ -107,7 +111,8 @@ export function createModelGroupsComponent( let rootFocused = false; let activeSelect: SelectList | null = null; const nameRow = () => access.policy === "global-project" ? 2 : 1; - const modelStartRow = () => nameRow() + 1; + const modalityRow = () => nameRow() + 1; + const modelStartRow = () => modalityRow() + 1; function syncInputFocus(): void { groupNameInput.focused = rootFocused && state.screen === "EDITOR" && state.row === nameRow() && state.activeTextInput === "group-name"; modelSearchInput.focused = rootFocused && state.screen === "WIZARD_MODEL"; @@ -235,7 +240,7 @@ export function createModelGroupsComponent( const group = currentEditGroup(); if (!group) return; try { - store.updateGroup(group.scope, access, group.name, def); + store.updateGroup(group.scope, access, group.name, def, modelRegistry); refresh(); const updated = state.groups.find((candidate) => candidate.name === group.name && candidate.scope === group.scope); if (updated) openEditor(updated); @@ -280,10 +285,28 @@ export function createModelGroupsComponent( return [undefined, ...supported]; } + function activeConstraintEditor(): { descriptor: AnyConstraintDescriptor; evaluation: ErasedConstraintEvaluation } | undefined { + const group = currentEditGroup(); + const evaluation = group?.evaluations?.find((candidate) => productionConstraintRegistry.get(candidate.key)?.editor.kind === "multi-select"); + const descriptor = evaluation && productionConstraintRegistry.get(evaluation.key); + if (descriptor && evaluation) return { descriptor, evaluation }; + // Test/store adapters that predate generic evaluations retain the production descriptor's compatibility projection. + const compatibilityDescriptor = productionConstraintRegistry.descriptors.find((candidate) => candidate.editor.kind === "multi-select"); + if (!group || !compatibilityDescriptor) return undefined; + const reconciled = compatibilityDescriptor.reconcile({ aggregate: group.modalities, override: state.editDraft?.constraints?.modalities }); + return { descriptor: compatibilityDescriptor, evaluation: { key: compatibilityDescriptor.key, aggregate: group.modalities, effective: reconciled.effective, diagnostics: reconciled.diagnostics } }; + } + + function modalityEditorRows(): readonly ConstraintEditorRow[] { + const editor = activeConstraintEditor(); + return editor ? constraintEditorRows(editor.descriptor, editor.evaluation, state.editDraft?.constraints?.[editor.descriptor.key]) : []; + } + function maxRow(): number { switch (state.screen) { case "LIST": return state.groups.length; case "EDITOR": return modelStartRow() + (state.editDraft?.models.length ?? 0); + case "MODALITIES": return Math.max(0, modalityEditorRows().length - 1); case "MODEL_EDIT": return thinkingOptionsFor(modelRegistry.find(state.editDraft?.models[state.modelEditIndex]?.provider ?? "", state.editDraft?.models[state.modelEditIndex]?.modelId ?? "") as Model | undefined).length; case "WIZARD_PROVIDER": return Math.max(0, allProviders().length - 1); case "WIZARD_MODEL": return Math.max(0, filteredModelsForProvider(state.wizardProvider).length - 1); @@ -303,7 +326,7 @@ export function createModelGroupsComponent( const name = uniqueNewGroupName(); try { const scope = access.policy === "global-project" ? "project" : "global"; - store.createGroup(scope, access, name, { models: [] }); + store.createGroup(scope, access, name, { models: [] }, modelRegistry); refresh(); const created = state.groups.find((group) => group.name === name && group.scope === scope); if (created) openEditor(created); @@ -318,6 +341,7 @@ export function createModelGroupsComponent( if (access.policy === "global-project" && state.row === 0) { switchScope("project"); return; } if ((access.policy === "global-project" && state.row === 1) || (access.policy === "global-only" && state.row === 0)) { switchScope("global"); return; } if (state.row === nameRow()) { state.activeTextInput = "group-name"; syncInputFocus(); return; } + if (state.row === modalityRow()) { state.screen = "MODALITIES"; state.row = 0; return; } if (!commitName()) return; const modelIndex = state.row - modelStartRow(); if (state.editDraft && modelIndex < state.editDraft.models.length) { @@ -331,6 +355,19 @@ export function createModelGroupsComponent( } return; } + case "MODALITIES": { + if (!state.editDraft) return; + const editor = activeConstraintEditor(); + const selected = modalityEditorRows()[state.row]; + if (!editor || !selected) return; + const next = cloneDef(state.editDraft); + if (selected.kind === "automatic") { + if (next.constraints) delete next.constraints[editor.descriptor.key]; + } else if (selected.kind === "choice") { + (next.constraints ??= {})[editor.descriptor.key] = [...selected.value] as ModelGroupModality[]; + } else return; + updateDraft(next, () => { state.screen = "EDITOR"; state.row = modalityRow(); }); return; + } case "MODEL_EDIT": { const model = state.editDraft?.models[state.modelEditIndex]; if (!state.editDraft || !model) return; @@ -390,6 +427,7 @@ export function createModelGroupsComponent( switch (state.screen) { case "LIST": state.finished = true; done(); return; case "EDITOR": commitName(); state.screen = "LIST"; state.row = 0; return; + case "MODALITIES": state.screen = "EDITOR"; state.row = modalityRow(); return; case "MODEL_EDIT": state.screen = "EDITOR"; state.row = 0; return; case "WIZARD_PROVIDER": resetModelSearch(); state.screen = "EDITOR"; state.row = 0; return; case "WIZARD_MODEL": resetModelSearch(); state.screen = "WIZARD_PROVIDER"; state.row = 0; return; @@ -493,10 +531,16 @@ export function createModelGroupsComponent( if (group.validation.unavailableRefs.length > 0) tags.push("✗ unavailable"); if (group.validation.shadowedByProject) tags.push("project override"); const models = group.models.map((model) => thinkingLabel(model.thinkingLevel)).join(", ") || "empty"; + if (group.evaluations) tags.push(...presentConstraintDiagnosticRecords(group.evaluations, productionConstraintRegistry).map((diagnostic) => diagnostic.text)); + else { + if (group.validation.emptyCommonModalities) tags.push("⚠ no common modalities"); + if (group.validation.unsupportedOverrideModalities.length > 0) tags.push(`⚠ stale modality override: ${group.validation.unsupportedOverrideModalities.join(", ")}`); + } return { value: String(index), label: escapeDisplayLabel(group.name), description: `[${group.scope}] ${group.models.length} models ${models}${tags.length ? ` — ${tags.join(" · ")}` : ""}` }; }); items.push({ value: String(state.groups.length), label: "+ Add group" }); container.addChild(buildSelect(items)); + for (const group of state.groups) container.addChild(textLine(theme.fg("dim", `${escapeDisplayLabel(group.name)}: modalities ${group.modalities?.effective.join(", ") || "none"}`))); container.addChild(textLine(theme.fg("dim", "↑↓ navigate • Enter open/add • D delete • Esc close"))); return container; } @@ -509,6 +553,9 @@ export function createModelGroupsComponent( if (access.policy === "global-project") container.addChild(textLine(selectableLine(state.row === 0, "Location: project", state.editScope === "project" ? " ✓" : ""))); container.addChild(textLine(selectableLine(state.row === (access.policy === "global-project" ? 1 : 0), "Location: global", state.editScope === "global" ? " ✓" : ""))); container.addChild(groupNameLineComponent()); + const modalities = current?.modalities; + container.addChild(textLine(theme.fg("dim", `Common: ${modalities?.common.join(", ") || "none"}`))); + container.addChild(textLine(selectableLine(state.row === modalityRow(), `Modalities: ${state.editDraft?.constraints?.modalities === undefined ? "automatic" : "override"} (${modalities?.effective.join(", ") || "none"})`))); state.editDraft?.models.forEach((model, index) => { const available = modelAvailable(modelRegistry, model.provider, model.modelId) ? "available" : "unavailable"; container.addChild(textLine(selectableLine(state.row === index + modelStartRow(), `${escapeDisplayLabel(model.provider)}/${escapeDisplayLabel(model.modelId)}`, ` (${available}, thinking ${thinkingLabel(model.thinkingLevel)})`))); @@ -518,6 +565,18 @@ export function createModelGroupsComponent( return container; } + function renderModalitiesComponent(): Component { + activeSelect = null; + const container = new Container(); + const editor = activeConstraintEditor(); + container.addChild(textLine(theme.fg("accent", editor?.descriptor.editor.label.toUpperCase() ?? "MODALITIES"))); + for (const [index, row] of modalityEditorRows().entries()) { + const label = row.kind === "number" ? `${row.label}: ${row.value ?? "none"} ${row.unit}` : row.label; + container.addChild(textLine(selectableLine(state.row === index, label))); + } + return container; + } + function renderModelEditComponent(): Component { activeSelect = null; const container = new Container(); @@ -577,6 +636,7 @@ export function createModelGroupsComponent( function activeComponent(): Component { if (state.screen === "LIST") return renderListComponent(); if (state.screen === "EDITOR") return renderEditorComponent(); + if (state.screen === "MODALITIES") return renderModalitiesComponent(); if (state.screen === "MODEL_EDIT") return renderModelEditComponent(); if (state.screen === "DELETE_CONFIRM") return renderDeleteComponent(); return renderWizardComponent(); diff --git a/model-groups/types.ts b/model-groups/types.ts index 9bb9463..9571303 100644 --- a/model-groups/types.ts +++ b/model-groups/types.ts @@ -1,65 +1,39 @@ import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; +import type { ErasedConstraintEvaluation } from "./constraints/types.js"; +export const MODEL_GROUP_MODALITIES = ["text", "image", "reasoning"] as const; +export type ModelGroupModality = typeof MODEL_GROUP_MODALITIES[number]; +export interface ModelGroupModalities { + common: ModelGroupModality[]; + supported: ModelGroupModality[]; + effective: ModelGroupModality[]; +} export type ModelGroupScope = "project" | "global"; export type ModelGroupsAccessPolicy = "global-project" | "global-only"; export interface ModelGroupsAccess { cwd: string; policy: ModelGroupsAccessPolicy } - -export interface ModelGroupModel { - provider: string; - modelId: string; - thinkingLevel?: ModelThinkingLevel; +export interface ModelGroupModel { provider: string; modelId: string; thinkingLevel?: ModelThinkingLevel } +export interface ModelGroupDef { + models: ModelGroupModel[]; + /** Canonical v2 keyed override envelope. Unknown keys are retained opaquely. */ + constraints?: Record; } -export interface ModelGroupDef { models: ModelGroupModel[] } -export interface ModelGroupsConfig { version: 1; groups: Record } +export interface ModelGroupsConfig { version: 2; groups: Record } export interface ModelGroupValidation { unavailableRefs: Array<{ provider: string; modelId: string }>; shadowedByProject: boolean; degraded: boolean; + emptyCommonModalities: boolean; + unsupportedOverrideModalities: ModelGroupModality[]; } -export interface ModelGroupsLoadedGroup extends ModelGroupDef { - name: string; - scope: ModelGroupScope; - sourcePath: string; -} -export interface ResolvedModelGroup extends ModelGroupsLoadedGroup { validation: ModelGroupValidation } +export interface ModelGroupsLoadedGroup extends ModelGroupDef { name: string; scope: ModelGroupScope; sourcePath: string } +export interface ResolvedModelGroup extends ModelGroupsLoadedGroup { modalities: ModelGroupModalities; validation: ModelGroupValidation; /** Ordered descriptor evaluations; presentation consumers must iterate these. */ evaluations?: readonly ErasedConstraintEvaluation[] } export type ModelGroupsLoadIssueKind = "corrupt-json" | "schema-invalid" | "unsupported-version"; -export interface ModelGroupsLoadIssue { - scope: ModelGroupScope; - sourcePath: string; - kind: ModelGroupsLoadIssueKind; - message: string; - backupPath?: string; - backupFailed?: boolean; - version?: number; -} +export interface ModelGroupsLoadIssue { scope: ModelGroupScope; sourcePath: string; kind: ModelGroupsLoadIssueKind; message: string; backupPath?: string; backupFailed?: boolean; version?: number } export type ModelGroupsPersistenceOperation = "save" | "delete" | "move"; export type ModelGroupsPersistencePhase = "config-validation" | "temp-write" | "rename" | "source-remove" | "load-recovery"; export class ModelGroupsPersistenceError extends Error { - readonly operation!: ModelGroupsPersistenceOperation; - readonly scope?: ModelGroupScope; - readonly sourcePath?: string; - readonly targetPath?: string; - readonly phase!: ModelGroupsPersistencePhase; - readonly partialMove?: "target-written-source-retained"; - readonly cause?: unknown; - constructor(details: { - operation: ModelGroupsPersistenceOperation; - scope?: ModelGroupScope; - sourcePath?: string; - targetPath?: string; - phase: ModelGroupsPersistencePhase; - partialMove?: "target-written-source-retained"; - message: string; - cause?: unknown; - }) { - super(details.message); - this.name = "ModelGroupsPersistenceError"; - Object.assign(this, details); - } -} -export interface ModelGroupsLoadResult { - configs: Record; - merged: ModelGroupsLoadedGroup[]; - issues: ModelGroupsLoadIssue[]; + readonly operation!: ModelGroupsPersistenceOperation; readonly scope?: ModelGroupScope; readonly sourcePath?: string; readonly targetPath?: string; readonly phase!: ModelGroupsPersistencePhase; readonly partialMove?: "target-written-source-retained"; readonly cause?: unknown; + constructor(details: { operation: ModelGroupsPersistenceOperation; scope?: ModelGroupScope; sourcePath?: string; targetPath?: string; phase: ModelGroupsPersistencePhase; partialMove?: "target-written-source-retained"; message: string; cause?: unknown }) { super(details.message); this.name = "ModelGroupsPersistenceError"; Object.assign(this, details); } } +export interface ModelGroupsLoadResult { configs: Record; merged: ModelGroupsLoadedGroup[]; issues: ModelGroupsLoadIssue[] } export interface ModelGroupsBootValidation { groups: ResolvedModelGroup[]; loadIssues: ModelGroupsLoadIssue[] } diff --git a/spawn/index.ts b/spawn/index.ts index 053c848..ce8e15e 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -33,6 +33,8 @@ import { abortChildSession, type AgenticodingState } from "../state.js"; import { formatPageList } from "../notebook/store.js"; import { createNotebookToolDefinitions } from "../notebook/tools.js"; import { resolveSpawnModelRoute } from "../model-groups/router.js"; +import { productionConstraintRegistry, type ConstraintRegistry } from "../model-groups/constraints/registry.js"; +import { MODEL_GROUP_MODALITIES } from "../model-groups/types.js"; import { applyReadonlyBashGuard } from "../readonly-bash.js"; import { renderSpawnCall, @@ -47,6 +49,7 @@ import { // ── Constants ───────────────────────────────────────────────────────── +const MODEL_GROUP_MODALITY_PROSE = MODEL_GROUP_MODALITIES.join(", ").replace(/, ([^,]+)$/, ", or $1"); const CHILD_MAX_LINES = 2000; const CHILD_MAX_BYTES = 50 * 1024; @@ -289,8 +292,11 @@ const SPAWN_PROMPT_SNIPPET = "Spawn a focused subtask agent"; const SPAWN_PROMPT_GUIDELINES = [ "Use spawn to delegate isolated work to child agents. They are trusted extensions of you with their own context and the same authority. Only condensed results are returned.", "If the operator requests a known Model Group confidently, pass its exact name as group. If no known/confident group is requested, omit group so the child inherits the parent model/thinking.", + `Declare constraints when the delegated task needs ${MODEL_GROUP_MODALITY_PROSE} capability; do not work around a missing required modality with third-party tools.`, ]; +const SPAWN_CONSTRAINT_REQUIREMENTS = Type.Object(Object.fromEntries(productionConstraintRegistry.descriptors.map((descriptor) => [descriptor.key, descriptor.requirement.schema])) as any); + const SPAWN_PARAMETERS = Type.Object({ prompt: Type.String({ description: @@ -300,6 +306,7 @@ const SPAWN_PARAMETERS = Type.Object({ group: Type.Optional(Type.String({ description: "Optional exact Model Group name for child model routing. Omit to inherit the parent model/thinking.", })), + constraints: Type.Optional(SPAWN_CONSTRAINT_REQUIREMENTS), thinking: Type.Optional(StringEnum( ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const, { @@ -342,12 +349,30 @@ export function createChildTools( * - both registries delete(toolCallId) on error and completion paths * */ +export type SpawnConstraintRequirements = Record; +export interface SpawnParameters { prompt: string; group?: string; constraints?: SpawnConstraintRequirements; thinking?: ThinkingValue } + +/** Decode the public constraint envelope once, rejecting unknown keys before routing. */ +export function normalizeSpawnRequirements(params: Pick, registry: ConstraintRegistry = productionConstraintRegistry): SpawnConstraintRequirements { + const raw = params.constraints; + if (raw !== undefined && (!raw || typeof raw !== "object" || Array.isArray(raw))) throw new Error("Spawn constraints must be an object."); + const normalized: SpawnConstraintRequirements = {}; + for (const [key, value] of Object.entries(raw ?? {})) { + const descriptor = registry.get(key); + if (!descriptor) throw new Error(`Unknown spawn constraint requirement '${key}'.`); + const decoded = descriptor.requirement.decode(value, `constraints.${key}`); + if (!decoded.ok) throw new Error(decoded.message); + normalized[key] = decoded.value; + } + return normalized; +} + export function executeSpawn( toolCallId: string, pi: ExtensionAPI, ctx: ExtensionContext, state: AgenticodingState, - params: { prompt: string; group?: string; thinking?: ThinkingValue }, + params: SpawnParameters, signal: AbortSignal | undefined, onUpdate: | ((result: { @@ -357,6 +382,7 @@ export function executeSpawn( | undefined, defaultThinking: ThinkingValue, sessionFactory: typeof createAgentSession = createAgentSession, + constraintRegistry: ConstraintRegistry = productionConstraintRegistry, ): Promise<{ content: TextContent[]; details: SpawnResultDetails }> { let execution!: Promise<{ content: TextContent[]; details: SpawnResultDetails }>; execution = (async () => { @@ -366,12 +392,15 @@ export function executeSpawn( } const inheritedChildThinking: ThinkingValue = params.thinking ?? defaultThinking; + const constraints = normalizeSpawnRequirements(params, constraintRegistry); const route = resolveSpawnModelRoute({ requestedGroup: params.group, + constraints, groups: state.modelGroups.groups, parentModel, parentThinking: inheritedChildThinking, modelRegistry: ctx.modelRegistry, + constraintRegistry, }); const childModel = route.model; const requestedChildThinking: ThinkingValue = route.thinking; @@ -608,6 +637,7 @@ export function registerSpawnTool( pi: ExtensionAPI, state: AgenticodingState, sessionFactory: typeof createAgentSession = createAgentSession, + constraintRegistry: ConstraintRegistry = productionConstraintRegistry, ): void { pi.registerTool({ name: "spawn", @@ -620,7 +650,7 @@ export function registerSpawnTool( execute( _toolCallId: string, - params: { prompt: string; group?: string; thinking?: ThinkingValue }, + params: SpawnParameters, signal: AbortSignal | undefined, onUpdate: | ((result: { @@ -641,6 +671,7 @@ export function registerSpawnTool( onUpdate, parentThinking, sessionFactory, + constraintRegistry, ); }, diff --git a/tests/unit/model-groups-constraints-fixture.ts b/tests/unit/model-groups-constraints-fixture.ts new file mode 100644 index 0000000..839f80d --- /dev/null +++ b/tests/unit/model-groups-constraints-fixture.ts @@ -0,0 +1,33 @@ +import { Type } from "typebox"; +import type { ConstraintDescriptor } from "../../model-groups/constraints/types.js"; + +type TestMinContextAggregate = { automatic: number | null; supported: number | null }; + +const positiveIntegerCodec = { + decode: (value: unknown, path: string) => typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? { ok: true as const, value } : { ok: false as const, message: `${path} must be a positive safe integer` }, + encode: (value: number) => value, + equals: (left: number, right: number) => left === right, + schema: Type.Integer({ minimum: 1 }), +}; + +// Tests-only scalar proof: production must never register or recognize this key. +export const testMinContext: ConstraintDescriptor<"testMinContext", number, TestMinContextAggregate, number, number, number | null> = { + key: "testMinContext", order: 10, + modelFact: (model) => model.contextWindow, + aggregate: ({ members }) => { + const facts = members.flatMap((member) => member.fact === undefined ? [] : [member.fact]); + return { automatic: members.length && facts.length === members.length ? Math.min(...facts) : null, supported: facts.length ? Math.max(...facts) : null }; + }, + reconcile: ({ aggregate, override }) => { + if (override === undefined) return { effective: aggregate.automatic, diagnostics: aggregate.automatic === null ? [{ key: "testMinContext", code: "unknown-automatic" }] : [] }; + return override <= (aggregate.supported ?? 0) + ? { effective: override, diagnostics: [] } + : { effective: null, diagnostics: [{ key: "testMinContext", code: "unsupported-override", details: override }] }; + }, + groupSatisfies: ({ effective, requirement }) => effective !== null && effective >= requirement ? { satisfied: true } : { satisfied: false, unsatisfied: requirement }, + modelSatisfies: ({ fact, requirement }) => fact >= requirement ? { satisfied: true } : { satisfied: false, unsatisfied: requirement }, + persistence: { override: positiveIntegerCodec, clone: (value) => value }, + requirement: positiveIntegerCodec, + editor: { kind: "number", label: "Test minimum context", unit: "tokens", min: 1, step: 1, automatic: () => "Automatic", value: (evaluation) => evaluation.effective, allowAutomatic: true }, + present: { group: (evaluation) => `minimum ${evaluation.effective ?? "unknown"} tokens`, prompt: (evaluation) => `minimum ${evaluation.effective ?? "unknown"} tokens`, diagnostic: (diagnostic) => diagnostic.code === "unsupported-override" ? `unsupported minimum ${diagnostic.details} tokens` : "minimum context unknown", violation: () => "minimum context unsatisfied" }, +}; diff --git a/tests/unit/model-groups-constraints.test.ts b/tests/unit/model-groups-constraints.test.ts new file mode 100644 index 0000000..5ee3f86 --- /dev/null +++ b/tests/unit/model-groups-constraints.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { evaluateConstraints } from "../../model-groups/constraints/engine.js"; +import { constraintEditorRows, presentConstraintPrompt } from "../../model-groups/constraints/presentation.js"; +import { modalitiesConstraint } from "../../model-groups/constraints/modalities.js"; +import { createConstraintRegistry, productionConstraintRegistry } from "../../model-groups/constraints/registry.js"; +import type { AnyConstraintDescriptor } from "../../model-groups/constraints/types.js"; +import { testMinContext } from "./model-groups-constraints-fixture.js"; + +const rich = { provider: "p", id: "rich", input: ["text", "image"], reasoning: true, contextWindow: 100 } as any; +const text = { provider: "p", id: "text", input: ["text"], reasoning: false, contextWindow: 10 } as any; +const resolution = (members: readonly any[]) => ({ members: members.map(({ provider, modelId, model }) => ({ ref: { provider, modelId }, ...(model ? { model } : {}) })) }); + +test("constraint registry orders descriptors and rejects duplicate keys", () => { + const registry = createConstraintRegistry([testMinContext as AnyConstraintDescriptor, modalitiesConstraint as AnyConstraintDescriptor]); + assert.deepEqual(registry.descriptors.map((descriptor) => descriptor.key), ["modalities", "testMinContext"]); + assert.throws(() => createConstraintRegistry([modalitiesConstraint as AnyConstraintDescriptor, modalitiesConstraint as AnyConstraintDescriptor]), /Duplicate model-group constraint key: modalities/); +}); + +test("engine preserves unresolved members as unknown facts", () => { + const result = evaluateConstraints(resolution([{ provider: "p", modelId: "rich", model: rich }, { provider: "p", modelId: "gone" }]), {}, createConstraintRegistry([modalitiesConstraint as AnyConstraintDescriptor])); + assert.deepEqual(result[0], { key: "modalities", aggregate: { common: [], supported: ["text", "image", "reasoning"], effective: [] }, effective: [], diagnostics: [{ key: "modalities", code: "empty-common" }] }); +}); + +test("descriptor codecs report errors and retain vocabulary ordering", () => { + assert.deepEqual(modalitiesConstraint.persistence.override.decode(["reasoning", "text"], "override"), { ok: true, value: ["text", "reasoning"] }); + assert.deepEqual(modalitiesConstraint.persistence.override.decode(["text", "text"], "override"), { ok: false, message: "override must be a unique modality vocabulary array" }); +}); + +test("injected scalar traverses resolution, aggregation, persistence, reconciliation, and production isolation", () => { + const injected = createConstraintRegistry([testMinContext as AnyConstraintDescriptor]); + const resolved = resolution([{ provider: "p", modelId: "rich", model: rich }, { provider: "p", modelId: "text", model: text }]); + const automatic = evaluateConstraints(resolved, {}, injected)[0]; + assert.deepEqual(automatic.aggregate, { automatic: 10, supported: 100 }); + assert.equal(automatic.effective, 10); + assert.deepEqual(evaluateConstraints(resolution([{ provider: "p", modelId: "rich", model: rich }, { provider: "p", modelId: "gone" }]), {}, injected)[0], { key: "testMinContext", aggregate: { automatic: null, supported: 100 }, effective: null, diagnostics: [{ key: "testMinContext", code: "unknown-automatic" }] }); + assert.deepEqual(evaluateConstraints(resolution([]), {}, injected)[0].aggregate, { automatic: null, supported: null }); + const envelope: Record = { testMinContext: testMinContext.persistence.override.encode(12) }; + const decoded = testMinContext.persistence.override.decode(envelope.testMinContext, "constraints.testMinContext"); + assert.deepEqual(decoded, { ok: true, value: 12 }); + assert.deepEqual({ testMinContext: testMinContext.persistence.override.encode(decoded.ok ? decoded.value : 0) }, envelope); + assert.equal(evaluateConstraints(resolved, envelope, injected)[0].effective, 12); + const unsupported = evaluateConstraints(resolved, { testMinContext: 101 }, injected)[0]; + assert.equal(unsupported.effective, null); + assert.deepEqual(unsupported.diagnostics, [{ key: "testMinContext", code: "unsupported-override", details: 101 }]); + assert.deepEqual(productionConstraintRegistry.descriptors.map((descriptor) => descriptor.key), ["modalities"]); + assert.equal(productionConstraintRegistry.get("testMinContext"), undefined); +}); + +test("generic modality prompt presentation preserves effective and empty labels", () => { + const registry = createConstraintRegistry([modalitiesConstraint as AnyConstraintDescriptor]); + const effective = evaluateConstraints(resolution([{ provider: "p", modelId: "rich", model: rich }]), {}, registry); + const empty = evaluateConstraints(resolution([]), {}, registry); + assert.equal(presentConstraintPrompt(effective, registry).filter(Boolean).join(", ") || "no common modalities", "text, image, reasoning"); + assert.equal(presentConstraintPrompt(empty, registry).filter(Boolean).join(", ") || "no common modalities", "no common modalities"); +}); + +test("generic presentation and number form rows use injected descriptor metadata", () => { + const injected = createConstraintRegistry([testMinContext as AnyConstraintDescriptor]); + const evaluations = evaluateConstraints(resolution([{ provider: "p", modelId: "rich", model: rich }]), { testMinContext: 12 }, injected); + assert.deepEqual(presentConstraintPrompt(evaluations, injected), ["minimum 12 tokens"]); + assert.deepEqual(constraintEditorRows(testMinContext as AnyConstraintDescriptor, evaluations[0]), [ + { kind: "automatic", label: "Automatic" }, + { kind: "number", label: "Test minimum context", value: 12, unit: "tokens", min: 1, step: 1 }, + ]); +}); + +test("engine uses only the supplied resolution and never calls host registry APIs", () => { + const spyResolution = Object.assign(resolution([{ provider: "p", modelId: "rich", model: rich }]), { + find: () => { throw new Error("find must not be called"); }, + hasConfiguredAuth: () => { throw new Error("auth must not be called"); }, + refresh: () => { throw new Error("refresh must not be called"); }, + }); + const result = evaluateConstraints(spyResolution, {}, createConstraintRegistry([modalitiesConstraint as AnyConstraintDescriptor])); + assert.deepEqual(result[0].effective, ["text", "image", "reasoning"]); +}); diff --git a/tests/unit/model-groups-crud.test.ts b/tests/unit/model-groups-crud.test.ts index b6bfbeb..2ca8e37 100644 --- a/tests/unit/model-groups-crud.test.ts +++ b/tests/unit/model-groups-crud.test.ts @@ -12,6 +12,7 @@ import { moveGroup, renameGroup, saveModelGroups, + summarizeBootValidation, updateGroup, validateModelGroups, } from "../../model-groups/store.js"; @@ -26,8 +27,8 @@ function read(scope: ModelGroupScope, cwd: string): any { function registry(available = new Set(["openai:gpt-5", "anthropic:claude"])): any { const models = [ - { provider: "openai", id: "gpt-5", reasoning: true, thinkingLevelMap: { xhigh: "x" } }, - { provider: "anthropic", id: "claude", reasoning: false }, + { provider: "openai", id: "gpt-5", input: ["text", "image"], reasoning: true, thinkingLevelMap: { xhigh: "x" } }, + { provider: "anthropic", id: "claude", input: ["text"], reasoning: false }, ]; return { getAll: () => models, @@ -39,11 +40,11 @@ function registry(available = new Set(["openai:gpt-5", "anthropic:claude"])): an test("model groups store creates, round-trips, validates, renames, updates, deletes, and moves", () => withTemp(({ cwd }) => { assert.equal(Object.keys(loadModelGroups(access(cwd)).configs.project.groups).length, 0); - createGroup("project", access(cwd), "review", { models: [] }); + createGroup("project", access(cwd), "review", { models: [] }, registry()); assert.deepEqual(read("project", cwd).groups.review.models, []); - assert.throws(() => createGroup("project", access(cwd), "review", { models: [] }), /already exists/); + assert.throws(() => createGroup("project", access(cwd), "review", { models: [] }, registry()), /already exists/); - createGroup("project", access(cwd), "inherit-roundtrip", { models: [{ provider: "anthropic", modelId: "claude" }] }); + createGroup("project", access(cwd), "inherit-roundtrip", { models: [{ provider: "anthropic", modelId: "claude" }] }, registry()); const inheritLoaded = loadModelGroups(access(cwd)).configs.project.groups["inherit-roundtrip"].models[0]; assert.equal(inheritLoaded.thinkingLevel, undefined); assert.equal(Object.prototype.hasOwnProperty.call(inheritLoaded, "thinkingLevel"), false); @@ -51,14 +52,14 @@ test("model groups store creates, round-trips, validates, renames, updates, dele assert.equal(inheritPersisted.thinkingLevel, undefined); assert.equal(Object.prototype.hasOwnProperty.call(inheritPersisted, "thinkingLevel"), false); - updateGroup("project", access(cwd), "review", { models: [{ provider: "openai", modelId: "gpt-5", thinkingLevel: "high" }] }); + updateGroup("project", access(cwd), "review", { models: [{ provider: "openai", modelId: "gpt-5", thinkingLevel: "high" }] }, registry()); renameGroup("project", access(cwd), "review", "reviewers"); assert.equal(read("project", cwd).groups.review, undefined); assert.equal(read("project", cwd).groups.reviewers.models[0].thinkingLevel, "high"); - createGroup("project", access(cwd), "collision", { models: [] }); + createGroup("project", access(cwd), "collision", { models: [] }, registry()); assert.throws(() => renameGroup("project", access(cwd), "reviewers", "collision"), /already exists/); - createGroup("global", access(cwd), "reviewers", { models: [{ provider: "openai", modelId: "gpt-5" }, { provider: "missing", modelId: "nope" }] }); + createGroup("global", access(cwd), "reviewers", { models: [{ provider: "openai", modelId: "gpt-5" }, { provider: "missing", modelId: "nope" }] }, registry()); const loaded = loadModelGroups(access(cwd)); const resolved = validateModelGroups(loaded, registry()); const globalReviewers = resolved.find((g) => g.name === "reviewers" && g.scope === "global"); @@ -66,8 +67,8 @@ test("model groups store creates, round-trips, validates, renames, updates, dele assert.deepEqual(globalReviewers?.validation.unavailableRefs, [{ provider: "missing", modelId: "nope" }]); assert.equal(globalReviewers?.validation.degraded, true); - createGroup("global", access(cwd), "move-collision", { models: [] }); - createGroup("project", access(cwd), "move-collision", { models: [] }); + createGroup("global", access(cwd), "move-collision", { models: [] }, registry()); + createGroup("project", access(cwd), "move-collision", { models: [] }, registry()); assert.throws(() => moveGroup(access(cwd), "move-collision", "project"), /already exists in project scope/); assert.ok(read("global", cwd).groups["move-collision"]); assert.ok(read("project", cwd).groups["move-collision"]); @@ -87,7 +88,7 @@ test("model groups load recovery handles malformed, schema-invalid, unsupported assert.ok(fs.existsSync(`${modelGroupsPath("global", cwd)}.bak`)); fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); - fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 1, groups: { bad: { models: [{ provider: 1 }] } } }), "utf8"); + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 2, groups: { bad: { models: [{ provider: 1 }] } } }), "utf8"); loaded = loadModelGroups(access(cwd)); const schemaIssue = loaded.issues.find((i) => i.scope === "project")!; assert.equal(schemaIssue.kind, "schema-invalid"); @@ -109,7 +110,7 @@ test("model groups load recovery handles malformed, schema-invalid, unsupported const issue = loaded.issues.find((i) => i.scope === "project")!; assert.equal(issue.backupFailed, true); assert.equal(fs.readFileSync(modelGroupsPath("project", cwd), "utf8"), "{bad"); - assert.throws(() => createGroup("project", access(cwd), "must-not-overwrite", { models: [] }), (error) => { + assert.throws(() => createGroup("project", access(cwd), "must-not-overwrite", { models: [] }, registry()), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.operation, "save"); assert.equal(error.phase, "load-recovery"); @@ -120,7 +121,7 @@ test("model groups load recovery handles malformed, schema-invalid, unsupported test("model groups rename failure removes the generated temp file and preserves committed bytes", () => withTemp(({ cwd }) => { const sourcePath = modelGroupsPath("project", cwd); - saveModelGroups("project", access(cwd), { version: 1, groups: { keep: { models: [] } } }); + saveModelGroups("project", access(cwd), { version: 2, groups: { keep: { models: [] } } }); const committedBytes = fs.readFileSync(sourcePath); const renameCause = new Error("rename denied"); let generatedTempPath = ""; @@ -133,7 +134,7 @@ test("model groups rename failure removes the generated temp file and preserves throw renameCause; }, }); - assert.throws(() => saveModelGroups("project", access(cwd), { version: 1, groups: { drop: { models: [] } } }), (error) => { + assert.throws(() => saveModelGroups("project", access(cwd), { version: 2, groups: { drop: { models: [] } } }), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.operation, "save"); assert.equal(error.phase, "rename"); @@ -151,7 +152,7 @@ test("model groups rename failure removes the generated temp file and preserves test("model groups rename cleanup failure remains supplemental to the original typed error", () => withTemp(({ cwd }) => { const sourcePath = modelGroupsPath("project", cwd); - saveModelGroups("project", access(cwd), { version: 1, groups: { keep: { models: [] } } }); + saveModelGroups("project", access(cwd), { version: 2, groups: { keep: { models: [] } } }); const committedBytes = fs.readFileSync(sourcePath); const renameCause = new Error("rename denied"); const cleanupCause = new Error("cleanup denied"); @@ -168,7 +169,7 @@ test("model groups rename cleanup failure remains supplemental to the original t throw cleanupCause; }, }); - assert.throws(() => saveModelGroups("project", access(cwd), { version: 1, groups: { drop: { models: [] } } }), (error) => { + assert.throws(() => saveModelGroups("project", access(cwd), { version: 2, groups: { drop: { models: [] } } }), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.operation, "save"); assert.equal(error.phase, "rename"); @@ -185,9 +186,9 @@ test("model groups rename cleanup failure remains supplemental to the original t })); test("model groups persistence failures throw typed errors and preserve committed state", () => withTemp(({ cwd }) => { - saveModelGroups("project", access(cwd), { version: 1, groups: { keep: { models: [] } } }); + saveModelGroups("project", access(cwd), { version: 2, groups: { keep: { models: [] } } }); __setModelGroupsFsForTests({ writeFileSync: () => { throw new Error("temp denied"); } }); - assert.throws(() => updateGroup("project", access(cwd), "keep", { models: [{ provider: "openai", modelId: "gpt-5" }] }), (error) => { + assert.throws(() => updateGroup("project", access(cwd), "keep", { models: [{ provider: "openai", modelId: "gpt-5" }] }, registry()), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.operation, "save"); assert.equal(error.phase, "temp-write"); @@ -201,7 +202,7 @@ test("model groups persistence failures throw typed errors and preserve committe assert.equal(read("project", cwd).groups.keep.models.length, 0); __setModelGroupsFsForTests({ renameSync: () => { throw new Error("rename denied"); } }); - assert.throws(() => saveModelGroups("project", access(cwd), { version: 1, groups: { drop: { models: [] } } }), (error) => { + assert.throws(() => saveModelGroups("project", access(cwd), { version: 2, groups: { drop: { models: [] } } }), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.phase, "rename"); return true; @@ -218,7 +219,7 @@ test("model groups persistence failures throw typed errors and preserve committe }); __setModelGroupsFsForTests(null); - createGroup("global", access(cwd), "move-target-fails", { models: [] }); + createGroup("global", access(cwd), "move-target-fails", { models: [] }, registry()); __setModelGroupsFsForTests({ renameSync: () => { throw new Error("target denied"); } }); assert.throws(() => moveGroup(access(cwd), "move-target-fails", "project"), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); @@ -228,7 +229,7 @@ test("model groups persistence failures throw typed errors and preserve committe }); __setModelGroupsFsForTests(null); - createGroup("global", access(cwd), "move-me", { models: [] }); + createGroup("global", access(cwd), "move-me", { models: [] }, registry()); let writes = 0; __setModelGroupsFsForTests({ renameSync: (from, to) => { writes++; if (writes === 2) throw new Error("source denied"); fs.renameSync(from, to); } }); assert.throws(() => moveGroup(access(cwd), "move-me", "project"), (error) => { @@ -245,14 +246,14 @@ test("model groups strictly partitions schema and legacy version domains", () => const invalid: Array<[string, unknown, RegExp]> = [ ["root", [], /root/], ["version type", { version: "1", groups: {} }, /version/], ["negative", { version: -1, groups: {} }, /version/], ["fraction low", { version: 0.5, groups: {} }, /version/], - ["fraction high", { version: 1.5, groups: {} }, /version/], ["groups", { version: 1, groups: [] }, /groups/], - ["group", { version: 1, groups: { bad: 1 } }, /group/], - ["provider missing", { version: 1, groups: { bad: { models: [{ modelId: "m" }] } } }, /provider/], - ["provider type", { version: 1, groups: { bad: { models: [{ provider: 1, modelId: "m" }] } } }, /provider/], - ["model missing", { version: 1, groups: { bad: { models: [{ provider: "p" }] } } }, /modelId/], - ["model type", { version: 1, groups: { bad: { models: [{ provider: "p", modelId: 1 }] } } }, /modelId/], - ["models", { version: 1, groups: { bad: { models: 1 } } }, /models/], - ["thinking", { version: 1, groups: { bad: { models: [{ provider: "p", modelId: "m", thinkingLevel: "turbo" }] } } }, /thinkingLevel/], + ["fraction high", { version: 1.5, groups: {} }, /version/], ["groups", { version: 2, groups: [] }, /groups/], + ["group", { version: 2, groups: { bad: 1 } }, /group/], + ["provider missing", { version: 2, groups: { bad: { models: [{ modelId: "m" }] } } }, /provider/], + ["provider type", { version: 2, groups: { bad: { models: [{ provider: 1, modelId: "m" }] } } }, /provider/], + ["model missing", { version: 2, groups: { bad: { models: [{ provider: "p" }] } } }, /modelId/], + ["model type", { version: 2, groups: { bad: { models: [{ provider: "p", modelId: 1 }] } } }, /modelId/], + ["models", { version: 2, groups: { bad: { models: 1 } } }, /models/], + ["thinking", { version: 2, groups: { bad: { models: [{ provider: "p", modelId: "m", thinkingLevel: "turbo" }] } } }, /thinkingLevel/], ]; for (const [label, raw, message] of invalid) { fs.writeFileSync(projectPath, JSON.stringify(raw), "utf8"); @@ -267,12 +268,238 @@ test("model groups strictly partitions schema and legacy version domains", () => fs.writeFileSync(projectPath, JSON.stringify(raw), "utf8"); const loaded = loadModelGroups(access(cwd)); assert.equal(loaded.issues.length, 0); - assert.equal(loaded.configs.project.version, 1); - updateGroup("project", access(cwd), "legacy", { models: [] }); - assert.equal(read("project", cwd).version, 1); + assert.equal(loaded.configs.project.version, 2); + updateGroup("project", access(cwd), "legacy", { models: [] }, registry()); + assert.equal(read("project", cwd).version, 2); } })); +test("legacy constraints recover as schema-invalid instead of crashing load", () => withTemp(({ cwd }) => { + const projectPath = modelGroupsPath("project", cwd); + fs.mkdirSync(path.dirname(projectPath), { recursive: true }); + for (const raw of [{ groups: { legacy: { models: [], constraints: {} } } }, { version: 0, groups: { legacy: { models: [], constraints: {} } } }, { version: 1, groups: { legacy: { models: [], constraints: {} } } }]) { + fs.writeFileSync(projectPath, JSON.stringify(raw), "utf8"); + const loaded = loadModelGroups(access(cwd)); + const issue = loaded.issues.find((candidate) => candidate.scope === "project")!; + assert.equal(issue.kind, "schema-invalid"); + assert.match(issue.message, /constraints/); + assert.ok(fs.existsSync(`${projectPath}.bak`)); + assert.equal(Object.keys(loaded.configs.project.groups).length, 0); + } +})); + +test("store-level validation derives empty-common and stale-override flags and counts them", () => withTemp(({ cwd }) => { + const a = access(cwd); + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 2, groups: { + empty: { models: [] }, + unresolved: { models: [{ provider: "openai", modelId: "gone" }, { provider: "openai", modelId: "gpt-5" }] }, + stale: { models: [{ provider: "anthropic", modelId: "claude" }], constraints: { modalities: ["text", "image"] } }, + } }), "utf8"); + const resolved = validateModelGroups(loadModelGroups(a), registry()); + // claude supports text only, so image is a stale unsupported override entry. + const empty = resolved.find((g) => g.name === "empty"); + assert.equal(empty?.validation.emptyCommonModalities, true, "empty group has no common modalities"); + const unresolved = resolved.find((g) => g.name === "unresolved"); + assert.equal(unresolved?.validation.emptyCommonModalities, true, "unresolved member fails closed"); + const stale = resolved.find((g) => g.name === "stale"); + assert.deepEqual(stale?.validation.unsupportedOverrideModalities, ["image"]); + const summary = summarizeBootValidation(resolved); + assert.equal(summary.emptyModalityCount, 2); + assert.equal(summary.staleModalityOverrideCount, 1); +})); + +test("create and update reject unsupported modality override before writing", () => withTemp(({ cwd }) => { + const a = access(cwd); + // claude supports only text, so an override of image must be rejected by the CRUD gate. + let writes = 0; + __setModelGroupsFsForTests({ writeFileSync: (_p?: unknown, _d?: unknown, ..._r: unknown[]) => { writes++; fs.writeFileSync(_p as any, _d as any, ...(_r as any)); } }); + assert.throws(() => createGroup("project", a, "claude-only", { models: [{ provider: "anthropic", modelId: "claude" }], constraints: { modalities: ["image"] } }, registry()), /unsupported modalities: image/); + assert.equal(writes, 0); + __setModelGroupsFsForTests(null); + + createGroup("project", a, "rich", { models: [{ provider: "openai", modelId: "gpt-5" }], constraints: { modalities: ["text", "image"] } }, registry()); + const before = fs.readFileSync(modelGroupsPath("project", cwd), "utf8"); + let writes2 = 0; + __setModelGroupsFsForTests({ writeFileSync: (_p: unknown, _d: unknown, _r: unknown) => { writes2++; fs.writeFileSync(_p as any, _d as any, _r as any); } }); + // Combined member change: replacing gpt-5 (text+image+reasoning) with claude (text only) + // makes the retained override's image unsupported → the gate must reject before any write. + assert.throws(() => updateGroup("project", a, "rich", { models: [{ provider: "anthropic", modelId: "claude" }], constraints: { modalities: ["text", "image"] } }, registry()), /unsupported modalities: image/); + assert.equal(writes2, 0, "rejected update must not write"); + assert.equal(fs.readFileSync(modelGroupsPath("project", cwd), "utf8"), before); + __setModelGroupsFsForTests(null); +})); + +test("v2 config load rejects non-array, duplicate, and out-of-vocabulary modality constraints", () => withTemp(({ cwd }) => { + const projectPath = modelGroupsPath("project", cwd); + fs.mkdirSync(path.dirname(projectPath), { recursive: true }); + const cases: Array<[string, unknown, RegExp]> = [ + ["non-array", { version: 2, groups: { g: { models: [], constraints: { modalities: { text: true } } } } }, /modalities/], + ["duplicate", { version: 2, groups: { g: { models: [], constraints: { modalities: ["text", "text"] } } } }, /unique/], + ["out-of-language", { version: 2, groups: { g: { models: [], constraints: { modalities: ["audio"] } } } }, /vocabulary/], + ]; + for (const [label, raw, message] of cases) { + fs.writeFileSync(projectPath, JSON.stringify(raw), "utf8"); + const loaded = loadModelGroups(access(cwd)); + const issue = loaded.issues.find((candidate) => candidate.scope === "project")!; + assert.equal(issue.kind, "schema-invalid", label); + assert.match(issue.message, message, label); + assert.ok(fs.existsSync(`${projectPath}.bak`), label); + } +})); + +test("v1 migration is in-memory until the first successful mutation writes v2 without an invented override", () => withTemp(({ cwd }) => { + const sourcePath = modelGroupsPath("project", cwd); + const v1Bytes = JSON.stringify({ version: 1, groups: { legacy: { models: [{ provider: "openai", modelId: "gpt-5" }] } } }, null, 2) + "\n"; + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, v1Bytes, "utf8"); + + const loaded = loadModelGroups(access(cwd)); + assert.equal(loaded.configs.project.version, 2); + assert.equal(loaded.configs.project.groups.legacy.constraints, undefined); + assert.equal(fs.readFileSync(sourcePath, "utf8"), v1Bytes); + + updateGroup("project", access(cwd), "legacy", { models: [{ provider: "anthropic", modelId: "claude" }] }, registry()); + const persisted = read("project", cwd); + assert.equal(persisted.version, 2); + assert.equal(Object.hasOwn(persisted.groups.legacy, "modalityOverride"), false); +})); + +test("legacy modalityOverride is opaque and not interpreted", () => withTemp(({ cwd }) => { + const sourcePath = modelGroupsPath("project", cwd); + const v1Bytes = JSON.stringify({ version: 1, groups: { legacy: { models: [{ provider: "openai", modelId: "gpt-5" }], modalityOverride: ["image"] } } }, null, 2) + "\n"; + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, v1Bytes, "utf8"); + const loaded = loadModelGroups(access(cwd)); + assert.equal(loaded.configs.project.groups.legacy.constraints, undefined); + assert.equal(Object.hasOwn(loaded.configs.project.groups.legacy, "modalityOverride"), true); + assert.equal(fs.readFileSync(sourcePath, "utf8"), v1Bytes); + saveModelGroups("project", access(cwd), loaded.configs.project); + assert.equal(Object.hasOwn(read("project", cwd).groups.legacy, "modalityOverride"), false); +})); + +test("v2 constraint envelope preserves explicit empty and opaque slots canonically", () => withTemp(({ cwd }) => { + const sourcePath = modelGroupsPath("project", cwd); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + const fixtures: Array<[string, any, string[] | undefined]> = [ + ["generic", { constraints: { modalities: ["reasoning", "text"] } }, ["text", "reasoning"]], + ["empty", { constraints: { modalities: [] } }, []], + ["automatic", {}, undefined], + ]; + fs.writeFileSync(sourcePath, JSON.stringify({ version: 2, groups: Object.fromEntries(fixtures.map(([name, envelope]) => [name, { models: [{ provider: "openai", modelId: "gpt-5" }], ...envelope }])) }), "utf8"); + const loaded = loadModelGroups(access(cwd)); + assert.equal(loaded.issues.length, 0); + for (const [name, _envelope, expected] of fixtures) assert.deepEqual(loaded.configs.project.groups[name].constraints?.modalities, expected, name); + saveModelGroups("project", access(cwd), loaded.configs.project); + const persisted = read("project", cwd); + for (const [name, _envelope, expected] of fixtures) { + const group = persisted.groups[name]; + if (expected === undefined) { + assert.equal(Object.hasOwn(group, "constraints"), false, name); + } else { + assert.deepEqual(group.constraints.modalities, expected, name); + } + } + + fs.writeFileSync(sourcePath, JSON.stringify({ version: 2, groups: { opaque: { models: [{ provider: "openai", modelId: "gpt-5", modelSentinel: true }], groupSentinel: true, constraints: { modalities: ["image"], cost: { future: true } } } } }), "utf8"); + const opaque = loadModelGroups(access(cwd)); + updateGroup("project", access(cwd), "opaque", { ...opaque.configs.project.groups.opaque, models: [{ provider: "openai", modelId: "gpt-5", modelSentinel: true, thinkingLevel: "high" } as any] }, registry()); + const opaquePersisted = read("project", cwd).groups.opaque; + assert.deepEqual(opaquePersisted.constraints, { modalities: ["image"], cost: { future: true } }); + assert.equal(opaquePersisted.groupSentinel, true); + assert.equal(opaquePersisted.models[0].modelSentinel, true); +})); + +test("v2 drops stale modalityOverride rather than reinterpreting it", () => withTemp(({ cwd }) => { + const sourcePath = modelGroupsPath("project", cwd); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, JSON.stringify({ version: 2, groups: { stale: { models: [], modalityOverride: ["image"] } } }), "utf8"); + const loaded = loadModelGroups(access(cwd)); + assert.equal(loaded.issues.length, 0); + assert.equal(loaded.configs.project.groups.stale.constraints, undefined); + saveModelGroups("project", access(cwd), loaded.configs.project); + assert.equal(Object.hasOwn(read("project", cwd).groups.stale, "modalityOverride"), false); +})); + +test("v2 normalization preserves opaque root group and model keys through load save and update", () => withTemp(({ cwd }) => { + const sourcePath = modelGroupsPath("project", cwd); + const raw = { + version: 2, + rootSentinel: { keep: true }, + groups: { + review: { + groupSentinel: "keep", + models: [{ provider: "openai", modelId: "gpt-5", modelSentinel: "keep" }], + }, + }, + }; + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, JSON.stringify(raw), "utf8"); + + const loaded = loadModelGroups(access(cwd)); + saveModelGroups("project", access(cwd), loaded.configs.project); + updateGroup("project", access(cwd), "review", { ...loaded.configs.project.groups.review, models: loaded.configs.project.groups.review.models.map((model) => ({ ...model, thinkingLevel: "high" })) }, registry()); + + const persisted = read("project", cwd); + assert.deepEqual(persisted.rootSentinel, { keep: true }); + assert.equal(persisted.groups.review.groupSentinel, "keep"); + assert.equal(persisted.groups.review.models[0].modelSentinel, "keep"); + assert.equal(persisted.groups.review.models[0].thinkingLevel, "high"); +})); + +test("store normalization strips runtime-derived group keys while preserving opaque keys", () => withTemp(({ cwd }) => { + const a = access(cwd); + createGroup("project", a, "review", { models: [{ provider: "openai", modelId: "gpt-5" }] }, registry()); + updateGroup("project", a, "review", { + models: [{ provider: "openai", modelId: "gpt-5" }], + constraints: { modalities: ["text", "image"] }, + opaqueSentinel: { keep: true }, + name: "review", scope: "project", sourcePath: "/runtime/model-groups.json", + modalities: { common: ["text"], supported: ["text", "image", "reasoning"], effective: ["text", "image"] }, + validation: { unavailableRefs: [], shadowedByProject: false, degraded: false, emptyCommonModalities: false, unsupportedOverrideModalities: [] }, + } as any, registry()); + const persisted = read("project", cwd).groups.review; + assert.deepEqual(Object.keys(persisted).sort(), ["constraints", "models", "opaqueSentinel"]); + assert.deepEqual(persisted.constraints.modalities, ["text", "image"]); + assert.deepEqual(persisted.opaqueSentinel, { keep: true }); + for (const key of ["name", "scope", "sourcePath", "modalities", "validation"]) assert.equal(Object.hasOwn(persisted, key), false); +})); + +test("version-3 mutations refuse before temp write including loadScopeConfig-backed CRUD", () => withTemp(({ cwd }) => { + const sourcePath = modelGroupsPath("project", cwd); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, JSON.stringify({ version: 3, groups: {} }), "utf8"); + let writes = 0; + __setModelGroupsFsForTests({ writeFileSync: () => { writes++; throw new Error("must not write"); } }); + for (const mutate of [ + () => saveModelGroups("project", access(cwd), { version: 2, groups: {} }), + () => createGroup("project", access(cwd), "blocked", { models: [] }, registry()), + ]) { + assert.throws(mutate, (error) => { + assert.ok(error instanceof ModelGroupsPersistenceError); + assert.equal(error.phase, "config-validation"); + return true; + }); + } + assert.equal(writes, 0); + assert.equal(fs.readFileSync(sourcePath, "utf8"), JSON.stringify({ version: 3, groups: {} })); +})); + +test("modality overrides survive CRUD rename and move lifecycle in both scopes", () => withTemp(({ cwd }) => { + const a = access(cwd); + createGroup("project", a, "review", { models: [{ provider: "openai", modelId: "gpt-5" }], constraints: { modalities: ["image"] } }, registry()); + updateGroup("project", a, "review", { models: [{ provider: "openai", modelId: "gpt-5" }], constraints: { modalities: ["text", "image"] } }, registry()); + renameGroup("project", a, "review", "reviewers"); + moveGroup(a, "reviewers", "global"); + saveModelGroups("global", a, loadModelGroups(a).configs.global); + assert.deepEqual(read("global", cwd).groups.reviewers.constraints.modalities, ["text", "image"]); + + renameGroup("global", a, "reviewers", "global-reviewers"); + moveGroup(a, "global-reviewers", "project"); + assert.equal(read("global", cwd).groups["global-reviewers"], undefined); + assert.deepEqual(read("project", cwd).groups["global-reviewers"].constraints.modalities, ["text", "image"]); +})); + test("model groups use branded paths, global-only access, canonical own keys, and native max", () => withTemp(({ cwd }) => { assert.equal(modelGroupsPath("project", cwd, "branded-pi"), path.join(cwd, "branded-pi", "pi-agenticoding", "model-groups.json")); const raw = '{"version":1,"groups":{" __proto__ ":{"models":[{"provider":"p","modelId":"proto","thinkingLevel":"max"}]},"constructor":{"models":[]},"toString":{"models":[]}}}'; @@ -288,8 +515,8 @@ test("model groups use branded paths, global-only access, canonical own keys, an for (const name of ["__proto__", "constructor", "toString"]) { deleteGroup("project", access(cwd), name); - createGroup("global", access(cwd), ` ${name} `, { models: [] }); - updateGroup("global", access(cwd), name, { models: [{ provider: "p", modelId: name }] }); + createGroup("global", access(cwd), ` ${name} `, { models: [] }, registry()); + updateGroup("global", access(cwd), name, { models: [{ provider: "p", modelId: name }] }, registry()); moveGroup(access(cwd), name, "project"); assert.ok(Object.hasOwn(read("project", cwd).groups, name)); deleteGroup("project", access(cwd), name); @@ -303,16 +530,16 @@ test("model groups use branded paths, global-only access, canonical own keys, an const untrusted = loadModelGroups(access(cwd, "global-only")); assert.equal(projectProbe, false); assert.equal(untrusted.merged.every((group) => group.scope === "global"), true); - assert.throws(() => createGroup("project", access(cwd, "global-only"), "forbidden", { models: [] }), /global-only/); + assert.throws(() => createGroup("project", access(cwd, "global-only"), "forbidden", { models: [] }, registry()), /global-only/); assert.equal(projectProbe, false); })); test("model groups direct save canonicalizes unique keys and rejects empty/colliding keys before write", () => withTemp(({ cwd }) => { const a = access(cwd); - saveModelGroups("project", a, { version: 1, groups: { committed: { models: [] } } }); + saveModelGroups("project", a, { version: 2, groups: { committed: { models: [] } } }); const unique: Record = Object.create(null); Object.defineProperty(unique, " unique ", { value: { models: [] }, enumerable: true }); - saveModelGroups("project", a, { version: 1, groups: unique }); + saveModelGroups("project", a, { version: 2, groups: unique }); assert.deepEqual(Object.keys(read("project", cwd).groups), ["unique"]); for (const keys of [[" "], ["same", " same "]]) { const groups: Record = Object.create(null); @@ -320,7 +547,7 @@ test("model groups direct save canonicalizes unique keys and rejects empty/colli const before = fs.readFileSync(modelGroupsPath("project", cwd), "utf8"); let writes = 0; __setModelGroupsFsForTests({ writeFileSync: (..._args: any[]) => { writes++; throw new Error("must not write"); } }); - assert.throws(() => saveModelGroups("project", a, { version: 1, groups }), (error) => { + assert.throws(() => saveModelGroups("project", a, { version: 2, groups }), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.operation, "save"); assert.equal(error.phase, "config-validation"); diff --git a/tests/unit/model-groups-helpers.ts b/tests/unit/model-groups-helpers.ts index 1d06411..ec3a3bc 100644 --- a/tests/unit/model-groups-helpers.ts +++ b/tests/unit/model-groups-helpers.ts @@ -26,6 +26,7 @@ export function group( opts: { scope?: "project" | "global"; models?: ResolvedModelGroup["models"]; + constraints?: { modalities: import("../../model-groups/types.js").ModelGroupModality[] } | Record; shadowedByProject?: boolean; unavailableRefs?: ResolvedModelGroup["validation"]["unavailableRefs"]; } = {}, @@ -36,10 +37,14 @@ export function group( scope, sourcePath: `<${scope}>`, models: opts.models ?? [], + ...(opts.constraints === undefined ? {} : { constraints: { ...opts.constraints, ...(Array.isArray(opts.constraints.modalities) ? { modalities: [...opts.constraints.modalities] } : {}) } }), validation: { unavailableRefs: opts.unavailableRefs ?? [], shadowedByProject: opts.shadowedByProject ?? false, degraded: (opts.unavailableRefs?.length ?? 0) > 0, + emptyCommonModalities: false, + unsupportedOverrideModalities: [], }, + modalities: { common: [], supported: [], effective: [] }, }; } diff --git a/tests/unit/model-groups-integration.test.ts b/tests/unit/model-groups-integration.test.ts index c5680e3..0925841 100644 --- a/tests/unit/model-groups-integration.test.ts +++ b/tests/unit/model-groups-integration.test.ts @@ -9,7 +9,7 @@ import { createTestPI, theme } from "./helpers.js"; import { withTemp } from "./model-groups-helpers.js"; function registry(available = new Set(["openai:gpt-5"])): any { - const models = [{ provider: "openai", id: "gpt-5", reasoning: true, thinkingLevelMap: { xhigh: "x" } }]; + const models = [{ provider: "openai", id: "gpt-5", input: ["text", "image"], reasoning: true, thinkingLevelMap: { xhigh: "x" } }]; return { getAll: () => models, getAvailable: () => models.filter((m) => available.has(`${m.provider}:${m.id}`)), @@ -53,7 +53,7 @@ test("/model-groups command registers and opens ctx.ui.custom with live registry assert.equal(customCalled, 1); assert.match(rendered, /Model Groups/); assert.match(rendered, /cwd-sentinel-group/); - assert.deepEqual(findCalls, ["openai:gpt-5"]); + assert.deepEqual(findCalls, ["openai:gpt-5", "openai:gpt-5"]); })); test("index session_start stores model group validation and notifies load and validation issues", async () => withTemp(async ({ cwd }) => { @@ -83,6 +83,31 @@ test("index session_start stores model group validation and notifies load and va assert.ok(notifications.some((m) => /1 unavailable model references · 1 project overrides/.test(m))); })); +test("index session_start notifies empty-common and stale-override boot counts", async () => withTemp(async ({ cwd }) => { + fs.mkdirSync(path.dirname(modelGroupsPath("global", cwd)), { recursive: true }); + // claude is NOT in the registry, so claude-only is unavailable with empty common modalities; its override is stale. + // The registry has only gpt-5 (text+image); an empty group also has empty common modalities. + fs.writeFileSync(modelGroupsPath("global", cwd), JSON.stringify({ version: 2, groups: { + empty: { models: [] }, + "claude-only": { models: [{ provider: "anthropic", modelId: "claude" }], constraints: { modalities: ["text", "image"] } }, + } }), "utf8"); + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notifications: string[] = []; + const ctx = { + hasUI: true, + mode: "tui", + isProjectTrusted: () => true, + cwd, + modelRegistry: registry(), + getContextUsage: () => ({ percent: 10 }), + ui: { theme, notify: (message: string) => notifications.push(message), setStatus: () => {}, setWidget: () => {} }, + }; + const handler = pi.handlers.get("session_start")!.at(-1)!; + await handler({ reason: "load" }, ctx); + assert.ok(notifications.some((m) => /1 unavailable model references · 0 project overrides · 2 groups with no common modalities · 1 stale modality overrides/.test(m)), JSON.stringify(notifications, null, 2)); +})); + test("index session_start notifies corrupt/schema/unsupported load issues", async () => withTemp(async ({ cwd }) => { fs.mkdirSync(path.dirname(modelGroupsPath("global", cwd)), { recursive: true }); fs.writeFileSync(modelGroupsPath("global", cwd), "{bad", "utf8"); @@ -149,7 +174,7 @@ test("index session_start includes backup-failure detail in load issue notificat assert.ok(notifications.some((m) => /corrupt-json/.test(m) && /backup failed.*original file left untouched/.test(m) && m.includes(escapeDisplayLabel(modelGroupsPath("project", cwd))))); })); -test("before_agent_start injects fresh names-only Model Groups guidance", async () => withTemp(async ({ cwd }) => { +test("before_agent_start injects fresh names-and-effective-modalities guidance", async () => withTemp(async ({ cwd }) => { fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 1, groups: { review: { models: [{ provider: "openai", modelId: "gpt-5" }] } } }), "utf8"); const pi = createTestPI(); @@ -157,7 +182,8 @@ test("before_agent_start injects fresh names-only Model Groups guidance", async const handler = pi.handlers.get("before_agent_start")!.at(-1)!; const result = await handler({ systemPrompt: "Base." }, { hasUI: false, isProjectTrusted: () => true, cwd, modelRegistry: registry(), getContextUsage: () => null }); assert.match(result.systemPrompt, /## Model Groups for spawn/); - assert.match(result.systemPrompt, /Available Model Groups: review/); + assert.match(result.systemPrompt, /Available Model Groups: review \(text, image, reasoning\)/); + assert.match(result.systemPrompt, /constraints/); assert.match(result.systemPrompt, /exact group name/); assert.match(result.systemPrompt, /known and confident/); assert.match(result.systemPrompt, /omit group and inherit/); @@ -165,6 +191,41 @@ test("before_agent_start injects fresh names-only Model Groups guidance", async assert.doesNotMatch(result.systemPrompt, /model-groups\.json/); })); +test("before_agent_start labels empty effective modalities unambiguously", async () => withTemp(async ({ cwd }) => { + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 2, groups: { foo: { models: [] }, "foo (none)": { models: [] } } }), "utf8"); + const pi = createTestPI(); + registerAgenticoding(pi as any); + const handler = pi.handlers.get("before_agent_start")!.at(-1)!; + const result = await handler({ systemPrompt: "Base." }, { hasUI: false, isProjectTrusted: () => true, cwd, modelRegistry: registry(), getContextUsage: () => null }); + assert.match(result.systemPrompt, /foo \(no common modalities\)/); + assert.match(result.systemPrompt, /foo \(none\) \(no common modalities\)/); + assert.doesNotMatch(result.systemPrompt, /foo \(none\),/); +})); + +test("before_agent_start reinjects updated effective modalities after registry changes", async () => withTemp(async ({ cwd }) => { + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 2, groups: { review: { models: [{ provider: "openai", modelId: "gpt-5" }] } } }), "utf8"); + let model = { provider: "openai", id: "gpt-5", input: ["text", "image"], reasoning: false, thinkingLevelMap: { xhigh: "x" } }; + const changingRegistry = { + getAll: () => [model], + getAvailable: () => [model], + find: () => model, + hasConfiguredAuth: () => true, + }; + const pi = createTestPI(); + registerAgenticoding(pi as any); + const handler = pi.handlers.get("before_agent_start")!.at(-1)!; + const ctx = { hasUI: false, isProjectTrusted: () => true, cwd, modelRegistry: changingRegistry, getContextUsage: () => null }; + const initial = await handler({ systemPrompt: "Base." }, ctx); + assert.match(initial.systemPrompt, /review \(text, image\)/); + + model = { ...model, input: ["text"], reasoning: true }; + const refreshed = await handler({ systemPrompt: "Base." }, ctx); + assert.match(refreshed.systemPrompt, /review \(text, reasoning\)/); + assert.doesNotMatch(refreshed.systemPrompt, /review \(text, image\)/); +})); + test("before_agent_start clears stale Model Groups guidance when registry becomes unavailable", async () => withTemp(async ({ cwd }) => { fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 1, groups: { review: { models: [{ provider: "openai", modelId: "gpt-5" }] } } }), "utf8"); diff --git a/tests/unit/model-groups-modalities.test.ts b/tests/unit/model-groups-modalities.test.ts new file mode 100644 index 0000000..1e5fed8 --- /dev/null +++ b/tests/unit/model-groups-modalities.test.ts @@ -0,0 +1,60 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { assertModalityOverrideSupported, deriveModelGroupModalities, getMissingModelModalities } from "../../model-groups/modalities.js"; +import { deriveModalitiesEvaluation } from "../../model-groups/constraints/modalities.js"; +import { resolveConstraintMembers } from "../../model-groups/constraints/resolution.js"; +import type { ModelGroupDef } from "../../model-groups/types.js"; + +function registry(models: any[]): { find(provider: string, id: string): any } { + return { find: (provider, id) => models.find((model) => model.provider === provider && model.id === id) }; +} + +test("derives ordered common, supported, and override-effective modalities from the live registry", () => { + const models = [ + { provider: "p", id: "rich", input: ["image", "text"], reasoning: true }, + { provider: "p", id: "text", input: ["text"], reasoning: false }, + ]; + const group = { models: [{ provider: "p", modelId: "rich" }, { provider: "p", modelId: "text" }] }; + assert.deepEqual(deriveModelGroupModalities(group, registry(models)), { + common: ["text"], supported: ["text", "image", "reasoning"], effective: ["text"], + }); + assert.deepEqual(deriveModelGroupModalities({ ...group, constraints: { modalities: ["reasoning", "image"] } }, registry(models)).effective, ["image", "reasoning"]); + assert.deepEqual(deriveModelGroupModalities({ models: [...group.models, { provider: "p", modelId: "gone" }] }, registry(models)).common, []); + models[1].input = ["text", "image"]; + assert.deepEqual(deriveModelGroupModalities(group, registry(models)).common, ["text", "image"], "each call reads the live registry"); +}); + +test("compatibility façade and descriptor remain parity-equivalent across modality fixtures", () => { + const models: any[] = [ + { provider: "p", id: "rich", input: ["image", "text"], reasoning: true }, + { provider: "p", id: "text", input: ["text"], reasoning: false }, + ]; + const fixtures: ModelGroupDef[] = [ + { models: [] }, + { models: [{ provider: "p", modelId: "gone" }] }, + { models: [{ provider: "p", modelId: "rich" }], constraints: { modalities: [] } }, + { models: [{ provider: "p", modelId: "text" }], constraints: { modalities: ["image"] } }, + { models: [{ provider: "p", modelId: "rich" }, { provider: "p", modelId: "text" }] }, + ]; + for (const group of fixtures) { + const resolved = resolveConstraintMembers(group.models, registry(models)); + const evaluation = deriveModalitiesEvaluation(resolved.members, group.constraints?.modalities as any); + assert.deepEqual(deriveModelGroupModalities(group, registry(models)), { + common: evaluation.aggregate.common, + supported: evaluation.aggregate.supported, + effective: evaluation.effective, + }); + } +}); + +test("caps stale overrides without mutation and restores them when catalog support returns", () => { + const def: ModelGroupDef = { models: [{ provider: "p", modelId: "m" }], constraints: { modalities: ["text", "image"] } }; + const models: any[] = [{ provider: "p", id: "m", input: ["text"], reasoning: false }]; + const first = deriveModelGroupModalities(def, registry(models)); + assert.deepEqual(first.effective, ["text"]); + assert.deepEqual(def.constraints?.modalities, ["text", "image"]); + models[0].input.push("image"); + assert.deepEqual(deriveModelGroupModalities(def, registry(models)).effective, ["text", "image"]); + assert.throws(() => assertModalityOverrideSupported(def, registry([{ provider: "p", id: "m", input: ["text"], reasoning: false }])), /unsupported modalities: image/); + assert.deepEqual(getMissingModelModalities(models[0], ["text", "reasoning"]), ["reasoning"]); +}); diff --git a/tests/unit/model-groups-router.test.ts b/tests/unit/model-groups-router.test.ts index 00f8f8d..63d1d55 100644 --- a/tests/unit/model-groups-router.test.ts +++ b/tests/unit/model-groups-router.test.ts @@ -1,11 +1,13 @@ import test from "node:test"; import assert from "node:assert/strict"; import { getEffectiveModelGroupNames, resolveSpawnModelRoute, SpawnRouteError } from "../../model-groups/router.js"; +import { createConstraintRegistry } from "../../model-groups/constraints/registry.js"; import type { ResolvedModelGroup } from "../../model-groups/types.js"; import { group } from "./model-groups-helpers.js"; +import { testMinContext } from "./model-groups-constraints-fixture.js"; function model(provider: string, id: string, overrides: Record = {}): any { - return { provider, id, reasoning: true, ...overrides }; + return { provider, id, reasoning: true, input: ["text"], ...overrides }; } function registry(models: any[], authenticated = new Set(models.map((m) => `${m.provider}:${m.id}`))): any { @@ -16,11 +18,7 @@ function registry(models: any[], authenticated = new Set(models.map((m) => `${m. } test("effective model group names use project-over-global names", () => { - const groups = [ - group("review", { scope: "global", shadowedByProject: true }), - group("review", { scope: "project" }), - group("research", { scope: "global" }), - ]; + const groups = [group("review", { scope: "global", shadowedByProject: true }), group("review", { scope: "project" }), group("research", { scope: "global" })]; assert.deepEqual(getEffectiveModelGroupNames(groups), ["research", "review"]); }); @@ -29,41 +27,44 @@ test("omitted and unknown groups inherit parent route with fallback metadata", ( const reg = registry([parent]); assert.deepEqual(resolveSpawnModelRoute({ groups: [], parentModel: parent, parentThinking: "medium", modelRegistry: reg }).status, "inherited"); const route = resolveSpawnModelRoute({ requestedGroup: "typo", groups: [], parentModel: parent, parentThinking: "medium", modelRegistry: reg }); - assert.equal(route.status, "unknown-fallback"); - assert.equal(route.requestedGroup, "typo"); - assert.equal(route.model, parent); - assert.equal(route.thinking, "medium"); + assert.equal(route.status, "unknown-fallback"); assert.equal(route.requestedGroup, "typo"); assert.equal(route.model, parent); assert.equal(route.thinking, "medium"); }); test("known empty and all-unusable groups fail clearly", () => { const parent = model("openai", "parent"); - assert.throws( - () => resolveSpawnModelRoute({ requestedGroup: "empty", groups: [group("empty", { scope: "project" })], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), - (error: unknown) => error instanceof SpawnRouteError && error.group === "empty" && error.reason === "empty" && /empty/.test(error.message), - ); - assert.throws( - () => resolveSpawnModelRoute({ requestedGroup: "bad", groups: [group("bad", { scope: "project", models: [{ provider: "openai", modelId: "missing" }] })], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), - (error: unknown) => error instanceof SpawnRouteError && error.group === "bad" && error.reason === "no-usable-models" && /configured\/authenticated/.test(error.message), - ); + assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "empty", constraints: { modalities: { required: ["image"] } }, groups: [group("empty", { scope: "project" })], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "empty"); + assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "bad", constraints: { modalities: { required: ["image"] } }, groups: [group("bad", { scope: "project", models: [{ provider: "openai", modelId: "missing" }] })], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "no-usable-models"); }); -test("known usable groups filter registry/auth, draw with rng seam, and clamp thinking", () => { - const parent = model("openai", "parent"); - const usableA = model("openai", "a", { thinkingLevelMap: { xhigh: "x" } }); - const usableB = model("anthropic", "b", { thinkingLevelMap: { xhigh: null } }); - const unauth = model("openai", "unauth"); - const groups = [group("review", { scope: "project", models: [ - { provider: "openai", modelId: "missing" }, - { provider: "openai", modelId: "unauth" }, - { provider: "openai", modelId: "a" }, - { provider: "anthropic", modelId: "b", thinkingLevel: "xhigh" }, - ] })]; - const reg = registry([parent, usableA, usableB, unauth], new Set(["openai:parent", "openai:a", "anthropic:b"])); - const first = resolveSpawnModelRoute({ requestedGroup: "review", groups, parentModel: parent, parentThinking: "low", modelRegistry: reg, rng: () => 0 }); - assert.equal(first.status, "routed"); - assert.equal(first.model, usableA); - assert.equal(first.thinking, "low", "entry without thinking inherits parent"); - const second = resolveSpawnModelRoute({ requestedGroup: "review", groups, parentModel: parent, parentThinking: "low", modelRegistry: reg, rng: () => 0.99 }); - assert.equal(second.model, usableB); - assert.equal(second.thinking, "high", "xhigh clamps when selected model does not support it"); +test("required modalities check the effective group and actual RNG-selected model", () => { + const parent = model("p", "parent"); + const text = model("p", "text"); + const image = model("p", "image", { input: ["text", "image"] }); + const routed = group("mixed", { models: [{ provider: "p", modelId: "text" }, { provider: "p", modelId: "image" }], constraints: { modalities: ["text", "image"] } }); + routed.modalities = { common: ["text"], supported: ["text", "image"], effective: ["text", "image"] }; + const reg = registry([parent, text, image]); + assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "mixed", constraints: { modalities: { required: ["image"] } }, groups: [routed], parentModel: parent, parentThinking: "low", modelRegistry: reg, rng: () => 0 }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "missing-modality" && error.missingFromGroup.length === 0 && error.missingFromModel[0] === "image" && /Routed model/.test(error.message)); + assert.equal(resolveSpawnModelRoute({ requestedGroup: "mixed", constraints: { modalities: { required: ["image"] } }, groups: [routed], parentModel: parent, parentThinking: "low", modelRegistry: reg, rng: () => .99 }).status, "routed"); +}); + +test("known group missing effective modality and inherited fallback reject requirements", () => { + const parent = model("p", "parent"); const text = model("p", "text"); const g = group("text", { models: [{ provider: "p", modelId: "text" }] }); + assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "text", constraints: { modalities: { required: ["image"] } }, groups: [g], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent, text]) }), (error: unknown) => error instanceof SpawnRouteError && error.missingFromGroup[0] === "image"); + assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "unknown", constraints: { modalities: { required: ["image"] } }, groups: [], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), (error: unknown) => error instanceof SpawnRouteError && error.group === "unknown" && /Spawn model/.test(error.message)); +}); + +test("injected scalar requirements use generic violations, not modality arrays", () => { + const parent = model("p", "parent", { contextWindow: 100 }); const small = model("p", "small", { contextWindow: 10 }); + assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "small", constraints: { testMinContext: 20 }, groups: [group("small", { models: [{ provider: "p", modelId: "small" }] })], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent, small]), constraintRegistry: createConstraintRegistry([testMinContext]) }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "constraint-unsatisfied" && error.constraintUnsatisfied?.length === 2 && error.missingModalities.length === 0 && error.missingFromGroup.length === 0 && error.missingFromModel.length === 0); +}); + +test("plain inherited route honors requiredModalities with empty-array no-op", () => { + const rich = model("p", "rich-parent", { input: ["text", "image"] }); + const text = model("p", "text-parent", { input: ["text"] }); + // Empty array is a no-op: route returns unchanged, no requirement check. + assert.deepEqual(resolveSpawnModelRoute({ constraints: { modalities: { required: [] } }, groups: [], parentModel: text, parentThinking: "medium", modelRegistry: registry([text]) }).status, "inherited"); + // Parent satisfies all requirements → inherited route succeeds. + assert.deepEqual(resolveSpawnModelRoute({ constraints: { modalities: { required: ["text", "image"] } }, groups: [], parentModel: rich, parentThinking: "medium", modelRegistry: registry([rich]) }).status, "inherited"); + // Parent lacks a required modality → missing-modality with the parent model details. + assert.throws(() => resolveSpawnModelRoute({ constraints: { modalities: { required: ["image"] } }, groups: [], parentModel: text, parentThinking: "medium", modelRegistry: registry([text]) }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "missing-modality" && error.group === "" && error.missingFromModel[0] === "image" && error.missingFromGroup.length === 0 && /Spawn model/.test(error.message)); }); diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index 29d2411..a73ba19 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -57,6 +57,15 @@ function rendered(c: { render: (width: number) => string[] }, width = 100): stri return c.render(width).join("\n"); } +function selectRenderedLabel(c: { handleInput?: (data: string) => void; render: (width: number) => string[] }, label: string): void { + for (let i = 0; i < 32; i++) { + const selected = stripAnsi(rendered(c)).split("\n").find((line) => line.includes("→")); + if (selected?.includes(label)) return; + press(c, DOWN); + } + assert.fail(`did not select rendered label: ${label}`); +} + function pressAndRender(c: { handleInput?: (data: string) => void; render: (width: number) => string[] }, ...inputs: string[]): void { for (const input of inputs) { c.render(100); @@ -76,7 +85,7 @@ function catalog(models: any[]): any { function atSearchableModel(models: any[], store?: any) { const c = component({ groups: [group("review", { scope: "project" })], modelRegistry: catalog(models), store }).c; - pressAndRender(c, ENTER, DOWN, DOWN, DOWN, ENTER, ENTER); + pressAndRender(c, ENTER, DOWN, DOWN, DOWN, DOWN, ENTER, ENTER); assert.match(rendered(c), /Add model — Step 2\/3 Model/); return c; } @@ -111,6 +120,76 @@ test("model groups TUI list renders validation summary, health tags, add row, no assert.doesNotMatch(c.render(100).join("\n"), /Delete Model Group/); }); +test("model groups TUI renders modality labels, warnings, and stale override choices", () => { + const review = group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] }); + review.modalities = { common: ["text"], supported: ["text", "image"], effective: ["text", "image"] }; + review.constraints = { modalities: ["text", "image", "reasoning"] }; + review.validation.emptyCommonModalities = true; + review.validation.unsupportedOverrideModalities = ["reasoning"]; + const { c } = component({ groups: [review] }); + assert.match(rendered(c, 200), /review: modalities text, image/); + assert.match(rendered(c, 200), /⚠ no common modalities/); + assert.match(rendered(c, 200), /⚠ stale modality override: reasoning/); + press(c, ENTER); + assert.match(rendered(c), /Common: text/); + assert.match(rendered(c), /Modalities: override \(text, image\)/); + press(c, DOWN, DOWN, DOWN, ENTER); + assert.match(rendered(c), /Automatic \(common: text\)/); + assert.match(rendered(c), /Override: none/); + assert.match(rendered(c), /Override: text, image, reasoning/); +}); + +test("model groups TUI modality editor commits override and Automatic through updateGroup", () => { + const review = group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] }); + review.modalities = { common: ["text"], supported: ["text", "image", "reasoning"], effective: ["text"] }; + const calls: Array<{ scope: string; name: string; def: any }> = []; + let groups = [review]; + const store = { + updateGroup: (scope: string, _cwd: string, name: string, def: any) => { + calls.push({ scope, name, def: { ...def, constraints: def.constraints ? { ...def.constraints, ...(Array.isArray(def.constraints.modalities) ? { modalities: [...def.constraints.modalities] } : {}) } : undefined } }); + groups = [group(name, { scope: scope as "project", models: def.models, constraints: def.constraints })]; + groups[0].modalities = { common: ["text"], supported: ["text", "image", "reasoning"], effective: ["text", "image", "reasoning"] }; + }, + listResolvedModelGroups: () => boot(groups), + }; + const { c } = component({ groups, store }); + press(c, ENTER); + selectRenderedLabel(c, "Modalities:"); + press(c, ENTER); + assert.match(rendered(c), /MODALITIES/); + selectRenderedLabel(c, "Override: text, image, reasoning"); + press(c, ENTER); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].def.constraints.modalities, ["text", "image", "reasoning"]); + assert.match(rendered(c), /Modalities: override/); + press(c, ENTER); + assert.match(rendered(c), /MODALITIES/); + selectRenderedLabel(c, "Automatic"); + press(c, ENTER); + assert.equal(calls.length, 2); + assert.equal(calls[1].def.constraints?.modalities, undefined); + assert.match(rendered(c), /Modalities: automatic/); +}); + +test("model groups TUI modality editor preserves state and notifies on updateGroup failure", () => { + const review = group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] }); + review.modalities = { common: ["text"], supported: ["text", "image"], effective: ["text"] }; + const messages: string[] = []; + let failing = true; + const store = { + updateGroup: (_scope: string, _cwd: string, _name: string, def: any) => { + if (failing) throw new ModelGroupsPersistenceError({ operation: "save", scope: "project", sourcePath: "/tmp/.pi/pi-agenticoding/model-groups.json", phase: "rename", message: "modality write denied" }); + review.constraints = def.constraints ? { ...def.constraints, ...(Array.isArray(def.constraints.modalities) ? { modalities: [...def.constraints.modalities] } : {}) } : undefined; + }, + listResolvedModelGroups: () => boot([review]), + }; + const { c } = component({ groups: [review], store, notify: (message) => messages.push(message) }); + press(c, ENTER, DOWN, DOWN, DOWN, ENTER); // open MODALITIES + press(c, DOWN, DOWN, DOWN, ENTER); // pick an override → updateGroup throws + assert.ok(messages.some((m) => /modality write denied/.test(m))); + assert.match(rendered(c), /MODALITIES/, "screen retained after failure"); +}); + test("model groups TUI computes unique new-group names and opens editor after create", () => { let groups = [group("new-group", { scope: "project" })]; const calls: string[] = []; @@ -146,7 +225,7 @@ test("model groups TUI wizard renders provider/model/thinking steps and preserve listResolvedModelGroups: () => boot(groups), }; const { c } = component({ groups, store, notify: (message) => messages.push(message) }); - press(c, ENTER, DOWN, DOWN, DOWN, ENTER); + press(c, ENTER, DOWN, DOWN, DOWN, DOWN, ENTER); let text = rendered(c); assert.match(text, /Add model — Step 1\/3 Provider/); assert.match(text, /anthropic/); @@ -183,7 +262,7 @@ test("model groups TUI wizard renders provider/model/thinking steps and preserve test("model groups TUI Esc and left-arrow share wizard back-step behavior", () => { function atProvider() { const { c } = component({ groups: [group("review", { scope: "project" })] }); - press(c, ENTER, DOWN, DOWN, DOWN, ENTER); + press(c, ENTER, DOWN, DOWN, DOWN, DOWN, ENTER); return c; } function atModel() { @@ -241,20 +320,16 @@ test("model groups TUI selected markers and primary labels use accent token", () press(editor, ENTER); text = rendered(editor); assert.match(text, /→<\/accent> Location: project<\/accent> ✓/); - press(editor, DOWN, DOWN, DOWN); + press(editor, DOWN, DOWN, DOWN, DOWN); assert.match(rendered(editor), /→<\/accent> openai\/gpt-5<\/accent> \(available/); press(editor, DOWN); assert.match(rendered(editor), /→<\/accent> \+ Add model…<\/accent>/); press(editor, ENTER); assert.match(rendered(editor), /→ anthropic<\/accent>/); - press(editor, DOWN, ENTER); - assert.match(rendered(editor), /→ openai\/gpt-5<\/accent>/); - press(editor, ENTER); - assert.match(rendered(editor), /→ inherit<\/accent>/); const modelEdit = component({ groups: [group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] })], renderTheme: accentTheme }).c; - press(modelEdit, ENTER, DOWN, DOWN, DOWN, ENTER); + press(modelEdit, ENTER, DOWN, DOWN, DOWN, DOWN, ENTER); assert.match(rendered(modelEdit), /→<\/accent> Thinking: inherit<\/accent>/); const deleteConfirm = component({ groups: [group("review", { scope: "project" })], renderTheme: accentTheme }).c; @@ -272,7 +347,7 @@ test("model groups TUI model edit renders identity/status and filters thinking o ] })]; const { c } = component({ groups }); - press(c, ENTER, DOWN, DOWN, DOWN, ENTER); + press(c, ENTER, DOWN, DOWN, DOWN, DOWN, ENTER); let text = rendered(c); assert.match(text, /Provider: anthropic/); assert.match(text, /Model ID: claude/); @@ -282,7 +357,7 @@ test("model groups TUI model edit renders identity/status and filters thinking o assert.doesNotMatch(text, /Thinking: off/); assert.doesNotMatch(text, /Thinking: (minimal|low|medium|high|xhigh)/); - press(c, ESC, DOWN, DOWN, DOWN, DOWN, ENTER); + press(c, ESC, DOWN, DOWN, DOWN, DOWN, DOWN, ENTER); text = rendered(c); assert.match(text, /Provider: openai/); assert.match(text, /Model ID: gpt-5/); @@ -291,7 +366,7 @@ test("model groups TUI model edit renders identity/status and filters thinking o assert.match(text, new RegExp(`Thinking: ${option}`)); } - press(c, ESC, DOWN, DOWN, DOWN, DOWN, DOWN, ENTER); + press(c, ESC, DOWN, DOWN, DOWN, DOWN, DOWN, DOWN, ENTER); text = rendered(c); assert.match(text, /Provider: missing/); assert.match(text, /Model ID: nope/); @@ -334,7 +409,7 @@ test("model groups TUI notifies and preserves model edit state when updateGroup listResolvedModelGroups: () => boot(groups), }; const { c } = component({ groups, store, notify: (message) => messages.push(message) }); - press(c, ENTER, DOWN, DOWN, DOWN, ENTER, DOWN, ENTER); + press(c, ENTER, DOWN, DOWN, DOWN, DOWN, ENTER, DOWN, ENTER); assert.deepEqual(attemptedModels[0], ["openai/gpt-5/off"]); assert.match(messages[0], /update failed 1/); let text = rendered(c); @@ -390,7 +465,7 @@ test("model groups TUI renders name editing inline and preserves edit/commit tra assert.match(rendered(c), / Name: abcde/); assert.equal(rendered(c).includes(CURSOR_MARKER), false); - press(c, DOWN, DOWN, ENTER, "f", DOWN); // row-change flushes the pending rename before moving + press(c, DOWN, DOWN, ENTER, "f", DOWN, DOWN); // row-change flushes the pending rename before moving assert.deepEqual(calls, ["abc->abcd", "abcd->abcde", "abcde->abcdef"]); text = rendered(c); assert.match(text, /Model Group: abcdef/); @@ -415,6 +490,7 @@ test("model groups TUI move, wizard add, model thinking, and remove persist thro c.handleInput?.("\r"); // switch global assert.equal(calls[0], "move:review:global"); + c.handleInput?.("\u001b[B"); c.handleInput?.("\u001b[B"); c.handleInput?.("\u001b[B"); c.handleInput?.("\u001b[B"); // first model row @@ -426,6 +502,7 @@ test("model groups TUI move, wizard add, model thinking, and remove persist thro c.handleInput?.("\u001b[B"); c.handleInput?.("\u001b[B"); c.handleInput?.("\u001b[B"); + c.handleInput?.("\u001b[B"); c.handleInput?.("\u001b[B"); // + add model press(c, ENTER); // provider step assert.match(rendered(c), /Step 1\/3 Provider/); @@ -436,6 +513,7 @@ test("model groups TUI move, wizard add, model thinking, and remove persist thro press(c, ENTER); // inherit thinking assert.match(calls.at(-1)!, /anthropic\/claude\/inherit/); + c.handleInput?.("\u001b[B"); c.handleInput?.("\u001b[B"); c.handleInput?.("\u001b[B"); c.handleInput?.("\u001b[B"); // first model row after refresh @@ -487,7 +565,7 @@ test("model groups TUI uses root Focusable propagation and MODEL_EDIT parent nav assert.ok(c.render(80).join("\n").includes(CURSOR_MARKER)); press(c, ENTER); assert.equal(c.render(80).join("\n").includes(CURSOR_MARKER), false); - press(c, DOWN, ENTER); + press(c, DOWN, DOWN, ENTER); assert.match(rendered(c), /Edit model/); press(c, ESC); assert.match(rendered(c), /Location: project/); @@ -536,7 +614,7 @@ test("model groups TUI escapes controlled labels, bounds width, and offers nativ listResolvedModelGroups: () => boot(maxGroups), }, }).c; - press(max, ENTER, DOWN, DOWN, DOWN, ENTER); + press(max, ENTER, DOWN, DOWN, DOWN, DOWN, ENTER); press(max, DOWN, ENTER, ENTER); assert.match(rendered(max), /Add model — Step 3\/3 Thinking/); assert.match(rendered(max), /max/); @@ -685,7 +763,7 @@ test("model groups TUI handles Model activation immediately after Provider trans }; const models = Array.from({ length: 2 }, (_, index) => ({ provider: "openai", id: `model-${index}`, reasoning: false })); const c = component({ groups, modelRegistry: catalog(models), store }).c; - pressAndRender(c, ENTER, DOWN, DOWN, DOWN, ENTER); + pressAndRender(c, ENTER, DOWN, DOWN, DOWN, DOWN, ENTER); assert.match(rendered(c), /Add model — Step 1\/3 Provider/); for (const input of [ENTER, ENTER]) c.handleInput?.(input); @@ -807,14 +885,14 @@ test("model groups TUI directly proves query preservation and every abandonment, assert.match(rendered(abandonedAndExited), /Step 1\/3 Provider/); pressAndRender(abandonedAndExited, ESC); assert.match(rendered(abandonedAndExited), /Model Group: review/); - pressAndRender(abandonedAndExited, ...Array(4).fill(DOWN), ENTER, ENTER); + pressAndRender(abandonedAndExited, ...Array(5).fill(DOWN), ENTER, ENTER); assert.match(rendered(abandonedAndExited), /Step 2\/3 Model/); assert.doesNotMatch(rendered(abandonedAndExited), /> target/); const completedAndReopened = atSearchableModel(models, store); pressAndRender(completedAndReopened, ..."target", ENTER, ENTER); assert.match(rendered(completedAndReopened), /Model Group: review/); - pressAndRender(completedAndReopened, ...Array(4).fill(DOWN), ENTER, ENTER); + pressAndRender(completedAndReopened, ...Array(5).fill(DOWN), ENTER, ENTER); assert.match(rendered(completedAndReopened), /Step 2\/3 Model/); assert.doesNotMatch(rendered(completedAndReopened), /> target/); }); @@ -852,7 +930,7 @@ test("model groups TUI directly proves every non-Model screen remains search-fre pressAndRender(c, ENTER); assert.match(rendered(c), /provider-11\/only-model/); // EDITOR remains uncapped. assert.equal(rendered(c).includes(CURSOR_MARKER), false); - pressAndRender(c, DOWN, DOWN, DOWN, ENTER); + pressAndRender(c, DOWN, DOWN, DOWN, DOWN, ENTER); assert.match(rendered(c), /Edit model/); // MODEL_EDIT. assert.equal(rendered(c).includes(CURSOR_MARKER), false); pressAndRender(c, ESC, ...Array(20).fill(DOWN), ENTER); diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index 400247f..6e1d1ef 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -7,11 +7,15 @@ import { buildChildToolNames, createChildTools, executeSpawn, + normalizeSpawnRequirements, registerSpawnTool, truncateText, } from "../../spawn/index.js"; import { renderSpawnResult } from "../../spawn/renderer.js"; import { SpawnRouteError } from "../../model-groups/router.js"; +import { createConstraintRegistry } from "../../model-groups/constraints/registry.js"; +import { Value } from "typebox/value"; +import { testMinContext } from "./model-groups-constraints-fixture.js"; import { createTestPI, createRenderContext, createSession, theme, createDeferred } from "./helpers.js"; import { createTestHarness, type TestHarness } from "../test-utils.js"; @@ -649,6 +653,7 @@ test("spawn execute clears childSessions after successful completion when unrend assert.equal(result.content[0].text, "child result"); assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); }); test("spawn execute fails explicitly without a configured model", async () => { @@ -710,6 +715,123 @@ test("executeSpawn propagates unusable-group errors before creating child work", assert.equal(state.liveChildSessions.size, 0, "no live child session registered"); }); +test("executeSpawn propagates missing modalities before creating child work", async () => { + const pi = createTestPI(); + const state = createState(); + state.modelGroups.groups = [{ + name: "text-only", scope: "project", sourcePath: "", models: [{ provider: "openai", modelId: "text" }], + modalities: { common: ["text"], supported: ["text"], effective: ["text"] }, + validation: { unavailableRefs: [], shadowedByProject: false, degraded: false, emptyCommonModalities: false, unsupportedOverrideModalities: [] }, + }]; + let factoryCalls = 0; + await assert.rejects(() => executeSpawn("missing-modality", pi as any, { + model: { provider: "openai", id: "parent", input: ["text"], reasoning: false }, cwd: "/tmp", + modelRegistry: { find: (_provider: string, id: string) => ({ provider: "openai", id, input: ["text"], reasoning: false }), hasConfiguredAuth: () => true }, + } as any, state, { prompt: "Do the task", group: "text-only", constraints: { modalities: { required: ["image"] } } }, undefined, undefined, "medium", async () => { factoryCalls++; throw new Error("must not create child"); }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "missing-modality"); + assert.equal(factoryCalls, 0); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("executeSpawn rejects unknown requirements before factory or session publication", async () => { + const pi = createTestPI(); const state = createState(); let factoryCalls = 0; + await assert.rejects(() => executeSpawn("unknown-constraint", pi as any, { + model: { provider: "openai", id: "parent", input: ["text"], reasoning: false }, cwd: "/tmp", + modelRegistry: { find: () => undefined, hasConfiguredAuth: () => false }, + } as any, state, { prompt: "Do the task", constraints: { unknown: {} } }, undefined, undefined, "medium", async () => { factoryCalls++; throw new Error("must not create child"); }), /Unknown spawn constraint/); + assert.equal(factoryCalls, 0); assert.equal(state.childSessions.size, 0); assert.equal(state.liveChildSessions.size, 0); +}); + +test("registered spawn tool rejects missing modalities before creating child work", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + state.modelGroups.groups = [{ + name: "text-only", scope: "project", sourcePath: "", models: [{ provider: "openai", modelId: "text" }], + modalities: { common: ["text"], supported: ["text"], effective: ["text"] }, + validation: { unavailableRefs: [], shadowedByProject: false, degraded: false, emptyCommonModalities: false, unsupportedOverrideModalities: [] }, + }]; + let factoryCalls = 0; + registerSpawnTool(pi as any, state, (async () => { factoryCalls++; throw new Error("sessionFactory must not be called"); }) as any); + + await assert.rejects( + () => pi.tools.get("spawn").execute("registered-missing-modality", { prompt: "Do the task", group: "text-only", constraints: { modalities: { required: ["image"] } } }, undefined, undefined, { + model: { provider: "openai", id: "parent", input: ["text"], reasoning: false }, cwd: "/tmp", + modelRegistry: { find: (_provider: string, id: string) => ({ provider: "openai", id, input: ["text"], reasoning: false }), hasConfiguredAuth: () => true }, + } as any), + (error: unknown) => { + assert.ok(error instanceof SpawnRouteError); + assert.equal(error.kind, "unusable-group"); + assert.equal(error.reason, "missing-modality"); + return true; + }, + ); + assert.equal(factoryCalls, 0); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("registered spawn tool rejects injected scalar group and model requirements before publication", async () => { + const pi = createTestPI(); pi.setActiveTools(["spawn"]); + const state = createState(); let factoryCalls = 0; + state.modelGroups.groups = [ + { name: "small", scope: "project", sourcePath: "", models: [{ provider: "openai", modelId: "small" }], modalities: { common: ["text"], supported: ["text"], effective: ["text"] }, validation: { unavailableRefs: [], shadowedByProject: false, degraded: false, emptyCommonModalities: false, unsupportedOverrideModalities: [] } }, + ]; + registerSpawnTool(pi as any, state, (async () => { factoryCalls++; throw new Error("sessionFactory must not be called"); }) as any, createConstraintRegistry([testMinContext])); + await assert.rejects( + () => pi.tools.get("spawn").execute("registered-scalar", { prompt: "Do the task", group: "small", constraints: { testMinContext: 20 } }, undefined, undefined, { + model: { provider: "openai", id: "parent", input: ["text"], reasoning: false, contextWindow: 100 }, cwd: "/tmp", + modelRegistry: { find: (_provider: string, id: string) => ({ provider: "openai", id, input: ["text"], reasoning: false, contextWindow: id === "small" ? 10 : 100 }), hasConfiguredAuth: () => true }, + } as any), + (error: unknown) => error instanceof SpawnRouteError && error.reason === "constraint-unsatisfied" && error.constraintUnsatisfied?.length === 2 && error.missingModalities.length === 0 && error.missingFromGroup.length === 0 && error.missingFromModel.length === 0, + ); + assert.equal(factoryCalls, 0); assert.equal(state.childSessions.size, 0); assert.equal(state.liveChildSessions.size, 0); +}); + +test("spawn requirements normalize the canonical envelope", () => { + assert.deepEqual(normalizeSpawnRequirements({ constraints: { modalities: { required: ["image", "text"] } } }), { modalities: ["text", "image"] }); + assert.deepEqual(normalizeSpawnRequirements({ constraints: { modalities: { required: ["image"] } } }), { modalities: ["image"] }); + assert.throws(() => normalizeSpawnRequirements({ constraints: { unknown: {} } }), /Unknown spawn constraint/); + assert.deepEqual(normalizeSpawnRequirements({}), normalizeSpawnRequirements({ constraints: {}})); +}); + +test("spawn tool schema validates constraints via Value.Check", () => { + const pi = createTestPI(); + const state = createState(); + registerSpawnTool(pi as any, state); + const tool = pi.tools.get("spawn"); + const schema = (tool as any).parameters; + assert.equal(Value.Check(schema, { prompt: "Do the task", constraints: { modalities: { required: ["text", "image"] } } }), true, "valid generic envelope accepted"); + assert.equal(Value.Check(schema, { prompt: "Do the task", constraints: { unknown: {} } }), false, "unknown generic requirement rejected"); + assert.equal(Value.Check(schema, { prompt: "Do the task" }), true, "omitted constraints allowed"); + assert.equal(Value.Check(schema, { prompt: "Do the task", constraints: { modalities: { required: [] } } }), true, "empty array allowed"); + assert.equal(Value.Check(schema, { prompt: "Do the task", constraints: { modalities: { required: ["text", "text"] } } }), false, "duplicates rejected"); + assert.equal(Value.Check(schema, { prompt: "Do the task", constraints: { modalities: { required: ["audio"] } } }), false, "out-of-vocabulary rejected"); + assert.equal(Value.Check(schema, { prompt: "Do the task", constraints: { modalities: "text" } }), false, "non-object requirement rejected"); +}); + +test("executeSpawn forwards inherited constraints to routing and succeeds when satisfied", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "spawn"]); + const state = createState(); + let factoryCalls = 0; + const session = { + messages: [] as any[], + prompt: async () => { + session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; + }, + abort: async () => {}, + getSessionStats: () => undefined, + }; + registerSpawnTool(pi as any, state, (async () => { factoryCalls++; return { session: session as any }; }) as any); + const result = await executeSpawn("spawn-inherited-rm", pi as any, { + model: { provider: "openai", id: "parent", input: ["text", "image"], reasoning: false }, cwd: "/tmp", + modelRegistry: { find: (_p: string, id: string) => ({ provider: "openai", id, input: ["text", "image"], reasoning: false }), hasConfiguredAuth: () => true }, + } as any, state, { prompt: "Do the task", constraints: { modalities: { required: ["text", "image"] } } }, undefined, undefined, "medium", async () => { factoryCalls++; return { session: session as any, extensionsResult: undefined as any }; }); + assert.equal(result.details.outcome, "success"); + assert.deepEqual(result.details.route, { status: "inherited" }); + assert.equal(factoryCalls, 1, "inherited route with satisfied requirements creates one child"); +}); test("spawn renderResult transfers session ownership out of shared state", () => { const state = createState(); @@ -1751,6 +1873,9 @@ test("registerSpawnTool registers a tool with correct name and metadata", () => assert.equal(typeof tool.renderResult, "function"); assert.equal(tool.renderShell, "self"); assert.ok(tool.parameters, "should have parameters"); + const constraints = (tool.parameters as any).properties.constraints; + assert.equal(constraints.type, "object"); + assert.ok(constraints.properties.modalities); assert.equal(tool.executionMode, undefined, "spawn should not be sequential"); }); diff --git a/tests/unit/state-invariants.test.ts b/tests/unit/state-invariants.test.ts index 379355a..a4b5385 100644 --- a/tests/unit/state-invariants.test.ts +++ b/tests/unit/state-invariants.test.ts @@ -279,7 +279,8 @@ test("Property 4: Reset clears all state fields", async () => { scope: "project", sourcePath: "", models: [], - validation: { unavailableRefs: [], shadowedByProject: false, degraded: false }, + modalities: { common: [], supported: [], effective: [] }, + validation: { unavailableRefs: [], shadowedByProject: false, degraded: false, emptyCommonModalities: false, unsupportedOverrideModalities: [] }, }]; s2.modelGroups.validation = { groups: s2.modelGroups.groups, loadIssues: [] }; resetState(s2);