From f6a46159a19173b29acfa2e5bf7c0415bd359687 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Thu, 20 Aug 2026 16:48:21 +0000 Subject: [PATCH 1/6] feat(model-groups): surface effective modalities and fail early on spawn Implement issue #26: spawn and main session become modality-aware. - Add pure derivation module model-groups/modalities.ts (common/supported/effective sets from live ModelRegistry input+reasoning). - Persist per-group modalityOverride with v2 schema-version guard: lossless normalization, opaque-key preservation, v1 in-memory migration, future-version write refusal. - Inject effective modalities per group into the main-session system prompt via before_agent_start. - Add optional spawn requiredModalities checked at the router gate; fail early before child session when the routed model/group cannot satisfy a requirement. - TUI: modality display, empty-common + stale-override warnings, editor for automatic/supported subsets. - Add focused AC1-AC7 test coverage incl. new model-groups-modalities test. Validators: typecheck, npm test 618/618, e2e 16/16, snapshots 11/11, compat:floor, package-host all pass. test:compat:current is skipped (pre-existing host-skew, unrelated; tracked separately). --- index.ts | 23 +- model-groups/modalities.ts | 43 ++++ model-groups/router.ts | 124 +++-------- model-groups/store.ts | 230 ++++---------------- model-groups/tui.ts | 54 ++++- model-groups/types.ts | 67 ++---- spawn/index.ts | 10 +- tests/unit/model-groups-crud.test.ts | 154 +++++++++---- tests/unit/model-groups-helpers.ts | 5 + tests/unit/model-groups-integration.test.ts | 32 ++- tests/unit/model-groups-modalities.test.ts | 35 +++ tests/unit/model-groups-router.test.ts | 57 ++--- tests/unit/model-groups-tui.test.ts | 58 +++-- tests/unit/spawn.test.ts | 21 ++ tests/unit/state-invariants.test.ts | 3 +- 15 files changed, 465 insertions(+), 451 deletions(-) create mode 100644 model-groups/modalities.ts create mode 100644 tests/unit/model-groups-modalities.test.ts diff --git a/index.ts b/index.ts index a310ea1..b67169b 100644 --- a/index.ts +++ b/index.ts @@ -71,7 +71,8 @@ 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 type { ResolvedModelGroup } 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"; @@ -461,13 +462,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.modalities?.effective.join(", ") || "none"})`); 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 text, image, or reasoning capability, pass those requirements as requiredModalities. 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 +757,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 +921,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/modalities.ts b/model-groups/modalities.ts new file mode 100644 index 0000000..b2e1108 --- /dev/null +++ b/model-groups/modalities.ts @@ -0,0 +1,43 @@ +import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { Api, Model } from "@earendil-works/pi-ai"; +import { MODEL_GROUP_MODALITIES, type ModelGroupDef, type ModelGroupModalities, type ModelGroupModality } from "./types.js"; + +function ordered(values: Iterable): ModelGroupModality[] { + const set = new Set(values); + return MODEL_GROUP_MODALITIES.filter((value) => set.has(value)); +} + +export function getModelModalities(model: Model): ModelGroupModality[] { + return ordered([...(Array.isArray(model.input) ? model.input as ModelGroupModality[] : []), ...(model.reasoning === true ? ["reasoning" as const] : [])]); +} + +export function deriveModelGroupModalities( + group: Pick, + modelRegistry: Pick, +): ModelGroupModalities { + const found = group.models.map((entry) => modelRegistry.find(entry.provider, entry.modelId) as Model | undefined); + const sets = found.map((model) => new Set(model ? getModelModalities(model) : [])); + const supported = ordered(sets.flatMap((set) => [...set])); + const common = found.length === 0 || found.some((model) => !model) + ? [] + : ordered(MODEL_GROUP_MODALITIES.filter((modality) => sets.every((set) => set.has(modality)))); + const effective = group.modalityOverride === undefined + ? common + : ordered(group.modalityOverride.filter((modality) => supported.includes(modality))); + return { common, supported, effective }; +} + +export function assertModalityOverrideSupported( + group: Pick, + modelRegistry: Pick, +): void { + if (group.modalityOverride === undefined) return; + const supported = new Set(deriveModelGroupModalities(group, modelRegistry).supported); + const missing = ordered(group.modalityOverride.filter((modality) => !supported.has(modality))); + if (missing.length) throw new Error(`Model group modality override includes unsupported modalities: ${missing.join(", ")}.`); +} + +export function getMissingModelModalities(model: Model, required: readonly ModelGroupModality[]): ModelGroupModality[] { + const modalities = new Set(getModelModalities(model)); + return ordered(required.filter((modality) => !modalities.has(modality))); +} diff --git a/model-groups/router.ts b/model-groups/router.ts index 73a9db3..d539284 100644 --- a/model-groups/router.ts +++ b/model-groups/router.ts @@ -1,106 +1,32 @@ 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 { deriveModelGroupModalities, getMissingModelModalities } from "./modalities.js"; +import { MODEL_GROUP_MODALITIES, 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"; 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; - } -} - -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); + readonly kind = "unusable-group" as const; readonly group: string; readonly reason: SpawnRouteErrorReason; readonly missingModalities: ModelGroupModality[]; readonly missingFromGroup: ModelGroupModality[]; readonly missingFromModel: ModelGroupModality[]; + constructor(group: string, reason: SpawnRouteErrorReason, details: { missingModalities?: ModelGroupModality[]; missingFromGroup?: ModelGroupModality[]; missingFromModel?: ModelGroupModality[]; 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.` : 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(", ")}.`; + super(message); this.name = "SpawnRouteError"; this.group = group; this.reason = reason; this.missingModalities = missingModalities; this.missingFromGroup = missingFromGroup; this.missingFromModel = missingFromModel; } - 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, - }; +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); } +function required(values: readonly ModelGroupModality[] | undefined): ModelGroupModality[] { const set = new Set(values); return MODEL_GROUP_MODALITIES.filter((m) => set.has(m)); } +export function resolveSpawnModelRoute(options: { requestedGroup?: string; requiredModalities?: readonly ModelGroupModality[]; groups: ResolvedModelGroup[]; parentModel: Model; parentThinking: ModelThinkingLevel; modelRegistry: Pick; rng?: () => number }): SpawnModelRoute { + const requestedGroup = options.requestedGroup?.trim(); const req = required(options.requiredModalities); + 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 (!req.length) return route; + const selectedGroup = group; + const missingFromGroup = selectedGroup ? required(req.filter((m) => !deriveModelGroupModalities(selectedGroup, options.modelRegistry).effective.includes(m))) : []; + const missingFromModel = getMissingModelModalities(route.model, req); const missingModalities = required([...missingFromGroup, ...missingFromModel]); + if (missingModalities.length) throw new SpawnRouteError(group?.name ?? (requestedGroup || ""), "missing-modality", { missingModalities, missingFromGroup, missingFromModel, 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..f93f84e 100644 --- a/model-groups/store.ts +++ b/model-groups/store.ts @@ -3,209 +3,71 @@ 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, deriveModelGroupModalities } from "./modalities.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 { MODEL_GROUP_MODALITIES, ModelGroupsPersistenceError, type ModelGroupDef, 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 { return { ...def, models: def.models.map((model) => ({ ...model })), ...(def.modalityOverride === undefined ? {} : { modalityOverride: [...def.modalityOverride] }) }; } +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 } { +function validateOverride(value: unknown, at: string): { ok: true; value?: ModelGroupDef["modalityOverride"] } | { ok: false; message: string } { + if (value === undefined) return { ok: true }; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !MODEL_GROUP_MODALITIES.includes(item as any)) || new Set(value).size !== value.length) return { ok: false, message: `${at} must be a unique modality vocabulary array` }; + return { ok: true, value: [...value] as ModelGroupDef["modalityOverride"] }; +} +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)) 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 }); + 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 override = sourceVersion >= 2 ? validateOverride(rawDef.modalityOverride, `group ${rawName}.modalityOverride`) : { ok: true as const }; + if (!override.ok) return override; + defineGroup(groups, name, { ...rawDef, models, ...(sourceVersion >= 2 && override.value !== undefined ? { modalityOverride: override.value } : {}) }); } 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 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)}`; - } - 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); - 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)) }; -} -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 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; -} + 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 }; } +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 }; +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`); assertModalityOverrideSupported(def, modelRegistry); defineGroup(config.groups, name, def); 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`); assertModalityOverrideSupported(def, modelRegistry); defineGroup(config.groups, name, def); 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 modalities = deriveModelGroupModalities(group, modelRegistry); const unsupportedOverrideModalities = group.modalityOverride === undefined ? [] : group.modalityOverride.filter((m) => !modalities.supported.includes(m)); return { ...group, modalities, validation: { unavailableRefs, shadowedByProject: group.scope === "global" && projectNames.has(group.name), degraded: unavailableRefs.length > 0 && unavailableRefs.length < group.models.length, emptyCommonModalities: modalities.common.length === 0, 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 } { return { unavailableCount: groups.reduce((sum, group) => sum + group.validation.unavailableRefs.length, 0), overrideCount: groups.filter((g) => g.validation.shadowedByProject).length, emptyModalityCount: groups.filter((g) => g.validation.emptyCommonModalities).length, staleModalityOverrideCount: groups.filter((g) => g.validation.unsupportedOverrideModalities.length > 0).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..f201903 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -11,11 +11,11 @@ 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"; -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 +44,7 @@ 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 })) }; + return { ...def, models: def.models.map((model) => ({ ...model })), ...(def.modalityOverride === undefined ? {} : { modalityOverride: [...def.modalityOverride] }) }; } function groupKey(group: Pick): string { @@ -107,7 +107,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 +236,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); @@ -284,6 +285,7 @@ export function createModelGroupsComponent( switch (state.screen) { case "LIST": return state.groups.length; case "EDITOR": return modelStartRow() + (state.editDraft?.models.length ?? 0); + case "MODALITIES": return modalityOverrideChoices(currentEditGroup()?.modalities.supported ?? []).length; 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 +305,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 +320,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 +334,17 @@ export function createModelGroupsComponent( } return; } + case "MODALITIES": { + if (!state.editDraft) return; + const current = currentEditGroup(); + const supported = current?.modalities.supported ?? []; + const choices = modalityOverrideChoices(supported); + const selected = choices[state.row - 1] ?? []; + const next = cloneDef(state.editDraft); + if (state.row === 0) delete next.modalityOverride; + else next.modalityOverride = [...selected]; + 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 +404,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 +508,13 @@ 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.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 +527,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?.modalityOverride === 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 +539,26 @@ export function createModelGroupsComponent( return container; } + function modalityOverrideChoices(supported: readonly ModelGroupModality[]): ModelGroupModality[][] { + const choices: ModelGroupModality[][] = []; + for (let mask = 0; mask < 2 ** supported.length; mask++) { + choices.push(supported.filter((_, index) => (mask & (1 << index)) !== 0)); + } + return choices; + } + + function renderModalitiesComponent(): Component { + activeSelect = null; + const container = new Container(); + const current = currentEditGroup(); + container.addChild(textLine(theme.fg("accent", "MODALITIES"))); + container.addChild(textLine(selectableLine(state.row === 0, `Automatic (common: ${current?.modalities.common.join(", ") || "none"})`))); + for (const [index, override] of modalityOverrideChoices(current?.modalities.supported ?? []).entries()) { + container.addChild(textLine(selectableLine(state.row === index + 1, `Override: ${override.join(", ") || "none"}`))); + } + return container; + } + function renderModelEditComponent(): Component { activeSelect = null; const container = new Container(); @@ -577,6 +618,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..46a2a34 100644 --- a/model-groups/types.ts +++ b/model-groups/types.ts @@ -1,65 +1,34 @@ import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; +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 ModelGroupDef { models: ModelGroupModel[] } -export interface ModelGroupsConfig { version: 1; groups: Record } +export interface ModelGroupModel { provider: string; modelId: string; thinkingLevel?: ModelThinkingLevel } +export interface ModelGroupDef { models: ModelGroupModel[]; modalityOverride?: ModelGroupModality[] } +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 } 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 4dc48f3..a47ba07 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -32,6 +32,7 @@ import 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 { MODEL_GROUP_MODALITIES, type ModelGroupModality } from "../model-groups/types.js"; import { applyReadonlyBashGuard } from "../readonly-bash.js"; import { renderSpawnCall, @@ -211,6 +212,7 @@ 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 requiredModalities when the delegated task needs text, image, or reasoning capability; do not work around a missing required modality with third-party tools.", ]; const SPAWN_PARAMETERS = Type.Object({ @@ -222,6 +224,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.", })), + requiredModalities: Type.Optional(Type.Array(StringEnum(MODEL_GROUP_MODALITIES, { description: "Optional modalities the selected child route must support. Routing fails before child creation if the effective Model Group or selected model lacks any requirement." }), { uniqueItems: true } as any)), thinking: Type.Optional(StringEnum( ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const, { @@ -264,12 +267,14 @@ export function createChildTools( * - both registries delete(toolCallId) on error and completion paths * */ +export interface SpawnParameters { prompt: string; group?: string; requiredModalities?: ModelGroupModality[]; thinking?: ThinkingValue } + 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: { @@ -290,6 +295,7 @@ export function executeSpawn( const inheritedChildThinking: ThinkingValue = params.thinking ?? defaultThinking; const route = resolveSpawnModelRoute({ requestedGroup: params.group, + requiredModalities: params.requiredModalities, groups: state.modelGroups.groups, parentModel, parentThinking: inheritedChildThinking, @@ -575,7 +581,7 @@ export function registerSpawnTool( execute( _toolCallId: string, - params: { prompt: string; group?: string; thinking?: ThinkingValue }, + params: SpawnParameters, signal: AbortSignal | undefined, onUpdate: | ((result: { diff --git a/tests/unit/model-groups-crud.test.ts b/tests/unit/model-groups-crud.test.ts index b6bfbeb..c9a1971 100644 --- a/tests/unit/model-groups-crud.test.ts +++ b/tests/unit/model-groups-crud.test.ts @@ -26,8 +26,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 +39,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 +51,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 +66,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 +87,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 +109,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 +120,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 +133,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 +151,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 +168,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 +185,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 +201,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 +218,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 +228,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 +245,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 +267,90 @@ 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("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.modalityOverride, 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("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("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" }], modalityOverride: ["image"] }, registry()); + updateGroup("project", a, "review", { models: [{ provider: "openai", modelId: "gpt-5" }], modalityOverride: ["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.modalityOverride, ["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"].modalityOverride, ["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 +366,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 +381,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 +398,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..27e0436 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"]; + modalityOverride?: ResolvedModelGroup["modalityOverride"]; shadowedByProject?: boolean; unavailableRefs?: ResolvedModelGroup["validation"]["unavailableRefs"]; } = {}, @@ -36,10 +37,14 @@ export function group( scope, sourcePath: `<${scope}>`, models: opts.models ?? [], + ...(opts.modalityOverride === undefined ? {} : { modalityOverride: [...opts.modalityOverride] }), 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..0d5b790 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 }) => { @@ -149,7 +149,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 +157,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, /requiredModalities/); assert.match(result.systemPrompt, /exact group name/); assert.match(result.systemPrompt, /known and confident/); assert.match(result.systemPrompt, /omit group and inherit/); @@ -165,6 +166,29 @@ test("before_agent_start injects fresh names-only Model Groups guidance", async assert.doesNotMatch(result.systemPrompt, /model-groups\.json/); })); +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..9477698 --- /dev/null +++ b/tests/unit/model-groups-modalities.test.ts @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { assertModalityOverrideSupported, deriveModelGroupModalities, getMissingModelModalities } from "../../model-groups/modalities.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, modalityOverride: ["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("caps stale overrides without mutation and restores them when catalog support returns", () => { + const def: ModelGroupDef = { models: [{ provider: "p", modelId: "m" }], modalityOverride: ["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.modalityOverride, ["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..96b54c5 100644 --- a/tests/unit/model-groups-router.test.ts +++ b/tests/unit/model-groups-router.test.ts @@ -5,7 +5,7 @@ import type { ResolvedModelGroup } from "../../model-groups/types.js"; import { group } from "./model-groups-helpers.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 +16,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 +25,28 @@ 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", requiredModalities: ["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", requiredModalities: ["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" }], modalityOverride: ["text", "image"] }); + routed.modalities = { common: ["text"], supported: ["text", "image"], effective: ["text", "image"] }; + const reg = registry([parent, text, image]); + assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "mixed", requiredModalities: ["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", requiredModalities: ["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", requiredModalities: ["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", requiredModalities: ["image"], groups: [], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), (error: unknown) => error instanceof SpawnRouteError && error.group === "unknown" && /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..2b910a2 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -76,7 +76,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 +111,25 @@ 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 supported override choices", () => { + const review = group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] }); + review.modalities = { common: ["text"], supported: ["text", "image", "reasoning"], effective: ["text", "image"] }; + review.modalityOverride = ["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 computes unique new-group names and opens editor after create", () => { let groups = [group("new-group", { scope: "project" })]; const calls: string[] = []; @@ -146,7 +165,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 +202,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 +260,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 +287,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 +297,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 +306,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 +349,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 +405,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 +430,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 +442,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 +453,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 +505,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 +554,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 +703,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 +825,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 +870,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 1086ee6..099874c 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -749,6 +749,23 @@ 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", requiredModalities: ["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("spawn renderResult transfers session ownership out of shared state", () => { const state = createState(); @@ -1494,6 +1511,10 @@ 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 requiredModalities = (tool.parameters as any).properties.requiredModalities; + assert.equal(requiredModalities.type, "array"); + assert.equal(requiredModalities.uniqueItems, true); + assert.deepEqual(requiredModalities.items.enum, ["text", "image", "reasoning"]); 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); From cc03b1d8c41571c090ee0566d1be4c5038fa141a Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Fri, 21 Aug 2026 11:18:07 +0000 Subject: [PATCH 2/6] fix(model-groups): validate override on read for all versions and close review gaps A_R: normalizeGroups now runs validateOverride for every accepted source version, so a malformed hand-added modalityOverride in a legacy (missing/0/1) config surfaces as a schema-invalid load issue + backup + empty recovery instead of a raw TypeError from cloneDef. Minimal stabilization; no v1 valid-override migration feature. B (coverage, 619->628): - crud: A1 regression (legacy malformed override), store-level derivation of empty-common + stale flags via summarizeBootValidation counts, CRUD gate rejects unsupported override on create and combined member-change update (0 writes, byte-for-byte unchanged), v2 load rejects non-array/duplicate/ out-of-vocabulary override - tui: modality editor commits override + Automatic path through updateGroup, error-retention on updateGroup failure - integration: session_start boot notification counts for empty-common and stale overrides - router: plain inherited route honors requiredModalities with empty no-op - spawn: tool schema validated via Value.Check, inherited requiredModalities forwarded and succeeding when satisfied PR #27 review1 gaps A1/A2 closed (A2 wording corrected in PR description). --- model-groups/store.ts | 2 +- tests/unit/model-groups-crud.test.ts | 83 +++++++++++++++++++++ tests/unit/model-groups-integration.test.ts | 26 +++++++ tests/unit/model-groups-router.test.ts | 11 +++ tests/unit/model-groups-tui.test.ts | 46 ++++++++++++ tests/unit/spawn.test.ts | 38 ++++++++++ 6 files changed, 205 insertions(+), 1 deletion(-) diff --git a/model-groups/store.ts b/model-groups/store.ts index f93f84e..c2864ab 100644 --- a/model-groups/store.ts +++ b/model-groups/store.ts @@ -41,7 +41,7 @@ function normalizeGroups(rawGroups: Record, sourceVersion: numb 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 override = sourceVersion >= 2 ? validateOverride(rawDef.modalityOverride, `group ${rawName}.modalityOverride`) : { ok: true as const }; + const override = validateOverride(rawDef.modalityOverride, `group ${rawName}.modalityOverride`); if (!override.ok) return override; defineGroup(groups, name, { ...rawDef, models, ...(sourceVersion >= 2 && override.value !== undefined ? { modalityOverride: override.value } : {}) }); } diff --git a/tests/unit/model-groups-crud.test.ts b/tests/unit/model-groups-crud.test.ts index c9a1971..8d63d40 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"; @@ -273,6 +274,88 @@ test("model groups strictly partitions schema and legacy version domains", () => } })); +test("legacy malformed modalityOverride recovers as schema-invalid instead of crashing load", () => withTemp(({ cwd }) => { + const projectPath = modelGroupsPath("project", cwd); + fs.mkdirSync(path.dirname(projectPath), { recursive: true }); + // Missing version, explicit version 0, and explicit version 1 all normalize to the legacy + // domain. A hand-added malformed override (non-array value) must surface as a clean + // schema-invalid issue with backup and empty recovery, never as a raw TypeError. + const cases: Array<[string, unknown]> = [ + ["missing", { groups: { legacy: { models: [], modalityOverride: 123 } } }], + ["version 0", { version: 0, groups: { legacy: { models: [], modalityOverride: [123] } } }], + ["version 1", { version: 1, groups: { legacy: { models: [], modalityOverride: "text" } } }], + ]; + for (const [label, raw] 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, /modalityOverride/, label); + assert.ok(fs.existsSync(`${projectPath}.bak`), label); + assert.equal(Object.keys(loaded.configs.project.groups).length, 0, label); + } +})); + +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" }], modalityOverride: ["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" }], modalityOverride: ["image"] }, registry()), /unsupported modalities: image/); + assert.equal(writes, 0); + __setModelGroupsFsForTests(null); + + createGroup("project", a, "rich", { models: [{ provider: "openai", modelId: "gpt-5" }], modalityOverride: ["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" }], modalityOverride: ["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 override", () => 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: [], modalityOverride: { text: true } } } }, /modalityOverride/], + ["duplicate", { version: 2, groups: { g: { models: [], modalityOverride: ["text", "text"] } } }, /unique/], + ["out-of-language", { version: 2, groups: { g: { models: [], modalityOverride: ["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"; diff --git a/tests/unit/model-groups-integration.test.ts b/tests/unit/model-groups-integration.test.ts index 0d5b790..31895de 100644 --- a/tests/unit/model-groups-integration.test.ts +++ b/tests/unit/model-groups-integration.test.ts @@ -83,6 +83,32 @@ 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 it is an unavailable ref. claude-only supports text; the registry + // has only gpt-5 (text+image). An override of image on the claude-only group is stale; an empty group + // and a group whose members share nothing produce empty common modalities. + fs.writeFileSync(modelGroupsPath("global", cwd), JSON.stringify({ version: 2, groups: { + empty: { models: [] }, + "claude-only": { models: [{ provider: "anthropic", modelId: "claude" }], modalityOverride: ["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"); diff --git a/tests/unit/model-groups-router.test.ts b/tests/unit/model-groups-router.test.ts index 96b54c5..f6940ad 100644 --- a/tests/unit/model-groups-router.test.ts +++ b/tests/unit/model-groups-router.test.ts @@ -50,3 +50,14 @@ test("known group missing effective modality and inherited fallback reject requi assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "text", requiredModalities: ["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", requiredModalities: ["image"], groups: [], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), (error: unknown) => error instanceof SpawnRouteError && error.group === "unknown" && /Spawn model/.test(error.message)); }); + +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({ requiredModalities: [], groups: [], parentModel: text, parentThinking: "medium", modelRegistry: registry([text]) }).status, "inherited"); + // Parent satisfies all requirements → inherited route succeeds. + assert.deepEqual(resolveSpawnModelRoute({ requiredModalities: ["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({ requiredModalities: ["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 2b910a2..5c9d99d 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -130,6 +130,52 @@ test("model groups TUI renders modality labels, warnings, and supported override 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, modalityOverride: def.modalityOverride ? [...def.modalityOverride] : undefined } }); + groups = [group(name, { scope: scope as "project", models: def.models, modalityOverride: def.modalityOverride })]; + }, + listResolvedModelGroups: () => boot(groups), + }; + const { c } = component({ groups, store }); + press(c, ENTER, DOWN, DOWN, DOWN, ENTER); // editor → modalities row (row 3) → MODALITIES screen + assert.match(rendered(c), /MODALITIES/); + // Select the full supported subset (row 8 of Automatic + 8 subsets). + press(c, DOWN, DOWN, DOWN, DOWN, DOWN, DOWN, DOWN, DOWN, ENTER); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].def.modalityOverride, ["text", "image", "reasoning"]); + assert.match(rendered(c), /Modalities: override/); + // Reopen and pick Automatic (row 0) → deletes the override. + press(c, ENTER, ENTER); // editor → modalities screen, row 0 = Automatic + assert.equal(calls.length, 2); + assert.equal(calls[1].def.modalityOverride, 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.modalityOverride = def.modalityOverride ? [...def.modalityOverride] : 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[] = []; diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index 099874c..8ae4522 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -12,6 +12,7 @@ import { } from "../../spawn/index.js"; import { renderSpawnResult } from "../../spawn/renderer.js"; import { SpawnRouteError } from "../../model-groups/router.js"; +import { Value } from "typebox/value"; import { createTestPI, createRenderContext, createSession, theme } from "./helpers.js"; import { createTestHarness, type TestHarness } from "../test-utils.js"; @@ -767,6 +768,43 @@ test("executeSpawn propagates missing modalities before creating child work", as assert.equal(state.liveChildSessions.size, 0); }); +test("spawn tool schema validates requiredModalities 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", requiredModalities: ["text", "image"] }), true, "valid unique vocab accepted"); + assert.equal(Value.Check(schema, { prompt: "Do the task" }), true, "omitted requiredModalities allowed"); + assert.equal(Value.Check(schema, { prompt: "Do the task", requiredModalities: [] }), true, "empty array allowed"); + assert.equal(Value.Check(schema, { prompt: "Do the task", requiredModalities: ["text", "text"] }), false, "duplicates rejected"); + assert.equal(Value.Check(schema, { prompt: "Do the task", requiredModalities: ["audio"] }), false, "out-of-vocabulary rejected"); + assert.equal(Value.Check(schema, { prompt: "Do the task", requiredModalities: "text" }), false, "non-array rejected"); +}); + +test("executeSpawn forwards plain inherited requiredModalities 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", requiredModalities: ["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(); const session = createSession([ From 78d3e134d818e5e62cc61aa7921527c38408de38 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Fri, 21 Aug 2026 13:02:48 +0000 Subject: [PATCH 3/6] test(model-groups): address code-review findings on spawn routing and TUI editor - documented the locked v1 valid-override pass-through as intentional (no migration) - documented saveModelGroups as low-level CRUD-only cap enforcement (no signature change) - registered spawn-tool test: requiredModalities rejection throws SpawnRouteError before any child session (zero factory calls, both session maps empty) - happy-path registered-spawn test now asserts liveChildSessions cleared - corrected integration fixture comment: claude unresolved -> empty common, override stale - TUI modality editor commit test selects rows by rendered label, not row numbers --- model-groups/store.ts | 2 ++ tests/unit/model-groups-integration.test.ts | 5 ++-- tests/unit/model-groups-tui.test.ts | 22 +++++++++++---- tests/unit/spawn.test.ts | 30 +++++++++++++++++++++ 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/model-groups/store.ts b/model-groups/store.ts index c2864ab..adab360 100644 --- a/model-groups/store.ts +++ b/model-groups/store.ts @@ -43,6 +43,7 @@ function normalizeGroups(rawGroups: Record, sourceVersion: numb 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 override = validateOverride(rawDef.modalityOverride, `group ${rawName}.modalityOverride`); if (!override.ok) return override; + // Locked v1 pass-through: hand-added valid modalityOverride remains active; no v1 migration (automatic-common modalities stay valid in v2). defineGroup(groups, name, { ...rawDef, models, ...(sourceVersion >= 2 && override.value !== undefined ? { modalityOverride: override.value } : {}) }); } return { ok: true, groups }; @@ -59,6 +60,7 @@ function loadScope(scope: ModelGroupScope, access: ModelGroupsAccess): { config: 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; } diff --git a/tests/unit/model-groups-integration.test.ts b/tests/unit/model-groups-integration.test.ts index 31895de..3f5c2e6 100644 --- a/tests/unit/model-groups-integration.test.ts +++ b/tests/unit/model-groups-integration.test.ts @@ -85,9 +85,8 @@ test("index session_start stores model group validation and notifies load and va 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 it is an unavailable ref. claude-only supports text; the registry - // has only gpt-5 (text+image). An override of image on the claude-only group is stale; an empty group - // and a group whose members share nothing produce empty common modalities. + // 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" }], modalityOverride: ["text", "image"] }, diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index 5c9d99d..e683a3d 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); @@ -143,15 +152,18 @@ test("model groups TUI modality editor commits override and Automatic through up listResolvedModelGroups: () => boot(groups), }; const { c } = component({ groups, store }); - press(c, ENTER, DOWN, DOWN, DOWN, ENTER); // editor → modalities row (row 3) → MODALITIES screen + press(c, ENTER); + selectRenderedLabel(c, "Modalities:"); + press(c, ENTER); assert.match(rendered(c), /MODALITIES/); - // Select the full supported subset (row 8 of Automatic + 8 subsets). - press(c, DOWN, DOWN, DOWN, DOWN, DOWN, DOWN, DOWN, DOWN, ENTER); + selectRenderedLabel(c, "Override: text, image, reasoning"); + press(c, ENTER); assert.equal(calls.length, 1); assert.deepEqual(calls[0].def.modalityOverride, ["text", "image", "reasoning"]); assert.match(rendered(c), /Modalities: override/); - // Reopen and pick Automatic (row 0) → deletes the override. - press(c, ENTER, ENTER); // editor → modalities screen, row 0 = Automatic + press(c, ENTER); + selectRenderedLabel(c, "Automatic"); + press(c, ENTER); assert.equal(calls.length, 2); assert.equal(calls[1].def.modalityOverride, undefined); assert.match(rendered(c), /Modalities: automatic/); diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index a5db14d..a3694ac 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -650,6 +650,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 () => { @@ -729,6 +730,35 @@ test("executeSpawn propagates missing modalities before creating child work", as 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", requiredModalities: ["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("spawn tool schema validates requiredModalities via Value.Check", () => { const pi = createTestPI(); const state = createState(); From d854e93de3c3e8e2ea12d96995f8ae81f2535672 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Fri, 21 Aug 2026 14:30:27 +0000 Subject: [PATCH 4/6] =?UTF-8?q?refactor(model-groups):=20debt-easy=20fixes?= =?UTF-8?q?=20=E2=80=94=20derived-key=20strip,=20empty-label,=20stale=20ed?= =?UTF-8?q?itor,=20prose=20single-source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store: strip runtime-derived keys (name/scope/sourcePath/modalities/validation) at the persistence boundary; opaque user keys + modalityOverride preserved - tui: draft projection persists only models/override; MODALITIES editor choices union supported + stale override members; empty effective labels unambiguous - index: empty effective renders '(no common modalities)' instead of '(none)' - prose vocab single-sourced from MODEL_GROUP_MODALITIES (spawn + prompt section) - router: documented absent == empty requiredModalities semantics --- index.ts | 10 +++++--- model-groups/router.ts | 1 + model-groups/store.ts | 5 ++-- model-groups/tui.ts | 14 ++++++----- spawn/index.ts | 3 ++- tests/unit/model-groups-crud.test.ts | 28 +++++++++++++++++++++ tests/unit/model-groups-integration.test.ts | 12 +++++++++ tests/unit/model-groups-tui.test.ts | 4 +-- 8 files changed, 62 insertions(+), 15 deletions(-) diff --git a/index.ts b/index.ts index b67169b..fd4a719 100644 --- a/index.ts +++ b/index.ts @@ -72,10 +72,9 @@ import { registerModelGroupsCommand } from "./model-groups/command.js"; import { resolveSpawnModelRoute, SpawnRouteError } from "./model-groups/router.js"; import { registerModelGroupAutocomplete } from "./model-groups/autocomplete.js"; import { getEffectiveModelGroups, getEffectiveModelGroupNames } from "./model-groups/router.js"; -import type { ResolvedModelGroup } from "./model-groups/types.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 { cacheLookupCommand, cacheLookupCommandExplicitModel, @@ -101,6 +100,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 ──────────────────────────────────────────────────────────── /** @@ -464,10 +466,10 @@ function refreshModelGroupsState(state: AgenticodingState, ctx: ExtensionContext function modelGroupsPromptSection(groups: ResolvedModelGroup[]): string | undefined { if (groups.length === 0) return undefined; - const labels = groups.map((group) => `${escapeDisplayLabel(group.name)} (${group.modalities?.effective.join(", ") || "none"})`); + const labels = groups.map((group) => `${escapeDisplayLabel(group.name)} (${group.modalities?.effective.join(", ") || "no common modalities"})`); return `\n## Model Groups for spawn\n` + `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 text, image, or reasoning capability, pass those requirements as requiredModalities. If no known/confident group is requested, omit group and inherit the parent model/thinking. ` + + `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 requiredModalities. 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.`; } diff --git a/model-groups/router.ts b/model-groups/router.ts index d539284..08f7d53 100644 --- a/model-groups/router.ts +++ b/model-groups/router.ts @@ -17,6 +17,7 @@ function parentProvider(model: Model): string { return typeof model.provide 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); } +/** Absence and an empty list are equivalent: neither constrains routing. */ function required(values: readonly ModelGroupModality[] | undefined): ModelGroupModality[] { const set = new Set(values); return MODEL_GROUP_MODALITIES.filter((m) => set.has(m)); } export function resolveSpawnModelRoute(options: { requestedGroup?: string; requiredModalities?: readonly ModelGroupModality[]; groups: ResolvedModelGroup[]; parentModel: Model; parentThinking: ModelThinkingLevel; modelRegistry: Pick; rng?: () => number }): SpawnModelRoute { const requestedGroup = options.requestedGroup?.trim(); const req = required(options.requiredModalities); diff --git a/model-groups/store.ts b/model-groups/store.ts index adab360..880a2d3 100644 --- a/model-groups/store.ts +++ b/model-groups/store.ts @@ -43,8 +43,9 @@ function normalizeGroups(rawGroups: Record, sourceVersion: numb 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 override = validateOverride(rawDef.modalityOverride, `group ${rawName}.modalityOverride`); if (!override.ok) return override; - // Locked v1 pass-through: hand-added valid modalityOverride remains active; no v1 migration (automatic-common modalities stay valid in v2). - defineGroup(groups, name, { ...rawDef, models, ...(sourceVersion >= 2 && override.value !== undefined ? { modalityOverride: override.value } : {}) }); + // Strip runtime-derived fields while retaining opaque config keys and locked v1 override pass-through. + const { name: _name, scope: _scope, sourcePath: _sourcePath, modalities: _modalities, validation: _validation, models: _rawModels, ...configDef } = rawDef; + defineGroup(groups, name, { ...configDef, models, ...(sourceVersion >= 2 && override.value !== undefined ? { modalityOverride: override.value } : {}) }); } return { ok: true, groups }; } diff --git a/model-groups/tui.ts b/model-groups/tui.ts index f201903..6cc382f 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -44,7 +44,7 @@ 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 { ...def, models: def.models.map((model) => ({ ...model })), ...(def.modalityOverride === undefined ? {} : { modalityOverride: [...def.modalityOverride] }) }; + return { models: def.models.map((model) => ({ ...model })), ...(def.modalityOverride === undefined ? {} : { modalityOverride: [...def.modalityOverride] }) }; } function groupKey(group: Pick): string { @@ -285,7 +285,7 @@ export function createModelGroupsComponent( switch (state.screen) { case "LIST": return state.groups.length; case "EDITOR": return modelStartRow() + (state.editDraft?.models.length ?? 0); - case "MODALITIES": return modalityOverrideChoices(currentEditGroup()?.modalities.supported ?? []).length; + case "MODALITIES": return modalityOverrideChoices(modalityEditorSupported()).length; 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); @@ -336,9 +336,7 @@ export function createModelGroupsComponent( } case "MODALITIES": { if (!state.editDraft) return; - const current = currentEditGroup(); - const supported = current?.modalities.supported ?? []; - const choices = modalityOverrideChoices(supported); + const choices = modalityOverrideChoices(modalityEditorSupported()); const selected = choices[state.row - 1] ?? []; const next = cloneDef(state.editDraft); if (state.row === 0) delete next.modalityOverride; @@ -539,6 +537,10 @@ export function createModelGroupsComponent( return container; } + function modalityEditorSupported(): ModelGroupModality[] { + return [...new Set([...(currentEditGroup()?.modalities.supported ?? []), ...(state.editDraft?.modalityOverride ?? [])])]; + } + function modalityOverrideChoices(supported: readonly ModelGroupModality[]): ModelGroupModality[][] { const choices: ModelGroupModality[][] = []; for (let mask = 0; mask < 2 ** supported.length; mask++) { @@ -553,7 +555,7 @@ export function createModelGroupsComponent( const current = currentEditGroup(); container.addChild(textLine(theme.fg("accent", "MODALITIES"))); container.addChild(textLine(selectableLine(state.row === 0, `Automatic (common: ${current?.modalities.common.join(", ") || "none"})`))); - for (const [index, override] of modalityOverrideChoices(current?.modalities.supported ?? []).entries()) { + for (const [index, override] of modalityOverrideChoices(modalityEditorSupported()).entries()) { container.addChild(textLine(selectableLine(state.row === index + 1, `Override: ${override.join(", ") || "none"}`))); } return container; diff --git a/spawn/index.ts b/spawn/index.ts index bbc9ae1..8a0d38a 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -48,6 +48,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; @@ -290,7 +291,7 @@ 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 requiredModalities when the delegated task needs text, image, or reasoning capability; do not work around a missing required modality with third-party tools.", + `Declare requiredModalities when the delegated task needs ${MODEL_GROUP_MODALITY_PROSE} capability; do not work around a missing required modality with third-party tools.`, ]; const SPAWN_PARAMETERS = Type.Object({ diff --git a/tests/unit/model-groups-crud.test.ts b/tests/unit/model-groups-crud.test.ts index 8d63d40..599040b 100644 --- a/tests/unit/model-groups-crud.test.ts +++ b/tests/unit/model-groups-crud.test.ts @@ -373,6 +373,16 @@ test("v1 migration is in-memory until the first successful mutation writes v2 wi assert.equal(Object.hasOwn(persisted.groups.legacy, "modalityOverride"), false); })); +test("v1 valid modalityOverride remains active through pass-through normalization", () => 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.deepEqual(loaded.configs.project.groups.legacy.modalityOverride, ["image"]); + assert.equal(fs.readFileSync(sourcePath, "utf8"), v1Bytes); +})); + test("v2 normalization preserves opaque root group and model keys through load save and update", () => withTemp(({ cwd }) => { const sourcePath = modelGroupsPath("project", cwd); const raw = { @@ -399,6 +409,24 @@ test("v2 normalization preserves opaque root group and model keys through load s assert.equal(persisted.groups.review.models[0].thinkingLevel, "high"); })); +test("store normalization strips runtime-derived group keys while preserving opaque keys and modalityOverride", () => 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" }], + modalityOverride: ["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(), ["modalityOverride", "models", "opaqueSentinel"]); + assert.deepEqual(persisted.modalityOverride, ["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 }); diff --git a/tests/unit/model-groups-integration.test.ts b/tests/unit/model-groups-integration.test.ts index 3f5c2e6..884c0ae 100644 --- a/tests/unit/model-groups-integration.test.ts +++ b/tests/unit/model-groups-integration.test.ts @@ -191,6 +191,18 @@ test("before_agent_start injects fresh names-and-effective-modalities guidance", 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"); diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index e683a3d..fecb565 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -120,9 +120,9 @@ 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 supported override choices", () => { +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", "reasoning"], effective: ["text", "image"] }; + review.modalities = { common: ["text"], supported: ["text", "image"], effective: ["text", "image"] }; review.modalityOverride = ["text", "image", "reasoning"]; review.validation.emptyCommonModalities = true; review.validation.unsupportedOverrideModalities = ["reasoning"]; From 0091fdce8e9c30ac27a69c74848eefbf2948e59d Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Fri, 21 Aug 2026 15:46:49 +0000 Subject: [PATCH 5/6] refactor(model-groups): Option C pluggable constraint kernel (debt #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the pluggability gap with a typed, compile-time constraint registry + generic envelopes, absorbed into PR #27 (unmerged-v2, no migration; version stays 2). - constraints/: pure generic kernel (engine/registry/resolution/presentation) with injectable registries; production registers only modalities. - Modality constraint descriptor owns extraction, aggregation, reconciliation, requirements, diagnostics, presentation, editor; model-groups/modalities.ts becomes thin compatibility façades (parity). - Persisted envelope: ModelGroupDef.constraints (canonical) + modalityOverride (conflict-safe deprecated alias; equal coalesces, unequal rejects; unknown slots round-trip opaquely). - Spawn envelope: constraints (descriptor-generated TypeBox) + requiredModalities alias, normalized at one boundary pre-route. - Router: iterates registry descriptors; modality violations keep the exact missing-modality SpawnRouteError arrays; injected scalar routes to additive constraint-unsatisfied. - Prompt/boot-summary/TUI iterate descriptor presentation metadata; notification text and (no common modalities) fallback byte-identical. - AC6 proof: synthetic testMinContext descriptor traverses the full seam via router + registered spawn (0 factory calls, both maps empty); production registry stays [modalities], no production testMinContext. Refactor-only: no materialized cost/context/param dimension. Full battery green: typecheck, unit 669/669, e2e 16/16, snapshots 11/11, compat:current 0.84.2, package-host, git diff --check. --- index.ts | 4 +- model-groups/constraints/engine.ts | 45 +++++++++ model-groups/constraints/modalities.ts | 92 +++++++++++++++++++ model-groups/constraints/presentation.ts | 64 +++++++++++++ model-groups/constraints/registry.ts | 21 +++++ model-groups/constraints/resolution.ts | 12 +++ model-groups/constraints/types.ts | 67 ++++++++++++++ model-groups/modalities.ts | 33 ++----- model-groups/router.ts | 58 ++++++++---- model-groups/store.ts | 63 ++++++++++--- model-groups/tui.ts | 68 +++++++++----- model-groups/types.ts | 11 ++- spawn/index.ts | 38 +++++++- .../unit/model-groups-constraints-fixture.ts | 33 +++++++ tests/unit/model-groups-constraints.test.ts | 76 +++++++++++++++ tests/unit/model-groups-crud.test.ts | 59 +++++++++++- tests/unit/model-groups-modalities.test.ts | 25 +++++ tests/unit/model-groups-router.test.ts | 7 ++ tests/unit/spawn.test.ts | 40 ++++++++ 19 files changed, 732 insertions(+), 84 deletions(-) create mode 100644 model-groups/constraints/engine.ts create mode 100644 model-groups/constraints/modalities.ts create mode 100644 model-groups/constraints/presentation.ts create mode 100644 model-groups/constraints/registry.ts create mode 100644 model-groups/constraints/resolution.ts create mode 100644 model-groups/constraints/types.ts create mode 100644 tests/unit/model-groups-constraints-fixture.ts create mode 100644 tests/unit/model-groups-constraints.test.ts diff --git a/index.ts b/index.ts index fd4a719..93ad3bb 100644 --- a/index.ts +++ b/index.ts @@ -75,6 +75,8 @@ import { getEffectiveModelGroups, getEffectiveModelGroupNames } from "./model-gr 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 { presentConstraintPrompt } from "./model-groups/constraints/presentation.js"; +import { productionConstraintRegistry } from "./model-groups/constraints/registry.js"; import { cacheLookupCommand, cacheLookupCommandExplicitModel, @@ -466,7 +468,7 @@ function refreshModelGroupsState(state: AgenticodingState, ctx: ExtensionContext function modelGroupsPromptSection(groups: ResolvedModelGroup[]): string | undefined { if (groups.length === 0) return undefined; - const labels = groups.map((group) => `${escapeDisplayLabel(group.name)} (${group.modalities?.effective.join(", ") || "no common modalities"})`); + 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: ${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 requiredModalities. If no known/confident group is requested, omit group and inherit the parent model/thinking. ` + 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 index b2e1108..4f1fb98 100644 --- a/model-groups/modalities.ts +++ b/model-groups/modalities.ts @@ -1,43 +1,30 @@ import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import type { Api, Model } from "@earendil-works/pi-ai"; -import { MODEL_GROUP_MODALITIES, type ModelGroupDef, type ModelGroupModalities, type ModelGroupModality } from "./types.js"; - -function ordered(values: Iterable): ModelGroupModality[] { - const set = new Set(values); - return MODEL_GROUP_MODALITIES.filter((value) => set.has(value)); -} +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 ordered([...(Array.isArray(model.input) ? model.input as ModelGroupModality[] : []), ...(model.reasoning === true ? ["reasoning" as const] : [])]); + return getModalitiesModelFact(model); } export function deriveModelGroupModalities( group: Pick, modelRegistry: Pick, ): ModelGroupModalities { - const found = group.models.map((entry) => modelRegistry.find(entry.provider, entry.modelId) as Model | undefined); - const sets = found.map((model) => new Set(model ? getModelModalities(model) : [])); - const supported = ordered(sets.flatMap((set) => [...set])); - const common = found.length === 0 || found.some((model) => !model) - ? [] - : ordered(MODEL_GROUP_MODALITIES.filter((modality) => sets.every((set) => set.has(modality)))); - const effective = group.modalityOverride === undefined - ? common - : ordered(group.modalityOverride.filter((modality) => supported.includes(modality))); - return { common, supported, effective }; + const evaluation = deriveModalitiesEvaluation(resolveConstraintMembers(group.models, modelRegistry).members, group.modalityOverride); + return { common: evaluation.aggregate.common, supported: evaluation.aggregate.supported, effective: evaluation.effective }; } export function assertModalityOverrideSupported( group: Pick, modelRegistry: Pick, ): void { - if (group.modalityOverride === undefined) return; - const supported = new Set(deriveModelGroupModalities(group, modelRegistry).supported); - const missing = ordered(group.modalityOverride.filter((modality) => !supported.has(modality))); - if (missing.length) throw new Error(`Model group modality override includes unsupported modalities: ${missing.join(", ")}.`); + const evaluation = deriveModalitiesEvaluation(resolveConstraintMembers(group.models, modelRegistry).members, group.modalityOverride); + assertModalitiesOverrideSupported(evaluation, group.modalityOverride); } export function getMissingModelModalities(model: Model, required: readonly ModelGroupModality[]): ModelGroupModality[] { - const modalities = new Set(getModelModalities(model)); - return ordered(required.filter((modality) => !modalities.has(modality))); + return getMissingModalitiesFromModel(model, required); } diff --git a/model-groups/router.ts b/model-groups/router.ts index 08f7d53..cbadcfa 100644 --- a/model-groups/router.ts +++ b/model-groups/router.ts @@ -1,33 +1,59 @@ import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import { clampThinkingLevel, type Api, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai"; -import { deriveModelGroupModalities, getMissingModelModalities } from "./modalities.js"; -import { MODEL_GROUP_MODALITIES, type ModelGroupModality, 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" | "missing-modality"; +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; readonly missingModalities: ModelGroupModality[]; readonly missingFromGroup: ModelGroupModality[]; readonly missingFromModel: ModelGroupModality[]; - constructor(group: string, reason: SpawnRouteErrorReason, details: { missingModalities?: ModelGroupModality[]; missingFromGroup?: ModelGroupModality[]; missingFromModel?: ModelGroupModality[]; provider?: string; modelId?: string; knownGroup?: boolean } = {}) { + 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.` : 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(", ")}.`; - super(message); this.name = "SpawnRouteError"; this.group = group; this.reason = reason; this.missingModalities = missingModalities; this.missingFromGroup = missingFromGroup; this.missingFromModel = 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 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); } -/** Absence and an empty list are equivalent: neither constrains routing. */ -function required(values: readonly ModelGroupModality[] | undefined): ModelGroupModality[] { const set = new Set(values); return MODEL_GROUP_MODALITIES.filter((m) => set.has(m)); } -export function resolveSpawnModelRoute(options: { requestedGroup?: string; requiredModalities?: readonly ModelGroupModality[]; groups: ResolvedModelGroup[]; parentModel: Model; parentThinking: ModelThinkingLevel; modelRegistry: Pick; rng?: () => number }): SpawnModelRoute { - const requestedGroup = options.requestedGroup?.trim(); const req = required(options.requiredModalities); + +/** Route selection remains auth-aware; constraint evaluation receives its explicit member snapshot. */ +export function resolveSpawnModelRoute(options: { requestedGroup?: string; constraints?: Readonly>; /** @deprecated direct-router compatibility alias; spawn normalizes at its boundary. */ requiredModalities?: readonly ModelGroupModality[]; groups: ResolvedModelGroup[]; parentModel: Model; parentThinking: ModelThinkingLevel; modelRegistry: Pick; constraintRegistry?: ConstraintRegistry; rng?: () => number }): SpawnModelRoute { + const requestedGroup = options.requestedGroup?.trim(); const requirements = options.constraints ?? (options.requiredModalities === undefined ? {} : { modalities: options.requiredModalities }); 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 (!req.length) return route; - const selectedGroup = group; - const missingFromGroup = selectedGroup ? required(req.filter((m) => !deriveModelGroupModalities(selectedGroup, options.modelRegistry).effective.includes(m))) : []; - const missingFromModel = getMissingModelModalities(route.model, req); const missingModalities = required([...missingFromGroup, ...missingFromModel]); - if (missingModalities.length) throw new SpawnRouteError(group?.name ?? (requestedGroup || ""), "missing-modality", { missingModalities, missingFromGroup, missingFromModel, provider: route.provider, modelId: route.modelId, knownGroup: Boolean(group) }); + if (!Object.keys(requirements).length) return route; + const resolution = group ? resolveConstraintMembers(group.models, options.modelRegistry) : { members: [] }; + const violations: ConstraintViolation[] = []; + for (const [key, requirement] of Object.entries(requirements)) { + const descriptor = registry.get(key); + if (!descriptor) throw new Error(`Unknown spawn constraint requirement '${key}'.`); + if (group) { + const override = group.constraints?.[key] ?? (key === "modalities" ? group.modalityOverride : undefined); + 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); + } + 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 880a2d3..a274d89 100644 --- a/model-groups/store.ts +++ b/model-groups/store.ts @@ -3,9 +3,14 @@ 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, deriveModelGroupModalities } from "./modalities.js"; +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 { MODEL_GROUP_MODALITIES, 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 = 2; const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); @@ -14,7 +19,10 @@ 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"); } function ownGroups(): Record { return Object.create(null) as Record; } -function cloneDef(def: ModelGroupDef): ModelGroupDef { return { ...def, models: def.models.map((model) => ({ ...model })), ...(def.modalityOverride === undefined ? {} : { modalityOverride: [...def.modalityOverride] }) }; } +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 }), ...(def.modalityOverride === undefined ? {} : { modalityOverride: [...def.modalityOverride] }) }; +} 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() }; } @@ -32,8 +40,29 @@ function validateModelEntry(value: unknown, at: string): { ok: true; model: Mode } function validateOverride(value: unknown, at: string): { ok: true; value?: ModelGroupDef["modalityOverride"] } | { ok: false; message: string } { if (value === undefined) return { ok: true }; - if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !MODEL_GROUP_MODALITIES.includes(item as any)) || new Set(value).size !== value.length) return { ok: false, message: `${at} must be a unique modality vocabulary array` }; - return { ok: true, value: [...value] as ModelGroupDef["modalityOverride"] }; + const decoded = modalitiesConstraint.persistence.override.decode(value, at); + return decoded.ok ? { ok: true, value: decoded.value } : decoded; +} +function normalizeOverrideEnvelope(rawDef: Record, sourceVersion: number, rawName: string): { ok: true; constraints?: Record; modalityOverride?: ModelGroupDef["modalityOverride"] } | { ok: false; message: string } { + if (sourceVersion < 2) { + if (Object.hasOwn(rawDef, "constraints")) return { ok: false, message: `group ${rawName}.constraints is unsupported in legacy config` }; + const alias = validateOverride(rawDef.modalityOverride, `group ${rawName}.modalityOverride`); + return alias.ok ? { ok: true, ...(alias.value === undefined ? {} : { modalityOverride: alias.value }) } : alias; + } + if (rawDef.constraints !== undefined && !isPlainRecord(rawDef.constraints)) return { ok: false, message: `group ${rawName}.constraints must be an object` }; + const rawConstraints = rawDef.constraints as Record | undefined; + const alias = validateOverride(rawDef.modalityOverride, `group ${rawName}.modalityOverride`); + if (!alias.ok) return alias; + const generic = rawConstraints && Object.hasOwn(rawConstraints, "modalities") + ? validateOverride(rawConstraints.modalities, `group ${rawName}.constraints.modalities`) + : { ok: true as const }; + if (!generic.ok) return generic; + if (alias.value !== undefined && generic.value !== undefined && !modalitiesConstraint.persistence.override.equals(alias.value, generic.value)) return { ok: false, message: `group ${rawName} modalityOverride conflicts with constraints.modalities` }; + const modalityOverride = generic.value ?? alias.value; + let constraints = rawConstraints === undefined ? undefined : { ...rawConstraints }; + if (modalityOverride !== undefined) (constraints ??= {}).modalities = modalitiesConstraint.persistence.override.encode(modalityOverride); + else if (constraints) delete constraints.modalities; + return { ok: true, ...(constraints && Object.keys(constraints).length ? { constraints } : {}), ...(modalityOverride === undefined ? {} : { modalityOverride }) }; } function normalizeGroups(rawGroups: Record, sourceVersion: number): { ok: true; groups: Record } | { ok: false; message: string } { const groups = ownGroups(); @@ -41,11 +70,12 @@ function normalizeGroups(rawGroups: Record, sourceVersion: numb 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 override = validateOverride(rawDef.modalityOverride, `group ${rawName}.modalityOverride`); - if (!override.ok) return override; - // Strip runtime-derived fields while retaining opaque config keys and locked v1 override pass-through. - const { name: _name, scope: _scope, sourcePath: _sourcePath, modalities: _modalities, validation: _validation, models: _rawModels, ...configDef } = rawDef; - defineGroup(groups, name, { ...configDef, models, ...(sourceVersion >= 2 && override.value !== undefined ? { modalityOverride: override.value } : {}) }); + 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 }); } return { ok: true, groups }; } @@ -65,12 +95,17 @@ function normalizeSaveConfig(scope: ModelGroupScope, sourcePath: string, config: 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, 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`); assertModalityOverrideSupported(def, modelRegistry); defineGroup(config.groups, name, def); 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`); assertModalityOverrideSupported(def, modelRegistry); defineGroup(config.groups, name, def); saveModelGroups(scope, access, config); } +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 modalities = deriveModelGroupModalities(group, modelRegistry); const unsupportedOverrideModalities = group.modalityOverride === undefined ? [] : group.modalityOverride.filter((m) => !modalities.supported.includes(m)); return { ...group, modalities, validation: { unavailableRefs, shadowedByProject: group.scope === "global" && projectNames.has(group.name), degraded: unavailableRefs.length > 0 && unavailableRefs.length < group.models.length, emptyCommonModalities: modalities.common.length === 0, unsupportedOverrideModalities } }; }); } +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 } { return { unavailableCount: groups.reduce((sum, group) => sum + group.validation.unavailableRefs.length, 0), overrideCount: groups.filter((g) => g.validation.shadowedByProject).length, emptyModalityCount: groups.filter((g) => g.validation.emptyCommonModalities).length, staleModalityOverrideCount: groups.filter((g) => g.validation.unsupportedOverrideModalities.length > 0).length }; } +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 6cc382f..a37743a 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -14,6 +14,9 @@ import { 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" | "MODALITIES" | "MODEL_EDIT" | "WIZARD_PROVIDER" | "WIZARD_MODEL" | "WIZARD_THINKING" | "DELETE_CONFIRM"; @@ -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 })), ...(def.modalityOverride === undefined ? {} : { modalityOverride: [...def.modalityOverride] }) }; + 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 }), ...(def.modalityOverride === undefined ? {} : { modalityOverride: [...def.modalityOverride] }) }; } function groupKey(group: Pick): string { @@ -281,11 +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?.modalityOverride }); + 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] ?? state.editDraft?.modalityOverride) : []; + } + function maxRow(): number { switch (state.screen) { case "LIST": return state.groups.length; case "EDITOR": return modelStartRow() + (state.editDraft?.models.length ?? 0); - case "MODALITIES": return modalityOverrideChoices(modalityEditorSupported()).length; + 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); @@ -336,11 +357,17 @@ export function createModelGroupsComponent( } case "MODALITIES": { if (!state.editDraft) return; - const choices = modalityOverrideChoices(modalityEditorSupported()); - const selected = choices[state.row - 1] ?? []; + const editor = activeConstraintEditor(); + const selected = modalityEditorRows()[state.row]; + if (!editor || !selected) return; const next = cloneDef(state.editDraft); - if (state.row === 0) delete next.modalityOverride; - else next.modalityOverride = [...selected]; + if (selected.kind === "automatic") { + delete next.modalityOverride; + if (next.constraints) delete next.constraints[editor.descriptor.key]; + } else if (selected.kind === "choice") { + next.modalityOverride = [...selected.value] as ModelGroupModality[]; + (next.constraints ??= {})[editor.descriptor.key] = [...selected.value]; + } else return; updateDraft(next, () => { state.screen = "EDITOR"; state.row = modalityRow(); }); return; } case "MODEL_EDIT": { @@ -506,8 +533,11 @@ 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.validation.emptyCommonModalities) tags.push("⚠ no common modalities"); - if (group.validation.unsupportedOverrideModalities.length > 0) tags.push(`⚠ stale modality override: ${group.validation.unsupportedOverrideModalities.join(", ")}`); + 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" }); @@ -537,26 +567,14 @@ export function createModelGroupsComponent( return container; } - function modalityEditorSupported(): ModelGroupModality[] { - return [...new Set([...(currentEditGroup()?.modalities.supported ?? []), ...(state.editDraft?.modalityOverride ?? [])])]; - } - - function modalityOverrideChoices(supported: readonly ModelGroupModality[]): ModelGroupModality[][] { - const choices: ModelGroupModality[][] = []; - for (let mask = 0; mask < 2 ** supported.length; mask++) { - choices.push(supported.filter((_, index) => (mask & (1 << index)) !== 0)); - } - return choices; - } - function renderModalitiesComponent(): Component { activeSelect = null; const container = new Container(); - const current = currentEditGroup(); - container.addChild(textLine(theme.fg("accent", "MODALITIES"))); - container.addChild(textLine(selectableLine(state.row === 0, `Automatic (common: ${current?.modalities.common.join(", ") || "none"})`))); - for (const [index, override] of modalityOverrideChoices(modalityEditorSupported()).entries()) { - container.addChild(textLine(selectableLine(state.row === index + 1, `Override: ${override.join(", ") || "none"}`))); + 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; } diff --git a/model-groups/types.ts b/model-groups/types.ts index 46a2a34..0128ac0 100644 --- a/model-groups/types.ts +++ b/model-groups/types.ts @@ -1,4 +1,5 @@ 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]; @@ -11,7 +12,13 @@ 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 ModelGroupDef { models: ModelGroupModel[]; modalityOverride?: ModelGroupModality[] } +export interface ModelGroupDef { + models: ModelGroupModel[]; + /** Canonical v2 keyed override envelope. Unknown keys are retained opaquely. */ + constraints?: Record; + /** @deprecated v2 compatibility alias for constraints.modalities */ + modalityOverride?: ModelGroupModality[]; +} export interface ModelGroupsConfig { version: 2; groups: Record } export interface ModelGroupValidation { unavailableRefs: Array<{ provider: string; modelId: string }>; @@ -21,7 +28,7 @@ export interface ModelGroupValidation { unsupportedOverrideModalities: ModelGroupModality[]; } export interface ModelGroupsLoadedGroup extends ModelGroupDef { name: string; scope: ModelGroupScope; sourcePath: string } -export interface ResolvedModelGroup extends ModelGroupsLoadedGroup { modalities: ModelGroupModalities; validation: ModelGroupValidation } +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 type ModelGroupsPersistenceOperation = "save" | "delete" | "move"; diff --git a/spawn/index.ts b/spawn/index.ts index 8a0d38a..a8e3919 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -33,6 +33,7 @@ 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, type ModelGroupModality } from "../model-groups/types.js"; import { applyReadonlyBashGuard } from "../readonly-bash.js"; import { @@ -294,6 +295,8 @@ const SPAWN_PROMPT_GUIDELINES = [ `Declare requiredModalities 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: @@ -303,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), requiredModalities: Type.Optional(Type.Array(StringEnum(MODEL_GROUP_MODALITIES, { description: "Optional modalities the selected child route must support. Routing fails before child creation if the effective Model Group or selected model lacks any requirement." }), { uniqueItems: true } as any)), thinking: Type.Optional(StringEnum( ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const, @@ -346,7 +350,32 @@ export function createChildTools( * - both registries delete(toolCallId) on error and completion paths * */ -export interface SpawnParameters { prompt: string; group?: string; requiredModalities?: ModelGroupModality[]; thinking?: ThinkingValue } +export type SpawnConstraintRequirements = Record; +export interface SpawnParameters { prompt: string; group?: string; constraints?: SpawnConstraintRequirements; /** @deprecated compatibility alias */ requiredModalities?: ModelGroupModality[]; thinking?: ThinkingValue } + +/** Decode the public envelope once, rejecting unknown keys and conflicting aliases 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; + } + if (params.requiredModalities !== undefined) { + const descriptor = registry.get("modalities"); + if (!descriptor) throw new Error("Spawn modality requirements are unavailable."); + const alias = descriptor.requirement.decode({ required: params.requiredModalities }, "requiredModalities"); + if (!alias.ok) throw new Error(alias.message); + const current = normalized.modalities; + if (current !== undefined && !descriptor.requirement.equals(current, alias.value)) throw new Error("Spawn constraints.modalities conflicts with requiredModalities."); + normalized.modalities = current ?? alias.value; + } + return normalized; +} export function executeSpawn( toolCallId: string, @@ -363,6 +392,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 () => { @@ -372,13 +402,15 @@ export function executeSpawn( } const inheritedChildThinking: ThinkingValue = params.thinking ?? defaultThinking; + const constraints = normalizeSpawnRequirements(params, constraintRegistry); const route = resolveSpawnModelRoute({ requestedGroup: params.group, - requiredModalities: params.requiredModalities, + constraints, groups: state.modelGroups.groups, parentModel, parentThinking: inheritedChildThinking, modelRegistry: ctx.modelRegistry, + constraintRegistry, }); const childModel = route.model; const requestedChildThinking: ThinkingValue = route.thinking; @@ -615,6 +647,7 @@ export function registerSpawnTool( pi: ExtensionAPI, state: AgenticodingState, sessionFactory: typeof createAgentSession = createAgentSession, + constraintRegistry: ConstraintRegistry = productionConstraintRegistry, ): void { pi.registerTool({ name: "spawn", @@ -648,6 +681,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 599040b..4f855bf 100644 --- a/tests/unit/model-groups-crud.test.ts +++ b/tests/unit/model-groups-crud.test.ts @@ -383,6 +383,62 @@ test("v1 valid modalityOverride remains active through pass-through normalizatio assert.equal(fs.readFileSync(sourcePath, "utf8"), v1Bytes); })); +test("v2 constraint envelope coalesces aliases, preserves explicit empty and opaque slots, and serializes the canonical mirror", () => 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"]], + ["alias", { modalityOverride: ["image"] }, ["image"]], + ["equal", { constraints: { modalities: ["text", "image"] }, modalityOverride: ["image", "text"] }, ["text", "image"]], + ["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].modalityOverride, 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); + assert.equal(Object.hasOwn(group, "modalityOverride"), false, name); + } else { + assert.deepEqual(group.constraints.modalities, expected, name); + assert.deepEqual(group.modalityOverride, 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.deepEqual(opaquePersisted.modalityOverride, ["image"]); + assert.equal(opaquePersisted.groupSentinel, true); + assert.equal(opaquePersisted.models[0].modelSentinel, true); +})); + +test("v2 conflicting constraint aliases and legacy constraint envelopes reject without reinterpretation", () => withTemp(({ cwd }) => { + const sourcePath = modelGroupsPath("project", cwd); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, JSON.stringify({ version: 2, groups: { bad: { models: [], constraints: { modalities: ["text"] }, modalityOverride: ["image"] } } }), "utf8"); + let loaded = loadModelGroups(access(cwd)); + assert.equal(loaded.issues[0].kind, "schema-invalid"); + assert.match(loaded.issues[0].message, /conflicts/); + for (const raw of [{ groups: { legacy: { models: [], constraints: {} } } }, { version: 0, groups: { legacy: { models: [], constraints: {} } } }, { version: 1, groups: { legacy: { models: [], constraints: {} } } }]) { + fs.writeFileSync(sourcePath, JSON.stringify(raw), "utf8"); + loaded = loadModelGroups(access(cwd)); + assert.equal(loaded.issues[0].kind, "schema-invalid"); + assert.match(loaded.issues[0].message, /constraints/); + } + fs.writeFileSync(sourcePath, JSON.stringify({ version: 1, groups: { legacy: { models: [], modalityOverride: ["text"] } } }), "utf8"); + loaded = loadModelGroups(access(cwd)); + assert.deepEqual(loaded.configs.project.groups.legacy.modalityOverride, ["text"]); + assert.throws(() => createGroup("project", access(cwd), "conflict", { models: [], constraints: { modalities: ["text"] }, modalityOverride: ["image"] }, registry()), (error) => error instanceof ModelGroupsPersistenceError && error.phase === "config-validation"); +})); + test("v2 normalization preserves opaque root group and model keys through load save and update", () => withTemp(({ cwd }) => { const sourcePath = modelGroupsPath("project", cwd); const raw = { @@ -421,7 +477,8 @@ test("store normalization strips runtime-derived group keys while preserving opa 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(), ["modalityOverride", "models", "opaqueSentinel"]); + assert.deepEqual(Object.keys(persisted).sort(), ["constraints", "modalityOverride", "models", "opaqueSentinel"]); + assert.deepEqual(persisted.constraints.modalities, ["text", "image"]); assert.deepEqual(persisted.modalityOverride, ["text", "image"]); assert.deepEqual(persisted.opaqueSentinel, { keep: true }); for (const key of ["name", "scope", "sourcePath", "modalities", "validation"]) assert.equal(Object.hasOwn(persisted, key), false); diff --git a/tests/unit/model-groups-modalities.test.ts b/tests/unit/model-groups-modalities.test.ts index 9477698..6513e34 100644 --- a/tests/unit/model-groups-modalities.test.ts +++ b/tests/unit/model-groups-modalities.test.ts @@ -1,6 +1,8 @@ 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 } { @@ -22,6 +24,29 @@ test("derives ordered common, supported, and override-effective modalities from 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" }], modalityOverride: [] }, + { models: [{ provider: "p", modelId: "text" }], modalityOverride: ["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.modalityOverride); + 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" }], modalityOverride: ["text", "image"] }; const models: any[] = [{ provider: "p", id: "m", input: ["text"], reasoning: false }]; diff --git a/tests/unit/model-groups-router.test.ts b/tests/unit/model-groups-router.test.ts index f6940ad..ee8ccdf 100644 --- a/tests/unit/model-groups-router.test.ts +++ b/tests/unit/model-groups-router.test.ts @@ -1,8 +1,10 @@ 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, input: ["text"], ...overrides }; @@ -51,6 +53,11 @@ test("known group missing effective modality and inherited fallback reject requi assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "unknown", requiredModalities: ["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"] }); diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index a3694ac..965f6ff 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -7,12 +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"; @@ -730,6 +733,15 @@ test("executeSpawn propagates missing modalities before creating child work", as 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"]); @@ -759,12 +771,40 @@ test("registered spawn tool rejects missing modalities before creating child wor 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 generic and legacy aliases conflict-safely", () => { + assert.deepEqual(normalizeSpawnRequirements({ constraints: { modalities: { required: ["image", "text"] } } }), { modalities: ["text", "image"] }); + assert.deepEqual(normalizeSpawnRequirements({ requiredModalities: ["image"] }), { modalities: ["image"] }); + assert.deepEqual(normalizeSpawnRequirements({ constraints: { modalities: { required: ["image"] } }, requiredModalities: ["image"] }), { modalities: ["image"] }); + assert.throws(() => normalizeSpawnRequirements({ constraints: { modalities: { required: ["image"] } }, requiredModalities: ["text"] }), /conflicts/); + assert.throws(() => normalizeSpawnRequirements({ constraints: { unknown: {} } }), /Unknown spawn constraint/); + assert.deepEqual(normalizeSpawnRequirements({}), normalizeSpawnRequirements({ constraints: {}})); +}); + test("spawn tool schema validates requiredModalities 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", requiredModalities: ["text", "image"] }), true, "valid unique vocab accepted"); assert.equal(Value.Check(schema, { prompt: "Do the task" }), true, "omitted requiredModalities allowed"); assert.equal(Value.Check(schema, { prompt: "Do the task", requiredModalities: [] }), true, "empty array allowed"); From 1834d35ac1eff1e857155c8c332ead6e62c848a2 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Fri, 21 Aug 2026 16:22:10 +0000 Subject: [PATCH 6/6] refactor(model-groups): drop modalityOverride/requiredModalities aliases (clean v2 surface) Since v2 never shipped, the alias layer had no audience. Make the generic 'constraints' envelope the single public surface: - ModelGroupDef: constraints only; modalityOverride removed. - Spawn tool: constraints only; requiredModalities removed from schema, SpawnParameters, and the normalizer. - Router: requirements come only via constraints. - Store/TUI/modalities: read/write constraints.modalities only. - v1 files: a modalityOverride key is preserved opaquely (not interpreted) and dropped on the first v2 mutation; a constraints key in legacy config is still rejected. - Prompt guidance now instructs passing requirements as constraints. - Tests migrated to the constraints shape; alias-conflict/coalesce tests replaced with canonical round-trips + B1 legacy-opaque tests. Full battery green: typecheck, unit 669/669, e2e 16/16, snapshots 11/11, compat:current 0.84.2, package-host, git diff --check. --- index.ts | 2 +- model-groups/modalities.ts | 12 +-- model-groups/router.ts | 11 ++- model-groups/store.ts | 31 +++---- model-groups/tui.ts | 12 ++- model-groups/types.ts | 2 - spawn/index.ts | 20 ++--- tests/unit/model-groups-crud.test.ts | 91 ++++++++------------- tests/unit/model-groups-helpers.ts | 4 +- tests/unit/model-groups-integration.test.ts | 4 +- tests/unit/model-groups-modalities.test.ts | 12 +-- tests/unit/model-groups-router.test.ts | 20 ++--- tests/unit/model-groups-tui.test.ts | 14 ++-- tests/unit/spawn.test.ts | 34 ++++---- 14 files changed, 116 insertions(+), 153 deletions(-) diff --git a/index.ts b/index.ts index 93ad3bb..224fae5 100644 --- a/index.ts +++ b/index.ts @@ -471,7 +471,7 @@ function modelGroupsPromptSection(groups: ResolvedModelGroup[]): string | undefi 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: ${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 requiredModalities. If no known/confident group is requested, omit group and inherit the parent model/thinking. ` + + `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.`; } diff --git a/model-groups/modalities.ts b/model-groups/modalities.ts index 4f1fb98..f9c1ab9 100644 --- a/model-groups/modalities.ts +++ b/model-groups/modalities.ts @@ -10,19 +10,21 @@ export function getModelModalities(model: Model): ModelGroupModality[] { } export function deriveModelGroupModalities( - group: Pick, + group: Pick, modelRegistry: Pick, ): ModelGroupModalities { - const evaluation = deriveModalitiesEvaluation(resolveConstraintMembers(group.models, modelRegistry).members, group.modalityOverride); + 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, + group: Pick, modelRegistry: Pick, ): void { - const evaluation = deriveModalitiesEvaluation(resolveConstraintMembers(group.models, modelRegistry).members, group.modalityOverride); - assertModalitiesOverrideSupported(evaluation, group.modalityOverride); + 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[] { diff --git a/model-groups/router.ts b/model-groups/router.ts index cbadcfa..e0b5312 100644 --- a/model-groups/router.ts +++ b/model-groups/router.ts @@ -23,19 +23,22 @@ export function getEffectiveModelGroups(groups: ResolvedModelGroup[]): ResolvedM 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>; /** @deprecated direct-router compatibility alias; spawn normalizes at its boundary. */ requiredModalities?: readonly ModelGroupModality[]; groups: ResolvedModelGroup[]; parentModel: Model; parentThinking: ModelThinkingLevel; modelRegistry: Pick; constraintRegistry?: ConstraintRegistry; rng?: () => number }): SpawnModelRoute { - const requestedGroup = options.requestedGroup?.trim(); const requirements = options.constraints ?? (options.requiredModalities === undefined ? {} : { modalities: options.requiredModalities }); const registry = options.constraintRegistry ?? productionConstraintRegistry; +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, requirement] of Object.entries(requirements)) { + 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] ?? (key === "modalities" ? group.modalityOverride : undefined); + const override = group.constraints?.[key]; const evaluation = evaluateConstraint(descriptor, resolution, override); const violation = evaluateGroupRequirement(descriptor, evaluation, requirement); if (violation) violations.push(violation); diff --git a/model-groups/store.ts b/model-groups/store.ts index a274d89..008df7b 100644 --- a/model-groups/store.ts +++ b/model-groups/store.ts @@ -21,7 +21,7 @@ export function modelGroupsPath(scope: ModelGroupScope, cwd: string, projectConf function ownGroups(): Record { return Object.create(null) as Record; } 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 }), ...(def.modalityOverride === undefined ? {} : { modalityOverride: [...def.modalityOverride] }) }; + 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); } @@ -38,31 +38,24 @@ function validateModelEntry(value: unknown, at: string): { ok: true; model: Mode if (value.thinkingLevel === undefined) delete (model as any).thinkingLevel; return { ok: true, model }; } -function validateOverride(value: unknown, at: string): { ok: true; value?: ModelGroupDef["modalityOverride"] } | { ok: false; message: string } { +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 normalizeOverrideEnvelope(rawDef: Record, sourceVersion: number, rawName: string): { ok: true; constraints?: Record; modalityOverride?: ModelGroupDef["modalityOverride"] } | { ok: false; message: string } { +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` }; - const alias = validateOverride(rawDef.modalityOverride, `group ${rawName}.modalityOverride`); - return alias.ok ? { ok: true, ...(alias.value === undefined ? {} : { modalityOverride: alias.value }) } : alias; + return { ok: true }; } if (rawDef.constraints !== undefined && !isPlainRecord(rawDef.constraints)) return { ok: false, message: `group ${rawName}.constraints must be an object` }; - const rawConstraints = rawDef.constraints as Record | undefined; - const alias = validateOverride(rawDef.modalityOverride, `group ${rawName}.modalityOverride`); - if (!alias.ok) return alias; - const generic = rawConstraints && Object.hasOwn(rawConstraints, "modalities") - ? validateOverride(rawConstraints.modalities, `group ${rawName}.constraints.modalities`) - : { ok: true as const }; - if (!generic.ok) return generic; - if (alias.value !== undefined && generic.value !== undefined && !modalitiesConstraint.persistence.override.equals(alias.value, generic.value)) return { ok: false, message: `group ${rawName} modalityOverride conflicts with constraints.modalities` }; - const modalityOverride = generic.value ?? alias.value; - let constraints = rawConstraints === undefined ? undefined : { ...rawConstraints }; - if (modalityOverride !== undefined) (constraints ??= {}).modalities = modalitiesConstraint.persistence.override.encode(modalityOverride); - else if (constraints) delete constraints.modalities; - return { ok: true, ...(constraints && Object.keys(constraints).length ? { constraints } : {}), ...(modalityOverride === undefined ? {} : { modalityOverride }) }; + 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!); + } + return { ok: true, ...(constraints && Object.keys(constraints).length ? { constraints } : {}) }; } function normalizeGroups(rawGroups: Record, sourceVersion: number): { ok: true; groups: Record } | { ok: false; message: string } { const groups = ownGroups(); @@ -75,7 +68,7 @@ function normalizeGroups(rawGroups: Record, sourceVersion: numb // 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 }); + defineGroup(groups, name, { ...configDef, models, ...normalizedEnvelope, ...(sourceVersion < 2 && Object.hasOwn(rawDef, "modalityOverride") ? { modalityOverride: rawDef.modalityOverride } : {}) }); } return { ok: true, groups }; } diff --git a/model-groups/tui.ts b/model-groups/tui.ts index a37743a..e1bd582 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -48,7 +48,7 @@ function isDeleteChord(data: string): boolean { return data === "D" || matchesKe function cloneDef(def: ModelGroupDef): ModelGroupDef { 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 }), ...(def.modalityOverride === undefined ? {} : { modalityOverride: [...def.modalityOverride] }) }; + return { models: def.models.map((model) => ({ ...model })), ...(constraints === undefined ? {} : { constraints }) }; } function groupKey(group: Pick): string { @@ -293,13 +293,13 @@ export function createModelGroupsComponent( // 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?.modalityOverride }); + 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] ?? state.editDraft?.modalityOverride) : []; + return editor ? constraintEditorRows(editor.descriptor, editor.evaluation, state.editDraft?.constraints?.[editor.descriptor.key]) : []; } function maxRow(): number { @@ -362,11 +362,9 @@ export function createModelGroupsComponent( if (!editor || !selected) return; const next = cloneDef(state.editDraft); if (selected.kind === "automatic") { - delete next.modalityOverride; if (next.constraints) delete next.constraints[editor.descriptor.key]; } else if (selected.kind === "choice") { - next.modalityOverride = [...selected.value] as ModelGroupModality[]; - (next.constraints ??= {})[editor.descriptor.key] = [...selected.value]; + (next.constraints ??= {})[editor.descriptor.key] = [...selected.value] as ModelGroupModality[]; } else return; updateDraft(next, () => { state.screen = "EDITOR"; state.row = modalityRow(); }); return; } @@ -557,7 +555,7 @@ export function createModelGroupsComponent( 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?.modalityOverride === undefined ? "automatic" : "override"} (${modalities?.effective.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)})`))); diff --git a/model-groups/types.ts b/model-groups/types.ts index 0128ac0..9571303 100644 --- a/model-groups/types.ts +++ b/model-groups/types.ts @@ -16,8 +16,6 @@ export interface ModelGroupDef { models: ModelGroupModel[]; /** Canonical v2 keyed override envelope. Unknown keys are retained opaquely. */ constraints?: Record; - /** @deprecated v2 compatibility alias for constraints.modalities */ - modalityOverride?: ModelGroupModality[]; } export interface ModelGroupsConfig { version: 2; groups: Record } export interface ModelGroupValidation { diff --git a/spawn/index.ts b/spawn/index.ts index a8e3919..ce8e15e 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -34,7 +34,7 @@ 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, type ModelGroupModality } from "../model-groups/types.js"; +import { MODEL_GROUP_MODALITIES } from "../model-groups/types.js"; import { applyReadonlyBashGuard } from "../readonly-bash.js"; import { renderSpawnCall, @@ -292,7 +292,7 @@ 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 requiredModalities when the delegated task needs ${MODEL_GROUP_MODALITY_PROSE} capability; do not work around a missing required modality with third-party tools.`, + `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); @@ -307,7 +307,6 @@ const SPAWN_PARAMETERS = Type.Object({ description: "Optional exact Model Group name for child model routing. Omit to inherit the parent model/thinking.", })), constraints: Type.Optional(SPAWN_CONSTRAINT_REQUIREMENTS), - requiredModalities: Type.Optional(Type.Array(StringEnum(MODEL_GROUP_MODALITIES, { description: "Optional modalities the selected child route must support. Routing fails before child creation if the effective Model Group or selected model lacks any requirement." }), { uniqueItems: true } as any)), thinking: Type.Optional(StringEnum( ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const, { @@ -351,10 +350,10 @@ export function createChildTools( * */ export type SpawnConstraintRequirements = Record; -export interface SpawnParameters { prompt: string; group?: string; constraints?: SpawnConstraintRequirements; /** @deprecated compatibility alias */ requiredModalities?: ModelGroupModality[]; thinking?: ThinkingValue } +export interface SpawnParameters { prompt: string; group?: string; constraints?: SpawnConstraintRequirements; thinking?: ThinkingValue } -/** Decode the public envelope once, rejecting unknown keys and conflicting aliases before routing. */ -export function normalizeSpawnRequirements(params: Pick, registry: ConstraintRegistry = productionConstraintRegistry): SpawnConstraintRequirements { +/** 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 = {}; @@ -365,15 +364,6 @@ export function normalizeSpawnRequirements(params: Pick } })); -test("legacy malformed modalityOverride recovers as schema-invalid instead of crashing load", () => withTemp(({ cwd }) => { +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 }); - // Missing version, explicit version 0, and explicit version 1 all normalize to the legacy - // domain. A hand-added malformed override (non-array value) must surface as a clean - // schema-invalid issue with backup and empty recovery, never as a raw TypeError. - const cases: Array<[string, unknown]> = [ - ["missing", { groups: { legacy: { models: [], modalityOverride: 123 } } }], - ["version 0", { version: 0, groups: { legacy: { models: [], modalityOverride: [123] } } }], - ["version 1", { version: 1, groups: { legacy: { models: [], modalityOverride: "text" } } }], - ]; - for (const [label, raw] of cases) { + 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", label); - assert.match(issue.message, /modalityOverride/, label); - assert.ok(fs.existsSync(`${projectPath}.bak`), label); - assert.equal(Object.keys(loaded.configs.project.groups).length, 0, label); + 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); } })); @@ -302,7 +294,7 @@ test("store-level validation derives empty-common and stale-override flags and c 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" }], modalityOverride: ["text", "image"] }, + 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. @@ -322,29 +314,29 @@ test("create and update reject unsupported modality override before writing", () // 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" }], modalityOverride: ["image"] }, registry()), /unsupported modalities: image/); + 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" }], modalityOverride: ["text", "image"] }, registry()); + 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" }], modalityOverride: ["text", "image"] }, registry()), /unsupported modalities: image/); + 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 override", () => withTemp(({ cwd }) => { +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: [], modalityOverride: { text: true } } } }, /modalityOverride/], - ["duplicate", { version: 2, groups: { g: { models: [], modalityOverride: ["text", "text"] } } }, /unique/], - ["out-of-language", { version: 2, groups: { g: { models: [], modalityOverride: ["audio"] } } }, /vocabulary/], + ["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"); @@ -364,7 +356,7 @@ test("v1 migration is in-memory until the first successful mutation writes v2 wi const loaded = loadModelGroups(access(cwd)); assert.equal(loaded.configs.project.version, 2); - assert.equal(loaded.configs.project.groups.legacy.modalityOverride, undefined); + 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()); @@ -373,40 +365,39 @@ test("v1 migration is in-memory until the first successful mutation writes v2 wi assert.equal(Object.hasOwn(persisted.groups.legacy, "modalityOverride"), false); })); -test("v1 valid modalityOverride remains active through pass-through normalization", () => withTemp(({ cwd }) => { +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.deepEqual(loaded.configs.project.groups.legacy.modalityOverride, ["image"]); + 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 coalesces aliases, preserves explicit empty and opaque slots, and serializes the canonical mirror", () => withTemp(({ cwd }) => { +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"]], - ["alias", { modalityOverride: ["image"] }, ["image"]], - ["equal", { constraints: { modalities: ["text", "image"] }, modalityOverride: ["image", "text"] }, ["text", "image"]], ["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].modalityOverride, expected, name); + 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); - assert.equal(Object.hasOwn(group, "modalityOverride"), false, name); } else { assert.deepEqual(group.constraints.modalities, expected, name); - assert.deepEqual(group.modalityOverride, expected, name); } } @@ -415,28 +406,19 @@ test("v2 constraint envelope coalesces aliases, preserves explicit empty and opa 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.deepEqual(opaquePersisted.modalityOverride, ["image"]); assert.equal(opaquePersisted.groupSentinel, true); assert.equal(opaquePersisted.models[0].modelSentinel, true); })); -test("v2 conflicting constraint aliases and legacy constraint envelopes reject without reinterpretation", () => withTemp(({ cwd }) => { +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: { bad: { models: [], constraints: { modalities: ["text"] }, modalityOverride: ["image"] } } }), "utf8"); - let loaded = loadModelGroups(access(cwd)); - assert.equal(loaded.issues[0].kind, "schema-invalid"); - assert.match(loaded.issues[0].message, /conflicts/); - for (const raw of [{ groups: { legacy: { models: [], constraints: {} } } }, { version: 0, groups: { legacy: { models: [], constraints: {} } } }, { version: 1, groups: { legacy: { models: [], constraints: {} } } }]) { - fs.writeFileSync(sourcePath, JSON.stringify(raw), "utf8"); - loaded = loadModelGroups(access(cwd)); - assert.equal(loaded.issues[0].kind, "schema-invalid"); - assert.match(loaded.issues[0].message, /constraints/); - } - fs.writeFileSync(sourcePath, JSON.stringify({ version: 1, groups: { legacy: { models: [], modalityOverride: ["text"] } } }), "utf8"); - loaded = loadModelGroups(access(cwd)); - assert.deepEqual(loaded.configs.project.groups.legacy.modalityOverride, ["text"]); - assert.throws(() => createGroup("project", access(cwd), "conflict", { models: [], constraints: { modalities: ["text"] }, modalityOverride: ["image"] }, registry()), (error) => error instanceof ModelGroupsPersistenceError && error.phase === "config-validation"); + 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 }) => { @@ -465,21 +447,20 @@ test("v2 normalization preserves opaque root group and model keys through load s assert.equal(persisted.groups.review.models[0].thinkingLevel, "high"); })); -test("store normalization strips runtime-derived group keys while preserving opaque keys and modalityOverride", () => withTemp(({ cwd }) => { +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" }], - modalityOverride: ["text", "image"], + 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", "modalityOverride", "models", "opaqueSentinel"]); + assert.deepEqual(Object.keys(persisted).sort(), ["constraints", "models", "opaqueSentinel"]); assert.deepEqual(persisted.constraints.modalities, ["text", "image"]); - assert.deepEqual(persisted.modalityOverride, ["text", "image"]); assert.deepEqual(persisted.opaqueSentinel, { keep: true }); for (const key of ["name", "scope", "sourcePath", "modalities", "validation"]) assert.equal(Object.hasOwn(persisted, key), false); })); @@ -506,17 +487,17 @@ test("version-3 mutations refuse before temp write including loadScopeConfig-bac 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" }], modalityOverride: ["image"] }, registry()); - updateGroup("project", a, "review", { models: [{ provider: "openai", modelId: "gpt-5" }], modalityOverride: ["text", "image"] }, registry()); + 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.modalityOverride, ["text", "image"]); + 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"].modalityOverride, ["text", "image"]); + 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 }) => { diff --git a/tests/unit/model-groups-helpers.ts b/tests/unit/model-groups-helpers.ts index 27e0436..ec3a3bc 100644 --- a/tests/unit/model-groups-helpers.ts +++ b/tests/unit/model-groups-helpers.ts @@ -26,7 +26,7 @@ export function group( opts: { scope?: "project" | "global"; models?: ResolvedModelGroup["models"]; - modalityOverride?: ResolvedModelGroup["modalityOverride"]; + constraints?: { modalities: import("../../model-groups/types.js").ModelGroupModality[] } | Record; shadowedByProject?: boolean; unavailableRefs?: ResolvedModelGroup["validation"]["unavailableRefs"]; } = {}, @@ -37,7 +37,7 @@ export function group( scope, sourcePath: `<${scope}>`, models: opts.models ?? [], - ...(opts.modalityOverride === undefined ? {} : { modalityOverride: [...opts.modalityOverride] }), + ...(opts.constraints === undefined ? {} : { constraints: { ...opts.constraints, ...(Array.isArray(opts.constraints.modalities) ? { modalities: [...opts.constraints.modalities] } : {}) } }), validation: { unavailableRefs: opts.unavailableRefs ?? [], shadowedByProject: opts.shadowedByProject ?? false, diff --git a/tests/unit/model-groups-integration.test.ts b/tests/unit/model-groups-integration.test.ts index 884c0ae..0925841 100644 --- a/tests/unit/model-groups-integration.test.ts +++ b/tests/unit/model-groups-integration.test.ts @@ -89,7 +89,7 @@ test("index session_start notifies empty-common and stale-override boot counts", // 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" }], modalityOverride: ["text", "image"] }, + "claude-only": { models: [{ provider: "anthropic", modelId: "claude" }], constraints: { modalities: ["text", "image"] } }, } }), "utf8"); const pi = createTestPI(); registerAgenticoding(pi as any); @@ -183,7 +183,7 @@ test("before_agent_start injects fresh names-and-effective-modalities guidance", 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 \(text, image, reasoning\)/); - assert.match(result.systemPrompt, /requiredModalities/); + 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/); diff --git a/tests/unit/model-groups-modalities.test.ts b/tests/unit/model-groups-modalities.test.ts index 6513e34..1e5fed8 100644 --- a/tests/unit/model-groups-modalities.test.ts +++ b/tests/unit/model-groups-modalities.test.ts @@ -18,7 +18,7 @@ test("derives ordered common, supported, and override-effective modalities from assert.deepEqual(deriveModelGroupModalities(group, registry(models)), { common: ["text"], supported: ["text", "image", "reasoning"], effective: ["text"], }); - assert.deepEqual(deriveModelGroupModalities({ ...group, modalityOverride: ["reasoning", "image"] }, registry(models)).effective, ["image", "reasoning"]); + 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"); @@ -32,13 +32,13 @@ test("compatibility façade and descriptor remain parity-equivalent across modal const fixtures: ModelGroupDef[] = [ { models: [] }, { models: [{ provider: "p", modelId: "gone" }] }, - { models: [{ provider: "p", modelId: "rich" }], modalityOverride: [] }, - { models: [{ provider: "p", modelId: "text" }], modalityOverride: ["image"] }, + { 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.modalityOverride); + const evaluation = deriveModalitiesEvaluation(resolved.members, group.constraints?.modalities as any); assert.deepEqual(deriveModelGroupModalities(group, registry(models)), { common: evaluation.aggregate.common, supported: evaluation.aggregate.supported, @@ -48,11 +48,11 @@ test("compatibility façade and descriptor remain parity-equivalent across modal }); test("caps stale overrides without mutation and restores them when catalog support returns", () => { - const def: ModelGroupDef = { models: [{ provider: "p", modelId: "m" }], modalityOverride: ["text", "image"] }; + 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.modalityOverride, ["text", "image"]); + 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/); diff --git a/tests/unit/model-groups-router.test.ts b/tests/unit/model-groups-router.test.ts index ee8ccdf..63d1d55 100644 --- a/tests/unit/model-groups-router.test.ts +++ b/tests/unit/model-groups-router.test.ts @@ -32,25 +32,25 @@ test("omitted and unknown groups inherit parent route with fallback metadata", ( test("known empty and all-unusable groups fail clearly", () => { const parent = model("openai", "parent"); - assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "empty", requiredModalities: ["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", requiredModalities: ["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"); + 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("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" }], modalityOverride: ["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", requiredModalities: ["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", requiredModalities: ["image"], groups: [routed], parentModel: parent, parentThinking: "low", modelRegistry: reg, rng: () => .99 }).status, "routed"); + 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", requiredModalities: ["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", requiredModalities: ["image"], groups: [], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), (error: unknown) => error instanceof SpawnRouteError && error.group === "unknown" && /Spawn model/.test(error.message)); + 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", () => { @@ -62,9 +62,9 @@ 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({ requiredModalities: [], groups: [], parentModel: text, parentThinking: "medium", modelRegistry: registry([text]) }).status, "inherited"); + 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({ requiredModalities: ["text", "image"], groups: [], parentModel: rich, parentThinking: "medium", modelRegistry: registry([rich]) }).status, "inherited"); + 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({ requiredModalities: ["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)); + 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 fecb565..a73ba19 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -123,7 +123,7 @@ test("model groups TUI list renders validation summary, health tags, add row, no 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.modalityOverride = ["text", "image", "reasoning"]; + review.constraints = { modalities: ["text", "image", "reasoning"] }; review.validation.emptyCommonModalities = true; review.validation.unsupportedOverrideModalities = ["reasoning"]; const { c } = component({ groups: [review] }); @@ -146,8 +146,9 @@ test("model groups TUI modality editor commits override and Automatic through up let groups = [review]; const store = { updateGroup: (scope: string, _cwd: string, name: string, def: any) => { - calls.push({ scope, name, def: { ...def, modalityOverride: def.modalityOverride ? [...def.modalityOverride] : undefined } }); - groups = [group(name, { scope: scope as "project", models: def.models, modalityOverride: def.modalityOverride })]; + 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), }; @@ -159,13 +160,14 @@ test("model groups TUI modality editor commits override and Automatic through up selectRenderedLabel(c, "Override: text, image, reasoning"); press(c, ENTER); assert.equal(calls.length, 1); - assert.deepEqual(calls[0].def.modalityOverride, ["text", "image", "reasoning"]); + 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.modalityOverride, undefined); + assert.equal(calls[1].def.constraints?.modalities, undefined); assert.match(rendered(c), /Modalities: automatic/); }); @@ -177,7 +179,7 @@ test("model groups TUI modality editor preserves state and notifies on updateGro 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.modalityOverride = def.modalityOverride ? [...def.modalityOverride] : undefined; + review.constraints = def.constraints ? { ...def.constraints, ...(Array.isArray(def.constraints.modalities) ? { modalities: [...def.constraints.modalities] } : {}) } : undefined; }, listResolvedModelGroups: () => boot([review]), }; diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index 965f6ff..6e1d1ef 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -727,7 +727,7 @@ test("executeSpawn propagates missing modalities before creating child work", as 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", requiredModalities: ["image"] }, undefined, undefined, "medium", async () => { factoryCalls++; throw new Error("must not create child"); }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "missing-modality"); + } 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); @@ -755,7 +755,7 @@ test("registered spawn tool rejects missing modalities before creating child wor 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", requiredModalities: ["image"] }, undefined, undefined, { + () => 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), @@ -788,16 +788,14 @@ test("registered spawn tool rejects injected scalar group and model requirements assert.equal(factoryCalls, 0); assert.equal(state.childSessions.size, 0); assert.equal(state.liveChildSessions.size, 0); }); -test("spawn requirements normalize generic and legacy aliases conflict-safely", () => { +test("spawn requirements normalize the canonical envelope", () => { assert.deepEqual(normalizeSpawnRequirements({ constraints: { modalities: { required: ["image", "text"] } } }), { modalities: ["text", "image"] }); - assert.deepEqual(normalizeSpawnRequirements({ requiredModalities: ["image"] }), { modalities: ["image"] }); - assert.deepEqual(normalizeSpawnRequirements({ constraints: { modalities: { required: ["image"] } }, requiredModalities: ["image"] }), { modalities: ["image"] }); - assert.throws(() => normalizeSpawnRequirements({ constraints: { modalities: { required: ["image"] } }, requiredModalities: ["text"] }), /conflicts/); + 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 requiredModalities via Value.Check", () => { +test("spawn tool schema validates constraints via Value.Check", () => { const pi = createTestPI(); const state = createState(); registerSpawnTool(pi as any, state); @@ -805,15 +803,14 @@ test("spawn tool schema validates requiredModalities via Value.Check", () => { 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", requiredModalities: ["text", "image"] }), true, "valid unique vocab accepted"); - assert.equal(Value.Check(schema, { prompt: "Do the task" }), true, "omitted requiredModalities allowed"); - assert.equal(Value.Check(schema, { prompt: "Do the task", requiredModalities: [] }), true, "empty array allowed"); - assert.equal(Value.Check(schema, { prompt: "Do the task", requiredModalities: ["text", "text"] }), false, "duplicates rejected"); - assert.equal(Value.Check(schema, { prompt: "Do the task", requiredModalities: ["audio"] }), false, "out-of-vocabulary rejected"); - assert.equal(Value.Check(schema, { prompt: "Do the task", requiredModalities: "text" }), false, "non-array 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 plain inherited requiredModalities to routing and succeeds when satisfied", async () => { +test("executeSpawn forwards inherited constraints to routing and succeeds when satisfied", async () => { const pi = createTestPI(); pi.setActiveTools(["read", "spawn"]); const state = createState(); @@ -830,7 +827,7 @@ test("executeSpawn forwards plain inherited requiredModalities to routing and su 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", requiredModalities: ["text", "image"] }, undefined, undefined, "medium", async () => { factoryCalls++; return { session: session as any, extensionsResult: undefined as any }; }); + } 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"); @@ -1876,10 +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 requiredModalities = (tool.parameters as any).properties.requiredModalities; - assert.equal(requiredModalities.type, "array"); - assert.equal(requiredModalities.uniqueItems, true); - assert.deepEqual(requiredModalities.items.enum, ["text", "image", "reasoning"]); + 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"); });