From f6a46159a19173b29acfa2e5bf7c0415bd359687 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Thu, 20 Aug 2026 16:48:21 +0000 Subject: [PATCH 01/33] 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 02/33] 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 03/33] 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 04/33] =?UTF-8?q?refactor(model-groups):=20debt-easy=20fix?= =?UTF-8?q?es=20=E2=80=94=20derived-key=20strip,=20empty-label,=20stale=20?= =?UTF-8?q?editor,=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 05/33] 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 06/33] 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"); }); From fef9cc9b53ac4453d214f5eada406cd26999aa47 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sat, 22 Aug 2026 06:17:07 +0000 Subject: [PATCH 07/33] feat(model-groups): inline color-coded modality chips in group list Replace the trailing per-group dim footer ('name: modalities ...') with colored inline chips appended to each group's select label, drawn from the effective modality set in vocab order (accent=text, success=image, thinkingHigh=reasoning). Add a single compact legend line above the list. Respects B29 token discipline (no console.*, no bare ANSI, escaped labels). --- model-groups/tui.ts | 22 ++++++++++++++++++---- tests/unit/model-groups-tui.test.ts | 2 +- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/model-groups/tui.ts b/model-groups/tui.ts index e1bd582..b228366 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -1,4 +1,4 @@ -import type { Theme } from "@earendil-works/pi-coding-agent"; +import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent"; import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import { getSupportedThinkingLevels, type Model, type ModelThinkingLevel, type Api } from "@earendil-works/pi-ai"; import { Container, fuzzyFilter, Input, Key, matchesKey, SelectList, truncateToWidth, visibleWidth, type Component, type Focusable, type SelectItem, type TUI } from "@earendil-works/pi-tui"; @@ -11,7 +11,7 @@ import { summarizeBootValidation, updateGroup, } from "./store.js"; -import { ModelGroupsPersistenceError, type ModelGroupDef, type ModelGroupModality, type ModelGroupScope, type ModelGroupsAccess, type ModelGroupsBootValidation, type ResolvedModelGroup } from "./types.js"; +import { MODEL_GROUP_MODALITIES, 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"; @@ -478,6 +478,19 @@ export function createModelGroupsComponent( }; } + const MODALITY_FG: Record = { + text: "accent", + image: "success", + reasoning: "thinkingHigh", + }; + const MODALITY_SEP = " "; + + /** Colored inline chips for a group's effective modalities. Empty effective set -> "". */ + function modalityChips(effective: readonly ModelGroupModality[] | null | undefined): string { + if (!effective || effective.length === 0) return ""; + return effective.map((modality) => theme.fg(MODALITY_FG[modality], modality)).join(MODALITY_SEP); + } + const selectTheme = { selectedPrefix: (text: string) => theme.fg("accent", text), selectedText: (text: string) => theme.fg("accent", text), @@ -525,6 +538,7 @@ export function createModelGroupsComponent( const container = new Container(); container.addChild(textLine(theme.fg("accent", "Model Groups"))); container.addChild(textLine(theme.fg("dim", `Boot validation: ${summary.unavailableCount} unavailable model references · ${summary.overrideCount} project overrides`))); + container.addChild(textLine(theme.fg("dim", "modalities: ") + modalityChips(MODEL_GROUP_MODALITIES))); const items: SelectItem[] = state.groups.map((group, index) => { const tags: string[] = []; if (group.validation.degraded) tags.push("⚠ degraded"); @@ -536,11 +550,11 @@ export function createModelGroupsComponent( 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(" · ")}` : ""}` }; + const chips = modalityChips(group.modalities?.effective); + return { value: String(index), label: `${escapeDisplayLabel(group.name)}${chips ? ` ${chips}` : ""}`, 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; } diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index a73ba19..a3afa0f 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -127,7 +127,7 @@ test("model groups TUI renders modality labels, warnings, and stale override cho 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), /\breview text image\b/); assert.match(rendered(c, 200), /⚠ no common modalities/); assert.match(rendered(c, 200), /⚠ stale modality override: reasoning/); press(c, ENTER); From a6f8d6fd9460429c734d0430353cfdbcd8059e6d Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sat, 22 Aug 2026 06:28:05 +0000 Subject: [PATCH 08/33] feat(model-groups): single-letter colored modality markers aligned with thinking effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per operator ADJ-001 tweaks: - Move modality markers out of the select label into the description column, aligned with the thinking-effort segment (same inline position). - Use single colored letters per modality instead of full words: T=text, I=image, R=reasoning. - Recolor text modality from accent (teal) to syntaxKeyword (blue) so it no longer collides with the selected-row highlight, which also wraps in accent. - Legend now reads 'modalities: T text · I image · R reasoning' with colored letters. Each description segment re-asserts dim after inner colored spans so the surrounding dim survives the fg reset (fg resets to terminal default). - Update unit test to scope single-letter assertions to the review row. --- model-groups/tui.ts | 39 +++++++++++++++++++++++------ tests/unit/model-groups-tui.test.ts | 6 ++++- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/model-groups/tui.ts b/model-groups/tui.ts index b228366..c829100 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -479,16 +479,38 @@ export function createModelGroupsComponent( } const MODALITY_FG: Record = { - text: "accent", + text: "syntaxKeyword", image: "success", reasoning: "thinkingHigh", }; - const MODALITY_SEP = " "; + const MODALITY_LETTER: Record = { + text: "T", + image: "I", + reasoning: "R", + }; + + /** Dim wrap, so each description run re-asserts dim after an inner colored span's \x1b[39m. */ + function dim(s: string): string { + return theme.fg("dim", s); + } - /** Colored inline chips for a group's effective modalities. Empty effective set -> "". */ - function modalityChips(effective: readonly ModelGroupModality[] | null | undefined): string { + /** Colored single-letter run for a group's effective modalities + dimmed padding. Empty set -> "". */ + function modalityLetterRun(effective: readonly ModelGroupModality[] | null | undefined): string { if (!effective || effective.length === 0) return ""; - return effective.map((modality) => theme.fg(MODALITY_FG[modality], modality)).join(MODALITY_SEP); + return effective.map((modality) => theme.fg(MODALITY_FG[modality], MODALITY_LETTER[modality])).join(dim(" ")); + } + + /** Build a modality-tagged description whose dim segments re-assert dim after each colored letter. */ + function modalityDescription( + scope: ModelGroupScope, + count: number, + thinking: string, + tags: string, + effective: readonly ModelGroupModality[] | null | undefined, + ): string { + const head = dim(`[${scope}] ${count} models`); + const letters = modalityLetterRun(effective); + return `${head}${letters ? `${dim(" ")}${letters}${dim(" ")}` : dim(" ")}${dim(thinking)}${tags ? dim(` — ${tags}`) : ""}`; } const selectTheme = { @@ -538,7 +560,8 @@ export function createModelGroupsComponent( const container = new Container(); container.addChild(textLine(theme.fg("accent", "Model Groups"))); container.addChild(textLine(theme.fg("dim", `Boot validation: ${summary.unavailableCount} unavailable model references · ${summary.overrideCount} project overrides`))); - container.addChild(textLine(theme.fg("dim", "modalities: ") + modalityChips(MODEL_GROUP_MODALITIES))); + const legend = `${theme.fg("dim", "modalities: ")}${MODEL_GROUP_MODALITIES.map((modality) => `${theme.fg(MODALITY_FG[modality], MODALITY_LETTER[modality])}${dim(" " + modality)}`).join(dim(" · "))}`; + container.addChild(textLine(legend)); const items: SelectItem[] = state.groups.map((group, index) => { const tags: string[] = []; if (group.validation.degraded) tags.push("⚠ degraded"); @@ -550,8 +573,8 @@ export function createModelGroupsComponent( if (group.validation.emptyCommonModalities) tags.push("⚠ no common modalities"); if (group.validation.unsupportedOverrideModalities.length > 0) tags.push(`⚠ stale modality override: ${group.validation.unsupportedOverrideModalities.join(", ")}`); } - const chips = modalityChips(group.modalities?.effective); - return { value: String(index), label: `${escapeDisplayLabel(group.name)}${chips ? ` ${chips}` : ""}`, description: `[${group.scope}] ${group.models.length} models ${models}${tags.length ? ` — ${tags.join(" · ")}` : ""}` }; + const description = modalityDescription(group.scope, group.models.length, models, tags.join(" · "), group.modalities?.effective); + return { value: String(index), label: escapeDisplayLabel(group.name), description }; }); items.push({ value: String(state.groups.length), label: "+ Add group" }); container.addChild(buildSelect(items)); diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index a3afa0f..f3cac6b 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -127,7 +127,11 @@ test("model groups TUI renders modality labels, warnings, and stale override cho review.validation.emptyCommonModalities = true; review.validation.unsupportedOverrideModalities = ["reasoning"]; const { c } = component({ groups: [review] }); - assert.match(rendered(c, 200), /\breview text image\b/); + const reviewRow = stripAnsi(rendered(c, 200)).split("\n").find((line) => line.includes("review")); + assert.ok(reviewRow, "expected review row"); + assert.match(reviewRow, /\bT\b/); + assert.match(reviewRow, /\bI\b/); + assert.match(reviewRow, /models .*?T I[\s]*/); assert.match(rendered(c, 200), /⚠ no common modalities/); assert.match(rendered(c, 200), /⚠ stale modality override: reasoning/); press(c, ENTER); From 2c71388c72aa28d908fa14e3fe783d648b19de67 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sat, 22 Aug 2026 07:06:40 +0000 Subject: [PATCH 09/33] feat(model-groups): per-modality toggle editor, reasoning kept out of modalities UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADJ-002 (approved) + option A: - Replace the power-set enumeration in the MODALITIES screen with an Automatic row (reset; [✓] while default) + one toggle row per media modality (text/image). Enter on a toggle flips that modality in/out, seeding from the current effective set on the first toggle so an automatic group transitions to an explicit override smoothly. - New ConstraintEditorRow kind 'toggle' in constraints/presentation.ts; multi-select rows now enumerate automatic + one toggle per choice instead of the 2^n subset list. Activation builds the next override (vocab-ordered) from effective plus/minus the pressed modality. - Reasoning is a distinct pre-existing capability (per-member thinkingLevel + routing gate), so it is excluded from the modality presentation: the MODALITIES screen rows, the list legend/chips, and the EDITOR summary lines all show only text/image. Kernel/schema/router unchanged; any persisted reasoning override value is preserved (never clobbered). - Add generic multi-select editor-shape test; re-baseline the TUI modal editor commit test to toggle individual modalities. --- model-groups/constraints/modalities.ts | 2 +- model-groups/constraints/presentation.ts | 9 ++-- model-groups/tui.ts | 50 +++++++++++++++++---- tests/unit/model-groups-constraints.test.ts | 11 +++++ tests/unit/model-groups-tui.test.ts | 9 ++-- 5 files changed, 63 insertions(+), 18 deletions(-) diff --git a/model-groups/constraints/modalities.ts b/model-groups/constraints/modalities.ts index 1957fa5..23a3c46 100644 --- a/model-groups/constraints/modalities.ts +++ b/model-groups/constraints/modalities.ts @@ -61,7 +61,7 @@ export const modalitiesConstraint: ConstraintDescriptor<"modalities", ModelGroup 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 }, + editor: { kind: "multi-select", label: "Modalities", choices: (evaluation) => evaluation.aggregate.supported, automatic: (evaluation) => `Automatic (common: ${evaluation.aggregate.common.filter((modality) => modality !== "reasoning").join(", ") || "none"})`, format: (value) => `Override: ${value.join(", ") || "none"}`, allowAutomatic: true }, present: { group: (evaluation) => evaluation.effective.join(", "), prompt: (evaluation) => evaluation.effective.join(", "), diff --git a/model-groups/constraints/presentation.ts b/model-groups/constraints/presentation.ts index 2b1f58e..4396bc2 100644 --- a/model-groups/constraints/presentation.ts +++ b/model-groups/constraints/presentation.ts @@ -7,6 +7,7 @@ export interface ConstraintDiagnosticRecord extends ConstraintDiagnostic { export type ConstraintEditorRow = | { kind: "automatic"; label: string } + | { kind: "toggle"; label: string; value: string; active: boolean } | { kind: "choice"; label: string; value: readonly string[] } | { kind: "number"; label: string; value: number | null; unit: string; min: number; step: number }; @@ -45,11 +46,11 @@ export function constraintEditorRows( ): 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 choices = editor.choices(evaluation as ConstraintEvaluation); + const effective = (evaluation as ConstraintEvaluation).effective as readonly string[] | undefined; 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 }); + for (const choice of choices) { + rows.push({ kind: "toggle", label: choice, value: choice, active: effective?.includes(choice) ?? false }); } return rows; } diff --git a/model-groups/tui.ts b/model-groups/tui.ts index c829100..b8ec5dd 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -299,7 +299,11 @@ export function createModelGroupsComponent( function modalityEditorRows(): readonly ConstraintEditorRow[] { const editor = activeConstraintEditor(); - return editor ? constraintEditorRows(editor.descriptor, editor.evaluation, state.editDraft?.constraints?.[editor.descriptor.key]) : []; + if (!editor) return []; + // Media-only editor: hide the reasoning capability row (handled via per-member thinkingLevel). + return constraintEditorRows(editor.descriptor, editor.evaluation, state.editDraft?.constraints?.[editor.descriptor.key]).filter( + (row) => row.kind !== "toggle" || row.value !== "reasoning", + ); } function maxRow(): number { @@ -363,8 +367,13 @@ export function createModelGroupsComponent( const next = cloneDef(state.editDraft); if (selected.kind === "automatic") { if (next.constraints) delete next.constraints[editor.descriptor.key]; - } else if (selected.kind === "choice") { - (next.constraints ??= {})[editor.descriptor.key] = [...selected.value] as ModelGroupModality[]; + } else if (selected.kind === "toggle") { + const base = [...(editor.evaluation.effective as ModelGroupModality[])]; + const pressed = selected.value as ModelGroupModality; + const toggled = base.includes(pressed) + ? base.filter((modality) => modality !== pressed) + : orderedModalities([...base, pressed]); + (next.constraints ??= {})[editor.descriptor.key] = toggled; } else return; updateDraft(next, () => { state.screen = "EDITOR"; state.row = modalityRow(); }); return; } @@ -488,16 +497,27 @@ export function createModelGroupsComponent( image: "I", reasoning: "R", }; + // Media modalities editable in the modalities screen. Reasoning is a distinct + // capability (per-member thinkingLevel / routing gate) and is not exposed here. + const VISIBLE_MODALITIES = MODEL_GROUP_MODALITIES.filter((modality) => modality !== "reasoning") as ModelGroupModality[]; + + /** Vocab-order the given modalities per MODEL_GROUP_MODALITIES. */ + function orderedModalities(values: Iterable): ModelGroupModality[] { + const set = new Set(values); + return MODEL_GROUP_MODALITIES.filter((modality) => set.has(modality)); + } /** Dim wrap, so each description run re-asserts dim after an inner colored span's \x1b[39m. */ function dim(s: string): string { return theme.fg("dim", s); } - /** Colored single-letter run for a group's effective modalities + dimmed padding. Empty set -> "". */ + /** Colored single-letter run for a group's effective media modalities + dimmed padding. Empty set -> "". */ function modalityLetterRun(effective: readonly ModelGroupModality[] | null | undefined): string { if (!effective || effective.length === 0) return ""; - return effective.map((modality) => theme.fg(MODALITY_FG[modality], MODALITY_LETTER[modality])).join(dim(" ")); + const visible = effective.filter((modality) => modality !== "reasoning"); + if (visible.length === 0) return ""; + return visible.map((modality) => theme.fg(MODALITY_FG[modality], MODALITY_LETTER[modality])).join(dim(" ")); } /** Build a modality-tagged description whose dim segments re-assert dim after each colored letter. */ @@ -560,7 +580,7 @@ export function createModelGroupsComponent( const container = new Container(); container.addChild(textLine(theme.fg("accent", "Model Groups"))); container.addChild(textLine(theme.fg("dim", `Boot validation: ${summary.unavailableCount} unavailable model references · ${summary.overrideCount} project overrides`))); - const legend = `${theme.fg("dim", "modalities: ")}${MODEL_GROUP_MODALITIES.map((modality) => `${theme.fg(MODALITY_FG[modality], MODALITY_LETTER[modality])}${dim(" " + modality)}`).join(dim(" · "))}`; + const legend = `${theme.fg("dim", "modalities: ")}${VISIBLE_MODALITIES.map((modality) => `${theme.fg(MODALITY_FG[modality], MODALITY_LETTER[modality])}${dim(" " + modality)}`).join(dim(" · "))}`; container.addChild(textLine(legend)); const items: SelectItem[] = state.groups.map((group, index) => { const tags: string[] = []; @@ -591,8 +611,8 @@ export function createModelGroupsComponent( container.addChild(textLine(selectableLine(state.row === (access.policy === "global-project" ? 1 : 0), "Location: global", state.editScope === "global" ? " ✓" : ""))); container.addChild(groupNameLineComponent()); const modalities = current?.modalities; - container.addChild(textLine(theme.fg("dim", `Common: ${modalities?.common.join(", ") || "none"}`))); - container.addChild(textLine(selectableLine(state.row === modalityRow(), `Modalities: ${state.editDraft?.constraints?.modalities === undefined ? "automatic" : "override"} (${modalities?.effective.join(", ") || "none"})`))); + container.addChild(textLine(theme.fg("dim", `Common: ${modalities?.common.filter((modality) => modality !== "reasoning").join(", ") || "none"}`))); + container.addChild(textLine(selectableLine(state.row === modalityRow(), `Modalities: ${state.editDraft?.constraints?.modalities === undefined ? "automatic" : "override"} (${modalities?.effective.filter((modality) => modality !== "reasoning").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)})`))); @@ -607,8 +627,20 @@ export function createModelGroupsComponent( const container = new Container(); const editor = activeConstraintEditor(); container.addChild(textLine(theme.fg("accent", editor?.descriptor.editor.label.toUpperCase() ?? "MODALITIES"))); + const isAutomatic = state.editDraft?.constraints?.[editor?.descriptor.key ?? "modalities"] === undefined; for (const [index, row] of modalityEditorRows().entries()) { - const label = row.kind === "number" ? `${row.label}: ${row.value ?? "none"} ${row.unit}` : row.label; + let label: string; + if (row.kind === "toggle") { + const modality = row.value as ModelGroupModality; + const letter = theme.fg(MODALITY_FG[modality], MODALITY_LETTER[modality]); + label = `${letter}${dim(" " + row.label)}${dim(row.active ? " [on]" : " [off]")}`; + } else if (row.kind === "automatic") { + label = `${dim(row.label)}${isAutomatic ? dim(" [✓]") : ""}`; + } else if (row.kind === "number") { + label = `${row.label}: ${row.value ?? "none"} ${row.unit}`; + } else { + label = row.label; + } container.addChild(textLine(selectableLine(state.row === index, label))); } return container; diff --git a/tests/unit/model-groups-constraints.test.ts b/tests/unit/model-groups-constraints.test.ts index 5ee3f86..6b7f76f 100644 --- a/tests/unit/model-groups-constraints.test.ts +++ b/tests/unit/model-groups-constraints.test.ts @@ -27,6 +27,17 @@ test("descriptor codecs report errors and retain vocabulary ordering", () => { assert.deepEqual(modalitiesConstraint.persistence.override.decode(["text", "text"], "override"), { ok: false, message: "override must be a unique modality vocabulary array" }); }); +test("generic multi-select editor enumerates automatic + one toggle per choice", () => { + const registry = createConstraintRegistry([modalitiesConstraint as AnyConstraintDescriptor]); + const evaluated = evaluateConstraints(resolution([{ provider: "p", modelId: "rich", model: rich }]), {}, registry); + assert.deepEqual(constraintEditorRows(modalitiesConstraint as AnyConstraintDescriptor, evaluated[0]), [ + { kind: "automatic", label: "Automatic (common: text, image)" }, + { kind: "toggle", label: "text", value: "text", active: true }, + { kind: "toggle", label: "image", value: "image", active: true }, + { kind: "toggle", label: "reasoning", value: "reasoning", active: true }, + ]); +}); + 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 }]); diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index f3cac6b..20405e3 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -139,8 +139,9 @@ test("model groups TUI renders modality labels, warnings, and stale override cho 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/); + assert.match(rendered(c), /T text \[on\]/); + assert.match(rendered(c), /I image \[on\]/); + assert.doesNotMatch(rendered(c), /reasoning/); }); test("model groups TUI modality editor commits override and Automatic through updateGroup", () => { @@ -161,10 +162,10 @@ test("model groups TUI modality editor commits override and Automatic through up selectRenderedLabel(c, "Modalities:"); press(c, ENTER); assert.match(rendered(c), /MODALITIES/); - selectRenderedLabel(c, "Override: text, image, reasoning"); + selectRenderedLabel(c, "I image"); press(c, ENTER); assert.equal(calls.length, 1); - assert.deepEqual(calls[0].def.constraints.modalities, ["text", "image", "reasoning"]); + assert.deepEqual(calls[0].def.constraints.modalities, ["text", "image"]); assert.match(rendered(c), /Modalities: override/); press(c, ENTER); assert.match(rendered(c), /MODALITIES/); From 2b8135b8f3803ed43589cb3baef1dd819db30644 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sat, 22 Aug 2026 07:41:50 +0000 Subject: [PATCH 10/33] feat(model-groups): modality toggles stay on screen; Space also toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADJ-002 follow-up: - Enter on a modality toggle (or the Automatic reset) now persists the change and STAYS on the MODALITIES screen so the user can keep toggling, instead of bouncing back to the group editor. Esc still closes back to the editor. - Space key now toggles the selected modality as well as Enter (both dispatch to activate on the MODALITIES screen). - Add a dim hint line: 'up/down navigate • Enter/Space toggle • Esc close'. - Refactor updateDraft into persistDraft (persist + refresh + re-resolve, no navigation) + updateDraft (navigates on success). The MODALITIES toggle uses persistDraft and re-binds the edit draft to the refreshed group. Preserves original stay-put-on-error semantics (afterSuccess only runs on success). - Tests: rework commit test to assert stay-on-screen + Esc exit; add a Space toggle test. --- model-groups/tui.ts | 33 +++++++++++++++++++++++------ tests/unit/model-groups-tui.test.ts | 33 ++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/model-groups/tui.ts b/model-groups/tui.ts index b8ec5dd..56a7258 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -40,6 +40,7 @@ const defaultStore: ModelGroupsStoreOps = { listResolvedModelGroups, createGroup function isEnter(data: string): boolean { return matchesKey(data, Key.enter) || data === "\n"; } function isEsc(data: string): boolean { return matchesKey(data, Key.escape); } +const isSpace = (data: string) => data === " "; function isUp(data: string): boolean { return matchesKey(data, Key.up); } function isDown(data: string): boolean { return matchesKey(data, Key.down); } function isLeft(data: string): boolean { return matchesKey(data, Key.left); } @@ -236,20 +237,27 @@ export function createModelGroupsComponent( } } - function updateDraft(def: ModelGroupDef, afterSuccess: () => void): void { + /** Persist the draft, refresh state, and re-resolve the updated group. No navigation. */ + function persistDraft(def: ModelGroupDef): ResolvedModelGroup | undefined { const group = currentEditGroup(); - if (!group) return; + if (!group) return undefined; try { 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); - afterSuccess(); + return state.groups.find((candidate) => candidate.name === group.name && candidate.scope === group.scope); } catch (error) { notifyError(error); + return undefined; } } + function updateDraft(def: ModelGroupDef, afterSuccess: () => void): void { + const updated = persistDraft(def); + if (!updated) return; // error already notified; do not navigate + openEditor(updated); + afterSuccess(); + } + function availableModels(): Model[] { return modelRegistry.getAvailable() .filter((model) => modelRegistry.hasConfiguredAuth(model)); @@ -375,7 +383,18 @@ export function createModelGroupsComponent( : orderedModalities([...base, pressed]); (next.constraints ??= {})[editor.descriptor.key] = toggled; } else return; - updateDraft(next, () => { state.screen = "EDITOR"; state.row = modalityRow(); }); return; + const updated = persistDraft(next); + if (updated) { + // Re-bind the draft to the toggled override while staying on this screen. + state.editKey = groupKey(updated); + state.editName = escapeDisplayLabel(updated.name); + setGroupNameInputValue(state.editName); + state.editScope = updated.scope; + state.editDraft = next; + state.activeTextInput = null; + syncInputFocus(); + } + return; } case "MODEL_EDIT": { const model = state.editDraft?.models[state.modelEditIndex]; @@ -643,6 +662,7 @@ export function createModelGroupsComponent( } container.addChild(textLine(selectableLine(state.row === index, label))); } + container.addChild(textLine(theme.fg("dim", "↑↓ navigate • Enter/Space toggle • Esc close"))); return container; } @@ -770,6 +790,7 @@ export function createModelGroupsComponent( else if (activeSelect && (state.screen === "LIST" || state.screen.startsWith("WIZARD_")) && isEnter(data)) activate(); else if (isUp(data)) { state.row--; clampRow(); } else if (isDown(data)) { state.row++; clampRow(); } + else if (state.screen === "MODALITIES" && isSpace(data)) activate(); else if (isLeft(data) || isEsc(data)) goBack(); else if (isEnter(data)) activate(); syncInputFocus(); diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index 20405e3..ab93ac8 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -166,16 +166,47 @@ test("model groups TUI modality editor commits override and Automatic through up press(c, ENTER); assert.equal(calls.length, 1); assert.deepEqual(calls[0].def.constraints.modalities, ["text", "image"]); - assert.match(rendered(c), /Modalities: override/); + assert.match(rendered(c), /MODALITIES/, "toggle stays on the modalities screen"); + assert.match(rendered(c), /I image \[on\]/); + press(c, ESC); + assert.match(rendered(c), /Modalities: override \(text, image\)/); press(c, ENTER); assert.match(rendered(c), /MODALITIES/); selectRenderedLabel(c, "Automatic"); press(c, ENTER); assert.equal(calls.length, 2); assert.equal(calls[1].def.constraints?.modalities, undefined); + assert.match(rendered(c), /MODALITIES/, "reset also stays on the modalities screen"); + press(c, ESC); assert.match(rendered(c), /Modalities: automatic/); }); +test("model groups TUI Space also toggles a modality and stays on screen", () => { + const review = group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] }); + review.modalities = { common: ["text"], supported: ["text", "image"], effective: ["text"] }; + const calls: Array<{ scope: string; name: string; def: any }> = []; + let groups = [review]; + const store = { + updateGroup: (scope: string, _cwd: string, name: string, def: any) => { + calls.push({ scope, name, def: { ...def, constraints: def.constraints ? { ...def.constraints, ...(Array.isArray(def.constraints.modalities) ? { modalities: [...def.constraints.modalities] } : {}) } : undefined } }); + groups = [group(name, { scope: scope as "project", models: def.models, constraints: def.constraints })]; + groups[0].modalities = { common: ["text"], supported: ["text", "image", "reasoning"], effective: ["text", "image", "reasoning"] }; + }, + listResolvedModelGroups: () => boot(groups), + }; + const { c } = component({ groups, store }); + press(c, ENTER); + selectRenderedLabel(c, "Modalities:"); + press(c, ENTER); + assert.match(rendered(c), /MODALITIES/); + selectRenderedLabel(c, "I image"); + press(c, " "); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].def.constraints.modalities, ["text", "image"]); + assert.match(rendered(c), /MODALITIES/, "space toggle stays on the modalities screen"); + assert.match(rendered(c), /I image \[on\]/); +}); + 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"] }; From 57caa5865f98f101e6341b6cfd800f0cbb8c9ece Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sat, 22 Aug 2026 08:05:00 +0000 Subject: [PATCH 11/33] feat(model-groups): text is always-present base modality, not toggleable Make text a structurally guaranteed base capability (matching the invariant that image/reasoning both imply text). reconcile now always keeps text in the effective set whenever the group supports it, regardless of override. This eliminates the empty-group asymmetry: deselecting every modality now yields a text-only group (effective=[text]) rather than effective=[] that let text spawns through while denying image/reasoning. MODALITIES screen becomes thin: a fixed non-selectable 'text [always]' line plus the Automatic reset and toggles for additive capabilities only (image; reasoning stays per-model via thinkingLevel, text is the base). Tests: update unresolved-member and override-effective expectations to include always-present text; add a backstop-invariant test; adjust TUI text-row assertion to '[always]'. --- model-groups/constraints/modalities.ts | 5 ++++- model-groups/tui.ts | 7 +++++-- tests/unit/model-groups-constraints.test.ts | 14 ++++++++++++-- tests/unit/model-groups-modalities.test.ts | 3 ++- tests/unit/model-groups-tui.test.ts | 2 +- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/model-groups/constraints/modalities.ts b/model-groups/constraints/modalities.ts index 23a3c46..f333fa9 100644 --- a/model-groups/constraints/modalities.ts +++ b/model-groups/constraints/modalities.ts @@ -41,7 +41,10 @@ export const modalitiesConstraint: ConstraintDescriptor<"modalities", ModelGroup return { common, supported, effective: common }; }, reconcile({ aggregate, override }) { - const effective = override === undefined ? aggregate.common : ordered(override.filter((modality) => aggregate.supported.includes(modality))); + const base = override === undefined ? aggregate.common : ordered(override.filter((modality) => aggregate.supported.includes(modality))); + // Text is the always-present base capability (image/reasoning both imply it); + // keep it in the effective set whenever the group supports it, regardless of override. + const effective = aggregate.supported.includes("text") ? ordered(["text", ...base.filter((modality) => modality !== "text")]) : base; const missing = override === undefined ? [] : ordered(override.filter((modality) => !aggregate.supported.includes(modality))); const diagnostics: ConstraintDiagnostic[] = [ ...(aggregate.common.length === 0 ? [{ key: "modalities", code: "empty-common" }] : []), diff --git a/model-groups/tui.ts b/model-groups/tui.ts index 56a7258..db29f85 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -308,9 +308,11 @@ export function createModelGroupsComponent( function modalityEditorRows(): readonly ConstraintEditorRow[] { const editor = activeConstraintEditor(); if (!editor) return []; - // Media-only editor: hide the reasoning capability row (handled via per-member thinkingLevel). + // Media editor rows are the additive, toggleable capabilities only: + // text is the always-present base, and reasoning is handled per-model (thinkingLevel), + // so both are excluded from the toggle list. return constraintEditorRows(editor.descriptor, editor.evaluation, state.editDraft?.constraints?.[editor.descriptor.key]).filter( - (row) => row.kind !== "toggle" || row.value !== "reasoning", + (row) => row.kind !== "toggle" || (row.value !== "reasoning" && row.value !== "text"), ); } @@ -647,6 +649,7 @@ export function createModelGroupsComponent( const editor = activeConstraintEditor(); container.addChild(textLine(theme.fg("accent", editor?.descriptor.editor.label.toUpperCase() ?? "MODALITIES"))); const isAutomatic = state.editDraft?.constraints?.[editor?.descriptor.key ?? "modalities"] === undefined; + container.addChild(textLine(`${theme.fg(MODALITY_FG.text, MODALITY_LETTER.text)}${dim(" text [always]")}`)); for (const [index, row] of modalityEditorRows().entries()) { let label: string; if (row.kind === "toggle") { diff --git a/tests/unit/model-groups-constraints.test.ts b/tests/unit/model-groups-constraints.test.ts index 6b7f76f..e04fb25 100644 --- a/tests/unit/model-groups-constraints.test.ts +++ b/tests/unit/model-groups-constraints.test.ts @@ -17,9 +17,11 @@ test("constraint registry orders descriptors and rejects duplicate keys", () => assert.throws(() => createConstraintRegistry([modalitiesConstraint as AnyConstraintDescriptor, modalitiesConstraint as AnyConstraintDescriptor]), /Duplicate model-group constraint key: modalities/); }); -test("engine preserves unresolved members as unknown facts", () => { + 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" }] }); + // An unresolved member leaves common empty, but text is always present via the + // resolved member (text is the base invariant), so effective carries text. + assert.deepEqual(result[0], { key: "modalities", aggregate: { common: [], supported: ["text", "image", "reasoning"], effective: [] }, effective: ["text"], diagnostics: [{ key: "modalities", code: "empty-common" }] }); }); test("descriptor codecs report errors and retain vocabulary ordering", () => { @@ -38,6 +40,14 @@ test("generic multi-select editor enumerates automatic + one toggle per choice", ]); }); +test("text is always present in effective even when the override drops it", () => { + const registry = createConstraintRegistry([modalitiesConstraint as AnyConstraintDescriptor]); + const deselected = evaluateConstraints(resolution([{ provider: "p", modelId: "rich", model: rich }]), { modalities: [] }, registry)[0]; + assert.deepEqual(deselected.effective, ["text"]); + const onlyImage = evaluateConstraints(resolution([{ provider: "p", modelId: "rich", model: rich }]), { modalities: ["image"] }, registry)[0]; + assert.deepEqual(onlyImage.effective, ["text", "image"]); +}); + 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 }]); diff --git a/tests/unit/model-groups-modalities.test.ts b/tests/unit/model-groups-modalities.test.ts index 1e5fed8..16f1e81 100644 --- a/tests/unit/model-groups-modalities.test.ts +++ b/tests/unit/model-groups-modalities.test.ts @@ -18,7 +18,8 @@ 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, constraints: { modalities: ["reasoning", "image"] } }, registry(models)).effective, ["image", "reasoning"]); + const effectiveAfterOverride = deriveModelGroupModalities({ ...group, constraints: { modalities: ["reasoning", "image"] } }, registry(models)).effective; + assert.deepEqual(effectiveAfterOverride, ["text", "image", "reasoning"], "text stays always-present ahead of overridden add-ons"); 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"); diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index ab93ac8..7d31140 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -139,7 +139,7 @@ test("model groups TUI renders modality labels, warnings, and stale override cho 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), /T text \[on\]/); + assert.match(rendered(c), /T text \[always\]/); assert.match(rendered(c), /I image \[on\]/); assert.doesNotMatch(rendered(c), /reasoning/); }); From c0ea888136a7c2ed6da3961285a9bccb6b51c12c Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sat, 22 Aug 2026 08:31:43 +0000 Subject: [PATCH 12/33] refactor(model-groups): modality-scoped TUI presentation polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modality-specific usability follow-up only (from #planner UX review); general TUI debt left untouched and tracked separately. - MODALITIES screen title now names the group: 'Modalities — ' instead of the bare 'MODALITIES'. - Fix hint wording: 'Esc close' -> 'Esc back', 'Enter/Space toggle' -> 'Enter/Space apply' (the Automatic row resets rather than toggles). - Static text row: align with selectable rows and reword '[always]' -> 'required base' so it describes the invariant without claiming an empty/all-unavailable group is usable. - EDITOR modality line: drop 'Common:' jargon -> 'Supported by every model:'. - Descriptor automatic label: 'Automatic (common: X)' -> 'Automatic (X)' (the 'common:' vocabulary was redundant; text is now the always-present base via the kernel invariant). - Update TUI + constraints test expectations for the new wording/labels. --- model-groups/constraints/modalities.ts | 2 +- model-groups/tui.ts | 9 +++++---- tests/unit/model-groups-constraints.test.ts | 2 +- tests/unit/model-groups-tui.test.ts | 22 ++++++++++----------- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/model-groups/constraints/modalities.ts b/model-groups/constraints/modalities.ts index f333fa9..1d1e339 100644 --- a/model-groups/constraints/modalities.ts +++ b/model-groups/constraints/modalities.ts @@ -64,7 +64,7 @@ export const modalitiesConstraint: ConstraintDescriptor<"modalities", ModelGroup 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.filter((modality) => modality !== "reasoning").join(", ") || "none"})`, format: (value) => `Override: ${value.join(", ") || "none"}`, allowAutomatic: true }, + editor: { kind: "multi-select", label: "Modalities", choices: (evaluation) => evaluation.aggregate.supported, automatic: (evaluation) => `Automatic (${evaluation.aggregate.common.filter((modality) => modality !== "reasoning").join(", ") || "none"})`, format: (value) => `Override: ${value.join(", ") || "none"}`, allowAutomatic: true }, present: { group: (evaluation) => evaluation.effective.join(", "), prompt: (evaluation) => evaluation.effective.join(", "), diff --git a/model-groups/tui.ts b/model-groups/tui.ts index db29f85..efe3a74 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -632,7 +632,7 @@ export function createModelGroupsComponent( 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.filter((modality) => modality !== "reasoning").join(", ") || "none"}`))); + container.addChild(textLine(theme.fg("dim", `Supported by every model: ${modalities?.common.filter((modality) => modality !== "reasoning").join(", ") || "none"}`))); container.addChild(textLine(selectableLine(state.row === modalityRow(), `Modalities: ${state.editDraft?.constraints?.modalities === undefined ? "automatic" : "override"} (${modalities?.effective.filter((modality) => modality !== "reasoning").join(", ") || "none"})`))); state.editDraft?.models.forEach((model, index) => { const available = modelAvailable(modelRegistry, model.provider, model.modelId) ? "available" : "unavailable"; @@ -647,9 +647,10 @@ export function createModelGroupsComponent( activeSelect = null; const container = new Container(); const editor = activeConstraintEditor(); - container.addChild(textLine(theme.fg("accent", editor?.descriptor.editor.label.toUpperCase() ?? "MODALITIES"))); + const current = currentEditGroup(); + container.addChild(textLine(theme.fg("accent", `Modalities — ${escapeDisplayLabel(current?.name ?? "")}`))); const isAutomatic = state.editDraft?.constraints?.[editor?.descriptor.key ?? "modalities"] === undefined; - container.addChild(textLine(`${theme.fg(MODALITY_FG.text, MODALITY_LETTER.text)}${dim(" text [always]")}`)); + container.addChild(textLine(` ${theme.fg(MODALITY_FG.text, MODALITY_LETTER.text)}${dim(" text required base")}`)); for (const [index, row] of modalityEditorRows().entries()) { let label: string; if (row.kind === "toggle") { @@ -665,7 +666,7 @@ export function createModelGroupsComponent( } container.addChild(textLine(selectableLine(state.row === index, label))); } - container.addChild(textLine(theme.fg("dim", "↑↓ navigate • Enter/Space toggle • Esc close"))); + container.addChild(textLine(theme.fg("dim", "↑↓ navigate • Enter/Space apply • Esc back"))); return container; } diff --git a/tests/unit/model-groups-constraints.test.ts b/tests/unit/model-groups-constraints.test.ts index e04fb25..9a503ae 100644 --- a/tests/unit/model-groups-constraints.test.ts +++ b/tests/unit/model-groups-constraints.test.ts @@ -33,7 +33,7 @@ test("generic multi-select editor enumerates automatic + one toggle per choice", const registry = createConstraintRegistry([modalitiesConstraint as AnyConstraintDescriptor]); const evaluated = evaluateConstraints(resolution([{ provider: "p", modelId: "rich", model: rich }]), {}, registry); assert.deepEqual(constraintEditorRows(modalitiesConstraint as AnyConstraintDescriptor, evaluated[0]), [ - { kind: "automatic", label: "Automatic (common: text, image)" }, + { kind: "automatic", label: "Automatic (text, image)" }, { kind: "toggle", label: "text", value: "text", active: true }, { kind: "toggle", label: "image", value: "image", active: true }, { kind: "toggle", label: "reasoning", value: "reasoning", active: true }, diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index 7d31140..76032cc 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -135,11 +135,11 @@ test("model groups TUI renders modality labels, warnings, and stale override cho 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), /Supported by every model: 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), /T text \[always\]/); + assert.match(rendered(c), /Automatic \(text\)/); + assert.match(rendered(c), /T text required base/); assert.match(rendered(c), /I image \[on\]/); assert.doesNotMatch(rendered(c), /reasoning/); }); @@ -161,22 +161,22 @@ test("model groups TUI modality editor commits override and Automatic through up press(c, ENTER); selectRenderedLabel(c, "Modalities:"); press(c, ENTER); - assert.match(rendered(c), /MODALITIES/); + assert.match(rendered(c), /Modalities/); selectRenderedLabel(c, "I image"); press(c, ENTER); assert.equal(calls.length, 1); assert.deepEqual(calls[0].def.constraints.modalities, ["text", "image"]); - assert.match(rendered(c), /MODALITIES/, "toggle stays on the modalities screen"); + assert.match(rendered(c), /Modalities/, "toggle stays on the modalities screen"); assert.match(rendered(c), /I image \[on\]/); press(c, ESC); assert.match(rendered(c), /Modalities: override \(text, image\)/); press(c, ENTER); - assert.match(rendered(c), /MODALITIES/); + assert.match(rendered(c), /Modalities/); selectRenderedLabel(c, "Automatic"); press(c, ENTER); assert.equal(calls.length, 2); assert.equal(calls[1].def.constraints?.modalities, undefined); - assert.match(rendered(c), /MODALITIES/, "reset also stays on the modalities screen"); + assert.match(rendered(c), /Modalities/, "reset also stays on the modalities screen"); press(c, ESC); assert.match(rendered(c), /Modalities: automatic/); }); @@ -198,12 +198,12 @@ test("model groups TUI Space also toggles a modality and stays on screen", () => press(c, ENTER); selectRenderedLabel(c, "Modalities:"); press(c, ENTER); - assert.match(rendered(c), /MODALITIES/); + assert.match(rendered(c), /Modalities/); selectRenderedLabel(c, "I image"); press(c, " "); assert.equal(calls.length, 1); assert.deepEqual(calls[0].def.constraints.modalities, ["text", "image"]); - assert.match(rendered(c), /MODALITIES/, "space toggle stays on the modalities screen"); + assert.match(rendered(c), /Modalities/, "space toggle stays on the modalities screen"); assert.match(rendered(c), /I image \[on\]/); }); @@ -220,10 +220,10 @@ test("model groups TUI modality editor preserves state and notifies on updateGro 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, 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"); + assert.match(rendered(c), /Modalities/, "screen retained after failure"); }); test("model groups TUI computes unique new-group names and opens editor after create", () => { From 85b4835d2ccb69c2215cb9f3be86a4bb1f0d943c Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sat, 22 Aug 2026 08:43:01 +0000 Subject: [PATCH 13/33] feat(model-groups): visual+conceptual separation of modalities from models in editor (#code-review design) Give the group editor two compact static section bars so the modalities config reads as its own stand-alone block distinct from the model membership: Model Group: review Location: project / global Name: review -- Capabilities -- Supported by every model: text Modalities: Automatic (text, image) -- Models -- provider/model (available, thinking X) + Add model... - 'Capabilities' bar groups the group-level modality policy ('Supported by every model' + the Modalities action). - 'Models' bar heads the member list/Add row. - Bars and the 'Supported by every model' line are static/non-selectable, so all logical row indices and Enter targets are unchanged (only visual bars added; zero keyboard rework). - Automatic/Override now capitalized in the editor summary line. - Theme discipline kept: dim rules, accent labels, no bare ANSI, no chips in the editor (full names are more legible here; LIST chips/legend unchanged), MODALITIES screen + constraint kernel untouched. - Update editor test assertions (Override/Automatic casing, add Capabilities/ Models coverage). --- model-groups/tui.ts | 11 +++++++++-- tests/unit/model-groups-tui.test.ts | 8 +++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/model-groups/tui.ts b/model-groups/tui.ts index efe3a74..502447d 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -533,6 +533,11 @@ export function createModelGroupsComponent( return theme.fg("dim", s); } + /** A non-selectable section bar: dim rule, accent label, dim rule. */ + function sectionBar(label: string): string { + return `${dim("── ")}${theme.fg("accent", label)}${dim(" ──")}`; + } + /** Colored single-letter run for a group's effective media modalities + dimmed padding. Empty set -> "". */ function modalityLetterRun(effective: readonly ModelGroupModality[] | null | undefined): string { if (!effective || effective.length === 0) return ""; @@ -632,8 +637,10 @@ export function createModelGroupsComponent( 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", `Supported by every model: ${modalities?.common.filter((modality) => modality !== "reasoning").join(", ") || "none"}`))); - container.addChild(textLine(selectableLine(state.row === modalityRow(), `Modalities: ${state.editDraft?.constraints?.modalities === undefined ? "automatic" : "override"} (${modalities?.effective.filter((modality) => modality !== "reasoning").join(", ") || "none"})`))); + container.addChild(textLine(sectionBar("Capabilities"))); + container.addChild(textLine(theme.fg("dim", ` Supported by every model: ${modalities?.common.filter((modality) => modality !== "reasoning").join(", ") || "none"}`))); + container.addChild(textLine(selectableLine(state.row === modalityRow(), `Modalities: ${state.editDraft?.constraints?.modalities === undefined ? "Automatic" : "Override"} (${modalities?.effective.filter((modality) => modality !== "reasoning").join(", ") || "none"})`))); + container.addChild(textLine(sectionBar("Models"))); 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/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index 76032cc..fe8d3eb 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -136,7 +136,9 @@ test("model groups TUI renders modality labels, warnings, and stale override cho assert.match(rendered(c, 200), /⚠ stale modality override: reasoning/); press(c, ENTER); assert.match(rendered(c), /Supported by every model: text/); - assert.match(rendered(c), /Modalities: override \(text, image\)/); + assert.match(rendered(c), /Modalities: Override \(text, image\)/); + assert.match(rendered(c), /Capabilities/); + assert.match(rendered(c), /Models/); press(c, DOWN, DOWN, DOWN, ENTER); assert.match(rendered(c), /Automatic \(text\)/); assert.match(rendered(c), /T text required base/); @@ -169,7 +171,7 @@ test("model groups TUI modality editor commits override and Automatic through up assert.match(rendered(c), /Modalities/, "toggle stays on the modalities screen"); assert.match(rendered(c), /I image \[on\]/); press(c, ESC); - assert.match(rendered(c), /Modalities: override \(text, image\)/); + assert.match(rendered(c), /Modalities: Override \(text, image\)/); press(c, ENTER); assert.match(rendered(c), /Modalities/); selectRenderedLabel(c, "Automatic"); @@ -178,7 +180,7 @@ test("model groups TUI modality editor commits override and Automatic through up assert.equal(calls[1].def.constraints?.modalities, undefined); assert.match(rendered(c), /Modalities/, "reset also stays on the modalities screen"); press(c, ESC); - assert.match(rendered(c), /Modalities: automatic/); + assert.match(rendered(c), /Modalities: Automatic \(text, image\)/); }); test("model groups TUI Space also toggles a modality and stays on screen", () => { From c71b840467bd1a9f53fd8b28f44794d22399a407 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sat, 22 Aug 2026 09:05:36 +0000 Subject: [PATCH 14/33] feat(model-groups): surface capability letters in #-mention autocomplete (ADJ-005) Share the modality letter/color lexicon in model-groups/modality.ts and have the #-mention suggestion tooltip prepend each group's effective media-modality letters (T=text I=image, reasoning excluded) via a lazy ctx.ui.theme colorizer, keeping the trigger/value/label and per-model route details intact. --- model-groups/autocomplete.ts | 39 +++++++++++++-- model-groups/modality.ts | 52 ++++++++++++++++++++ model-groups/tui.ts | 21 +++----- tests/unit/model-groups-autocomplete.test.ts | 23 +++++++++ tests/unit/model-groups-modality.test.ts | 29 +++++++++++ 5 files changed, 144 insertions(+), 20 deletions(-) create mode 100644 model-groups/modality.ts create mode 100644 tests/unit/model-groups-modality.test.ts diff --git a/model-groups/autocomplete.ts b/model-groups/autocomplete.ts index deccdae..9758601 100644 --- a/model-groups/autocomplete.ts +++ b/model-groups/autocomplete.ts @@ -1,6 +1,7 @@ -import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "../state.js"; import { getEffectiveModelGroups } from "./router.js"; +import { MODALITY_FG, modalityLetterRun } from "./modality.js"; import type { ModelGroupModel, ResolvedModelGroup } from "./types.js"; const registeredUis = new WeakSet(); @@ -20,7 +21,28 @@ function formatModelGroupRouteDetails(group: ResolvedModelGroup): string { .join("; "); } -export function createModelGroupAutocompleteProvider(state: AgenticodingState) { +export type DescriptionColorizer = (color: ThemeColor, text: string) => string; + +/** + * Build the `#`-mention tooltip description. With a colorizer, prefix the + * per-model route details with the group's colored effective modality letters + * (media only; reasoning excluded), re-asserting the muted wrapper after each + * colored span so the description stays legible on one line. Without one, the + * plain per-model route details are returned unchanged. + */ +function formatModelGroupSuggestionDetails(group: ResolvedModelGroup, colorize?: DescriptionColorizer): string { + const routeDetails = formatModelGroupRouteDetails(group); + if (!colorize) return routeDetails; + const letters = modalityLetterRun(group.modalities?.effective, { + render: (modality, letter) => colorize(MODALITY_FG[modality], letter), + separator: colorize("muted", " "), + }); + if (!letters) return routeDetails; + const sep = colorize("muted", " "); + return `${letters}${sep}${colorize("muted", routeDetails)}`; +} + +export function createModelGroupAutocompleteProvider(state: AgenticodingState, colorize?: DescriptionColorizer) { return (current: any) => ({ async getSuggestions(lines: string[], cursorLine: number, cursorCol: number, options: unknown) { const line = lines[cursorLine] ?? ""; @@ -37,7 +59,7 @@ export function createModelGroupAutocompleteProvider(state: AgenticodingState) { .map((group) => ({ value: `#${group.name}`, label: `#${group.name}`, - description: formatModelGroupRouteDetails(group), + description: formatModelGroupSuggestionDetails(group, colorize), })); return { prefix: `#${match[1] ?? ""}`, items }; }, @@ -54,10 +76,17 @@ export function createModelGroupAutocompleteProvider(state: AgenticodingState) { export function registerModelGroupAutocomplete(ctx: ExtensionContext, state: AgenticodingState): void { if (!ctx.hasUI) return; - const ui = ctx.ui as unknown as { addAutocompleteProvider?: (factory: ReturnType) => void }; + const ui = ctx.ui as unknown as { + addAutocompleteProvider?: (factory: ReturnType) => void; + theme?: { fg: (color: ThemeColor, text: string) => string }; + }; if (typeof ui.addAutocompleteProvider !== "function") return; const key = ui as object; if (registeredUis.has(key)) return; registeredUis.add(key); - ui.addAutocompleteProvider(createModelGroupAutocompleteProvider(state)); + // Lazy adapter so the colorizer reflects the live theme (not a snapshot). + const colorize: DescriptionColorizer | undefined = ui.theme + ? (color, text) => ui.theme!.fg(color, text) + : undefined; + ui.addAutocompleteProvider(createModelGroupAutocompleteProvider(state, colorize)); } diff --git a/model-groups/modality.ts b/model-groups/modality.ts new file mode 100644 index 0000000..53139ba --- /dev/null +++ b/model-groups/modality.ts @@ -0,0 +1,52 @@ +import type { ThemeColor } from "@earendil-works/pi-coding-agent"; +import { MODEL_GROUP_MODALITIES, type ModelGroupModality } from "./types.js"; + +/** + * Single-letter color/letter lexicon for a group's modality capabilities. + * Shared by the model-groups TUI list and the `#`-mention autocomplete so both + * render the same colored letters (T=text blue, I=image green, R=reasoning purple). + */ +export const MODALITY_FG: Record = { + text: "syntaxKeyword", + image: "success", + reasoning: "thinkingHigh", +}; + +export const MODALITY_LETTER: Record = { + text: "T", + image: "I", + reasoning: "R", +}; + +export interface ModalityLetterRunOptions { + /** Include the reasoning letter. Default false, matching model list rows which surface R per-model. */ + includeReasoning?: boolean; + /** Render one colored/letter token (e.g. a theme colorizer). Defaults to identity. */ + render?(modality: ModelGroupModality, letter: string): string; + /** Separator between colored letters. Default " ". */ + separator?: string; +} + +/** + * Build a compact single-letter capability run for an effective modality set, + * ordered per MODEL_GROUP_MODALITIES (text first). Reasoning is omitted unless + * includeReasoning is set. Returns "" for an empty/absent set. + */ +export function modalityLetterRun( + effective: readonly ModelGroupModality[] | null | undefined, + options: ModalityLetterRunOptions = {}, +): string { + if (!effective || effective.length === 0) return ""; + const includeReasoning = options.includeReasoning ?? false; + const render = options.render ?? ((_modality: ModelGroupModality, letter: string) => letter); + const separator = options.separator ?? " "; + const seen = new Set(effective); + const letters: string[] = []; + for (const modality of MODEL_GROUP_MODALITIES) { + if (!seen.has(modality)) continue; + if (!includeReasoning && modality === "reasoning") continue; + letters.push(render(modality, MODALITY_LETTER[modality])); + } + if (letters.length === 0) return ""; + return letters.join(separator); +} \ No newline at end of file diff --git a/model-groups/tui.ts b/model-groups/tui.ts index 502447d..63fefba 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -1,4 +1,4 @@ -import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent"; +import type { Theme } from "@earendil-works/pi-coding-agent"; import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import { getSupportedThinkingLevels, type Model, type ModelThinkingLevel, type Api } from "@earendil-works/pi-ai"; import { Container, fuzzyFilter, Input, Key, matchesKey, SelectList, truncateToWidth, visibleWidth, type Component, type Focusable, type SelectItem, type TUI } from "@earendil-works/pi-tui"; @@ -14,6 +14,7 @@ import { import { MODEL_GROUP_MODALITIES, 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 { MODALITY_FG, MODALITY_LETTER, modalityLetterRun as buildModalityLetterRun } from "./modality.js"; import { constraintEditorRows, presentConstraintDiagnosticRecords, type ConstraintEditorRow } from "./constraints/presentation.js"; import { productionConstraintRegistry } from "./constraints/registry.js"; import type { AnyConstraintDescriptor, ErasedConstraintEvaluation } from "./constraints/types.js"; @@ -508,16 +509,6 @@ export function createModelGroupsComponent( }; } - const MODALITY_FG: Record = { - text: "syntaxKeyword", - image: "success", - reasoning: "thinkingHigh", - }; - const MODALITY_LETTER: Record = { - text: "T", - image: "I", - reasoning: "R", - }; // Media modalities editable in the modalities screen. Reasoning is a distinct // capability (per-member thinkingLevel / routing gate) and is not exposed here. const VISIBLE_MODALITIES = MODEL_GROUP_MODALITIES.filter((modality) => modality !== "reasoning") as ModelGroupModality[]; @@ -540,10 +531,10 @@ export function createModelGroupsComponent( /** Colored single-letter run for a group's effective media modalities + dimmed padding. Empty set -> "". */ function modalityLetterRun(effective: readonly ModelGroupModality[] | null | undefined): string { - if (!effective || effective.length === 0) return ""; - const visible = effective.filter((modality) => modality !== "reasoning"); - if (visible.length === 0) return ""; - return visible.map((modality) => theme.fg(MODALITY_FG[modality], MODALITY_LETTER[modality])).join(dim(" ")); + return buildModalityLetterRun(effective, { + render: (modality, letter) => theme.fg(MODALITY_FG[modality], letter), + separator: dim(" "), + }); } /** Build a modality-tagged description whose dim segments re-assert dim after each colored letter. */ diff --git a/tests/unit/model-groups-autocomplete.test.ts b/tests/unit/model-groups-autocomplete.test.ts index 9368b58..c3c0ddf 100644 --- a/tests/unit/model-groups-autocomplete.test.ts +++ b/tests/unit/model-groups-autocomplete.test.ts @@ -43,6 +43,29 @@ test("#group autocomplete suggests effective live group names and delegates else assert.equal(provider.shouldTriggerFileCompletion([], 0, 0), false); }); +test("#group autocomplete prepends colored effective modality letters when a colorizer is supplied", async () => { + const state = createState(); + state.modelGroups.groups = [group("research", { models: [{ provider: "google", modelId: "gemini-2.5-pro", thinkingLevel: "high" }] })]; + // Unordered effective set -> must canonicalize to text-first and drop reasoning. + state.modelGroups.groups[0].modalities = { common: [], supported: [], effective: ["reasoning", "image", "text"] }; + let delegated = 0; + const current = { + getSuggestions: async () => { delegated++; return { prefix: "", items: [{ value: "delegated" }] }; }, + applyCompletion: () => "applied", + shouldTriggerFileCompletion: () => false, + }; + const provide = createModelGroupAutocompleteProvider(state, (color, text) => `<${color}>${text}`)(current as any); + const { items } = await provide.getSuggestions(["#res"], 0, 4, {}); + const description = items[0].description; + // Media letters present, canonical order, no reasoning letter. + assert.match(description, /T<\/syntaxKeyword>/); + assert.match(description, /I<\/success>/); + assert.ok(!/R<\//.test(description), "reasoning excluded"); + // Muted wrappers around the separator and the appended per-model route details. + assert.match(description, / <\/muted>google\/gemini-2\.5-pro • high<\/muted>/); + assert.equal(delegated, 0); +}); + test("registerModelGroupAutocomplete uses ctx.ui.addAutocompleteProvider once", () => { const state = createState(); const providers: any[] = []; diff --git a/tests/unit/model-groups-modality.test.ts b/tests/unit/model-groups-modality.test.ts new file mode 100644 index 0000000..36347af --- /dev/null +++ b/tests/unit/model-groups-modality.test.ts @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { MODALITY_FG, MODALITY_LETTER, modalityLetterRun } from "../../model-groups/modality.js"; +import type { ModelGroupModality } from "../../model-groups/types.js"; + +test("MODALITY_LETTER / MODALITY_FG cover the established single-letter lexicon", () => { + assert.deepEqual(MODALITY_LETTER, { text: "T", image: "I", reasoning: "R" }); + assert.equal(MODALITY_FG.text, "syntaxKeyword"); + assert.equal(MODALITY_FG.image, "success"); + assert.equal(MODALITY_FG.reasoning, "thinkingHigh"); +}); + +test("modalityLetterRun canonicalizes order and excludes reasoning by default", () => { + const effective = ["reasoning", "image", "text"] as readonly ModelGroupModality[]; + assert.equal(modalityLetterRun(effective), "T I"); // reasoning dropped, text first + assert.equal(modalityLetterRun(effective, { includeReasoning: true }), "T I R"); + assert.equal(modalityLetterRun(["reasoning"]), ""); + assert.equal(modalityLetterRun([]), ""); + assert.equal(modalityLetterRun(undefined), ""); + assert.equal(modalityLetterRun(null), ""); +}); + +test("modalityLetterRun honors a renderer and separator", () => { + const rendered = modalityLetterRun(["text", "image"], { + render: (modality, letter) => `${MODALITY_FG[modality]}(${letter})`, + separator: "·", + }); + assert.equal(rendered, "syntaxKeyword(T)·success(I)"); +}); \ No newline at end of file From 1084533a0f53c2980d67c2741a5a64344c290b55 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sat, 22 Aug 2026 09:21:47 +0000 Subject: [PATCH 15/33] refine(model-groups): align caps/route columns in #-autocomplete (ADJ-005) Pad the effective media-modality letter column to a shared width across the visible suggestion rows so per-model route details start at the same column, instead of variable-offset text that can cut into the capability column. --- model-groups/autocomplete.ts | 63 +++++++++++++------- tests/unit/model-groups-autocomplete.test.ts | 21 +++++++ 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/model-groups/autocomplete.ts b/model-groups/autocomplete.ts index 9758601..d3235a5 100644 --- a/model-groups/autocomplete.ts +++ b/model-groups/autocomplete.ts @@ -1,3 +1,4 @@ +import { visibleWidth } from "@earendil-works/pi-tui"; import type { ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "../state.js"; import { getEffectiveModelGroups } from "./router.js"; @@ -24,22 +25,37 @@ function formatModelGroupRouteDetails(group: ResolvedModelGroup): string { export type DescriptionColorizer = (color: ThemeColor, text: string) => string; /** - * Build the `#`-mention tooltip description. With a colorizer, prefix the - * per-model route details with the group's colored effective modality letters - * (media only; reasoning excluded), re-asserting the muted wrapper after each - * colored span so the description stays legible on one line. Without one, the - * plain per-model route details are returned unchanged. + * Build one colored/muted single-letter capability run for a group, padded so + * the subsequent per-model route column starts at the same offset across rows. + * Handles an edge case where the group has no effective media modalities. */ -function formatModelGroupSuggestionDetails(group: ResolvedModelGroup, colorize?: DescriptionColorizer): string { - const routeDetails = formatModelGroupRouteDetails(group); - if (!colorize) return routeDetails; - const letters = modalityLetterRun(group.modalities?.effective, { +function buildCapsLetters(group: ResolvedModelGroup, colorize: DescriptionColorizer): string { + return modalityLetterRun(group.modalities?.effective, { render: (modality, letter) => colorize(MODALITY_FG[modality], letter), separator: colorize("muted", " "), }); - if (!letters) return routeDetails; - const sep = colorize("muted", " "); - return `${letters}${sep}${colorize("muted", routeDetails)}`; +} + +/** + * Assemble the `#`-mention tooltip description. + * + * With a colorizer: `T I provider/model • thinking` — the colored media + * letters (reasoning excluded) are padded to a fixed `capsWidth` column, then + * the per-model route details in muted. The muted color is re-asserted after + * every colored span and after the padding so the one-line row stays legible. + * + * Without a colorizer: the plain per-model route details are returned + * unchanged (rows are inherently aligned to the left edge). + */ +function buildSuggestionDescription(route: string, letters: string, capsWidth: number, colorize?: DescriptionColorizer): string { + if (!colorize) return route; + const gap = colorize("muted", " "); + if (capsWidth === 0) return colorize("muted", route); + const pad = Math.max(0, capsWidth - visibleWidth(letters)); + const padded = letters + ? `${letters}${colorize("muted", " ".repeat(pad))}` + : colorize("muted", " ".repeat(capsWidth)); + return `${padded}${gap}${colorize("muted", route)}`; } export function createModelGroupAutocompleteProvider(state: AgenticodingState, colorize?: DescriptionColorizer) { @@ -54,13 +70,20 @@ export function createModelGroupAutocompleteProvider(state: AgenticodingState, c const partial = (match[1] ?? "").toLowerCase(); const groups = getEffectiveModelGroups(state.modelGroups.groups); - const items = groups - .filter((group) => group.name.toLowerCase().startsWith(partial)) - .map((group) => ({ - value: `#${group.name}`, - label: `#${group.name}`, - description: formatModelGroupSuggestionDetails(group, colorize), - })); + const matched = groups.filter((group) => group.name.toLowerCase().startsWith(partial)); + const rows = matched.map((group) => ({ + value: `#${group.name}`, + label: `#${group.name}`, + route: formatModelGroupRouteDetails(group), + letters: colorize ? buildCapsLetters(group, colorize) : "", + })); + // Align the route column across every visible suggestion row. + const capsWidth = rows.reduce((max, row) => Math.max(max, visibleWidth(row.letters)), 0); + const items = rows.map((row) => ({ + value: row.value, + label: row.label, + description: buildSuggestionDescription(row.route, row.letters, capsWidth, colorize), + })); return { prefix: `#${match[1] ?? ""}`, items }; }, @@ -89,4 +112,4 @@ export function registerModelGroupAutocomplete(ctx: ExtensionContext, state: Age ? (color, text) => ui.theme!.fg(color, text) : undefined; ui.addAutocompleteProvider(createModelGroupAutocompleteProvider(state, colorize)); -} +} \ No newline at end of file diff --git a/tests/unit/model-groups-autocomplete.test.ts b/tests/unit/model-groups-autocomplete.test.ts index c3c0ddf..c8d28f3 100644 --- a/tests/unit/model-groups-autocomplete.test.ts +++ b/tests/unit/model-groups-autocomplete.test.ts @@ -74,3 +74,24 @@ test("registerModelGroupAutocomplete uses ctx.ui.addAutocompleteProvider once", registerModelGroupAutocomplete(ctx as any, state); assert.equal(providers.length, 1); }); + +test("group autocomplete aligns the route column through a fixed caps width", async () => { + const state = createState(); + state.modelGroups.groups = [ + group("alpha", { models: [{ provider: "anthropic", modelId: "claude", thinkingLevel: "high" }] }), + group("beta", { models: [{ provider: "openai", modelId: "gpt-5" }] }), + ]; + // alpha has a wider caps run (T I) than beta (T). + state.modelGroups.groups[0].modalities = { common: [], supported: [], effective: ["text", "image"] }; + state.modelGroups.groups[1].modalities = { common: [], supported: [], effective: ["text"] }; + const identity = (color: string, text: string) => text; + const provide = createModelGroupAutocompleteProvider(state, identity as any)({ getSuggestions: async () => null } as any); + const { items } = await provide.getSuggestions(["#"], 0, 1, {}); + const alpha = items[0].description; + const beta = items[1].description; + // alpha: "T I" + gap; beta: "T" padded to width 3 + gap -> both routes start at col 5. + assert.equal(alpha, "T I anthropic/claude • high"); + assert.equal(beta, "T openai/gpt-5 • inherit"); + assert.equal(alpha.indexOf("anthropic/claude"), 5); + assert.equal(beta.indexOf("openai/gpt-5"), 5); +}); From 7171dcf07db1a18eecf24c4ec32b094bfbd08341 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sat, 22 Aug 2026 09:41:20 +0000 Subject: [PATCH 16/33] feat(model-groups): consolidate implied text letter in #-autocomplete (ADJ-005) Image presence implies text, so the #-mentions show a single capability letter (OpenRouter-style): text-only groups keep T, image-bearing groups show only I. Shared modalityLetterRun gains hideTextWhenOtherMedia; the model list rows keep the full T/I lexicon. --- model-groups/autocomplete.ts | 3 +++ model-groups/modality.ts | 23 +++++++++++++++---- tests/unit/model-groups-autocomplete.test.ts | 24 ++++++++++---------- tests/unit/model-groups-modality.test.ts | 11 +++++++++ 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/model-groups/autocomplete.ts b/model-groups/autocomplete.ts index d3235a5..e83a6e6 100644 --- a/model-groups/autocomplete.ts +++ b/model-groups/autocomplete.ts @@ -33,6 +33,9 @@ function buildCapsLetters(group: ResolvedModelGroup, colorize: DescriptionColori return modalityLetterRun(group.modalities?.effective, { render: (modality, letter) => colorize(MODALITY_FG[modality], letter), separator: colorize("muted", " "), + // OpenRouter-style: text is the implied base, so only show I when image is + // present; text-only rows keep the single T. + hideTextWhenOtherMedia: true, }); } diff --git a/model-groups/modality.ts b/model-groups/modality.ts index 53139ba..754c5ff 100644 --- a/model-groups/modality.ts +++ b/model-groups/modality.ts @@ -21,6 +21,13 @@ export const MODALITY_LETTER: Record = { export interface ModalityLetterRunOptions { /** Include the reasoning letter. Default false, matching model list rows which surface R per-model. */ includeReasoning?: boolean; + /** + * OpenRouter-style consolidation: when the visible media set contains any + * non-text modality (image), drop the text letter — text is the always-present + * base, so showing it alongside image is redundant. Text-only groups still + * render T. Default false (preserves the full-text-significant list rows). + */ + hideTextWhenOtherMedia?: boolean; /** Render one colored/letter token (e.g. a theme colorizer). Defaults to identity. */ render?(modality: ModelGroupModality, letter: string): string; /** Separator between colored letters. Default " ". */ @@ -38,15 +45,23 @@ export function modalityLetterRun( ): string { if (!effective || effective.length === 0) return ""; const includeReasoning = options.includeReasoning ?? false; + const hideTextWhenOtherMedia = options.hideTextWhenOtherMedia ?? false; const render = options.render ?? ((_modality: ModelGroupModality, letter: string) => letter); const separator = options.separator ?? " "; const seen = new Set(effective); - const letters: string[] = []; + const rendered: string[] = []; + let hasOtherMedia = false; for (const modality of MODEL_GROUP_MODALITIES) { if (!seen.has(modality)) continue; if (!includeReasoning && modality === "reasoning") continue; - letters.push(render(modality, MODALITY_LETTER[modality])); + if (modality !== "text") hasOtherMedia = true; } - if (letters.length === 0) return ""; - return letters.join(separator); + for (const modality of MODEL_GROUP_MODALITIES) { + if (!seen.has(modality)) continue; + if (!includeReasoning && modality === "reasoning") continue; + if (hideTextWhenOtherMedia && modality === "text" && hasOtherMedia) continue; + rendered.push(render(modality, MODALITY_LETTER[modality])); + } + if (rendered.length === 0) return ""; + return rendered.join(separator); } \ No newline at end of file diff --git a/tests/unit/model-groups-autocomplete.test.ts b/tests/unit/model-groups-autocomplete.test.ts index c8d28f3..a7b2b38 100644 --- a/tests/unit/model-groups-autocomplete.test.ts +++ b/tests/unit/model-groups-autocomplete.test.ts @@ -43,10 +43,10 @@ test("#group autocomplete suggests effective live group names and delegates else assert.equal(provider.shouldTriggerFileCompletion([], 0, 0), false); }); -test("#group autocomplete prepends colored effective modality letters when a colorizer is supplied", async () => { +test("#group autocomplete prepends colored effective modal letters when a colorizer is supplied", async () => { const state = createState(); state.modelGroups.groups = [group("research", { models: [{ provider: "google", modelId: "gemini-2.5-pro", thinkingLevel: "high" }] })]; - // Unordered effective set -> must canonicalize to text-first and drop reasoning. + // Unordered effective set; must canonicalize, drop reasoning, and consolidate text being implied. state.modelGroups.groups[0].modalities = { common: [], supported: [], effective: ["reasoning", "image", "text"] }; let delegated = 0; const current = { @@ -57,11 +57,11 @@ test("#group autocomplete prepends colored effective modality letters when a col const provide = createModelGroupAutocompleteProvider(state, (color, text) => `<${color}>${text}`)(current as any); const { items } = await provide.getSuggestions(["#res"], 0, 4, {}); const description = items[0].description; - // Media letters present, canonical order, no reasoning letter. - assert.match(description, /T<\/syntaxKeyword>/); + // Image presence implies text -> only the image letter shows; no reasoning. assert.match(description, /I<\/success>/); + assert.ok(!/T<\/syntaxKeyword>/.test(description), "text suppressed when image present"); assert.ok(!/R<\//.test(description), "reasoning excluded"); - // Muted wrappers around the separator and the appended per-model route details. + // Muted wrappers around the gaps and the appended per-model route details. assert.match(description, / <\/muted>google\/gemini-2\.5-pro • high<\/muted>/); assert.equal(delegated, 0); }); @@ -75,13 +75,13 @@ test("registerModelGroupAutocomplete uses ctx.ui.addAutocompleteProvider once", assert.equal(providers.length, 1); }); -test("group autocomplete aligns the route column through a fixed caps width", async () => { +test("group autocomplete consolidates to a single consistent letter column", async () => { const state = createState(); state.modelGroups.groups = [ group("alpha", { models: [{ provider: "anthropic", modelId: "claude", thinkingLevel: "high" }] }), group("beta", { models: [{ provider: "openai", modelId: "gpt-5" }] }), ]; - // alpha has a wider caps run (T I) than beta (T). + // alpha has image (implies text) -> single I; beta is text-only -> single T. state.modelGroups.groups[0].modalities = { common: [], supported: [], effective: ["text", "image"] }; state.modelGroups.groups[1].modalities = { common: [], supported: [], effective: ["text"] }; const identity = (color: string, text: string) => text; @@ -89,9 +89,9 @@ test("group autocomplete aligns the route column through a fixed caps width", as const { items } = await provide.getSuggestions(["#"], 0, 1, {}); const alpha = items[0].description; const beta = items[1].description; - // alpha: "T I" + gap; beta: "T" padded to width 3 + gap -> both routes start at col 5. - assert.equal(alpha, "T I anthropic/claude • high"); - assert.equal(beta, "T openai/gpt-5 • inherit"); - assert.equal(alpha.indexOf("anthropic/claude"), 5); - assert.equal(beta.indexOf("openai/gpt-5"), 5); + // Both collapse to a single colored column, so each route starts at the same offset. + assert.equal(alpha, "I anthropic/claude • high"); + assert.equal(beta, "T openai/gpt-5 • inherit"); + assert.equal(alpha.indexOf("anthropic/claude"), 3); + assert.equal(beta.indexOf("openai/gpt-5"), 3); }); diff --git a/tests/unit/model-groups-modality.test.ts b/tests/unit/model-groups-modality.test.ts index 36347af..80e40b2 100644 --- a/tests/unit/model-groups-modality.test.ts +++ b/tests/unit/model-groups-modality.test.ts @@ -20,6 +20,17 @@ test("modalityLetterRun canonicalizes order and excludes reasoning by default", assert.equal(modalityLetterRun(null), ""); }); +test("modalityLetterRun consolidates text away when another media modality is present", () => { + // Image implies text -> only the image letter shows (OpenRouter-style). + assert.equal(modalityLetterRun(["text", "image"], { hideTextWhenOtherMedia: true }), "I"); + // Text-only keeps the T. + assert.equal(modalityLetterRun(["text"], { hideTextWhenOtherMedia: true }), "T"); + // Reasoning is not a media modality; consolidation still applies after it is excluded. + assert.equal(modalityLetterRun(["text", "image", "reasoning"], { hideTextWhenOtherMedia: true }), "I"); + // Default leaves full letters (list rows keep text alongside image). + assert.equal(modalityLetterRun(["text", "image"]), "T I"); +}); + test("modalityLetterRun honors a renderer and separator", () => { const rendered = modalityLetterRun(["text", "image"], { render: (modality, letter) => `${MODALITY_FG[modality]}(${letter})`, From 210f9695e6763a226b4543f440705d891ac00361 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sat, 22 Aug 2026 10:41:16 +0000 Subject: [PATCH 17/33] feat(spawn): orient child to model-group capability ceiling (ADJ-005 L1) When a routed group carries an explicit modality override that excludes image, the spawned child prompt now includes a Model Group capability ceiling notice telling it to report a capability mismatch rather than silently work around it (e.g. reading an image). The router exposes the override ceiling on the route regardless of whether the caller declared a requirement. Advisory only: no enforcement or model-input stripping yet (operator will test-drive before extending scope). --- model-groups/router.ts | 27 +++++++++- spawn/index.ts | 11 ++++ tests/unit/model-groups-router.test.ts | 23 +++++++++ tests/unit/spawn.test.ts | 70 ++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 2 deletions(-) diff --git a/model-groups/router.ts b/model-groups/router.ts index e0b5312..0e218a9 100644 --- a/model-groups/router.ts +++ b/model-groups/router.ts @@ -7,7 +7,22 @@ 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 interface SpawnModelRoute { + status: SpawnRouteStatus; + requestedGroup?: string; + groupName?: string; + model: Model; + provider: string; + modelId: string; + thinking: ModelThinkingLevel; + /** + * For a routed group with an explicit modality override, the group's + * effective capability set. Undefined for inherited/unknown-fallback routes + * and for groups without an explicit override. Exposed so spawn can orient + * the child to the group's declared capability ceiling (Level-1 advisory). + */ + modalityCeiling?: readonly ModelGroupModality[]; +} 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[]; readonly constraintUnsatisfied?: readonly ConstraintViolation[]; @@ -27,7 +42,15 @@ export function resolveSpawnModelRoute(options: { requestedGroup?: string; const 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 (!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) }; + // Level-1 capability orientation: an explicit modality override is the + // group's declared capability ceiling. Carry it on the route regardless + // of whether the caller declared a requirement, so spawn can orient the + // child to the group's allowed scope. + if (group.constraints?.modalities !== undefined) { + route = { ...route, modalityCeiling: [...(group.modalities?.effective ?? [])] }; + } + } } if (!Object.keys(requirements).length) return route; const resolution = group ? resolveConstraintMembers(group.models, options.modelRegistry) : { members: [] }; const violations: ConstraintViolation[] = []; diff --git a/spawn/index.ts b/spawn/index.ts index ce8e15e..92b7d89 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -430,6 +430,16 @@ export function executeSpawn( const authorityNote = state.readonlyEnabled ? READONLY_CHILD_AUTHORITY_NOTE : "You have the same authority as the parent."; + // Level-1 capability orientation: when a model group carries an explicit + // modality override, the child is told the group's allowed scope so it can + // report a capability mismatch instead of silently working around it (e.g. + // reading an image when the group has image disabled). + const imageDisabled = + route.modalityCeiling !== undefined && !route.modalityCeiling.includes("image"); + const capabilityNotice = + route.status === "routed" && imageDisabled + ? `\n\n## Model Group capability ceiling\nImage input is disabled for this group. If the task requires reading or inspecting an image, do not work around it with OCR, third-party tools, or an alternate route; report the capability mismatch to the parent instead.\n\n` + : ""; const fullPrompt = `You are a focused child agent spawned by a parent agent. ` + `${authorityNote} ` + @@ -438,6 +448,7 @@ export function executeSpawn( `${notebookListing}\n\n` + `If you write notebook pages, store only durable shared memory for the parent and future contexts. ` + `Keep transient task state in your final reply to the parent.\n\n` + + `${capabilityNotice}` + `## Task\n\n${params.prompt}${readonlyNotice}\n\n` + `When complete, provide a concise summary of findings. ` + `Keep the result under ${CHILD_MAX_LINES} lines / ${(CHILD_MAX_BYTES / 1024).toFixed(0)}KB.`; diff --git a/tests/unit/model-groups-router.test.ts b/tests/unit/model-groups-router.test.ts index 63d1d55..612c062 100644 --- a/tests/unit/model-groups-router.test.ts +++ b/tests/unit/model-groups-router.test.ts @@ -68,3 +68,26 @@ test("plain inherited route honors requiredModalities with empty-array no-op", ( // Parent lacks a required modality → missing-modality with the parent model details. assert.throws(() => resolveSpawnModelRoute({ constraints: { modalities: { required: ["image"] } }, groups: [], parentModel: text, parentThinking: "medium", modelRegistry: registry([text]) }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "missing-modality" && error.group === "" && error.missingFromModel[0] === "image" && error.missingFromGroup.length === 0 && /Spawn model/.test(error.message)); }); + +test("explicit group override carries a modality ceiling even when caller declares no requirement", () => { + const parent = model("openai", "gpt-parent"); + const vision = model("openai", "gpt-vision", { input: ["text", "image"], reasoning: true }); + const g = group("posed", { + models: [{ provider: "openai", modelId: "gpt-vision" }], + constraints: { modalities: ["text", "reasoning"] }, + }); + g.modalities.effective = ["text", "reasoning"]; + const route = resolveSpawnModelRoute({ requestedGroup: "posed", groups: [g], parentModel: parent, parentThinking: "medium", modelRegistry: registry([parent, vision]) }); + assert.equal(route.status, "routed"); + assert.deepEqual(route.modalityCeiling, ["text", "reasoning"]); +}); + +test("groups without an explicit override get no modality ceiling", () => { + const parent = model("openai", "gpt-parent"); + const vision = model("openai", "gpt-vision", { input: ["text", "image"], reasoning: true }); + const g = group("openbox", { models: [{ provider: "openai", modelId: "gpt-vision" }] }); + g.modalities.effective = ["text", "image", "reasoning"]; + const route = resolveSpawnModelRoute({ requestedGroup: "openbox", groups: [g], parentModel: parent, parentThinking: "medium", modelRegistry: registry([parent, vision]) }); + assert.equal(route.status, "routed"); + assert.equal(route.modalityCeiling, undefined); +}); diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index 6e1d1ef..bebab97 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -244,6 +244,45 @@ test("spawn execute composes Model Group routing with readonly child guards", as assert.deepEqual(result.details.route, { status: "routed", group: "review", provider: "openai", modelId: "gpt-routed" }); }); +test("spawn injects a capability ceiling notice for a routed group with image disabled", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + state.notebookPages.set("entry-a", "preview\nbody"); + const routedModel = { provider: "openai", id: "gpt-vision", reasoning: true, input: ["text", "image"] }; + state.modelGroups.groups = [{ + name: "quick", + scope: "project", + sourcePath: "", + models: [{ provider: "openai", modelId: "gpt-vision" }], + constraints: { modalities: ["text", "reasoning"] }, + modalities: { + common: ["text", "reasoning"], + supported: ["text", "image", "reasoning"], + effective: ["text", "reasoning"], + }, + } as any]; + const parentRegistry = { + find: (_provider: string, modelId: string) => modelId === "gpt-vision" ? routedModel : undefined, + hasConfiguredAuth: (model: any) => model === routedModel, + }; + let seenPrompt = ""; + registerSpawnTool(pi as any, state, async (config: any) => ({ + session: mockSessionFactory({ prompt: async (p?: string) => { seenPrompt = p ?? ""; } }), + extensionsResult: undefined as any, + })); + await pi.tools.get("spawn").execute( + "spawn-quick", + { prompt: "Read the image in file.png", group: "quick" }, + undefined, + undefined, + { model: { provider: "openai", id: "parent" }, cwd: "/tmp", modelRegistry: parentRegistry }, + ); + assert.match(seenPrompt, /## Model Group capability ceiling/i); + assert.match(seenPrompt, /image input is disabled/i); + assert.match(seenPrompt, /report the capability mismatch/i); +}); + test("spawn execute builds prompt with notebook pages and task", async () => { const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); @@ -270,6 +309,37 @@ test("spawn execute builds prompt with notebook pages and task", async () => { assert.doesNotMatch(seenPrompt, /durable grounding/i); }); +test("spawn emits no capability notice when the routed group has no explicit override", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + state.modelGroups.groups = [{ + name: "open", + scope: "project", + sourcePath: "", + models: [{ provider: "openai", modelId: "gpt-vision" }], + } as any]; + const routedModel = { provider: "openai", id: "gpt-vision", reasoning: true, input: ["text", "image"] }; + const parentRegistry = { + find: (_provider: string, modelId: string) => modelId === "gpt-vision" ? routedModel : undefined, + hasConfiguredAuth: (model: any) => model === routedModel, + }; + let seenPrompt = ""; + registerSpawnTool(pi as any, state, async (config: any) => ({ + session: mockSessionFactory({ prompt: async (p?: string) => { seenPrompt = p ?? ""; } }), + extensionsResult: undefined as any, + })); + await pi.tools.get("spawn").execute( + "spawn-open", + { prompt: "Do the task", group: "open" }, + undefined, + undefined, + { model: { provider: "openai", id: "parent" }, cwd: "/tmp", modelRegistry: parentRegistry }, + ); + assert.doesNotMatch(seenPrompt, /capability ceiling/i); + assert.doesNotMatch(seenPrompt, /image input is disabled/i); +}); + test("truncateText handles multi-byte boundaries correctly", () => { assert.equal(truncateText("🙂", 10, 2), ""); assert.equal(truncateText("🙂", 10, 4), "🙂"); From 5e36ff32b801d501266d58c09cdc61fc78bc674d Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sat, 22 Aug 2026 11:34:19 +0000 Subject: [PATCH 18/33] feat(spawn): named model group is binding (ADJ-005 policy B) When the operator explicitly names a specific model group (e.g. #quick-review) and the task requires a capability that group lacks, the parent must NOT substitute a different group, inherit the parent model, or work around the missing capability. It stops and reports the mismatch, asking whether to pick a different group or drop the capability. Aligns the system-prompt model-groups guidance (index.ts modelGroupsPromptSection) with the spawn tool prompt guidelines (spawn/index.ts SPAWN_PROMPT_GUIDELINES). --- index.ts | 1 + spawn/index.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/index.ts b/index.ts index 224fae5..7c75cb2 100644 --- a/index.ts +++ b/index.ts @@ -472,6 +472,7 @@ function modelGroupsPromptSection(groups: ResolvedModelGroup[]): string | undefi 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 constraints. If no known/confident group is requested, omit group and inherit the parent model/thinking. ` + + `An explicitly-named group is binding: if the operator requests a specific group and the task also needs a capability that group lacks, do NOT fall back to a different group, inherit, or work around the missing capability. Stop and report to the operator that the named group cannot do the task; ask whether to pick a different group or drop the capability. ` + `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/spawn/index.ts b/spawn/index.ts index 92b7d89..7245836 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -293,6 +293,7 @@ const SPAWN_PROMPT_GUIDELINES = [ "Use spawn to delegate isolated work to child agents. They are trusted extensions of you with their own context and the same authority. Only condensed results are returned.", "If the operator requests a known Model Group confidently, pass its exact name as group. If no known/confident group is requested, omit group so the child inherits the parent model/thinking.", `Declare constraints when the delegated task needs ${MODEL_GROUP_MODALITY_PROSE} capability; do not work around a missing required modality with third-party tools.`, + `A specified group is binding: if the operator asks for a specific group and it lacks a needed capability, do NOT substitute a different group or inherit the parent model. Stop and report to the operator that the named group cannot satisfy the task, and ask how to proceed.`, ]; const SPAWN_CONSTRAINT_REQUIREMENTS = Type.Object(Object.fromEntries(productionConstraintRegistry.descriptors.map((descriptor) => [descriptor.key, descriptor.requirement.schema])) as any); From 2566eb1710f3c124eac062b1405d69dc8858060d Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sat, 22 Aug 2026 12:17:26 +0000 Subject: [PATCH 19/33] feat(spawn): capability-aware pre-selection with round-robin (D1) and picker chips (D2) D1: when a #group spawn declares a modality requirement, resolveSpawnModelRoute now narrows the selection pool to members whose individual modality fact satisfies it, and round-robins across that capable set via an in-memory session cursor (state.spawnRouteCursors) instead of uniform random over all usable members. Unauthenticated models and non-capable members are excluded; overridden group ceilings still govern and wholly-incapable groups still reject missing-modality. D2: the Add-model picker (WIZARD_MODEL) now renders a per-model colored T/I capability chip by reusing the shared modality lexicon and getModalitiesModelFact, so capability is visible while adding. --- model-groups/router.ts | 5 ++-- model-groups/tui.ts | 7 ++++- spawn/index.ts | 1 + state.ts | 10 +++++++ tests/unit/model-groups-router.test.ts | 39 ++++++++++++++++++++++++-- tests/unit/model-groups-tui.test.ts | 20 +++++++++++++ tests/unit/spawn.test.ts | 28 ++++++++++++++++++ 7 files changed, 104 insertions(+), 6 deletions(-) diff --git a/model-groups/router.ts b/model-groups/router.ts index 0e218a9..de4ca01 100644 --- a/model-groups/router.ts +++ b/model-groups/router.ts @@ -3,6 +3,7 @@ import { clampThinkingLevel, type Api, type Model, type ModelThinkingLevel } fro import { evaluateConstraint, evaluateGroupRequirement, evaluateModelRequirement } from "./constraints/engine.js"; import { productionConstraintRegistry, type ConstraintRegistry } from "./constraints/registry.js"; import { resolveConstraintMembers } from "./constraints/resolution.js"; +import { getModalitiesModelFact } from "./constraints/modalities.js"; import type { ConstraintViolation } from "./constraints/types.js"; import { type ModelGroupModality, type ResolvedModelGroup } from "./types.js"; @@ -38,11 +39,11 @@ 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>; groups: ResolvedModelGroup[]; parentModel: Model; parentThinking: ModelThinkingLevel; modelRegistry: Pick; constraintRegistry?: ConstraintRegistry; rng?: () => number }): SpawnModelRoute { +export function resolveSpawnModelRoute(options: { requestedGroup?: string; constraints?: Readonly>; groups: ResolvedModelGroup[]; parentModel: Model; parentThinking: ModelThinkingLevel; modelRegistry: Pick; constraintRegistry?: ConstraintRegistry; rng?: () => number; routeCursor?: Map }): 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 (!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 rawModal = requirements.modalities; const modalityRequirement = rawModal === undefined ? [] : Array.isArray(rawModal) ? rawModal as ModelGroupModality[] : (() => { const d = registry.get("modalities"); if (!d) return []; const dec = d.requirement.decode(rawModal, "constraints.modalities"); return dec.ok ? dec.value as ModelGroupModality[] : []; })(); const capable = modalityRequirement.length ? usable.filter(({ model }) => modalityRequirement.every((m) => getModalitiesModelFact(model).includes(m))) : usable; const pool = capable.length ? capable : usable; let selected; if (options.routeCursor && modalityRequirement.length && capable.length) { const index = options.routeCursor.get(group.name) ?? 0; options.routeCursor.set(group.name, (index + 1) % pool.length); selected = pool[index % pool.length]; } else { selected = pool[Math.min(pool.length - 1, Math.max(0, Math.floor((options.rng ?? Math.random)() * pool.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) }; // Level-1 capability orientation: an explicit modality override is the // group's declared capability ceiling. Carry it on the route regardless // of whether the caller declared a requirement, so spawn can orient the diff --git a/model-groups/tui.ts b/model-groups/tui.ts index 63fefba..28cb147 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -15,6 +15,7 @@ import { MODEL_GROUP_MODALITIES, ModelGroupsPersistenceError, type ModelGroupDef import { canonicalizeModelGroupName } from "./names.js"; import { decodeDisplayLabel, escapeDisplayLabel } from "./display.js"; import { MODALITY_FG, MODALITY_LETTER, modalityLetterRun as buildModalityLetterRun } from "./modality.js"; +import { getModalitiesModelFact } from "./constraints/modalities.js"; import { constraintEditorRows, presentConstraintDiagnosticRecords, type ConstraintEditorRow } from "./constraints/presentation.js"; import { productionConstraintRegistry } from "./constraints/registry.js"; import type { AnyConstraintDescriptor, ErasedConstraintEvaluation } from "./constraints/types.js"; @@ -574,7 +575,11 @@ export function createModelGroupsComponent( } function buildModelSelect(models: Model[]): SelectList { - const items = models.map((model, index) => ({ value: String(index), label: modelDisplay(model) })); + const items = models.map((model, index) => ({ + value: String(index), + label: modelDisplay(model), + description: modalityLetterRun(getModalitiesModelFact(model)), + })); const select = new SelectList(items, 10, selectTheme); select.setSelectedIndex(Math.min(state.row, Math.max(0, items.length - 1))); select.onSelectionChange = (item) => { state.row = Number(item.value); syncInputFocus(); }; diff --git a/spawn/index.ts b/spawn/index.ts index 7245836..0450c94 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -402,6 +402,7 @@ export function executeSpawn( parentThinking: inheritedChildThinking, modelRegistry: ctx.modelRegistry, constraintRegistry, + routeCursor: state.spawnRouteCursors, }); const childModel = route.model; const requestedChildThinking: ThinkingValue = route.thinking; diff --git a/state.ts b/state.ts index e797705..4a22ea1 100644 --- a/state.ts +++ b/state.ts @@ -136,6 +136,14 @@ export interface AgenticodingState { */ lastWatchdogBand: number | null; + /** + * Capability-aware spawn round-robin cursors keyed by model-group name. + * In-memory only (never persisted); reset on /new so alternation is + * per-live-session fairness, not a cross-session schedule. Identity kept so + * references stay valid across resetState — only .clear() and .set() are used. + */ + spawnRouteCursors: Map; + } /** Create a fresh state instance. Call reset() on /new. */ @@ -172,6 +180,7 @@ export function createState(): AgenticodingState { frontmatterPromptIssues, pendingReadonlyCommands: [], lastWatchdogBand: null, + spawnRouteCursors: new Map(), }; // Prevent replacement — spawn lifecycle code and renderer ownership checks // depend on stable map identity. Only .clear() and .delete() are valid — @@ -215,6 +224,7 @@ export function resetState(state: AgenticodingState): void { state.frontmatterSkillIssues.clear(); state.frontmatterPromptIssues.clear(); state.pendingReadonlyCommands.length = 0; + state.spawnRouteCursors.clear(); abortAndClearChildSessions(state); } diff --git a/tests/unit/model-groups-router.test.ts b/tests/unit/model-groups-router.test.ts index 612c062..0f66553 100644 --- a/tests/unit/model-groups-router.test.ts +++ b/tests/unit/model-groups-router.test.ts @@ -36,15 +36,48 @@ test("known empty and all-unusable groups fail clearly", () => { 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", () => { +test("required modalities prefer a capable member and still reject a wholly-incapable group", () => { const parent = model("p", "parent"); const text = model("p", "text"); const image = model("p", "image", { input: ["text", "image"] }); const routed = group("mixed", { models: [{ provider: "p", modelId: "text" }, { provider: "p", modelId: "image" }], constraints: { modalities: ["text", "image"] } }); routed.modalities = { common: ["text"], supported: ["text", "image"], effective: ["text", "image"] }; const reg = registry([parent, text, image]); - assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "mixed", constraints: { modalities: { required: ["image"] } }, groups: [routed], parentModel: parent, parentThinking: "low", modelRegistry: reg, rng: () => 0 }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "missing-modality" && error.missingFromGroup.length === 0 && error.missingFromModel[0] === "image" && /Routed model/.test(error.message)); - assert.equal(resolveSpawnModelRoute({ requestedGroup: "mixed", constraints: { modalities: { required: ["image"] } }, groups: [routed], parentModel: parent, parentThinking: "low", modelRegistry: reg, rng: () => .99 }).status, "routed"); + // Capability pre-selection: even when rng would land on the text-only member (index 0), + // the router narrows to members whose individual modality fact satisfies image. + const route = resolveSpawnModelRoute({ requestedGroup: "mixed", constraints: { modalities: { required: ["image"] } }, groups: [routed], parentModel: parent, parentThinking: "low", modelRegistry: reg, rng: () => 0 }); + assert.equal(route.status, "routed"); + assert.equal(route.modelId, "image"); + // A group whose effective set does not contain image still rejects. + const textOnly = group("text-only", { models: [{ provider: "p", modelId: "text" }] }); + assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "text-only", constraints: { modalities: { required: ["image"] } }, groups: [textOnly], parentModel: parent, parentThinking: "low", modelRegistry: reg }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "missing-modality" && error.missingFromGroup[0] === "image"); +}); + +test("capability pre-selection excludes unauthenticated capable models and round-robins the capable set", () => { + const parent = model("p", "parent"); + const capA = model("p", "cap-a", { input: ["text", "image"] }); + const capB = model("p", "cap-b", { input: ["text", "image"] }); + const unavailable = model("p", "cap-unavailable", { input: ["text", "image"] }); + const text = model("p", "text"); + const routed = group("mixed", { models: [ + { provider: "p", modelId: "text" }, + { provider: "p", modelId: "cap-a" }, + { provider: "p", modelId: "cap-unavailable" }, + { provider: "p", modelId: "cap-b" }, + ], constraints: { modalities: ["text", "image"] } }); + routed.modalities = { common: ["text"], supported: ["text", "image"], effective: ["text", "image"] }; + // cap-unavailable is not auth-configured, so it must be excluded from the capable pool. + const reg = registry([parent, capA, capB, text], new Set(["p:cap-a", "p:cap-b", "p:text"])); + const cursor = new Map(); + const pick = (rng: () => number) => resolveSpawnModelRoute({ requestedGroup: "mixed", constraints: { modalities: { required: ["image"] } }, groups: [routed], parentModel: parent, parentThinking: "low", modelRegistry: reg, routeCursor: cursor, rng }).modelId; + const seen = new Set(); + for (let i = 0; i < 6; i++) { + const id = pick(() => 0); // rng is ignored when a capability cursor is present + assert.ok(id !== "text", "must never pick the non-capable member"); + assert.ok(id !== "cap-unavailable", "must never pick the unauthenticated member"); + seen.add(id); + } + assert.ok(seen.has("cap-a") && seen.has("cap-b"), `expected both capable members to be reached, got ${[...seen]}`); }); test("known group missing effective modality and inherited fallback reject requirements", () => { diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index fe8d3eb..52c8c20 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -146,6 +146,26 @@ test("model groups TUI renders modality labels, warnings, and stale override cho assert.doesNotMatch(rendered(c), /reasoning/); }); +test("model groups TUI Add-model picker shows capability chips per model", () => { + const review = group("review", { scope: "project" }); + const models = [ + { provider: "openai", id: "gpt-text", reasoning: false, input: ["text"] }, + { provider: "openai", id: "gpt-vision", reasoning: false, input: ["text", "image"] }, + { provider: "openai", id: "gpt-no-auth", reasoning: false, input: ["text", "image"], configuredAuth: false }, + ]; + const { c } = component({ groups: [review], modelRegistry: catalog(models) }); + pressAndRender(c, ENTER, DOWN, DOWN, DOWN, DOWN, ENTER); + assert.match(rendered(c), /Add model — Step 1\/3 Provider/); + pressAndRender(c, DOWN, ENTER); + const text = rendered(c); + assert.match(text, /Add model — Step 2\/3 Model/); + // Capable members show colored T/I chips; the unauthenticated model is not selectable. + assert.doesNotMatch(text, /gpt-no-auth/); + const stripped = stripAnsi(text); + assert.match(stripped, /openai\/gpt-text\s+T/); + assert.match(stripped, /openai\/gpt-vision\s+T I/); +}); + 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"] }; diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index bebab97..b9b7b97 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -244,6 +244,34 @@ test("spawn execute composes Model Group routing with readonly child guards", as assert.deepEqual(result.details.route, { status: "routed", group: "review", provider: "openai", modelId: "gpt-routed" }); }); +test("spawn round-robins across capable members via the session cursor", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + const capA = { provider: "openai", id: "gpt-cap-a", reasoning: true, input: ["text", "image"] }; + const capB = { provider: "openai", id: "gpt-cap-b", reasoning: true, input: ["text", "image"] }; + state.modelGroups.groups = [{ + name: "multi", + scope: "project", + sourcePath: "", + models: [{ provider: "openai", modelId: "gpt-cap-a" }, { provider: "openai", modelId: "gpt-cap-b" }], + constraints: { modalities: ["text", "image"] }, + modalities: { common: ["text"], supported: ["text", "image"], effective: ["text", "image"] }, + } as any]; + const seenModels: string[] = []; + registerSpawnTool(pi as any, state, async (config: any) => { + seenModels.push(config.model.id); + return { session: mockSessionFactory({ prompt: async () => {} }), extensionsResult: undefined as any }; + }); + const ctx = { model: { provider: "openai", id: "parent" }, cwd: "/tmp", modelRegistry: { + find: (_p: string, id: string) => id === "gpt-cap-a" ? capA : id === "gpt-cap-b" ? capB : undefined, + hasConfiguredAuth: (m: any) => m === capA || m === capB, + } } as any; + await pi.tools.get("spawn").execute("spawn-a", { prompt: "t", group: "multi", constraints: { modalities: { required: ["image"] } } }, undefined, undefined, ctx); + await pi.tools.get("spawn").execute("spawn-b", { prompt: "t", group: "multi", constraints: { modalities: { required: ["image"] } } }, undefined, undefined, ctx); + assert.deepEqual(seenModels.sort(), ["gpt-cap-a", "gpt-cap-b"].sort(), "two image-required spawns should alternate across the two capable members"); +}); + test("spawn injects a capability ceiling notice for a routed group with image disabled", async () => { const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); From d53c3ae66164e0236b06773dac00da3e6bcef3ad Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 23 Aug 2026 03:35:15 +0000 Subject: [PATCH 20/33] feat(model-groups): automatic groups default effective modalities to the union Automatic (no-override) groups now expose the union of their members' individual modality facts as the effective capability set, instead of the intersection. This fixes a real routing gate bug: an Automatic mixed group [text, text+image] with an image spawn selected the image-capable member via D1 pre-selection, but the group gate then saw effective=common=[text] and rejected a missingFromGroup=image. An explicit persisted override remains an authoritative subtractive ceiling (text always present; filtered to each member's supported list), preserving ADJ-005 Policy B and the named-group 'stops + reports' contract (e.g. quick-review [text, reasoning] still rejects image spawns). common stays the factual every-member editor field; only the reconcile default changes. Battery: unit 688, snapshots 11, e2e 16, compat 16, package-host, audit-ci. --- model-groups/constraints/modalities.ts | 7 ++++- tests/unit/model-groups-constraints.test.ts | 7 +++-- tests/unit/model-groups-integration.test.ts | 16 ++++++++++ tests/unit/model-groups-modalities.test.ts | 2 +- tests/unit/model-groups-router.test.ts | 35 +++++++++++++++++++++ tests/unit/model-groups-tui.test.ts | 32 +++++++++++++------ 6 files changed, 85 insertions(+), 14 deletions(-) diff --git a/model-groups/constraints/modalities.ts b/model-groups/constraints/modalities.ts index 1d1e339..ace0520 100644 --- a/model-groups/constraints/modalities.ts +++ b/model-groups/constraints/modalities.ts @@ -41,7 +41,12 @@ export const modalitiesConstraint: ConstraintDescriptor<"modalities", ModelGroup return { common, supported, effective: common }; }, reconcile({ aggregate, override }) { - const base = override === undefined ? aggregate.common : ordered(override.filter((modality) => aggregate.supported.includes(modality))); + // Without an explicit override the group's capability is the union of its + // members' individual modalities (any member can satisfy the capability, and + // D1 route pre-selection picks a capable member). An explicit override stays + // an authoritative subtractive ceiling: only members' supported modalities + // may be listed, so the union is never exceeded. + const base = override === undefined ? aggregate.supported : ordered(override.filter((modality) => aggregate.supported.includes(modality))); // Text is the always-present base capability (image/reasoning both imply it); // keep it in the effective set whenever the group supports it, regardless of override. const effective = aggregate.supported.includes("text") ? ordered(["text", ...base.filter((modality) => modality !== "text")]) : base; diff --git a/tests/unit/model-groups-constraints.test.ts b/tests/unit/model-groups-constraints.test.ts index 9a503ae..5d25fd6 100644 --- a/tests/unit/model-groups-constraints.test.ts +++ b/tests/unit/model-groups-constraints.test.ts @@ -19,9 +19,10 @@ test("constraint registry orders descriptors and rejects duplicate keys", () => 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])); - // An unresolved member leaves common empty, but text is always present via the - // resolved member (text is the base invariant), so effective carries text. - assert.deepEqual(result[0], { key: "modalities", aggregate: { common: [], supported: ["text", "image", "reasoning"], effective: [] }, effective: ["text"], diagnostics: [{ key: "modalities", code: "empty-common" }] }); + // An unresolved member leaves common empty (hence the warning), but the union + // default still carries every capability the resolved members support: text is + // the base invariant, so effective carries the full resolved capability set. + assert.deepEqual(result[0], { key: "modalities", aggregate: { common: [], supported: ["text", "image", "reasoning"], effective: [] }, effective: ["text", "image", "reasoning"], diagnostics: [{ key: "modalities", code: "empty-common" }] }); }); test("descriptor codecs report errors and retain vocabulary ordering", () => { diff --git a/tests/unit/model-groups-integration.test.ts b/tests/unit/model-groups-integration.test.ts index 0925841..e1b7b04 100644 --- a/tests/unit/model-groups-integration.test.ts +++ b/tests/unit/model-groups-integration.test.ts @@ -191,6 +191,22 @@ test("before_agent_start injects fresh names-and-effective-modalities guidance", assert.doesNotMatch(result.systemPrompt, /model-groups\.json/); })); +test("before_agent_start exposes union-effective modalities for automatic mixed groups", async () => withTemp(async ({ cwd }) => { + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 2, groups: { mixed: { models: [{ provider: "openai", modelId: "gpt-5" }, { provider: "google", modelId: "gemini-text" }] } } }), "utf8"); + const pi = createTestPI(); + registerAgenticoding(pi as any); + const handler = pi.handlers.get("before_agent_start")!.at(-1)!; + const models = [ + { provider: "openai", id: "gpt-5", input: ["text", "image"], reasoning: true, thinkingLevelMap: { xhigh: "x" } }, + { provider: "google", id: "gemini-text", input: ["text"], reasoning: false }, + ]; + const reg = { getAll: () => models, getAvailable: () => models, find: (provider: string, id: string) => models.find((m) => m.provider === provider && m.id === id), hasConfiguredAuth: () => true }; + // Automatic mixed group: guidance lists the union — image/reasoning present via the capable member. + const result = await handler({ systemPrompt: "Base." }, { hasUI: false, isProjectTrusted: () => true, cwd, modelRegistry: reg, getContextUsage: () => null }); + assert.match(result.systemPrompt, /Available Model Groups: mixed \(text, image, reasoning\)/); +})); + 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"); diff --git a/tests/unit/model-groups-modalities.test.ts b/tests/unit/model-groups-modalities.test.ts index 16f1e81..8b5eb7f 100644 --- a/tests/unit/model-groups-modalities.test.ts +++ b/tests/unit/model-groups-modalities.test.ts @@ -16,7 +16,7 @@ test("derives ordered common, supported, and override-effective modalities from ]; 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"], + common: ["text"], supported: ["text", "image", "reasoning"], effective: ["text", "image", "reasoning"], }); const effectiveAfterOverride = deriveModelGroupModalities({ ...group, constraints: { modalities: ["reasoning", "image"] } }, registry(models)).effective; assert.deepEqual(effectiveAfterOverride, ["text", "image", "reasoning"], "text stays always-present ahead of overridden add-ons"); diff --git a/tests/unit/model-groups-router.test.ts b/tests/unit/model-groups-router.test.ts index 0f66553..14c8374 100644 --- a/tests/unit/model-groups-router.test.ts +++ b/tests/unit/model-groups-router.test.ts @@ -53,6 +53,29 @@ test("required modalities prefer a capable member and still reject a wholly-inca assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "text-only", constraints: { modalities: { required: ["image"] } }, groups: [textOnly], parentModel: parent, parentThinking: "low", modelRegistry: reg }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "missing-modality" && error.missingFromGroup[0] === "image"); }); +test("automatic mixed groups default effective to the union so a capability routes to a capable member", () => { + const parent = model("p", "parent"); + const text = model("p", "text"); + const image = model("p", "image", { input: ["text", "image"] }); + const routed = group("mixed-auto", { models: [{ provider: "p", modelId: "text" }, { provider: "p", modelId: "image" }] }); + // No explicit override: the router reconciles fresh, and the group gate now sees + // the union default, so the image requirement passes and D1 lands on the image + // member even when rng targets the text-only member (index 0). + const reg = registry([parent, text, image]); + const route = resolveSpawnModelRoute({ requestedGroup: "mixed-auto", constraints: { modalities: { required: ["image"] } }, groups: [routed], parentModel: parent, parentThinking: "low", modelRegistry: reg, rng: () => 0 }); + assert.equal(route.status, "routed"); + assert.equal(route.modelId, "image"); + // Automatic groups carry no ceiling. + assert.equal(route.modalityCeiling, undefined); + // The union never invents capabilities no member has: a reasoning requirement on + // members that all lack reasoning still rejects with the group miss. + const textNr = model("p", "text-nr", { reasoning: false }); + const imageNr = model("p", "image-nr", { input: ["text", "image"], reasoning: false }); + const reg2 = registry([parent, textNr, imageNr]); + const routed2 = group("mixed-auto-noreasoning", { models: [{ provider: "p", modelId: "text-nr" }, { provider: "p", modelId: "image-nr" }] }); + assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "mixed-auto-noreasoning", constraints: { modalities: { required: ["reasoning"] } }, groups: [routed2], parentModel: parent, parentThinking: "low", modelRegistry: reg2 }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "missing-modality" && error.missingFromGroup[0] === "reasoning"); +}); + test("capability pre-selection excludes unauthenticated capable models and round-robins the capable set", () => { const parent = model("p", "parent"); const capA = model("p", "cap-a", { input: ["text", "image"] }); @@ -124,3 +147,15 @@ test("groups without an explicit override get no modality ceiling", () => { assert.equal(route.status, "routed"); assert.equal(route.modalityCeiling, undefined); }); + +test("explicit override stays a subtractive ceiling even when a capable member exists", () => { + const parent = model("p", "parent"); + const vision = model("p", "gpt-vision", { input: ["text", "image"], reasoning: true }); + const text = model("p", "text"); + const g = group("posed", { models: [{ provider: "p", modelId: "gpt-vision" }, { provider: "p", modelId: "text" }], constraints: { modalities: ["text", "reasoning"] } }); + g.modalities.effective = ["text", "reasoning"]; + // The group gate must still reject image even though the routed member itself + // supports it: the override is the declared ceiling (rng lands on the vision member). + const reg = registry([parent, vision, text]); + assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "posed", constraints: { modalities: { required: ["image"] } }, groups: [g], parentModel: parent, parentThinking: "low", modelRegistry: reg, rng: () => 0 }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "missing-modality" && error.missingFromGroup[0] === "image" && error.missingFromModel.length === 0); +}); diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index 52c8c20..a8a76db 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -168,14 +168,22 @@ test("model groups TUI Add-model picker shows capability chips per model", () => 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"] }; + // Automatic groups open with their union capability set active; un-toggling a + // supported capability writes a subtractive override excluding it. + review.modalities = { common: ["text"], supported: ["text", "image", "reasoning"], effective: ["text", "image", "reasoning"] }; const calls: Array<{ scope: string; name: string; def: any }> = []; let groups = [review]; + // Mock mirrors production reconciliation: an override narrows effective to its + // supported list; automatic stays at the union. + const reconciledEffective = (def: any, supported: string[]) => { + const base = Array.isArray(def.constraints?.modalities) ? def.constraints.modalities.filter((m: string) => supported.includes(m)) : [...supported]; + return ["text", ...base.filter((m: string) => m !== "text")]; + }; const store = { updateGroup: (scope: string, _cwd: string, name: string, def: any) => { calls.push({ scope, name, def: { ...def, constraints: def.constraints ? { ...def.constraints, ...(Array.isArray(def.constraints.modalities) ? { modalities: [...def.constraints.modalities] } : {}) } : undefined } }); groups = [group(name, { scope: scope as "project", models: def.models, constraints: def.constraints })]; - groups[0].modalities = { common: ["text"], supported: ["text", "image", "reasoning"], effective: ["text", "image", "reasoning"] }; + groups[0].modalities = { common: ["text"], supported: ["text", "image", "reasoning"], effective: reconciledEffective(def, ["text", "image", "reasoning"]) }; }, listResolvedModelGroups: () => boot(groups), }; @@ -187,11 +195,13 @@ test("model groups TUI modality editor commits override and Automatic through up selectRenderedLabel(c, "I image"); press(c, ENTER); assert.equal(calls.length, 1); - assert.deepEqual(calls[0].def.constraints.modalities, ["text", "image"]); + assert.deepEqual(calls[0].def.constraints.modalities, ["text", "reasoning"]); assert.match(rendered(c), /Modalities/, "toggle stays on the modalities screen"); - assert.match(rendered(c), /I image \[on\]/); + assert.match(rendered(c), /I image \[off\]/); press(c, ESC); - assert.match(rendered(c), /Modalities: Override \(text, image\)/); + // The stored override is [text, reasoning]; the modalities detail line hides + // reasoning (per-model thinking), so the visible subtraction is just image. + assert.match(rendered(c), /Modalities: Override \(text\)/); press(c, ENTER); assert.match(rendered(c), /Modalities/); selectRenderedLabel(c, "Automatic"); @@ -205,14 +215,18 @@ test("model groups TUI modality editor commits override and Automatic through up test("model groups TUI Space also toggles a modality and stays on screen", () => { const review = group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] }); - review.modalities = { common: ["text"], supported: ["text", "image"], effective: ["text"] }; + review.modalities = { common: ["text"], supported: ["text", "image"], effective: ["text", "image"] }; const calls: Array<{ scope: string; name: string; def: any }> = []; let groups = [review]; + const reconciledEffective = (def: any, supported: string[]) => { + const base = Array.isArray(def.constraints?.modalities) ? def.constraints.modalities.filter((m: string) => supported.includes(m)) : [...supported]; + return ["text", ...base.filter((m: string) => m !== "text")]; + }; const store = { updateGroup: (scope: string, _cwd: string, name: string, def: any) => { calls.push({ scope, name, def: { ...def, constraints: def.constraints ? { ...def.constraints, ...(Array.isArray(def.constraints.modalities) ? { modalities: [...def.constraints.modalities] } : {}) } : undefined } }); groups = [group(name, { scope: scope as "project", models: def.models, constraints: def.constraints })]; - groups[0].modalities = { common: ["text"], supported: ["text", "image", "reasoning"], effective: ["text", "image", "reasoning"] }; + groups[0].modalities = { common: ["text"], supported: ["text", "image"], effective: reconciledEffective(def, ["text", "image"]) }; }, listResolvedModelGroups: () => boot(groups), }; @@ -224,9 +238,9 @@ test("model groups TUI Space also toggles a modality and stays on screen", () => selectRenderedLabel(c, "I image"); press(c, " "); assert.equal(calls.length, 1); - assert.deepEqual(calls[0].def.constraints.modalities, ["text", "image"]); + assert.deepEqual(calls[0].def.constraints.modalities, ["text"]); assert.match(rendered(c), /Modalities/, "space toggle stays on the modalities screen"); - assert.match(rendered(c), /I image \[on\]/); + assert.match(rendered(c), /I image \[off\]/); }); test("model groups TUI modality editor preserves state and notifies on updateGroup failure", () => { From 6dd3952f12e45538f8cc61e05d1fe2fb5a3e65d1 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 23 Aug 2026 03:47:58 +0000 Subject: [PATCH 21/33] feat(tui): per-model capability chips in the editor and limited/unlimited modalities guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model rows in the editor now render the same colored T/I chips as the Add-model picker (D2), derived per-member from getModalitiesModelFact; unresolved members render without a chip. The modalities hand-edit screen gains two short dim guidance lines explaining Automatic (uses every capability its members support) vs Override (limited to exactly the listed capabilities) — the D3 union-default vs subtractive-ceiling semantics. --- model-groups/tui.ts | 10 +++++++++- tests/unit/model-groups-tui.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/model-groups/tui.ts b/model-groups/tui.ts index 28cb147..aab3550 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -639,7 +639,12 @@ export function createModelGroupsComponent( container.addChild(textLine(sectionBar("Models"))); 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)})`))); + const found = modelRegistry.find(model.provider, model.modelId) as Model | undefined; + // Per-model capability chip, mirroring the Add-model picker (D2). Unresolved + // members have no fact, so they render without a chip. + const chip = found ? modalityLetterRun(getModalitiesModelFact(found)) : ""; + const id = `${escapeDisplayLabel(model.provider)}/${escapeDisplayLabel(model.modelId)}`; + container.addChild(textLine(selectableLine(state.row === index + modelStartRow(), chip ? `${id} ${chip}` : id, ` (${available}, thinking ${thinkingLabel(model.thinkingLevel)})`))); }); const addRow = modelStartRow() + (state.editDraft?.models.length ?? 0); container.addChild(textLine(selectableLine(state.row === addRow, "+ Add model…"))); @@ -670,6 +675,9 @@ export function createModelGroupsComponent( container.addChild(textLine(selectableLine(state.row === index, label))); } container.addChild(textLine(theme.fg("dim", "↑↓ navigate • Enter/Space apply • Esc back"))); + // Short guidance on limited vs unlimited groups when hand-editing modalities. + container.addChild(textLine(theme.fg("dim", "Automatic: the group uses every capability its members support."))); + container.addChild(textLine(theme.fg("dim", "Override: the group is limited to exactly the listed capabilities."))); return container; } diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index a8a76db..3c69f6f 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -166,6 +166,33 @@ test("model groups TUI Add-model picker shows capability chips per model", () => assert.match(stripped, /openai\/gpt-vision\s+T I/); }); +test("model groups TUI editor rows show per-model capability chips and modalities screen explains limited vs unlimited", () => { + const review = group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-text" }, { provider: "openai", modelId: "gpt-vision" }] }); + review.modalities = { common: ["text"], supported: ["text", "image"], effective: ["text", "image"] }; + const models = [ + { provider: "openai", id: "gpt-text", reasoning: false, input: ["text"] }, + { provider: "openai", id: "gpt-vision", reasoning: false, input: ["text", "image"] }, + { provider: "openai", id: "gpt-missing", reasoning: true }, + ]; + const { c } = component({ groups: [review], modelRegistry: catalog(models) }); + press(c, ENTER); + const editor = stripAnsi(rendered(c)); + // Per-model capability chips mirror the Add-model picker; unresolved members carry none. + assert.match(editor, /openai\/gpt-text\s+T\s+\(available/); + assert.match(editor, /openai\/gpt-vision\s+T I\s+\(available/); + assert.doesNotMatch(editor, /gpt-missing/); + // Hand-editing modalities surfaces short guidance for limited vs unlimited groups. + selectRenderedLabel(c, "Modalities:"); + press(c, ENTER); + const modalities = stripAnsi(rendered(c)); + assert.match(modalities, /Automatic: the group uses every capability its members support/); + assert.match(modalities, /Override: the group is limited to exactly the listed capabilities/); + selectRenderedLabel(c, "I image"); + press(c, ENTER); + const afterOverride = stripAnsi(rendered(c)); + assert.match(afterOverride, /Override: the group is limited to exactly the listed capabilities/); +}); + test("model groups TUI modality editor commits override and Automatic through updateGroup", () => { const review = group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] }); // Automatic groups open with their union capability set active; un-toggling a From 1c066d8141d970a141fb180edd45d5e7c85b13f7 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 23 Aug 2026 04:18:30 +0000 Subject: [PATCH 22/33] feat(tui): rebuild modalities editor around disabling union capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modalities hand-edit screen drops the Automatic selector row and is now built around disabling capabilities from the available union set: T text [required] → I image [off] - Text is a non-toggleable required base row (always [required], matching the operator decision that text is present whenever any model is added). - The remaining union media capabilities are [on]/[off] toggles. - Toggling OFF a capability writes a subtractive override from the current effective (preserving hidden reasoning ceilings, e.g. image off on [text,image,reasoning] stores [text,reasoning]). - Toggling the last capability back ON collapses to Automatic only when the ordered candidate equals the full union — so no override (and no spawn ceiling) is stored for a non-limit; entering Automatic remains fully reachable even without a selector row. - A single dynamic status line replaces the two static guidance lines: 'Automatic — using every capability its members support.' vs 'Override — media limited to .' - Zero-toggle screens (text-only) show 'No optional media capabilities available.' + 'Esc back'; Enter/Space are inert. Generic constraintEditorRows() and its constraint-layer test are unchanged; only the TUI screen specializes. Tests updated for the new interaction (override commit, re-enable-to-Automatic collapse, failure notify, dynamic status) plus a new text-only inert-screen test. Battery: unit 690, snapshots 11, e2e 16, compat, package-host. --- model-groups/tui.ts | 70 ++++++++++++++------------ tests/unit/model-groups-tui.test.ts | 76 ++++++++++++++++++++++------- 2 files changed, 97 insertions(+), 49 deletions(-) diff --git a/model-groups/tui.ts b/model-groups/tui.ts index aab3550..b1bd213 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -310,11 +310,12 @@ export function createModelGroupsComponent( function modalityEditorRows(): readonly ConstraintEditorRow[] { const editor = activeConstraintEditor(); if (!editor) return []; - // Media editor rows are the additive, toggleable capabilities only: - // text is the always-present base, and reasoning is handled per-model (thinkingLevel), - // so both are excluded from the toggle list. + // The modalities hand-edit screen is rebuilt around disabling capabilities + // from the union: only the toggleable media rows remain (no Automatic + // selector row). Text is the always-present required base and reasoning is + // handled per-model (thinkingLevel), so both are excluded from toggles. return constraintEditorRows(editor.descriptor, editor.evaluation, state.editDraft?.constraints?.[editor.descriptor.key]).filter( - (row) => row.kind !== "toggle" || (row.value !== "reasoning" && row.value !== "text"), + (row) => row.kind === "toggle" && row.value !== "reasoning" && row.value !== "text", ); } @@ -375,18 +376,25 @@ export function createModelGroupsComponent( if (!state.editDraft) return; const editor = activeConstraintEditor(); const selected = modalityEditorRows()[state.row]; - if (!editor || !selected) return; + // No toggle rows (text-only / unresolvable) → Enter/Space are inert. + if (!editor || !selected || selected.kind !== "toggle") return; const next = cloneDef(state.editDraft); - if (selected.kind === "automatic") { + const base = [...(editor.evaluation.effective as ModelGroupModality[])]; + const pressed = selected.value as ModelGroupModality; + // Disable a capability by subtracting it from the current effective + // (preserving hidden reasoning); re-enable by adding it back. + const toggled = base.includes(pressed) + ? base.filter((modality) => modality !== pressed) + : orderedModalities([...base, pressed]); + const supported = orderedModalities((editor.evaluation.aggregate as { supported?: ModelGroupModality[] }).supported ?? []); + if (orderedModalities(toggled).join(",") === supported.join(",")) { + // Re-enabling back to the full union returns the group to Automatic: + // no override is stored, so no spawn ceiling attaches to a non-limit. if (next.constraints) delete next.constraints[editor.descriptor.key]; - } else if (selected.kind === "toggle") { - const base = [...(editor.evaluation.effective as ModelGroupModality[])]; - const pressed = selected.value as ModelGroupModality; - const toggled = base.includes(pressed) - ? base.filter((modality) => modality !== pressed) - : orderedModalities([...base, pressed]); + if (next.constraints && Object.keys(next.constraints).length === 0) delete next.constraints; + } else { (next.constraints ??= {})[editor.descriptor.key] = toggled; - } else return; + } const updated = persistDraft(next); if (updated) { // Re-bind the draft to the toggled override while staying on this screen. @@ -656,28 +664,26 @@ export function createModelGroupsComponent( const container = new Container(); const editor = activeConstraintEditor(); const current = currentEditGroup(); + const key = editor?.descriptor.key ?? "modalities"; + const isAutomatic = state.editDraft?.constraints?.[key] === undefined; + const effective = (editor?.evaluation.effective ?? []) as ModelGroupModality[]; container.addChild(textLine(theme.fg("accent", `Modalities — ${escapeDisplayLabel(current?.name ?? "")}`))); - const isAutomatic = state.editDraft?.constraints?.[editor?.descriptor.key ?? "modalities"] === undefined; - container.addChild(textLine(` ${theme.fg(MODALITY_FG.text, MODALITY_LETTER.text)}${dim(" text required base")}`)); - for (const [index, row] of modalityEditorRows().entries()) { - let label: string; - if (row.kind === "toggle") { - const modality = row.value as ModelGroupModality; - const letter = theme.fg(MODALITY_FG[modality], MODALITY_LETTER[modality]); - label = `${letter}${dim(" " + row.label)}${dim(row.active ? " [on]" : " [off]")}`; - } else if (row.kind === "automatic") { - label = `${dim(row.label)}${isAutomatic ? dim(" [✓]") : ""}`; - } else if (row.kind === "number") { - label = `${row.label}: ${row.value ?? "none"} ${row.unit}`; - } else { - label = row.label; - } + // Text is the always-present base capability, not toggleable. + container.addChild(textLine(` ${theme.fg(MODALITY_FG.text, MODALITY_LETTER.text)}${dim(" text [required]")}`)); + const rows = modalityEditorRows(); + for (const [index, row] of rows.entries()) { + if (row.kind !== "toggle") continue; + const modality = row.value as ModelGroupModality; + const letter = theme.fg(MODALITY_FG[modality], MODALITY_LETTER[modality]); + const label = `${letter}${dim(" " + row.label)}${dim(row.active ? " [on]" : " [off]")}`; container.addChild(textLine(selectableLine(state.row === index, label))); } - container.addChild(textLine(theme.fg("dim", "↑↓ navigate • Enter/Space apply • Esc back"))); - // Short guidance on limited vs unlimited groups when hand-editing modalities. - container.addChild(textLine(theme.fg("dim", "Automatic: the group uses every capability its members support."))); - container.addChild(textLine(theme.fg("dim", "Override: the group is limited to exactly the listed capabilities."))); + if (rows.length === 0) container.addChild(textLine(theme.fg("dim", " No optional media capabilities available."))); + container.addChild(textLine(theme.fg("dim", rows.length ? "↑↓ navigate • Enter/Space toggle • Esc back" : "Esc back"))); + // Single dynamic status: Automatic uses the union; an override limits the media set. + container.addChild(textLine(theme.fg("dim", isAutomatic + ? "Automatic — using every capability its members support." + : `Override — media limited to ${effective.filter((modality) => modality !== "reasoning").join(", ") || "none"}.`))); return container; } diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index 3c69f6f..71276eb 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -140,9 +140,10 @@ test("model groups TUI renders modality labels, warnings, and stale override cho assert.match(rendered(c), /Capabilities/); assert.match(rendered(c), /Models/); press(c, DOWN, DOWN, DOWN, ENTER); - assert.match(rendered(c), /Automatic \(text\)/); - assert.match(rendered(c), /T text required base/); + assert.match(rendered(c), /T text \[required\]/); assert.match(rendered(c), /I image \[on\]/); + // The group carries a persisted override (text, image, reasoning) → status reflects it. + assert.match(rendered(c), /Override — media limited to text, image/); assert.doesNotMatch(rendered(c), /reasoning/); }); @@ -166,7 +167,7 @@ test("model groups TUI Add-model picker shows capability chips per model", () => assert.match(stripped, /openai\/gpt-vision\s+T I/); }); -test("model groups TUI editor rows show per-model capability chips and modalities screen explains limited vs unlimited", () => { +test("model groups TUI editor rows show per-model capability chips and modalities screen shows dynamic Automatic/Override status", () => { const review = group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-text" }, { provider: "openai", modelId: "gpt-vision" }] }); review.modalities = { common: ["text"], supported: ["text", "image"], effective: ["text", "image"] }; const models = [ @@ -174,23 +175,41 @@ test("model groups TUI editor rows show per-model capability chips and modalitie { provider: "openai", id: "gpt-vision", reasoning: false, input: ["text", "image"] }, { provider: "openai", id: "gpt-missing", reasoning: true }, ]; - const { c } = component({ groups: [review], modelRegistry: catalog(models) }); + // Mock store mirrors production reconciliation so the toggle can persist. + let groups = [review]; + const reconciledEffective = (def: any, supported: string[]) => { + const base = Array.isArray(def.constraints?.modalities) ? def.constraints.modalities.filter((m: string) => supported.includes(m)) : [...supported]; + return ["text", ...base.filter((m: string) => m !== "text")]; + }; + const store = { + updateGroup: (scope: string, _cwd: string, name: string, def: any) => { + groups = [group(name, { scope: scope as "project", models: def.models, constraints: def.constraints })]; + groups[0].modalities = { common: ["text"], supported: ["text", "image"], effective: reconciledEffective(def, ["text", "image"]) }; + }, + listResolvedModelGroups: () => boot(groups), + }; + const { c } = component({ groups: [review], store, modelRegistry: catalog(models) }); press(c, ENTER); const editor = stripAnsi(rendered(c)); // Per-model capability chips mirror the Add-model picker; unresolved members carry none. assert.match(editor, /openai\/gpt-text\s+T\s+\(available/); assert.match(editor, /openai\/gpt-vision\s+T I\s+\(available/); assert.doesNotMatch(editor, /gpt-missing/); - // Hand-editing modalities surfaces short guidance for limited vs unlimited groups. + // Hand-editing modalities surfaces a dynamic status for Automatic vs Override. selectRenderedLabel(c, "Modalities:"); press(c, ENTER); const modalities = stripAnsi(rendered(c)); - assert.match(modalities, /Automatic: the group uses every capability its members support/); - assert.match(modalities, /Override: the group is limited to exactly the listed capabilities/); + // Text is a non-toggleable required base; no Automatic selector row remains. + assert.match(modalities, /T text \[required\]/); + assert.match(modalities, /Automatic — using every capability its members support/); + assert.doesNotMatch(modalities, /Automatic \(/); + assert.doesNotMatch(modalities, /required base/); + assert.match(modalities, /↑↓ navigate • Enter\/Space toggle • Esc back/); selectRenderedLabel(c, "I image"); press(c, ENTER); const afterOverride = stripAnsi(rendered(c)); - assert.match(afterOverride, /Override: the group is limited to exactly the listed capabilities/); + assert.match(afterOverride, /Override — media limited to text/); + assert.match(afterOverride, /I image \[off\]/); }); test("model groups TUI modality editor commits override and Automatic through updateGroup", () => { @@ -225,17 +244,15 @@ test("model groups TUI modality editor commits override and Automatic through up assert.deepEqual(calls[0].def.constraints.modalities, ["text", "reasoning"]); assert.match(rendered(c), /Modalities/, "toggle stays on the modalities screen"); assert.match(rendered(c), /I image \[off\]/); - press(c, ESC); - // The stored override is [text, reasoning]; the modalities detail line hides - // reasoning (per-model thinking), so the visible subtraction is just image. - assert.match(rendered(c), /Modalities: Override \(text\)/); - press(c, ENTER); - assert.match(rendered(c), /Modalities/); - selectRenderedLabel(c, "Automatic"); + assert.match(rendered(c), /Override — media limited to text/); + // Re-enabling the capability back to the full union returns the group to Automatic + // (no stored override, so no spawn ceiling attaches to a non-limit). + selectRenderedLabel(c, "I image"); press(c, ENTER); assert.equal(calls.length, 2); assert.equal(calls[1].def.constraints?.modalities, undefined); - assert.match(rendered(c), /Modalities/, "reset also stays on the modalities screen"); + assert.match(rendered(c), /Modalities/, "re-enable also stays on the modalities screen"); + assert.match(rendered(c), /Automatic — using every capability its members support/); press(c, ESC); assert.match(rendered(c), /Modalities: Automatic \(text, image\)/); }); @@ -284,11 +301,36 @@ test("model groups TUI modality editor preserves state and notifies on updateGro }; 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 + press(c, ENTER); // toggle the single media row → 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 text-only modalities screen is inert and states Automatic", () => { + const textOnly = group("text-only", { scope: "project", models: [{ provider: "openai", modelId: "gpt-text" }] }); + textOnly.modalities = { common: ["text"], supported: ["text"], effective: ["text"] }; + let updateCalls = 0; + const store = { + updateGroup: () => { updateCalls++; }, + listResolvedModelGroups: () => boot([textOnly]), + }; + const { c } = component({ groups: [textOnly], store }); + press(c, ENTER); + selectRenderedLabel(c, "Modalities:"); + press(c, ENTER); + const screen = stripAnsi(rendered(c)); + // No toggleable media rows: only the required text row, no toggle nav, no selection. + assert.match(screen, /T text \[required\]/); + assert.match(screen, /No optional media capabilities available/); + assert.match(screen, /Automatic — using every capability its members support/); + assert.match(screen, /Esc back/); + assert.doesNotMatch(screen, /Enter\/Space toggle/); + assert.doesNotMatch(screen, /→/); + // Enter/Space on the inert row must not persist anything. + press(c, ENTER, " ", ENTER); + assert.equal(updateCalls, 0); +}); + test("model groups TUI computes unique new-group names and opens editor after create", () => { let groups = [group("new-group", { scope: "project" })]; const calls: string[] = []; From cf588b01d13622d7d5c1a6caa97cf80f7befed51 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 23 Aug 2026 04:25:31 +0000 Subject: [PATCH 23/33] feat(tui): advertise toggleability on editable modality rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The [on]/[off] state of a capability row read like a fixed fact beside the [required] text base row. Editable rows now append a dim hint so the image capability is clearly interactive: T text [required] → I image [on] — Enter/Space toggles The required text row and text-only screens are unchanged (no hint). Tests extended: editable row shows the hint, required row does not, text-only screen never shows it. --- model-groups/tui.ts | 4 +++- tests/unit/model-groups-tui.test.ts | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/model-groups/tui.ts b/model-groups/tui.ts index b1bd213..0d33739 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -675,7 +675,9 @@ export function createModelGroupsComponent( if (row.kind !== "toggle") continue; const modality = row.value as ModelGroupModality; const letter = theme.fg(MODALITY_FG[modality], MODALITY_LETTER[modality]); - const label = `${letter}${dim(" " + row.label)}${dim(row.active ? " [on]" : " [off]")}`; + // Editable rows advertise the toggle action so [on]/[off] reads as live + // state, not a fixed fact — unlike the [required] text base row. + const label = `${letter}${dim(" " + row.label)}${dim(row.active ? " [on]" : " [off]")}${dim(" — Enter/Space toggles")}`; container.addChild(textLine(selectableLine(state.row === index, label))); } if (rows.length === 0) container.addChild(textLine(theme.fg("dim", " No optional media capabilities available."))); diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index 71276eb..43de0a3 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -205,6 +205,9 @@ test("model groups TUI editor rows show per-model capability chips and modalitie assert.doesNotMatch(modalities, /Automatic \(/); assert.doesNotMatch(modalities, /required base/); assert.match(modalities, /↑↓ navigate • Enter\/Space toggle • Esc back/); + // Editable rows advertise the toggle action; the required text row does not. + assert.match(modalities, /I image \[on\] — Enter\/Space toggles/); + assert.doesNotMatch(modalities, /text \[required\].*Enter\/Space toggles/); selectRenderedLabel(c, "I image"); press(c, ENTER); const afterOverride = stripAnsi(rendered(c)); @@ -325,6 +328,7 @@ test("model groups TUI text-only modalities screen is inert and states Automatic assert.match(screen, /Automatic — using every capability its members support/); assert.match(screen, /Esc back/); assert.doesNotMatch(screen, /Enter\/Space toggle/); + assert.doesNotMatch(screen, /Enter\/Space toggles/); assert.doesNotMatch(screen, /→/); // Enter/Space on the inert row must not persist anything. press(c, ENTER, " ", ENTER); From 601374d274db5c09d501da0fcc4c1a0e05b18dc2 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 23 Aug 2026 04:42:56 +0000 Subject: [PATCH 24/33] feat(tui): streamline modalities editor to a single toggle with accent highlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per planner-design + operator calls: - The per-row '— Enter/Space toggles' hint is removed (was noise on the typical T+I vocabulary). - Single editable row screens drop '↑↓ navigate' — the footer is just 'Enter/Space toggle • Esc back' (arrow-nav returns only if a second visible media row ever appears). - The selected capability row is now accent-highlighted: accent arrow + accent label while the modality letter keeps its own color. Previously the selectableLine accent wrap was neutralized by inner color resets, so the row appeared unhighlighted. - Text-only screens keep 'No optional media capabilities available.' + 'Esc back' with Enter/Space inert. Tests: hint assertions replaced by compact-footer assertions; accent-token test extended to MODALITIES asserting the highlight markup; width-bounded test comment corrected (the assertion after the name row is MODALITIES, not MODEL_EDIT). Battery: unit 690, snapshots 11, e2e 16, compat, package-host. --- model-groups/tui.ts | 17 +++++++++++------ tests/unit/model-groups-tui.test.ts | 19 ++++++++++++++----- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/model-groups/tui.ts b/model-groups/tui.ts index 0d33739..0f87e6a 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -667,21 +667,26 @@ export function createModelGroupsComponent( const key = editor?.descriptor.key ?? "modalities"; const isAutomatic = state.editDraft?.constraints?.[key] === undefined; const effective = (editor?.evaluation.effective ?? []) as ModelGroupModality[]; + const rows = modalityEditorRows(); container.addChild(textLine(theme.fg("accent", `Modalities — ${escapeDisplayLabel(current?.name ?? "")}`))); // Text is the always-present base capability, not toggleable. container.addChild(textLine(` ${theme.fg(MODALITY_FG.text, MODALITY_LETTER.text)}${dim(" text [required]")}`)); - const rows = modalityEditorRows(); for (const [index, row] of rows.entries()) { if (row.kind !== "toggle") continue; const modality = row.value as ModelGroupModality; const letter = theme.fg(MODALITY_FG[modality], MODALITY_LETTER[modality]); - // Editable rows advertise the toggle action so [on]/[off] reads as live - // state, not a fixed fact — unlike the [required] text base row. - const label = `${letter}${dim(" " + row.label)}${dim(row.active ? " [on]" : " [off]")}${dim(" — Enter/Space toggles")}`; - container.addChild(textLine(selectableLine(state.row === index, label))); + const stateText = row.active ? "[on]" : "[off]"; + // The selected row is accent-highlighted (arrow + label) while the modality + // letter keeps its own color; [on]/[off] read as live toggle state. + const selected = state.row === index; + const label = selected + ? `${theme.fg("accent", "→")} ${letter}${theme.fg("accent", ` ${row.label} ${stateText}`)}` + : ` ${letter}${dim(` ${row.label} ${stateText}`)}`; + container.addChild(textLine(label)); } if (rows.length === 0) container.addChild(textLine(theme.fg("dim", " No optional media capabilities available."))); - container.addChild(textLine(theme.fg("dim", rows.length ? "↑↓ navigate • Enter/Space toggle • Esc back" : "Esc back"))); + // Single editable row screens drop arrow navigation — toggling + Esc is the whole flow. + container.addChild(textLine(theme.fg("dim", rows.length > 1 ? "↑↓ navigate • Enter/Space toggle • Esc back" : rows.length === 1 ? "Enter/Space toggle • Esc back" : "Esc back"))); // Single dynamic status: Automatic uses the union; an override limits the media set. container.addChild(textLine(theme.fg("dim", isAutomatic ? "Automatic — using every capability its members support." diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index 43de0a3..a17e1f2 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -204,10 +204,11 @@ test("model groups TUI editor rows show per-model capability chips and modalitie assert.match(modalities, /Automatic — using every capability its members support/); assert.doesNotMatch(modalities, /Automatic \(/); assert.doesNotMatch(modalities, /required base/); - assert.match(modalities, /↑↓ navigate • Enter\/Space toggle • Esc back/); - // Editable rows advertise the toggle action; the required text row does not. - assert.match(modalities, /I image \[on\] — Enter\/Space toggles/); - assert.doesNotMatch(modalities, /text \[required\].*Enter\/Space toggles/); + // Single editable row: compact footer without arrow-nav; no per-row hint. + assert.match(modalities, /Enter\/Space toggle • Esc back/); + assert.doesNotMatch(modalities, /↑↓ navigate/); + assert.doesNotMatch(modalities, /Enter\/Space toggles/); + assert.match(modalities, /I image \[on\]/); selectRenderedLabel(c, "I image"); press(c, ENTER); const afterOverride = stripAnsi(rendered(c)); @@ -477,6 +478,14 @@ test("model groups TUI selected markers and primary labels use accent token", () press(modelEdit, ENTER, DOWN, DOWN, DOWN, DOWN, ENTER); assert.match(rendered(modelEdit), /→<\/accent> Thinking: inherit<\/accent>/); + const g = group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] }); + g.modalities = { common: ["text"], supported: ["text", "image"], effective: ["text", "image"] }; + const modalitiesC = component({ groups: [g], renderTheme: accentTheme }).c; + press(modalitiesC, ENTER, DOWN, DOWN, DOWN, ENTER); + // The selected capability row is accent-highlighted; the modality letter keeps its own color. + assert.match(rendered(modalitiesC), /→<\/accent> I image \[on\]<\/accent>/); + assert.match(rendered(modalitiesC), /Enter\/Space toggle • Esc back/); + const deleteConfirm = component({ groups: [group("review", { scope: "project" })], renderTheme: accentTheme }).c; press(deleteConfirm, "D"); assert.match(rendered(deleteConfirm), /→<\/accent> Keep group<\/accent>/); @@ -829,7 +838,7 @@ test("model groups TUI keeps every screen width-bounded without wrapping logical if (width === 12) assert.match(stripAnsi(lines.join("\n")).replaceAll(CURSOR_MARKER, ""), /界e\u0301/); } press(c, DOWN, ENTER); - assertScreen(c); // MODEL_EDIT + assertScreen(c); // MODALITIES (from the name row, Down lands on the Modalities row) press(c, ESC, DOWN, DOWN, DOWN, DOWN, ENTER); assertScreen(c); // WIZARD_PROVIDER press(c, DOWN, DOWN, ENTER); From 84b54e23f2e47d63b6d3a1aff2e07aba80cfd504 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 23 Aug 2026 08:40:19 +0000 Subject: [PATCH 25/33] fix(review): fold cross-cutting capability-generic seam (PR #27) - V2-A-01: fully-stale non-empty modality override falls back to derived union; explicit [] override stays text-only; subtractive ceiling kept - V2-B-02: TUI cloneDef preserves opaque top-level group keys on edit - V1-01: trim trailing whitespace in modalities diagnostic - V2-B-01/V1-03: low-level save is shape-only, CRUD rejects union-cap invalid overrides; boundary locked by test - V1-04: malformed legacy v1 modalityOverride loads opaquely, drops on v2 - V3-A-02: generic descriptor selection + descriptor-owned ceiling advisories (no image literal in spawn) - test-only synthetic min-context and max-budget descriptors prove the capability-generic seam; production registry stays exactly {modalities} Battery: typecheck, npm test 695/695, e2e 16/16, snapshots 11/11, compat 166/166, package-host, audit-ci, git diff --check --- model-groups/constraints/modalities.ts | 10 +++- model-groups/constraints/types.ts | 2 + model-groups/router.ts | 50 +++++++++---------- model-groups/tui.ts | 2 +- spawn/index.ts | 12 ++--- .../unit/model-groups-constraints-fixture.ts | 44 +++++++++++++++- tests/unit/model-groups-constraints.test.ts | 29 ++++++++++- tests/unit/model-groups-crud.test.ts | 21 +++++++- tests/unit/model-groups-router.test.ts | 25 ++++++++-- tests/unit/model-groups-tui.test.ts | 2 + tests/unit/spawn.test.ts | 25 ++++++++++ 11 files changed, 177 insertions(+), 45 deletions(-) diff --git a/model-groups/constraints/modalities.ts b/model-groups/constraints/modalities.ts index ace0520..072f91e 100644 --- a/model-groups/constraints/modalities.ts +++ b/model-groups/constraints/modalities.ts @@ -46,7 +46,10 @@ export const modalitiesConstraint: ConstraintDescriptor<"modalities", ModelGroup // D1 route pre-selection picks a capable member). An explicit override stays // an authoritative subtractive ceiling: only members' supported modalities // may be listed, so the union is never exceeded. - const base = override === undefined ? aggregate.supported : ordered(override.filter((modality) => aggregate.supported.includes(modality))); + const filteredOverride = override === undefined ? undefined : ordered(override.filter((modality) => aggregate.supported.includes(modality))); + // An explicit [] is an intentional text-only ceiling. A non-empty override + // made entirely stale, however, must fall back to the derived union. + const base = override === undefined ? aggregate.supported : override.length === 0 ? [] : filteredOverride!.length ? filteredOverride! : aggregate.supported; // Text is the always-present base capability (image/reasoning both imply it); // keep it in the effective set whenever the group supports it, regardless of override. const effective = aggregate.supported.includes("text") ? ordered(["text", ...base.filter((modality) => modality !== "text")]) : base; @@ -75,8 +78,11 @@ export const modalitiesConstraint: ConstraintDescriptor<"modalities", ModelGroup prompt: (evaluation) => evaluation.effective.join(", "), diagnostic: (diagnostic) => diagnostic.code === "empty-common" ? "⚠ no common modalities" - : `⚠ stale modality override: ${((diagnostic.details as ModelGroupModality[] | undefined) ?? []).join(", ")}`, + : `⚠ stale modality override: ${((diagnostic.details as ModelGroupModality[] | undefined) ?? []).join(", ")}`, violation: (violation: ConstraintViolation) => violation.key, + ceiling: (evaluation) => evaluation.aggregate.supported.includes("image") && !evaluation.effective.includes("image") + ? "Image input is disabled for this group. If the task requires reading or inspecting an image, do not work around it with OCR, third-party tools, or an alternate route; report the capability mismatch to the parent instead." + : undefined, }, }; diff --git a/model-groups/constraints/types.ts b/model-groups/constraints/types.ts index 82ca613..9263d82 100644 --- a/model-groups/constraints/types.ts +++ b/model-groups/constraints/types.ts @@ -60,6 +60,8 @@ export interface ConstraintDescriptor): string; diagnostic(diagnostic: ConstraintDiagnostic): string; violation(violation: ConstraintViolation): string; + /** Optional child-facing note for an explicit group capability ceiling. */ + ceiling?(evaluation: ConstraintEvaluation): string | undefined; }; } diff --git a/model-groups/router.ts b/model-groups/router.ts index de4ca01..9ae17b1 100644 --- a/model-groups/router.ts +++ b/model-groups/router.ts @@ -3,8 +3,7 @@ import { clampThinkingLevel, type Api, type Model, type ModelThinkingLevel } fro import { evaluateConstraint, evaluateGroupRequirement, evaluateModelRequirement } from "./constraints/engine.js"; import { productionConstraintRegistry, type ConstraintRegistry } from "./constraints/registry.js"; import { resolveConstraintMembers } from "./constraints/resolution.js"; -import { getModalitiesModelFact } from "./constraints/modalities.js"; -import type { ConstraintViolation } from "./constraints/types.js"; +import type { AnyConstraintDescriptor, ConstraintViolation } from "./constraints/types.js"; import { type ModelGroupModality, type ResolvedModelGroup } from "./types.js"; export type SpawnRouteStatus = "inherited" | "routed" | "unknown-fallback"; @@ -16,13 +15,8 @@ export interface SpawnModelRoute { provider: string; modelId: string; thinking: ModelThinkingLevel; - /** - * For a routed group with an explicit modality override, the group's - * effective capability set. Undefined for inherited/unknown-fallback routes - * and for groups without an explicit override. Exposed so spawn can orient - * the child to the group's declared capability ceiling (Level-1 advisory). - */ - modalityCeiling?: readonly ModelGroupModality[]; + /** Child-facing notes produced by constraint descriptors for explicit group ceilings. */ + groupCapabilityCeilings?: readonly string[]; } export type SpawnRouteErrorReason = "empty" | "no-usable-models" | "missing-modality" | "constraint-unsatisfied"; export class SpawnRouteError extends Error { @@ -41,28 +35,32 @@ export function getEffectiveModelGroupNames(groups: ResolvedModelGroup[]): strin /** Route selection remains auth-aware; constraint evaluation receives its explicit member snapshot. */ export function resolveSpawnModelRoute(options: { requestedGroup?: string; constraints?: Readonly>; groups: ResolvedModelGroup[]; parentModel: Model; parentThinking: ModelThinkingLevel; modelRegistry: Pick; constraintRegistry?: ConstraintRegistry; rng?: () => number; routeCursor?: Map }): 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 rawModal = requirements.modalities; const modalityRequirement = rawModal === undefined ? [] : Array.isArray(rawModal) ? rawModal as ModelGroupModality[] : (() => { const d = registry.get("modalities"); if (!d) return []; const dec = d.requirement.decode(rawModal, "constraints.modalities"); return dec.ok ? dec.value as ModelGroupModality[] : []; })(); const capable = modalityRequirement.length ? usable.filter(({ model }) => modalityRequirement.every((m) => getModalitiesModelFact(model).includes(m))) : usable; const pool = capable.length ? capable : usable; let selected; if (options.routeCursor && modalityRequirement.length && capable.length) { const index = options.routeCursor.get(group.name) ?? 0; options.routeCursor.set(group.name, (index + 1) % pool.length); selected = pool[index % pool.length]; } else { selected = pool[Math.min(pool.length - 1, Math.max(0, Math.floor((options.rng ?? Math.random)() * pool.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) }; - // Level-1 capability orientation: an explicit modality override is the - // group's declared capability ceiling. Carry it on the route regardless - // of whether the caller declared a requirement, so spawn can orient the - // child to the group's allowed scope. - if (group.constraints?.modalities !== undefined) { - route = { ...route, modalityCeiling: [...(group.modalities?.effective ?? [])] }; - } - } } - if (!Object.keys(requirements).length) return route; - const resolution = group ? resolveConstraintMembers(group.models, options.modelRegistry) : { members: [] }; - const violations: ConstraintViolation[] = []; - for (const [key, rawRequirement] of Object.entries(requirements)) { + let declaredRequirements: readonly { descriptor: AnyConstraintDescriptor; requirement: unknown }[] | undefined; + const getDeclaredRequirements = () => declaredRequirements ??= Object.entries(requirements).map(([key, rawRequirement]) => { 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; + return { descriptor, requirement: decoded.value }; + }); + 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 declared = getDeclaredRequirements(); const capable = declared.length ? usable.filter(({ model }) => declared.every(({ descriptor, requirement }) => descriptor.modelSatisfies({ fact: descriptor.modelFact(model), requirement }).satisfied)) : usable; const pool = capable.length ? capable : usable; let selected; if (options.routeCursor && declared.length && capable.length) { const index = options.routeCursor.get(group.name) ?? 0; options.routeCursor.set(group.name, (index + 1) % pool.length); selected = pool[index % pool.length]; } else { selected = pool[Math.min(pool.length - 1, Math.max(0, Math.floor((options.rng ?? Math.random)() * pool.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) }; + } } + const resolution = group ? resolveConstraintMembers(group.models, options.modelRegistry) : { members: [] }; + if (group && route.status === "routed") { + const groupCapabilityCeilings = registry.descriptors.flatMap((descriptor) => { + if (group.constraints?.[descriptor.key] === undefined || !descriptor.present.ceiling) return []; + const note = descriptor.present.ceiling(evaluateConstraint(descriptor, resolution, group.constraints[descriptor.key])); + return note ? [note] : []; + }); + if (groupCapabilityCeilings.length) route = { ...route, groupCapabilityCeilings }; + } + if (!Object.keys(requirements).length) return route; + const violations: ConstraintViolation[] = []; + for (const { descriptor, requirement } of getDeclaredRequirements()) { if (group) { - const override = group.constraints?.[key]; + const override = group.constraints?.[descriptor.key]; const evaluation = evaluateConstraint(descriptor, resolution, override); const violation = evaluateGroupRequirement(descriptor, evaluation, requirement); if (violation) violations.push(violation); diff --git a/model-groups/tui.ts b/model-groups/tui.ts index 0f87e6a..0ff8f0d 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -51,7 +51,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 }) }; + return { ...def, models: def.models.map((model) => ({ ...model })), ...(constraints === undefined ? {} : { constraints }) }; } function groupKey(group: Pick): string { diff --git a/spawn/index.ts b/spawn/index.ts index 0450c94..94be361 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -432,15 +432,11 @@ export function executeSpawn( const authorityNote = state.readonlyEnabled ? READONLY_CHILD_AUTHORITY_NOTE : "You have the same authority as the parent."; - // Level-1 capability orientation: when a model group carries an explicit - // modality override, the child is told the group's allowed scope so it can - // report a capability mismatch instead of silently working around it (e.g. - // reading an image when the group has image disabled). - const imageDisabled = - route.modalityCeiling !== undefined && !route.modalityCeiling.includes("image"); + // Constraint descriptors own child-facing orientation for explicit group + // ceilings; spawn only presents the generic notes supplied by the route. const capabilityNotice = - route.status === "routed" && imageDisabled - ? `\n\n## Model Group capability ceiling\nImage input is disabled for this group. If the task requires reading or inspecting an image, do not work around it with OCR, third-party tools, or an alternate route; report the capability mismatch to the parent instead.\n\n` + route.status === "routed" && route.groupCapabilityCeilings?.length + ? `\n\n## Model Group capability ceiling\n${route.groupCapabilityCeilings.join("\n")}\n\n` : ""; const fullPrompt = `You are a focused child agent spawned by a parent agent. ` + diff --git a/tests/unit/model-groups-constraints-fixture.ts b/tests/unit/model-groups-constraints-fixture.ts index 839f80d..1ac2f5a 100644 --- a/tests/unit/model-groups-constraints-fixture.ts +++ b/tests/unit/model-groups-constraints-fixture.ts @@ -2,6 +2,7 @@ import { Type } from "typebox"; import type { ConstraintDescriptor } from "../../model-groups/constraints/types.js"; type TestMinContextAggregate = { automatic: number | null; supported: number | null }; +type TestMaxBudgetAggregate = { 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` }, @@ -10,7 +11,7 @@ const positiveIntegerCodec = { schema: Type.Integer({ minimum: 1 }), }; -// Tests-only scalar proof: production must never register or recognize this key. +// Tests-only scalar proofs: production must never register or recognize these keys. export const testMinContext: ConstraintDescriptor<"testMinContext", number, TestMinContextAggregate, number, number, number | null> = { key: "testMinContext", order: 10, modelFact: (model) => model.contextWindow, @@ -29,5 +30,44 @@ export const testMinContext: ConstraintDescriptor<"testMinContext", number, Test 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" }, + 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", + ceiling: (evaluation) => evaluation.effective !== null && (evaluation.aggregate.automatic === null || evaluation.effective > evaluation.aggregate.automatic) + ? `Minimum context is capped at ${evaluation.effective} tokens for this group.` + : undefined, + }, +}; + +// Tests-only lower-is-better proof. The automatic value is the group's worst +// output cost; an explicit cap may not exceed the cheapest supported member. +export const testMaxBudget: ConstraintDescriptor<"testMaxBudget", number, TestMaxBudgetAggregate, number, number, number | null> = { + key: "testMaxBudget", order: 11, + modelFact: (model) => model.cost.output, + aggregate: ({ members }) => { + const facts = members.flatMap((member) => member.fact === undefined ? [] : [member.fact]); + return { automatic: members.length && facts.length === members.length ? Math.max(...facts) : null, supported: facts.length ? Math.min(...facts) : null }; + }, + reconcile: ({ aggregate, override }) => { + if (override === undefined) return { effective: aggregate.automatic, diagnostics: aggregate.automatic === null ? [{ key: "testMaxBudget", code: "unknown-automatic" }] : [] }; + return override <= (aggregate.supported ?? 0) + ? { effective: override, diagnostics: [] } + : { effective: null, diagnostics: [{ key: "testMaxBudget", 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 maximum budget", unit: "credits", min: 1, step: 1, automatic: () => "Automatic", value: (evaluation) => evaluation.effective, allowAutomatic: true }, + present: { + group: (evaluation) => `maximum ${evaluation.effective ?? "unknown"} credits`, + prompt: (evaluation) => `maximum ${evaluation.effective ?? "unknown"} credits`, + diagnostic: (diagnostic) => diagnostic.code === "unsupported-override" ? `unsupported maximum ${diagnostic.details} credits` : "maximum budget unknown", + violation: () => "maximum budget unsatisfied", + ceiling: (evaluation) => evaluation.effective !== null && (evaluation.aggregate.automatic === null || evaluation.effective < evaluation.aggregate.automatic) + ? `Maximum output budget is capped at ${evaluation.effective} credits for this group.` + : undefined, + }, }; diff --git a/tests/unit/model-groups-constraints.test.ts b/tests/unit/model-groups-constraints.test.ts index 5d25fd6..af5437a 100644 --- a/tests/unit/model-groups-constraints.test.ts +++ b/tests/unit/model-groups-constraints.test.ts @@ -5,7 +5,7 @@ import { constraintEditorRows, presentConstraintPrompt } from "../../model-group 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"; +import { testMaxBudget, 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; @@ -49,6 +49,16 @@ test("text is always present in effective even when the override drops it", () = assert.deepEqual(onlyImage.effective, ["text", "image"]); }); +test("fully stale non-empty modality overrides fall back to the union while explicit empty remains text-only", () => { + const registry = createConstraintRegistry([modalitiesConstraint as AnyConstraintDescriptor]); + const visionWithoutReasoning = { provider: "p", id: "vision", input: ["text", "image"], reasoning: false } as any; + const resolved = resolution([{ provider: "p", modelId: "vision", model: visionWithoutReasoning }]); + const stale = evaluateConstraints(resolved, { modalities: ["reasoning"] }, registry)[0]; + assert.deepEqual(stale.effective, ["text", "image"]); + assert.deepEqual(stale.diagnostics, [{ key: "modalities", code: "unsupported-override", details: ["reasoning"] }]); + assert.deepEqual(evaluateConstraints(resolved, { modalities: [] }, registry)[0].effective, ["text"]); +}); + 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 }]); @@ -67,6 +77,23 @@ test("injected scalar traverses resolution, aggregation, persistence, reconcilia 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); + assert.equal(productionConstraintRegistry.get("testMaxBudget"), undefined); +}); + +test("injected lower-is-better budget rejects an override above the supported cap", () => { + const injected = createConstraintRegistry([testMaxBudget as AnyConstraintDescriptor]); + const resolved = resolution([ + { provider: "p", modelId: "cheap", model: { ...text, cost: { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 } } }, + { provider: "p", modelId: "costly", model: { ...rich, cost: { input: 1, output: 5, cacheRead: 1, cacheWrite: 1 } } }, + ]); + const automatic = evaluateConstraints(resolved, {}, injected)[0]; + assert.deepEqual(automatic.aggregate, { automatic: 5, supported: 1 }); + assert.equal(automatic.effective, 5); + const capped = evaluateConstraints(resolved, { testMaxBudget: 1 }, injected)[0]; + assert.equal(capped.effective, 1); + const unsupported = evaluateConstraints(resolved, { testMaxBudget: 2 }, injected)[0]; + assert.equal(unsupported.effective, null); + assert.deepEqual(unsupported.diagnostics, [{ key: "testMaxBudget", code: "unsupported-override", details: 2 }]); }); test("generic modality prompt presentation preserves effective and empty labels", () => { diff --git a/tests/unit/model-groups-crud.test.ts b/tests/unit/model-groups-crud.test.ts index 2ca8e37..9b09061 100644 --- a/tests/unit/model-groups-crud.test.ts +++ b/tests/unit/model-groups-crud.test.ts @@ -309,8 +309,14 @@ test("store-level validation derives empty-common and stale-override flags and c assert.equal(summary.staleModalityOverrideCount, 1); })); -test("create and update reject unsupported modality override before writing", () => withTemp(({ cwd }) => { +test("low-level save permits shape-valid overrides while CRUD rejects union-cap-invalid overrides", () => withTemp(({ cwd }) => { const a = access(cwd); + // The low-level persistence boundary only validates v2 shape. It deliberately + // persists this image override even though claude's union has text only. + saveModelGroups("project", a, { version: 2, groups: { persistenceOnly: { models: [{ provider: "anthropic", modelId: "claude" }], constraints: { modalities: ["image"] } } } }); + assert.deepEqual(read("project", cwd).groups.persistenceOnly.constraints.modalities, ["image"]); + // CRUD owns the union-cap invariant and must reject the same invalid shape + // before any write. // 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)); } }); @@ -378,6 +384,19 @@ test("legacy modalityOverride is opaque and not interpreted", () => withTemp(({ assert.equal(Object.hasOwn(read("project", cwd).groups.legacy, "modalityOverride"), false); })); +test("malformed legacy modalityOverride loads opaquely and is dropped on a v2 write", () => withTemp(({ cwd }) => { + const sourcePath = modelGroupsPath("project", cwd); + const v1Bytes = JSON.stringify({ version: 1, groups: { legacy: { models: [], modalityOverride: "not-an-array" } } }, null, 2) + "\n"; + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, v1Bytes, "utf8"); + const loaded = loadModelGroups(access(cwd)); + assert.equal(loaded.issues.length, 0); + assert.equal((loaded.configs.project.groups.legacy as any).modalityOverride, "not-an-array"); + assert.equal(fs.readFileSync(sourcePath, "utf8"), v1Bytes); + saveModelGroups("project", access(cwd), loaded.configs.project); + assert.equal(Object.hasOwn(read("project", cwd).groups.legacy, "modalityOverride"), false); +})); + test("v2 constraint envelope preserves explicit empty and opaque slots canonically", () => withTemp(({ cwd }) => { const sourcePath = modelGroupsPath("project", cwd); fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); diff --git a/tests/unit/model-groups-router.test.ts b/tests/unit/model-groups-router.test.ts index 14c8374..07a05a5 100644 --- a/tests/unit/model-groups-router.test.ts +++ b/tests/unit/model-groups-router.test.ts @@ -4,7 +4,7 @@ import { getEffectiveModelGroupNames, resolveSpawnModelRoute, SpawnRouteError } 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"; +import { testMaxBudget, testMinContext } from "./model-groups-constraints-fixture.js"; function model(provider: string, id: string, overrides: Record = {}): any { return { provider, id, reasoning: true, input: ["text"], ...overrides }; @@ -66,7 +66,7 @@ test("automatic mixed groups default effective to the union so a capability rout assert.equal(route.status, "routed"); assert.equal(route.modelId, "image"); // Automatic groups carry no ceiling. - assert.equal(route.modalityCeiling, undefined); + assert.equal(route.groupCapabilityCeilings, undefined); // The union never invents capabilities no member has: a reasoning requirement on // members that all lack reasoning still rejects with the group miss. const textNr = model("p", "text-nr", { reasoning: false }); @@ -114,6 +114,23 @@ test("injected scalar requirements use generic violations, not modality arrays", 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("generic scalar requirements narrow mixed groups in both comparison directions", () => { + const parent = model("p", "parent", { contextWindow: 100, cost: { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 } }); + const smallCheap = model("p", "small-cheap", { contextWindow: 10, cost: { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 } }); + const largeCostly = model("p", "large-costly", { contextWindow: 100, cost: { input: 1, output: 5, cacheRead: 1, cacheWrite: 1 } }); + const reg = registry([parent, smallCheap, largeCostly]); + const injected = createConstraintRegistry([testMinContext, testMaxBudget]); + const minGroup = group("min", { models: [{ provider: "p", modelId: "small-cheap" }, { provider: "p", modelId: "large-costly" }], constraints: { testMinContext: 50 } }); + const minRoute = resolveSpawnModelRoute({ requestedGroup: "min", constraints: { testMinContext: 50 }, groups: [minGroup], parentModel: parent, parentThinking: "low", modelRegistry: reg, constraintRegistry: injected, rng: () => 0 }); + assert.equal(minRoute.modelId, "large-costly", "higher-is-better requirements select the >= capable member"); + assert.deepEqual(minRoute.groupCapabilityCeilings, ["Minimum context is capped at 50 tokens for this group."]); + + const budgetGroup = group("budget", { models: [{ provider: "p", modelId: "large-costly" }, { provider: "p", modelId: "small-cheap" }], constraints: { testMaxBudget: 1 } }); + const budgetRoute = resolveSpawnModelRoute({ requestedGroup: "budget", constraints: { testMaxBudget: 2 }, groups: [budgetGroup], parentModel: parent, parentThinking: "low", modelRegistry: reg, constraintRegistry: injected, rng: () => 0 }); + assert.equal(budgetRoute.modelId, "small-cheap", "lower-is-better requirements select the <= capable member"); + assert.deepEqual(budgetRoute.groupCapabilityCeilings, ["Maximum output budget is capped at 1 credits for this group."]); +}); + 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"] }); @@ -135,7 +152,7 @@ test("explicit group override carries a modality ceiling even when caller declar g.modalities.effective = ["text", "reasoning"]; const route = resolveSpawnModelRoute({ requestedGroup: "posed", groups: [g], parentModel: parent, parentThinking: "medium", modelRegistry: registry([parent, vision]) }); assert.equal(route.status, "routed"); - assert.deepEqual(route.modalityCeiling, ["text", "reasoning"]); + assert.deepEqual(route.groupCapabilityCeilings, ["Image input is disabled for this group. If the task requires reading or inspecting an image, do not work around it with OCR, third-party tools, or an alternate route; report the capability mismatch to the parent instead."]); }); test("groups without an explicit override get no modality ceiling", () => { @@ -145,7 +162,7 @@ test("groups without an explicit override get no modality ceiling", () => { g.modalities.effective = ["text", "image", "reasoning"]; const route = resolveSpawnModelRoute({ requestedGroup: "openbox", groups: [g], parentModel: parent, parentThinking: "medium", modelRegistry: registry([parent, vision]) }); assert.equal(route.status, "routed"); - assert.equal(route.modalityCeiling, undefined); + assert.equal(route.groupCapabilityCeilings, undefined); }); test("explicit override stays a subtractive ceiling even when a capable member exists", () => { diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index a17e1f2..41b4519 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -218,6 +218,7 @@ test("model groups TUI editor rows show per-model capability chips and modalitie 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 as any).opaqueTopLevel = { preserve: true }; // Automatic groups open with their union capability set active; un-toggling a // supported capability writes a subtractive override excluding it. review.modalities = { common: ["text"], supported: ["text", "image", "reasoning"], effective: ["text", "image", "reasoning"] }; @@ -246,6 +247,7 @@ test("model groups TUI modality editor commits override and Automatic through up press(c, ENTER); assert.equal(calls.length, 1); assert.deepEqual(calls[0].def.constraints.modalities, ["text", "reasoning"]); + assert.deepEqual(calls[0].def.opaqueTopLevel, { preserve: true }, "TUI edits retain opaque top-level group keys"); assert.match(rendered(c), /Modalities/, "toggle stays on the modalities screen"); assert.match(rendered(c), /I image \[off\]/); assert.match(rendered(c), /Override — media limited to text/); diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index b9b7b97..398512c 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -311,6 +311,31 @@ test("spawn injects a capability ceiling notice for a routed group with image di assert.match(seenPrompt, /report the capability mismatch/i); }); +test("spawn injects descriptor-provided scalar capability ceilings", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + const small = { provider: "openai", id: "small", input: ["text"], reasoning: false, contextWindow: 10 }; + const large = { provider: "openai", id: "large", input: ["text"], reasoning: false, contextWindow: 100 }; + state.modelGroups.groups = [{ + name: "context-capped", scope: "project", sourcePath: "", + models: [{ provider: "openai", modelId: "small" }, { provider: "openai", modelId: "large" }], + constraints: { testMinContext: 50 }, + modalities: { common: ["text"], supported: ["text"], effective: ["text"] }, + } as any]; + let seenPrompt = ""; + registerSpawnTool(pi as any, state, async () => ({ + session: mockSessionFactory({ prompt: async (prompt?: string) => { seenPrompt = prompt ?? ""; } }), + extensionsResult: undefined as any, + }), createConstraintRegistry([testMinContext])); + await pi.tools.get("spawn").execute("spawn-context-cap", { prompt: "Do the task", group: "context-capped" }, undefined, undefined, { + model: { provider: "openai", id: "parent", contextWindow: 100 }, cwd: "/tmp", + modelRegistry: { find: (_provider: string, id: string) => id === "small" ? small : id === "large" ? large : undefined, hasConfiguredAuth: () => true }, + } as any); + assert.match(seenPrompt, /## Model Group capability ceiling/i); + assert.match(seenPrompt, /minimum context is capped at 50 tokens/i); +}); + test("spawn execute builds prompt with notebook pages and task", async () => { const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); From 9733cadc80f79b27ba59c72ff7a6a521145c7bcd Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 23 Aug 2026 10:14:08 +0000 Subject: [PATCH 26/33] =?UTF-8?q?fix(review):=20fold=20batch=202=20?= =?UTF-8?q?=E2=80=94=20cursor,=20schema=20seam,=20debt,=20readability=20(P?= =?UTF-8?q?R=20#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - N-27-1: cursor round-robin engages only when declared requirements genuinely narrow the pool (capable < usable); routed empty {required:[]} returns to uniform RNG (regression fixed + routed test) - F2-3: reset invariant now populates and asserts spawnRouteCursors cleared - F2-2: spawn tool constraints schema built from the registry passed at registration (injected/test descriptors accepted at tool-schema level); whole-path test validates through real registered schema, not direct execute - D-7: removed dead descriptor fields (persistence.clone, editor.format, editor.allowAutomatic); single-sourced MODEL_GROUP_MODALITY_PROSE; corrected reasoning-doc default - readability: split modalities.ts nested ternary into explicit four cases F2-1 (downgrade data-loss) intentionally left open per operator. Battery: typecheck, npm test 697/697, e2e 16/16, snapshots 11/11, compat 167/167, package-host, audit-ci, git diff --check. --- index.ts | 4 +- model-groups/constraints/modalities.ts | 15 ++++-- model-groups/constraints/presentation.ts | 2 +- model-groups/constraints/types.ts | 10 ++-- model-groups/modality.ts | 2 +- model-groups/router.ts | 2 +- model-groups/types.ts | 1 + spawn/index.ts | 46 ++++++++++--------- .../unit/model-groups-constraints-fixture.ts | 8 ++-- tests/unit/model-groups-router.test.ts | 21 +++++++++ tests/unit/spawn.test.ts | 28 +++++++++-- tests/unit/state-invariants.test.ts | 9 +++- 12 files changed, 103 insertions(+), 45 deletions(-) diff --git a/index.ts b/index.ts index 7c75cb2..8c7fb42 100644 --- a/index.ts +++ b/index.ts @@ -72,7 +72,7 @@ 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 { MODEL_GROUP_MODALITIES, type ResolvedModelGroup, type ModelGroupsAccess } from "./model-groups/types.js"; +import { MODEL_GROUP_MODALITY_PROSE, 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"; @@ -103,8 +103,6 @@ import { } from "./tui.js"; import { applyReadonlyBashGuard } from "./readonly-bash.js"; -const MODEL_GROUP_MODALITY_PROSE = MODEL_GROUP_MODALITIES.join(", ").replace(/, ([^,]+)$/, ", or $1"); - // ── Helpers ──────────────────────────────────────────────────────────── /** diff --git a/model-groups/constraints/modalities.ts b/model-groups/constraints/modalities.ts index 072f91e..9c4aa65 100644 --- a/model-groups/constraints/modalities.ts +++ b/model-groups/constraints/modalities.ts @@ -46,10 +46,17 @@ export const modalitiesConstraint: ConstraintDescriptor<"modalities", ModelGroup // D1 route pre-selection picks a capable member). An explicit override stays // an authoritative subtractive ceiling: only members' supported modalities // may be listed, so the union is never exceeded. - const filteredOverride = override === undefined ? undefined : ordered(override.filter((modality) => aggregate.supported.includes(modality))); // An explicit [] is an intentional text-only ceiling. A non-empty override // made entirely stale, however, must fall back to the derived union. - const base = override === undefined ? aggregate.supported : override.length === 0 ? [] : filteredOverride!.length ? filteredOverride! : aggregate.supported; + let base: ModelGroupModality[]; + if (override === undefined) { + base = aggregate.supported; + } else if (override.length === 0) { + base = []; + } else { + const filteredOverride = ordered(override.filter((modality) => aggregate.supported.includes(modality))); + base = filteredOverride.length ? filteredOverride : aggregate.supported; + } // Text is the always-present base capability (image/reasoning both imply it); // keep it in the effective set whenever the group supports it, regardless of override. const effective = aggregate.supported.includes("text") ? ordered(["text", ...base.filter((modality) => modality !== "text")]) : base; @@ -62,7 +69,7 @@ export const modalitiesConstraint: ConstraintDescriptor<"modalities", ModelGroup }, 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] }, + persistence: { override: modalityCodec() }, 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` }; @@ -72,7 +79,7 @@ export const modalitiesConstraint: ConstraintDescriptor<"modalities", ModelGroup 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 (${evaluation.aggregate.common.filter((modality) => modality !== "reasoning").join(", ") || "none"})`, format: (value) => `Override: ${value.join(", ") || "none"}`, allowAutomatic: true }, + editor: { kind: "multi-select", label: "Modalities", choices: (evaluation) => evaluation.aggregate.supported, automatic: (evaluation) => `Automatic (${evaluation.aggregate.common.filter((modality) => modality !== "reasoning").join(", ") || "none"})` }, present: { group: (evaluation) => evaluation.effective.join(", "), prompt: (evaluation) => evaluation.effective.join(", "), diff --git a/model-groups/constraints/presentation.ts b/model-groups/constraints/presentation.ts index 4396bc2..1299a71 100644 --- a/model-groups/constraints/presentation.ts +++ b/model-groups/constraints/presentation.ts @@ -44,7 +44,7 @@ export function constraintEditorRows( evaluation: ErasedConstraintEvaluation, override?: unknown, ): readonly ConstraintEditorRow[] { - const editor = descriptor.editor as ConstraintEditorSpec; + const editor = descriptor.editor as ConstraintEditorSpec; if (editor.kind === "multi-select") { const choices = editor.choices(evaluation as ConstraintEvaluation); const effective = (evaluation as ConstraintEvaluation).effective as readonly string[] | undefined; diff --git a/model-groups/constraints/types.ts b/model-groups/constraints/types.ts index 9263d82..6380b81 100644 --- a/model-groups/constraints/types.ts +++ b/model-groups/constraints/types.ts @@ -33,9 +33,9 @@ export interface ConstraintViolation { 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 type ConstraintEditorSpec = + | { kind: "multi-select"; label: string; choices(evaluation: ConstraintEvaluation): readonly string[]; automatic(evaluation: ConstraintEvaluation): string } + | { kind: "number"; label: string; unit: string; min: number; step: number; automatic(evaluation: ConstraintEvaluation): string; value(evaluation: ConstraintEvaluation): number | null }; export interface ConstraintEvaluation { key: string; @@ -52,9 +52,9 @@ export interface ConstraintDescriptor; clone(value: Override): Override }; + persistence: { override: ConstraintCodec }; requirement: ConstraintCodec; - editor: ConstraintEditorSpec; + editor: ConstraintEditorSpec; present: { group(evaluation: ConstraintEvaluation): string; prompt(evaluation: ConstraintEvaluation): string; diff --git a/model-groups/modality.ts b/model-groups/modality.ts index 754c5ff..5f2dbfe 100644 --- a/model-groups/modality.ts +++ b/model-groups/modality.ts @@ -19,7 +19,7 @@ export const MODALITY_LETTER: Record = { }; export interface ModalityLetterRunOptions { - /** Include the reasoning letter. Default false, matching model list rows which surface R per-model. */ + /** Include the reasoning letter. Defaults to false, so capability rows omit R unless requested. */ includeReasoning?: boolean; /** * OpenRouter-style consolidation: when the visible media set contains any diff --git a/model-groups/router.ts b/model-groups/router.ts index 9ae17b1..fd468a2 100644 --- a/model-groups/router.ts +++ b/model-groups/router.ts @@ -45,7 +45,7 @@ export function resolveSpawnModelRoute(options: { requestedGroup?: string; const }); 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 declared = getDeclaredRequirements(); const capable = declared.length ? usable.filter(({ model }) => declared.every(({ descriptor, requirement }) => descriptor.modelSatisfies({ fact: descriptor.modelFact(model), requirement }).satisfied)) : usable; const pool = capable.length ? capable : usable; let selected; if (options.routeCursor && declared.length && capable.length) { const index = options.routeCursor.get(group.name) ?? 0; options.routeCursor.set(group.name, (index + 1) % pool.length); selected = pool[index % pool.length]; } else { selected = pool[Math.min(pool.length - 1, Math.max(0, Math.floor((options.rng ?? Math.random)() * pool.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 (!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 declared = getDeclaredRequirements(); const capable = declared.length ? usable.filter(({ model }) => declared.every(({ descriptor, requirement }) => descriptor.modelSatisfies({ fact: descriptor.modelFact(model), requirement }).satisfied)) : usable; const pool = capable.length ? capable : usable; let selected; if (options.routeCursor && declared.length && capable.length && capable.length < usable.length) { const index = options.routeCursor.get(group.name) ?? 0; options.routeCursor.set(group.name, (index + 1) % pool.length); selected = pool[index % pool.length]; } else { selected = pool[Math.min(pool.length - 1, Math.max(0, Math.floor((options.rng ?? Math.random)() * pool.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) }; } } const resolution = group ? resolveConstraintMembers(group.models, options.modelRegistry) : { members: [] }; if (group && route.status === "routed") { diff --git a/model-groups/types.ts b/model-groups/types.ts index 9571303..61d7bd5 100644 --- a/model-groups/types.ts +++ b/model-groups/types.ts @@ -2,6 +2,7 @@ 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 const MODEL_GROUP_MODALITY_PROSE = MODEL_GROUP_MODALITIES.join(", ").replace(/, ([^,]+)$/, ", or $1"); export type ModelGroupModality = typeof MODEL_GROUP_MODALITIES[number]; export interface ModelGroupModalities { common: ModelGroupModality[]; diff --git a/spawn/index.ts b/spawn/index.ts index 94be361..7d09ca7 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 } from "../model-groups/types.js"; +import { MODEL_GROUP_MODALITY_PROSE } from "../model-groups/types.js"; import { applyReadonlyBashGuard } from "../readonly-bash.js"; import { renderSpawnCall, @@ -49,7 +49,6 @@ 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; @@ -296,26 +295,29 @@ const SPAWN_PROMPT_GUIDELINES = [ `A specified group is binding: if the operator asks for a specific group and it lacks a needed capability, do NOT substitute a different group or inherit the parent model. Stop and report to the operator that the named group cannot satisfy the task, and ask how to proceed.`, ]; -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: - "Self-contained task description. Reference notebook pages by name — " + - "child will notebook_read them on demand.", - }), - group: Type.Optional(Type.String({ - description: "Optional exact Model Group name for child model routing. Omit to inherit the parent model/thinking.", - })), - constraints: Type.Optional(SPAWN_CONSTRAINT_REQUIREMENTS), - thinking: Type.Optional(StringEnum( - ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const, - { +export function buildSpawnParameters(constraintRegistry: ConstraintRegistry) { + const constraintRequirements = Type.Object( + Object.fromEntries(constraintRegistry.descriptors.map((descriptor) => [descriptor.key, descriptor.requirement.schema])) as any, + ); + return Type.Object({ + prompt: Type.String({ description: - "Override child thinking level. A routed Model Group entry may override it.", - }, - )), -}); + "Self-contained task description. Reference notebook pages by name — " + + "child will notebook_read them on demand.", + }), + 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(constraintRequirements), + thinking: Type.Optional(StringEnum( + ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const, + { + description: + "Override child thinking level. A routed Model Group entry may override it.", + }, + )), + }); +} /** @@ -654,7 +656,7 @@ export function registerSpawnTool( description: SPAWN_DESCRIPTION, promptSnippet: SPAWN_PROMPT_SNIPPET, promptGuidelines: SPAWN_PROMPT_GUIDELINES, - parameters: SPAWN_PARAMETERS, + parameters: buildSpawnParameters(constraintRegistry), renderShell: "self", execute( diff --git a/tests/unit/model-groups-constraints-fixture.ts b/tests/unit/model-groups-constraints-fixture.ts index 1ac2f5a..13eb037 100644 --- a/tests/unit/model-groups-constraints-fixture.ts +++ b/tests/unit/model-groups-constraints-fixture.ts @@ -27,9 +27,9 @@ export const testMinContext: ConstraintDescriptor<"testMinContext", number, Test }, 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 }, + persistence: { override: positiveIntegerCodec }, requirement: positiveIntegerCodec, - editor: { kind: "number", label: "Test minimum context", unit: "tokens", min: 1, step: 1, automatic: () => "Automatic", value: (evaluation) => evaluation.effective, allowAutomatic: true }, + editor: { kind: "number", label: "Test minimum context", unit: "tokens", min: 1, step: 1, automatic: () => "Automatic", value: (evaluation) => evaluation.effective }, present: { group: (evaluation) => `minimum ${evaluation.effective ?? "unknown"} tokens`, prompt: (evaluation) => `minimum ${evaluation.effective ?? "unknown"} tokens`, @@ -58,9 +58,9 @@ export const testMaxBudget: ConstraintDescriptor<"testMaxBudget", number, TestMa }, 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 }, + persistence: { override: positiveIntegerCodec }, requirement: positiveIntegerCodec, - editor: { kind: "number", label: "Test maximum budget", unit: "credits", min: 1, step: 1, automatic: () => "Automatic", value: (evaluation) => evaluation.effective, allowAutomatic: true }, + editor: { kind: "number", label: "Test maximum budget", unit: "credits", min: 1, step: 1, automatic: () => "Automatic", value: (evaluation) => evaluation.effective }, present: { group: (evaluation) => `maximum ${evaluation.effective ?? "unknown"} credits`, prompt: (evaluation) => `maximum ${evaluation.effective ?? "unknown"} credits`, diff --git a/tests/unit/model-groups-router.test.ts b/tests/unit/model-groups-router.test.ts index 07a05a5..554f324 100644 --- a/tests/unit/model-groups-router.test.ts +++ b/tests/unit/model-groups-router.test.ts @@ -131,6 +131,27 @@ test("generic scalar requirements narrow mixed groups in both comparison directi assert.deepEqual(budgetRoute.groupCapabilityCeilings, ["Maximum output budget is capped at 1 credits for this group."]); }); +test("routed empty modality requirements use uniform RNG instead of the capability cursor", () => { + const parent = model("p", "parent"); + const first = model("p", "first"); + const second = model("p", "second"); + const cursor = new Map(); + let rngCalls = 0; + const route = resolveSpawnModelRoute({ + requestedGroup: "routed", + constraints: { modalities: { required: [] } }, + groups: [group("routed", { models: [{ provider: "p", modelId: "first" }, { provider: "p", modelId: "second" }] })], + parentModel: parent, + parentThinking: "medium", + modelRegistry: registry([parent, first, second]), + routeCursor: cursor, + rng: () => { rngCalls++; return 0.75; }, + }); + assert.equal(route.modelId, "second", "the RNG-selected member wins when no members are narrowed out"); + assert.equal(rngCalls, 1, "an empty requirement must consult RNG instead of the cursor"); + assert.equal(cursor.size, 0, "an empty requirement must not advance a group cursor"); +}); + 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 398512c..b307647 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -250,11 +250,12 @@ test("spawn round-robins across capable members via the session cursor", async ( const state = createState(); const capA = { provider: "openai", id: "gpt-cap-a", reasoning: true, input: ["text", "image"] }; const capB = { provider: "openai", id: "gpt-cap-b", reasoning: true, input: ["text", "image"] }; + const textOnly = { provider: "openai", id: "gpt-text", reasoning: true, input: ["text"] }; state.modelGroups.groups = [{ name: "multi", scope: "project", sourcePath: "", - models: [{ provider: "openai", modelId: "gpt-cap-a" }, { provider: "openai", modelId: "gpt-cap-b" }], + models: [{ provider: "openai", modelId: "gpt-cap-a" }, { provider: "openai", modelId: "gpt-cap-b" }, { provider: "openai", modelId: "gpt-text" }], constraints: { modalities: ["text", "image"] }, modalities: { common: ["text"], supported: ["text", "image"], effective: ["text", "image"] }, } as any]; @@ -264,8 +265,8 @@ test("spawn round-robins across capable members via the session cursor", async ( return { session: mockSessionFactory({ prompt: async () => {} }), extensionsResult: undefined as any }; }); const ctx = { model: { provider: "openai", id: "parent" }, cwd: "/tmp", modelRegistry: { - find: (_p: string, id: string) => id === "gpt-cap-a" ? capA : id === "gpt-cap-b" ? capB : undefined, - hasConfiguredAuth: (m: any) => m === capA || m === capB, + find: (_p: string, id: string) => id === "gpt-cap-a" ? capA : id === "gpt-cap-b" ? capB : id === "gpt-text" ? textOnly : undefined, + hasConfiguredAuth: (m: any) => m === capA || m === capB || m === textOnly, } } as any; await pi.tools.get("spawn").execute("spawn-a", { prompt: "t", group: "multi", constraints: { modalities: { required: ["image"] } } }, undefined, undefined, ctx); await pi.tools.get("spawn").execute("spawn-b", { prompt: "t", group: "multi", constraints: { modalities: { required: ["image"] } } }, undefined, undefined, ctx); @@ -911,6 +912,27 @@ 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("registered spawn tool schema accepts injected scalar requirements", () => { + const pi = createTestPI(); + registerSpawnTool(pi as any, createState(), undefined, createConstraintRegistry([testMinContext])); + const schema = pi.tools.get("spawn").parameters; + assert.equal( + Value.Check(schema, { prompt: "Do the task", constraints: { testMinContext: 20 } }), + true, + "the registered schema accepts an injected descriptor's scalar requirement", + ); + assert.equal( + Value.Check(schema, { prompt: "Do the task", constraints: { testMinContext: 0 } }), + false, + "the injected descriptor retains its requirement schema", + ); + assert.equal( + Value.Check(schema, { prompt: "Do the task", constraints: { modalities: { required: ["text"] } } }), + false, + "the injected registry, not the production registry, defines the registered schema", + ); +}); + test("spawn requirements normalize the canonical envelope", () => { assert.deepEqual(normalizeSpawnRequirements({ constraints: { modalities: { required: ["image", "text"] } } }), { modalities: ["text", "image"] }); assert.deepEqual(normalizeSpawnRequirements({ constraints: { modalities: { required: ["image"] } } }), { modalities: ["image"] }); diff --git a/tests/unit/state-invariants.test.ts b/tests/unit/state-invariants.test.ts index a4b5385..687eaf8 100644 --- a/tests/unit/state-invariants.test.ts +++ b/tests/unit/state-invariants.test.ts @@ -30,7 +30,8 @@ type StateAction = | { type: "clearTopic" } | { type: "savePage"; name: string } | { type: "addChildSession"; id: string } - | { type: "abortChildren" }; + | { type: "abortChildren" } + | { type: "setCursor"; key: string; value: number }; /** Generator for valid normalized topic names (non-empty after normalizeNotebookTopic). */ const arbTopicName = fc @@ -72,6 +73,9 @@ async function apply( case "abortChildren": abortAndClearChildSessions(state); break; + case "setCursor": + state.spawnRouteCursors.set(action.key, action.value); + break; } } @@ -103,6 +107,7 @@ function assertResetClears(state: AgenticodingState): void { assert.equal(state.notebookPages.size, 0, "notebookPages must be empty after reset"); assert.equal(state.childSessions.size, 0, "childSessions must be empty after reset"); assert.equal(state.liveChildSessions.size, 0, "liveChildSessions must be empty after reset"); + assert.equal(state.spawnRouteCursors.size, 0, "spawnRouteCursors must be empty after reset"); assert.equal(state.epoch, 0, "epoch must be 0 after reset"); assert.equal(state.activeNotebookTopic, null, "topic must be null after reset"); assert.equal(state.activeNotebookTopicSource, null, "topic source must be null after reset"); @@ -247,6 +252,7 @@ test("Property 4: Reset clears all state fields", async () => { fc.constant({ type: "clearTopic" } as StateAction), fc.record({ type: fc.constant("savePage"), name: arbPageName }), fc.record({ type: fc.constant("addChildSession"), id: arbSessionId }), + fc.record({ type: fc.constant("setCursor"), key: arbSessionId, value: fc.nat() }), fc.constant({ type: "abortChildren" } as StateAction), ), { maxLength: 30 }, @@ -274,6 +280,7 @@ test("Property 4: Reset clears all state fields", async () => { s2.frontmatterSkillIssues.set("skill-b", { kind: "invalid-readonly-value", filePath: "/tmp/skill-b.md" }); s2.frontmatterPromptIssues.set("prompt-b", { kind: "unreadable-file", filePath: "/tmp/prompt-b.md" }); s2.pendingReadonlyCommands.push({ type: "skill", name: "skill-a" }); + s2.spawnRouteCursors.set("stale", 1); s2.modelGroups.groups = [{ name: "stale", scope: "project", From a34a65c3f6d2e7221cb9dae090da606f19336359 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 23 Aug 2026 10:24:01 +0000 Subject: [PATCH 27/33] refactor(router): un-clunk SpawnRouteError (PR #27) Split the single-line field declarations + nested-ternary message builder into a documented class with one field per line, an explicit details interface, and a branch-based describeRouteError helper. Public surface (reason/group/missing*/constraintUnsatisfied/message strings) is unchanged and asserted by existing router + spawn tests. Battery: typecheck, unit 697/697, e2e 16/16, snapshots 11/11, compat 167/167, package-host, audit-ci, git diff --check. --- model-groups/router.ts | 52 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/model-groups/router.ts b/model-groups/router.ts index fd468a2..3f51254 100644 --- a/model-groups/router.ts +++ b/model-groups/router.ts @@ -19,12 +19,54 @@ export interface SpawnModelRoute { groupCapabilityCeilings?: readonly string[]; } export type SpawnRouteErrorReason = "empty" | "no-usable-models" | "missing-modality" | "constraint-unsatisfied"; +export interface SpawnRouteErrorDetails { + missingModalities?: ModelGroupModality[]; + missingFromGroup?: ModelGroupModality[]; + missingFromModel?: ModelGroupModality[]; + constraintUnsatisfied?: readonly ConstraintViolation[]; + provider?: string; + modelId?: string; + knownGroup?: boolean; +} + +function describeRouteError(group: string, reason: SpawnRouteErrorReason, details: SpawnRouteErrorDetails, missingModalities: ModelGroupModality[], missingFromGroup: ModelGroupModality[], missingFromModel: ModelGroupModality[]): string { + if (reason === "empty") return `Model Group '${group}' has no model entries.`; + if (reason === "no-usable-models") return `Model Group '${group}' has no configured/authenticated usable models.`; + if (reason === "missing-modality") { + if (details.knownGroup) { + return `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"}.`; + } + return `Spawn model '${details.provider}/${details.modelId}' cannot satisfy required modalities: ${missingModalities.join(", ")}.`; + } + return `Spawn route '${group}' cannot satisfy constraint requirements.`; +} + +/** + * Unusable/unsatisfiable spawn route. Carries structured failure detail so + * callers can branch on reason without parsing the message; message strings + * are stable and asserted by tests. + */ 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[]; readonly constraintUnsatisfied?: readonly ConstraintViolation[]; - constructor(group: string, reason: SpawnRouteErrorReason, details: { missingModalities?: ModelGroupModality[]; missingFromGroup?: ModelGroupModality[]; missingFromModel?: ModelGroupModality[]; constraintUnsatisfied?: readonly ConstraintViolation[]; provider?: string; modelId?: string; knownGroup?: boolean } = {}) { - const missingModalities = details.missingModalities ?? [], missingFromGroup = details.missingFromGroup ?? [], missingFromModel = details.missingFromModel ?? []; - const message = reason === "empty" ? `Model Group '${group}' has no model entries.` : reason === "no-usable-models" ? `Model Group '${group}' has no configured/authenticated usable models.` : reason === "missing-modality" ? details.knownGroup ? `Model Group '${group}' cannot satisfy required modalities: ${missingModalities.join(", ")}. Effective group modalities missing: ${missingFromGroup.join(", ") || "none"}. Routed model '${details.provider}/${details.modelId}' missing: ${missingFromModel.join(", ") || "none"}.` : `Spawn model '${details.provider}/${details.modelId}' cannot satisfy required modalities: ${missingModalities.join(", ")}.` : `Spawn route '${group}' cannot satisfy constraint requirements.`; - super(message); this.name = "SpawnRouteError"; this.group = group; this.reason = reason; this.missingModalities = missingModalities; this.missingFromGroup = missingFromGroup; this.missingFromModel = missingFromModel; if (details.constraintUnsatisfied) this.constraintUnsatisfied = details.constraintUnsatisfied; + 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: SpawnRouteErrorDetails = {}) { + const missingModalities = details.missingModalities ?? []; + const missingFromGroup = details.missingFromGroup ?? []; + const missingFromModel = details.missingFromModel ?? []; + super(describeRouteError(group, reason, details, missingModalities, missingFromGroup, missingFromModel)); + 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 : ""; } From 0649ee8354b3e1853928aa8f14eb97e4c6bcbf33 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 23 Aug 2026 10:44:34 +0000 Subject: [PATCH 28/33] refactor(pr27): un-clunk multi-statement one-liners across router/store/tui/types Structure-only readability pass (no logic change, no new behavior): - router: expand effectiveGroupMap loop, resolveSpawnModelRoute setup/pool/ cursor/route construction, and modality-error extraction into named locals - store: split backups, loadScope, mergeLoaded, normalizeGroups, saveModelGroups, CRUD (create/update/rename/delete/move), validateModelGroups into readable multi-line form; preserved error-wrapping order and phases - tui: bind MODEL_EDIT lookup + decompose renderEditorComponent nested chain - types: expand ModelGroupsPersistenceError ctor Independent review caught and I fixed one real delta introduced mid-refactor: moveGroup source-removal failure rewrap reported newScope path; restored to oldScope sourcePath. F2-1 downgrade guard deliberately NOT added (store.ts semantics unchanged). Battery: typecheck, unit 697/697, e2e 16/16, snapshots 11/11, compat 167/167, package-host, audit-ci, git diff --check. --- model-groups/router.ts | 87 ++++++++++-- model-groups/store.ts | 302 ++++++++++++++++++++++++++++++++++++++--- model-groups/tui.ts | 46 +++++-- model-groups/types.ts | 15 +- 4 files changed, 411 insertions(+), 39 deletions(-) diff --git a/model-groups/router.ts b/model-groups/router.ts index 3f51254..623a283 100644 --- a/model-groups/router.ts +++ b/model-groups/router.ts @@ -70,13 +70,23 @@ export class SpawnRouteError extends Error { } } 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; } +function effectiveGroupMap(groups: ResolvedModelGroup[]): Map { + const map = new Map(); + for (const group of groups) { + if (group.validation?.shadowedByProject) continue; + const current = map.get(group.name); + if (!current || group.scope === "project") map.set(group.name, group); + } + return map; +} export function getEffectiveModelGroups(groups: ResolvedModelGroup[]): ResolvedModelGroup[] { return [...effectiveGroupMap(groups).values()].sort((a, b) => a.name.localeCompare(b.name)); } export function getEffectiveModelGroupNames(groups: ResolvedModelGroup[]): string[] { return getEffectiveModelGroups(groups).map((group) => group.name); } /** Route selection remains auth-aware; constraint evaluation receives its explicit member snapshot. */ export function resolveSpawnModelRoute(options: { requestedGroup?: string; constraints?: Readonly>; groups: ResolvedModelGroup[]; parentModel: Model; parentThinking: ModelThinkingLevel; modelRegistry: Pick; constraintRegistry?: ConstraintRegistry; rng?: () => number; routeCursor?: Map }): SpawnModelRoute { - const requestedGroup = options.requestedGroup?.trim(); const requirements = options.constraints ?? {}; const registry = options.constraintRegistry ?? productionConstraintRegistry; + const requestedGroup = options.requestedGroup?.trim(); + const requirements = options.constraints ?? {}; + const registry = options.constraintRegistry ?? productionConstraintRegistry; let declaredRequirements: readonly { descriptor: AnyConstraintDescriptor; requirement: unknown }[] | undefined; const getDeclaredRequirements = () => declaredRequirements ??= Object.entries(requirements).map(([key, rawRequirement]) => { const descriptor = registry.get(key); @@ -85,10 +95,59 @@ export function resolveSpawnModelRoute(options: { requestedGroup?: string; const if (!decoded.ok) throw new Error(decoded.message); return { descriptor, requirement: decoded.value }; }); - 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 declared = getDeclaredRequirements(); const capable = declared.length ? usable.filter(({ model }) => declared.every(({ descriptor, requirement }) => descriptor.modelSatisfies({ fact: descriptor.modelFact(model), requirement }).satisfied)) : usable; const pool = capable.length ? capable : usable; let selected; if (options.routeCursor && declared.length && capable.length && capable.length < usable.length) { const index = options.routeCursor.get(group.name) ?? 0; options.routeCursor.set(group.name, (index + 1) % pool.length); selected = pool[index % pool.length]; } else { selected = pool[Math.min(pool.length - 1, Math.max(0, Math.floor((options.rng ?? Math.random)() * pool.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) }; - } } + 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 declared = getDeclaredRequirements(); + const capable = declared.length + ? usable.filter(({ model }) => declared.every(({ descriptor, requirement }) => descriptor.modelSatisfies({ fact: descriptor.modelFact(model), requirement }).satisfied)) + : usable; + const pool = capable.length ? capable : usable; + + let selected; + if (options.routeCursor && declared.length && capable.length && capable.length < usable.length) { + const index = options.routeCursor.get(group.name) ?? 0; + options.routeCursor.set(group.name, (index + 1) % pool.length); + selected = pool[index % pool.length]; + } else { + const randomIndex = Math.min(pool.length - 1, Math.max(0, Math.floor((options.rng ?? Math.random)() * pool.length))); + selected = pool[randomIndex]; + } + 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), + }; + } + } const resolution = group ? resolveConstraintMembers(group.models, options.modelRegistry) : { members: [] }; if (group && route.status === "routed") { const groupCapabilityCeilings = registry.descriptors.flatMap((descriptor) => { @@ -112,14 +171,24 @@ export function resolveSpawnModelRoute(options: { requestedGroup?: string; const } 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 groupViolations = modalityViolations.filter((violation) => violation.scope === "group"); + const missingFromGroup = groupViolations.flatMap((violation) => violation.satisfaction.missing as ModelGroupModality[] ?? []); + const modelViolations = modalityViolations.filter((violation) => violation.scope === "model"); + const missingFromModel = modelViolations.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) }); + const missingModalities = ordered([...missingFromGroup, ...missingFromModel]); + throw new SpawnRouteError(group?.name ?? (requestedGroup || ""), "missing-modality", { + missingModalities, + 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 008df7b..c350c5e 100644 --- a/model-groups/store.ts +++ b/model-groups/store.ts @@ -60,15 +60,32 @@ function normalizeOverrideEnvelope(rawDef: Record, sourceVersio function normalizeGroups(rawGroups: Record, sourceVersion: number): { ok: true; groups: Record } | { ok: false; message: string } { const groups = ownGroups(); for (const rawName of Object.keys(rawGroups)) { - const name = canonicalizeModelGroupName(rawName); if (!name) return { ok: false, message: "group name must not be empty after trimming" }; if (hasOwnGroup(groups, name)) return { ok: false, message: `group keys collide after trimming at '${name}'` }; - const rawDef = rawGroups[rawName]; if (!isPlainRecord(rawDef) || !Array.isArray(rawDef.models)) return { ok: false, message: `group ${rawName}${isPlainRecord(rawDef) ? ".models must be an array" : " must be an object"}` }; - const models: ModelGroupModel[] = []; for (let i = 0; i < rawDef.models.length; i++) { const result = validateModelEntry(rawDef.models[i], `group ${rawName}.models[${i}]`); if (!result.ok) return result; models.push(result.model); } + const name = canonicalizeModelGroupName(rawName); + if (!name) return { ok: false, message: "group name must not be empty after trimming" }; + if (hasOwnGroup(groups, name)) return { ok: false, message: `group keys collide after trimming at '${name}'` }; + + const rawDef = rawGroups[rawName]; + if (!isPlainRecord(rawDef) || !Array.isArray(rawDef.models)) { + return { ok: false, message: `group ${rawName}${isPlainRecord(rawDef) ? ".models must be an array" : " must be an object"}` }; + } + + const models: ModelGroupModel[] = []; + for (let i = 0; i < rawDef.models.length; i++) { + const result = validateModelEntry(rawDef.models[i], `group ${rawName}.models[${i}]`); + if (!result.ok) return result; + models.push(result.model); + } const envelope = normalizeOverrideEnvelope(rawDef, sourceVersion, rawName); if (!envelope.ok) return envelope; // Strip runtime-derived fields while retaining opaque config keys and the v2 envelope. const { name: _name, scope: _scope, sourcePath: _sourcePath, modalities: _modalities, validation: _validation, models: _rawModels, constraints: _constraints, modalityOverride: _modalityOverride, ...configDef } = rawDef; const { ok: _ok, ...normalizedEnvelope } = envelope; - defineGroup(groups, name, { ...configDef, models, ...normalizedEnvelope, ...(sourceVersion < 2 && Object.hasOwn(rawDef, "modalityOverride") ? { modalityOverride: rawDef.modalityOverride } : {}) }); + defineGroup(groups, name, { + ...configDef, + models, + ...normalizedEnvelope, + ...(sourceVersion < 2 && Object.hasOwn(rawDef, "modalityOverride") ? { modalityOverride: rawDef.modalityOverride } : {}), + }); } return { ok: true, groups }; } @@ -79,26 +96,273 @@ function validateConfig(raw: unknown): { ok: true; config: ModelGroupsConfig } | 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 }; } +function backupAndIssue(scope: ModelGroupScope, sourcePath: string, kind: ModelGroupsLoadIssue["kind"], message: string, version?: number): ModelGroupsLoadIssue { + const issue: ModelGroupsLoadIssue = { scope, sourcePath, kind, message, backupPath: `${sourcePath}.bak`, version }; + if (kind === "unsupported-version") return issue; + try { + fsOps.copyFileSync(sourcePath, issue.backupPath!); + } catch (cause) { + issue.backupFailed = true; + issue.message = `${message}; backup failed: ${cause instanceof Error ? cause.message : String(cause)}`; + } + return issue; +} +function loadScope(scope: ModelGroupScope, access: ModelGroupsAccess): { config: ModelGroupsConfig; issue?: ModelGroupsLoadIssue } { + assertScopeAllowed(scope, access); + const sourcePath = modelGroupsPath(scope, access.cwd); + if (!fsOps.existsSync(sourcePath)) return { config: emptyConfig() }; + + let parsed: unknown; + try { + parsed = JSON.parse(String(fsOps.readFileSync(sourcePath, "utf8"))); + } catch (cause) { + return { + config: emptyConfig(), + issue: backupAndIssue(scope, sourcePath, "corrupt-json", cause instanceof Error ? cause.message : String(cause)), + }; + } + if (isPlainRecord(parsed) && typeof parsed.version === "number" && Number.isInteger(parsed.version) && parsed.version > CURRENT_VERSION) { + return { + config: emptyConfig(), + issue: backupAndIssue(scope, sourcePath, "unsupported-version", `unsupported version ${parsed.version}`, parsed.version), + }; + } + const validated = validateConfig(parsed); + return validated.ok + ? { config: validated.config } + : { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "schema-invalid", validated.message) }; +} +function mergeLoaded(configs: Record, access: ModelGroupsAccess): ModelGroupsLoadedGroup[] { + const names = new Set([...Object.keys(configs.global.groups), ...Object.keys(configs.project.groups)]); + const out: ModelGroupsLoadedGroup[] = []; + for (const name of [...names].sort()) { + if (hasOwnGroup(configs.global.groups, name)) { + out.push({ + name, + scope: "global", + sourcePath: modelGroupsPath("global", access.cwd), + ...cloneDef(configs.global.groups[name]), + }); + } + if (access.policy === "global-project" && hasOwnGroup(configs.project.groups, name)) { + out.push({ + name, + scope: "project", + sourcePath: modelGroupsPath("project", access.cwd), + ...cloneDef(configs.project.groups[name]), + }); + } + } + return out; +} +export function loadModelGroups(access: ModelGroupsAccess): ModelGroupsLoadResult { + const global = loadScope("global", access); + const project = access.policy === "global-project" ? loadScope("project", access) : { config: emptyConfig() }; + return { + configs: { global: global.config, project: project.config }, + merged: mergeLoaded({ global: global.config, project: project.config }, access), + issues: [global.issue, project.issue].filter((i): i is ModelGroupsLoadIssue => Boolean(i)), + }; +} +function normalizeSaveConfig(scope: ModelGroupScope, sourcePath: string, config: ModelGroupsConfig): ModelGroupsConfig { + const normalized = normalizeGroups(config.groups as any, 2); + if (!normalized.ok) { + throw persistenceError({ operation: "save", scope, sourcePath, phase: "config-validation", message: normalized.message }); + } + return { version: CURRENT_VERSION, groups: normalized.groups }; +} /** Low-level persistence: unlike createGroup/updateGroup, this does not enforce the modality union-cap invariant; cap enforcement is CRUD-only, so rename/delete/move are out of scope. */ -export function saveModelGroups(scope: ModelGroupScope, access: ModelGroupsAccess, config: ModelGroupsConfig): void { assertScopeAllowed(scope, access); const sourcePath = modelGroupsPath(scope, access.cwd); const normalized = normalizeSaveConfig(scope, sourcePath, config); let raw: Record = {}; if (fsOps.existsSync(sourcePath)) { try { const parsed = JSON.parse(String(fsOps.readFileSync(sourcePath, "utf8"))); if (isPlainRecord(parsed)) { if (typeof parsed.version === "number" && Number.isInteger(parsed.version) && parsed.version > CURRENT_VERSION) throw persistenceError({ operation: "save", scope, sourcePath, phase: "config-validation", message: `unsupported version ${parsed.version}` }); raw = parsed; } } catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw cause; } } const tempPath = `${sourcePath}.${process.pid}.${Date.now()}.tmp`; try { fsOps.mkdirSync(path.dirname(sourcePath), { recursive: true }); fsOps.writeFileSync(tempPath, JSON.stringify({ ...raw, version: CURRENT_VERSION, groups: normalized.groups }, null, 2) + "\n", "utf8"); } catch (cause) { throw persistenceError({ operation: "save", scope, sourcePath, targetPath: tempPath, phase: "temp-write", message: `Failed to write temp model-groups file for ${scope}: ${cause instanceof Error ? cause.message : String(cause)}`, cause }); } try { fsOps.renameSync(tempPath, sourcePath); } catch (cause) { let detail = ""; try { fsOps.unlinkSync(tempPath); } catch (cleanup) { detail = `; temp cleanup failed: ${cleanup instanceof Error ? cleanup.message : String(cleanup)}`; } throw persistenceError({ operation: "save", scope, sourcePath, targetPath: tempPath, phase: "rename", message: `Failed to commit model-groups file for ${scope}: ${cause instanceof Error ? cause.message : String(cause)}${detail}`, cause }); } } -function loadScopeConfig(scope: ModelGroupScope, access: ModelGroupsAccess): ModelGroupsConfig { const loaded = loadScope(scope, access); if (loaded.issue?.backupFailed || loaded.issue?.kind === "unsupported-version") throw persistenceError({ operation: "save", scope, sourcePath: loaded.issue!.sourcePath, targetPath: loaded.issue!.backupPath, phase: loaded.issue?.kind === "unsupported-version" ? "config-validation" : "load-recovery", message: `Refusing to overwrite ${scope} model-groups config after ${loaded.issue!.kind} recovery because ${loaded.issue!.message}`, cause: loaded.issue }); return loaded.config; } +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; } function normalizeMutationDef(def: ModelGroupDef): ModelGroupDef { const normalized = normalizeGroups({ group: def }, CURRENT_VERSION); if (!normalized.ok) throw persistenceError({ operation: "save", phase: "config-validation", message: normalized.message }); return normalized.groups.group; } -export function createGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawName: string, def: ModelGroupDef, modelRegistry: Pick): void { assertScopeAllowed(scope, access); const name = canonicalName(rawName); const config = loadScopeConfig(scope, access); if (hasOwnGroup(config.groups, name)) throw new Error(`Model group '${name}' already exists in ${scope} scope`); const normalizedDef = normalizeMutationDef(def); assertModalityOverrideSupported(normalizedDef, modelRegistry); defineGroup(config.groups, name, normalizedDef); saveModelGroups(scope, access, config); } -export function updateGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawName: string, def: ModelGroupDef, modelRegistry: Pick): void { assertScopeAllowed(scope, access); const name = canonicalName(rawName); const config = loadScopeConfig(scope, access); if (!hasOwnGroup(config.groups, name)) throw new Error(`Model group '${name}' does not exist in ${scope} scope`); const normalizedDef = normalizeMutationDef(def); assertModalityOverrideSupported(normalizedDef, modelRegistry); defineGroup(config.groups, name, normalizedDef); saveModelGroups(scope, access, config); } -export function renameGroup(scope: ModelGroupScope, access: ModelGroupsAccess, old: string, next: string): void { const config = loadScopeConfig(scope, access); const a = canonicalName(old), b = canonicalName(next); if (a === b) return; if (!hasOwnGroup(config.groups, a)) throw new Error(`Model group '${a}' does not exist in ${scope} scope`); if (hasOwnGroup(config.groups, b)) throw new Error(`Model group '${b}' already exists in ${scope} scope`); const def = config.groups[a]; delete config.groups[a]; defineGroup(config.groups, b, def); saveModelGroups(scope, access, config); } -export function deleteGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawName: string): { otherScopeHasOverride: boolean } { const config = loadScopeConfig(scope, access); const name = canonicalName(rawName); if (!hasOwnGroup(config.groups, name)) throw new Error(`Model group '${name}' does not exist in ${scope} scope`); delete config.groups[name]; const other = access.policy === "global-only" ? emptyConfig() : loadScopeConfig(scope === "global" ? "project" : "global", access); try { saveModelGroups(scope, access, config); } catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw new ModelGroupsPersistenceError({ operation: "delete", scope: cause.scope, sourcePath: cause.sourcePath, targetPath: cause.targetPath, phase: cause.phase, message: cause.message, cause }); throw cause; } return { otherScopeHasOverride: hasOwnGroup(other.groups, name) }; } -export function moveGroup(access: ModelGroupsAccess, rawName: string, newScope: ModelGroupScope): void { const name = canonicalName(rawName), oldScope: ModelGroupScope = newScope === "project" ? "global" : "project"; const source = loadScopeConfig(oldScope, access), target = loadScopeConfig(newScope, access); if (!hasOwnGroup(source.groups, name)) throw new Error(`Model group '${name}' does not exist in ${oldScope} scope`); if (hasOwnGroup(target.groups, name)) throw new Error(`Model group '${name}' already exists in ${newScope} scope`); defineGroup(target.groups, name, source.groups[name]); try { saveModelGroups(newScope, access, target); } catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw new ModelGroupsPersistenceError({ operation: "move", scope: newScope, sourcePath: modelGroupsPath(oldScope, access.cwd), targetPath: cause.targetPath, phase: cause.phase, message: cause.message, cause }); throw cause; } delete source.groups[name]; try { saveModelGroups(oldScope, access, source); } catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw new ModelGroupsPersistenceError({ operation: "move", scope: oldScope, sourcePath: modelGroupsPath(oldScope, access.cwd), targetPath: modelGroupsPath(newScope, access.cwd), phase: "source-remove", partialMove: "target-written-source-retained", message: cause.message, cause }); throw cause; } } -export function validateModelGroups(loadResult: ModelGroupsLoadResult, modelRegistry: ModelRegistry): ResolvedModelGroup[] { const projectNames = new Set(Object.keys(loadResult.configs.project.groups)); return loadResult.merged.map((group) => { const unavailableRefs = group.models.filter((ref) => { const model = modelRegistry.find(ref.provider, ref.modelId); return !model || !modelRegistry.hasConfiguredAuth(model); }).map(({ provider, modelId }) => ({ provider, modelId })); const evaluations = evaluateConstraints(resolveConstraintMembers(group.models, modelRegistry), group.constraints ?? {}, productionConstraintRegistry); const modalityEvaluation = evaluations.find((evaluation) => evaluation.key === modalitiesConstraint.key)!; const modalities = modalityEvaluation.aggregate as ModelGroupModalities; const diagnostics = presentConstraintDiagnosticRecords(evaluations, productionConstraintRegistry); const unsupportedOverrideModalities = (diagnostics.find((diagnostic) => diagnostic.key === "modalities" && diagnostic.code === "unsupported-override")?.details as ModelGroupModality[] | undefined) ?? []; return { ...group, modalities: { ...modalities, effective: modalityEvaluation.effective as ModelGroupModality[] }, evaluations, validation: { unavailableRefs, shadowedByProject: group.scope === "global" && projectNames.has(group.name), degraded: unavailableRefs.length > 0 && unavailableRefs.length < group.models.length, emptyCommonModalities: diagnostics.some((diagnostic) => diagnostic.key === "modalities" && diagnostic.code === "empty-common"), unsupportedOverrideModalities } }; }); } -export function listResolvedModelGroups(access: ModelGroupsAccess, registry: ModelRegistry): ModelGroupsBootValidation { const loaded = loadModelGroups(access); return { groups: validateModelGroups(loaded, registry), loadIssues: loaded.issues }; } -export function summarizeBootValidation(groups: ResolvedModelGroup[]): { unavailableCount: number; overrideCount: number; emptyModalityCount: number; staleModalityOverrideCount: number } { const diagnostics = groups.flatMap((group) => group.evaluations ? presentConstraintDiagnosticRecords(group.evaluations, productionConstraintRegistry) : []); return { unavailableCount: groups.reduce((sum, group) => sum + group.validation.unavailableRefs.length, 0), overrideCount: groups.filter((g) => g.validation.shadowedByProject).length, emptyModalityCount: diagnostics.filter((diagnostic) => diagnostic.key === "modalities" && diagnostic.code === "empty-common").length, staleModalityOverrideCount: diagnostics.filter((diagnostic) => diagnostic.key === "modalities" && diagnostic.code === "unsupported-override").length }; } +export 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); + const 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); + const oldScope: ModelGroupScope = newScope === "project" ? "global" : "project"; + 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: cause.targetPath, phase: cause.phase, message: cause.message, cause }); + } + throw cause; + } + delete source.groups[name]; + try { + saveModelGroups(oldScope, access, source); + } catch (cause) { + if (cause instanceof ModelGroupsPersistenceError) { + throw new ModelGroupsPersistenceError({ operation: "move", scope: oldScope, sourcePath: modelGroupsPath(oldScope, access.cwd), targetPath: modelGroupsPath(newScope, access.cwd), phase: "source-remove", partialMove: "target-written-source-retained", message: cause.message, cause }); + } + throw cause; + } +} +export function validateModelGroups(loadResult: ModelGroupsLoadResult, modelRegistry: ModelRegistry): ResolvedModelGroup[] { + const projectNames = new Set(Object.keys(loadResult.configs.project.groups)); + return loadResult.merged.map((group) => { + const unavailableRefs = group.models + .filter((ref) => { + const model = modelRegistry.find(ref.provider, ref.modelId); + return !model || !modelRegistry.hasConfiguredAuth(model); + }) + .map(({ provider, modelId }) => ({ provider, modelId })); + const evaluations = evaluateConstraints(resolveConstraintMembers(group.models, modelRegistry), group.constraints ?? {}, productionConstraintRegistry); + const modalityEvaluation = evaluations.find((evaluation) => evaluation.key === modalitiesConstraint.key)!; + const modalities = modalityEvaluation.aggregate as ModelGroupModalities; + const diagnostics = presentConstraintDiagnosticRecords(evaluations, productionConstraintRegistry); + const unsupportedOverrideModalities = (diagnostics.find((diagnostic) => diagnostic.key === "modalities" && diagnostic.code === "unsupported-override")?.details as ModelGroupModality[] | undefined) ?? []; + const shadowedByProject = group.scope === "global" && projectNames.has(group.name); + const degraded = unavailableRefs.length > 0 && unavailableRefs.length < group.models.length; + const emptyCommonModalities = diagnostics.some((diagnostic) => diagnostic.key === "modalities" && diagnostic.code === "empty-common"); + return { + ...group, + modalities: { ...modalities, effective: modalityEvaluation.effective as ModelGroupModality[] }, + evaluations, + validation: { + unavailableRefs, + shadowedByProject, + degraded, + emptyCommonModalities, + unsupportedOverrideModalities, + }, + }; + }); +} +export function listResolvedModelGroups(access: ModelGroupsAccess, registry: ModelRegistry): ModelGroupsBootValidation { + const loaded = loadModelGroups(access); + return { groups: validateModelGroups(loaded, registry), loadIssues: loaded.issues }; +} +export function summarizeBootValidation(groups: ResolvedModelGroup[]): { unavailableCount: number; overrideCount: number; emptyModalityCount: number; staleModalityOverrideCount: number } { + const diagnostics = groups.flatMap((group) => group.evaluations ? presentConstraintDiagnosticRecords(group.evaluations, productionConstraintRegistry) : []); + return { + unavailableCount: groups.reduce((sum, group) => sum + group.validation.unavailableRefs.length, 0), + overrideCount: groups.filter((g) => g.validation.shadowedByProject).length, + emptyModalityCount: diagnostics.filter((diagnostic) => diagnostic.key === "modalities" && diagnostic.code === "empty-common").length, + staleModalityOverrideCount: diagnostics.filter((diagnostic) => diagnostic.key === "modalities" && diagnostic.code === "unsupported-override").length, + }; +} export const EMPTY_MODEL_GROUPS_CONFIG: ModelGroupsConfig = emptyConfig(); export { CURRENT_VERSION as MODEL_GROUPS_CONFIG_VERSION, hasOwnGroup }; diff --git a/model-groups/tui.ts b/model-groups/tui.ts index 0ff8f0d..4692dc2 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -324,7 +324,12 @@ export function createModelGroupsComponent( case "LIST": return state.groups.length; case "EDITOR": return modelStartRow() + (state.editDraft?.models.length ?? 0); case "MODALITIES": return Math.max(0, modalityEditorRows().length - 1); - case "MODEL_EDIT": return thinkingOptionsFor(modelRegistry.find(state.editDraft?.models[state.modelEditIndex]?.provider ?? "", state.editDraft?.models[state.modelEditIndex]?.modelId ?? "") as Model | undefined).length; + case "MODEL_EDIT": { + const reference = state.editDraft?.models[state.modelEditIndex]; + const model = modelRegistry.find(reference?.provider ?? "", reference?.modelId ?? "") as Model | undefined; + const options = thinkingOptionsFor(model); + return options.length; + } case "WIZARD_PROVIDER": return Math.max(0, allProviders().length - 1); case "WIZARD_MODEL": return Math.max(0, filteredModelsForProvider(state.wizardProvider).length - 1); case "WIZARD_THINKING": return Math.max(0, thinkingOptionsFor(currentWizardModel()).length - 1); @@ -636,15 +641,31 @@ export function createModelGroupsComponent( activeSelect = null; const container = new Container(); const current = currentEditGroup(); - container.addChild(textLine(theme.fg("accent", `Model Group: ${escapeDisplayLabel(current?.name ?? "")}`))); - 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" ? " ✓" : ""))); + const title = theme.fg("accent", `Model Group: ${escapeDisplayLabel(current?.name ?? "")}`); + container.addChild(textLine(title)); + + if (access.policy === "global-project") { + const projectLocation = selectableLine(state.row === 0, "Location: project", state.editScope === "project" ? " ✓" : ""); + container.addChild(textLine(projectLocation)); + } + const globalLocationRow = access.policy === "global-project" ? 1 : 0; + const globalLocation = selectableLine(state.row === globalLocationRow, "Location: global", state.editScope === "global" ? " ✓" : ""); + container.addChild(textLine(globalLocation)); container.addChild(groupNameLineComponent()); + const modalities = current?.modalities; - container.addChild(textLine(sectionBar("Capabilities"))); - container.addChild(textLine(theme.fg("dim", ` Supported by every model: ${modalities?.common.filter((modality) => modality !== "reasoning").join(", ") || "none"}`))); - container.addChild(textLine(selectableLine(state.row === modalityRow(), `Modalities: ${state.editDraft?.constraints?.modalities === undefined ? "Automatic" : "Override"} (${modalities?.effective.filter((modality) => modality !== "reasoning").join(", ") || "none"})`))); - container.addChild(textLine(sectionBar("Models"))); + const capabilitiesBar = sectionBar("Capabilities"); + container.addChild(textLine(capabilitiesBar)); + const commonModalities = modalities?.common.filter((modality) => modality !== "reasoning").join(", ") || "none"; + const commonCapabilities = theme.fg("dim", ` Supported by every model: ${commonModalities}`); + container.addChild(textLine(commonCapabilities)); + const modalityState = state.editDraft?.constraints?.modalities === undefined ? "Automatic" : "Override"; + const effectiveModalities = modalities?.effective.filter((modality) => modality !== "reasoning").join(", ") || "none"; + const modalityLine = selectableLine(state.row === modalityRow(), `Modalities: ${modalityState} (${effectiveModalities})`); + container.addChild(textLine(modalityLine)); + const modelsBar = sectionBar("Models"); + container.addChild(textLine(modelsBar)); + state.editDraft?.models.forEach((model, index) => { const available = modelAvailable(modelRegistry, model.provider, model.modelId) ? "available" : "unavailable"; const found = modelRegistry.find(model.provider, model.modelId) as Model | undefined; @@ -652,10 +673,15 @@ export function createModelGroupsComponent( // members have no fact, so they render without a chip. const chip = found ? modalityLetterRun(getModalitiesModelFact(found)) : ""; const id = `${escapeDisplayLabel(model.provider)}/${escapeDisplayLabel(model.modelId)}`; - container.addChild(textLine(selectableLine(state.row === index + modelStartRow(), chip ? `${id} ${chip}` : id, ` (${available}, thinking ${thinkingLabel(model.thinkingLevel)})`))); + const selected = state.row === index + modelStartRow(); + const label = chip ? `${id} ${chip}` : id; + const suffix = ` (${available}, thinking ${thinkingLabel(model.thinkingLevel)})`; + const modelLine = selectableLine(selected, label, suffix); + container.addChild(textLine(modelLine)); }); const addRow = modelStartRow() + (state.editDraft?.models.length ?? 0); - container.addChild(textLine(selectableLine(state.row === addRow, "+ Add model…"))); + const addModelLine = selectableLine(state.row === addRow, "+ Add model…"); + container.addChild(textLine(addModelLine)); return container; } diff --git a/model-groups/types.ts b/model-groups/types.ts index 61d7bd5..3b55c3e 100644 --- a/model-groups/types.ts +++ b/model-groups/types.ts @@ -34,7 +34,20 @@ 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); } + 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[] } From 8eb1e0615dea7cec86a2cff4c21584f3b5d77ee8 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 23 Aug 2026 11:02:18 +0000 Subject: [PATCH 29/33] refactor(router): reduce cyclomatic complexity of resolveSpawnModelRoute Extract cohesive helpers (decodeDeclared, inheritedRoute, usableMembers, selectMember, buildRoutedRoute, attachCeilings, collectViolations, raiseRouteFailure) so the routing function drops from ~43 decision points to ~7. Stictly behavior-preserving: cursor round-robin semantics, capable-pool fallback, RNG clamp, error taxonomy/messages, and empty-requirements guard are unchanged and lock-blocked by the existing router + spawn suites. Battery: typecheck, unit 697/697, e2e 16/16, snapshots 11/11, compat 167/167, package-host, audit-ci, git diff --check. --- model-groups/router.ts | 171 +++++++++++++++++++++++++---------------- 1 file changed, 103 insertions(+), 68 deletions(-) diff --git a/model-groups/router.ts b/model-groups/router.ts index 623a283..679bc65 100644 --- a/model-groups/router.ts +++ b/model-groups/router.ts @@ -82,84 +82,90 @@ function effectiveGroupMap(groups: ResolvedModelGroup[]): Map a.name.localeCompare(b.name)); } export function getEffectiveModelGroupNames(groups: ResolvedModelGroup[]): string[] { return getEffectiveModelGroups(groups).map((group) => group.name); } -/** Route selection remains auth-aware; constraint evaluation receives its explicit member snapshot. */ -export function resolveSpawnModelRoute(options: { requestedGroup?: string; constraints?: Readonly>; groups: ResolvedModelGroup[]; parentModel: Model; parentThinking: ModelThinkingLevel; modelRegistry: Pick; constraintRegistry?: ConstraintRegistry; rng?: () => number; routeCursor?: Map }): SpawnModelRoute { - const requestedGroup = options.requestedGroup?.trim(); - const requirements = options.constraints ?? {}; - const registry = options.constraintRegistry ?? productionConstraintRegistry; - let declaredRequirements: readonly { descriptor: AnyConstraintDescriptor; requirement: unknown }[] | undefined; - const getDeclaredRequirements = () => declaredRequirements ??= Object.entries(requirements).map(([key, rawRequirement]) => { +type SpawnRouteOptions = { + requestedGroup?: string; + constraints?: Readonly>; + groups: ResolvedModelGroup[]; + parentModel: Model; + parentThinking: ModelThinkingLevel; + modelRegistry: Pick; + constraintRegistry?: ConstraintRegistry; + rng?: () => number; + routeCursor?: Map; +}; +type DeclaredRequirement = { descriptor: AnyConstraintDescriptor; requirement: unknown }; +type RoutedMember = { entry: ResolvedModelGroup["models"][number]; model: Model }; + +function decodeDeclared(registry: ConstraintRegistry, requirements: Readonly>): () => readonly DeclaredRequirement[] { + let declaredRequirements: readonly DeclaredRequirement[] | undefined; + return () => declaredRequirements ??= Object.entries(requirements).map(([key, rawRequirement]) => { 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); return { descriptor, requirement: decoded.value }; }); - const inherited = (status: "inherited" | "unknown-fallback"): SpawnModelRoute => ({ +} + +function inheritedRoute(status: "inherited" | "unknown-fallback", requestedGroup: string | undefined, parentModel: Model, parentThinking: ModelThinkingLevel): SpawnModelRoute { + return { 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"); + model: parentModel, + provider: parentProvider(parentModel), + modelId: parentModel.id, + thinking: parentThinking, + }; +} - const declared = getDeclaredRequirements(); - const capable = declared.length - ? usable.filter(({ model }) => declared.every(({ descriptor, requirement }) => descriptor.modelSatisfies({ fact: descriptor.modelFact(model), requirement }).satisfied)) - : usable; - const pool = capable.length ? capable : usable; +function usableMembers(group: ResolvedModelGroup, modelRegistry: SpawnRouteOptions["modelRegistry"]): RoutedMember[] { + return group.models + .map((entry) => { + const model = modelRegistry.find(entry.provider, entry.modelId) as Model | undefined; + return model && modelRegistry.hasConfiguredAuth(model) ? { entry, model } : undefined; + }) + .filter((entry): entry is RoutedMember => Boolean(entry)); +} - let selected; - if (options.routeCursor && declared.length && capable.length && capable.length < usable.length) { - const index = options.routeCursor.get(group.name) ?? 0; - options.routeCursor.set(group.name, (index + 1) % pool.length); - selected = pool[index % pool.length]; - } else { - const randomIndex = Math.min(pool.length - 1, Math.max(0, Math.floor((options.rng ?? Math.random)() * pool.length))); - selected = pool[randomIndex]; - } - 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), - }; - } +function selectMember(group: ResolvedModelGroup, usable: RoutedMember[], declared: readonly DeclaredRequirement[], options: Pick): RoutedMember { + const capable = declared.length + ? usable.filter(({ model }) => declared.every(({ descriptor, requirement }) => descriptor.modelSatisfies({ fact: descriptor.modelFact(model), requirement }).satisfied)) + : usable; + const pool = capable.length ? capable : usable; + if (options.routeCursor && declared.length && capable.length && capable.length < usable.length) { + const index = options.routeCursor.get(group.name) ?? 0; + options.routeCursor.set(group.name, (index + 1) % pool.length); + return pool[index % pool.length]; } - const resolution = group ? resolveConstraintMembers(group.models, options.modelRegistry) : { members: [] }; - if (group && route.status === "routed") { - const groupCapabilityCeilings = registry.descriptors.flatMap((descriptor) => { - if (group.constraints?.[descriptor.key] === undefined || !descriptor.present.ceiling) return []; - const note = descriptor.present.ceiling(evaluateConstraint(descriptor, resolution, group.constraints[descriptor.key])); - return note ? [note] : []; - }); - if (groupCapabilityCeilings.length) route = { ...route, groupCapabilityCeilings }; - } - if (!Object.keys(requirements).length) return route; + const randomIndex = Math.min(pool.length - 1, Math.max(0, Math.floor((options.rng ?? Math.random)() * pool.length))); + return pool[randomIndex]; +} + +function buildRoutedRoute(group: ResolvedModelGroup, requestedGroup: string, _usable: RoutedMember[], selected: RoutedMember, options: Pick): SpawnModelRoute { + return { + 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), + }; +} + +function attachCeilings(route: SpawnModelRoute, group: ResolvedModelGroup | undefined, resolution: ReturnType | { members: never[] }, registry: ConstraintRegistry): SpawnModelRoute { + if (!group || route.status !== "routed") return route; + const groupCapabilityCeilings = registry.descriptors.flatMap((descriptor) => { + if (group.constraints?.[descriptor.key] === undefined || !descriptor.present.ceiling) return []; + const note = descriptor.present.ceiling(evaluateConstraint(descriptor, resolution, group.constraints[descriptor.key])); + return note ? [note] : []; + }); + return groupCapabilityCeilings.length ? { ...route, groupCapabilityCeilings } : route; +} + +function collectViolations(getDeclared: () => readonly DeclaredRequirement[], group: ResolvedModelGroup | undefined, resolution: ReturnType | { members: never[] }, route: SpawnModelRoute, _registry: ConstraintRegistry): ConstraintViolation[] { const violations: ConstraintViolation[] = []; - for (const { descriptor, requirement } of getDeclaredRequirements()) { + for (const { descriptor, requirement } of getDeclared()) { if (group) { const override = group.constraints?.[descriptor.key]; const evaluation = evaluateConstraint(descriptor, resolution, override); @@ -169,6 +175,10 @@ export function resolveSpawnModelRoute(options: { requestedGroup?: string; const const violation = evaluateModelRequirement(descriptor, route.model, requirement); if (violation) violations.push(violation); } + return violations; +} + +function raiseRouteFailure(violations: ConstraintViolation[], group: ResolvedModelGroup | undefined, requestedGroup: string | undefined, route: SpawnModelRoute, registry: ConstraintRegistry): never { const modalityViolations = violations.filter((violation) => violation.key === "modalities"); if (modalityViolations.length) { const groupViolations = modalityViolations.filter((violation) => violation.scope === "group"); @@ -190,6 +200,31 @@ export function resolveSpawnModelRoute(options: { requestedGroup?: string; const 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; + throw new SpawnRouteError(group?.name ?? (requestedGroup || ""), "constraint-unsatisfied", { constraintUnsatisfied: violations, provider: route.provider, modelId: route.modelId, knownGroup: Boolean(group) }); +} + +/** Route selection remains auth-aware; constraint evaluation receives its explicit member snapshot. */ +export function resolveSpawnModelRoute(options: SpawnRouteOptions): SpawnModelRoute { + const requestedGroup = options.requestedGroup?.trim(); + const requirements = options.constraints ?? {}; + const registry = options.constraintRegistry ?? productionConstraintRegistry; + const getDeclaredRequirements = decodeDeclared(registry, requirements); + const group = requestedGroup ? effectiveGroupMap(options.groups).get(requestedGroup) : undefined; + let route = !requestedGroup + ? inheritedRoute("inherited", requestedGroup, options.parentModel, options.parentThinking) + : !group + ? inheritedRoute("unknown-fallback", requestedGroup, options.parentModel, options.parentThinking) + : undefined; + if (group) { + if (group.models.length === 0) throw new SpawnRouteError(group.name, "empty"); + const usable = usableMembers(group, options.modelRegistry); + if (!usable.length) throw new SpawnRouteError(group.name, "no-usable-models"); + const selected = selectMember(group, usable, getDeclaredRequirements(), options); + route = buildRoutedRoute(group, requestedGroup!, usable, selected, options); + } + const resolution = group ? resolveConstraintMembers(group.models, options.modelRegistry) : { members: [] }; + route = attachCeilings(route!, group, resolution, registry); + if (!Object.keys(requirements).length) return route; + const violations = collectViolations(getDeclaredRequirements, group, resolution, route, registry); + return violations.length ? raiseRouteFailure(violations, group, requestedGroup, route, registry) : route; } From 502b507235b5a73b5d45933b01c530e24b3bfd28 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Sun, 23 Aug 2026 11:25:45 +0000 Subject: [PATCH 30/33] docs(pr27): document model capabilities and spawn constraint routing README: add a plain-language Capabilities feature bullet and section covering the T/I modality chips in pickers/editor rows, narrowable group sets, and fail-early spawn constraint checks. Model Groups bullet unchanged. CHANGELOG [Unreleased]: add capability-aware spawn routing + pluggable capability kernel entries under Added, and per-member capability chips under Changed. Modalities scoped to user-facing text/image chips. --- CHANGELOG.md | 3 +++ README.md | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 763df1d..b34249d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Model Groups manager** — added `/model-groups` with durable project/global JSON persistence, boot validation, CRUD TUI flows, per-model thinking levels, and operator notifications for invalid configs or unavailable model refs. - **Model Groups spawn routing** — `spawn` can route children through an optional exact Model Group name with names-only prompt guidance, `#group` autocomplete sugar that shows model/thinking details, authenticated random entry selection, thinking inheritance/clamping, and routed/fallback result identity lines. +- **Capability-aware spawn routing** — a Model Group now carries a capability set (currently input modalities: `text` and `image`) derived from its configured, authenticated members, with the option to narrow it via an explicit override capped at the member union. A `spawn` call can declare `constraints` the delegated task needs; the router checks the group and the exact selected model and fails before any child would be created when the requirement can't be met. The main-session prompt lists each group's capabilities. +- **Pluggable capability kernel** — added a small constraint system so each capability is one descriptor (read fact → aggregate → reconcile override → satisfy check → present). Modalities are the first, and only, production capability; a synthetic test-only descriptor exercises the full extension point so a future capability (e.g. min context window) needs no edits to config load, spawn routing, or UI render. ### Changed - Improved Model Groups editing with a searchable complete-result, ten-visible-row add-model picker and a prompt-free inline group-name editor. - Migrated child spawning to Pi's public selected-model and child-owned runtime APIs, added `max` thinking support, and disposed every created child session exactly once across completion, failure, abort, and reset races. Pi 0.82.0 and Node 22.19.0 are now the documented minimums; parent-only transient provider/auth state fails explicitly without model fallback. +- **Model Group editing** — the editor now shows per-member capability chips (T/I) and lets users narrow the automatically derived modality set with an explicit override; group capability summaries also appear in `#group` autocomplete. ### Fixed diff --git a/README.md b/README.md index de6aa64..b07a343 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Deeper rationale: [docs/why.md](docs/why.md) · companion book: [agenticoding.ai - **Spawn** — run research or implementation in a clean child context so the parent stays focused - **Model Groups** — manage durable project/global model pools with `/model-groups`; route `spawn` by an exact group name, with `#group` autocomplete showing model/thinking details +- **Capabilities** — model pickers and editor rows show a chip beside each model for the input modalities it handles (`T` text, `I` image); a group advertises what its members can do, and a spawn that needs a capability the group can't deliver fails early instead of working around it - **Notebook** — task-scoped named pages for facts and decisions; survives handoff, dies with the conversation (`/new`) — no forever-memory rot - **Handoff** — deliberate clean restart with a task prompt when the topic changes or context turns to noise - **Topic** — same problem → prefer spawn; new problem → prefer handoff (human-set topics win) @@ -76,13 +77,21 @@ The agent set a topic, spawned research, saved decisions, delegated implementati | | | |---|---| -| **Spawn** | Subtask in a clean child context. Parent orchestrates; siblings run in parallel. Children inherit active registered parent tools executable in the child session — MCP/extension tools such as ChunkHound — plus child-local notebook tools. Children cannot spawn grandchildren or handoff. Omit `group` to inherit the parent model/thinking. An unknown group reports fallback to the parent. A known group randomly selects among configured/authenticated usable entries and fails before child creation if none are usable. The selected entry supplies the model and, when configured, overrides explicit/inherited thinking before Pi clamps it; the final selected public model runs in the child-owned runtime. | +| **Spawn** | Subtask in a clean child context. Parent orchestrates; siblings run in parallel. Children inherit active registered parent tools executable in the child session — MCP/extension tools such as ChunkHound — plus child-local notebook tools. Children cannot spawn grandchildren or handoff. Omit `group` to inherit the parent model/thinking. An unknown group reports fallback to the parent. A known group randomly selects among configured/authenticated usable entries and fails before child creation if none are usable. When the delegated task declares a `constraints` requirement, the group **and** the exact selected model are both checked before any child is created, and the child is told which capabilities the group allows. The selected entry supplies the model and, when configured, overrides explicit/inherited thinking before Pi clamps it; the final selected public model runs in the child-owned runtime. | | **Notebook** | Named pages coupled to this conversation/task. Carries memory across handoff; cleared on `/new`. Not a long-lived memory store — lifetime matches the work, so it cannot go stale across unrelated sessions. | | **Handoff** | Write a prompt, compact, resume clean. Notebook holds reusable memory for this task; the prompt holds only remaining situational context. | | **Readonly** | Blocks write/edit and guards bash while researching. Spawn inherits the posture. **macOS/Linux:** bash can run under OS sandbox (`sandbox-exec` / `bwrap`) — syscall-level write denial outside temp. **Windows:** no OS sandbox — **best-effort command classifier only** (interpreters and clever pipes can bypass). A coding guardrail on every OS — not a hardened security boundary. | **Commands:** `/handoff` · `/notebook` · `/notebook ` · `/readonly` · `Ctrl+Shift+R` · `--readonly` +## Capabilities + +Model groups advertise what their members can do. Today that means input modalities: `text` and `image`. + +Model pickers and editor rows put a small chip beside each model (`T` for text, `I` for image) so you can tell at a glance which inputs it handles. A group's set is derived from its members — you can **narrow** it, but never widen it beyond what the members actually support. + +When you delegate a task with `spawn`, you can declare which capabilities it needs. Spawn checks that requirement against the group and the exact model it selects — and if it can't be met, it fails before creating any child rather than improvising around the gap. + ## Comparison | Approach | Who decides | Across cuts | From c48cf47ccb71f54314afc7ce3963583b55b80b81 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Mon, 24 Aug 2026 06:03:08 +0000 Subject: [PATCH 31/33] fix(spawn): self-document modality constraints to stop invalid-value retry storms The spawn constraints schema rejects out-of-vocabulary modality values before any child is created (behavior-correct), but carried no description metadata and only a buried guideline, so a model that invents a value (e.g. 'code') got a cryptic Ajv error and blind-retried. Add: - union + required-object schema descriptions naming the exact allowed values (text, image, reasoning) - a generic constraints-parameter description (registry-parametric, no modality literal hardcoded) - an assertive prompt guideline forbidding invented capability names Metadata/prompt-text only; validation semantics unchanged. Battery: typecheck, unit 697/697, e2e 16/16, snapshots 11/11, compat 167/167, package-host, audit-ci, git diff --check. --- model-groups/constraints/modalities.ts | 4 ++-- spawn/index.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/model-groups/constraints/modalities.ts b/model-groups/constraints/modalities.ts index 9c4aa65..7edbee6 100644 --- a/model-groups/constraints/modalities.ts +++ b/model-groups/constraints/modalities.ts @@ -16,7 +16,7 @@ function modalityCodec(): ConstraintCodec { }, 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 }), + schema: Type.Array(Type.Union(MODEL_GROUP_MODALITIES.map((value) => Type.Literal(value))), { uniqueItems: true, description: `one of: ${MODEL_GROUP_MODALITIES.join(", ")}` }), }; } @@ -77,7 +77,7 @@ export const modalitiesConstraint: ConstraintDescriptor<"modalities", ModelGroup }, encode: (value) => ({ required: [...value] }), equals: (left, right) => modalityCodec().equals(left, right), - schema: Type.Object({ required: modalityCodec().schema }), + schema: Type.Object({ required: modalityCodec().schema }, { description: `required input modalities for the delegated task — values must be exactly ${MODEL_GROUP_MODALITIES.join(", ")} (no other capability names)` }), }, editor: { kind: "multi-select", label: "Modalities", choices: (evaluation) => evaluation.aggregate.supported, automatic: (evaluation) => `Automatic (${evaluation.aggregate.common.filter((modality) => modality !== "reasoning").join(", ") || "none"})` }, present: { diff --git a/spawn/index.ts b/spawn/index.ts index 7d09ca7..241a0c4 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -292,13 +292,11 @@ const SPAWN_PROMPT_GUIDELINES = [ "Use spawn to delegate isolated work to child agents. They are trusted extensions of you with their own context and the same authority. Only condensed results are returned.", "If the operator requests a known Model Group confidently, pass its exact name as group. If no known/confident group is requested, omit group so the child inherits the parent model/thinking.", `Declare constraints when the delegated task needs ${MODEL_GROUP_MODALITY_PROSE} capability; do not work around a missing required modality with third-party tools.`, + `Valid modality values are exactly text, image, or reasoning — do not invent capability names (e.g. \"code\"). Invalid values are rejected before any child is created.`, `A specified group is binding: if the operator asks for a specific group and it lacks a needed capability, do NOT substitute a different group or inherit the parent model. Stop and report to the operator that the named group cannot satisfy the task, and ask how to proceed.`, ]; export function buildSpawnParameters(constraintRegistry: ConstraintRegistry) { - const constraintRequirements = Type.Object( - Object.fromEntries(constraintRegistry.descriptors.map((descriptor) => [descriptor.key, descriptor.requirement.schema])) as any, - ); return Type.Object({ prompt: Type.String({ description: @@ -308,7 +306,9 @@ export function buildSpawnParameters(constraintRegistry: ConstraintRegistry) { 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(constraintRequirements), + constraints: Type.Optional(Type.Object(Object.fromEntries(constraintRegistry.descriptors.map((descriptor) => [descriptor.key, descriptor.requirement.schema])) as any, { + description: "Capability requirements for the delegated task, keyed by constraint name. Keys and values are defined by the constraint registry; unknown keys or invalid values are rejected before a child is created.", + })), thinking: Type.Optional(StringEnum( ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const, { From 90d88aa280219014666481af33c475b68b279ccb Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Mon, 24 Aug 2026 06:33:30 +0000 Subject: [PATCH 32/33] fix(spawn): name the constraint keys in schema + guidance to stop key-typo retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A model that misspells the constraint key (e.g. 'modelities' instead of 'modalities') gets a cryptic 'must have required properties' host error and blind-retries. The schema description named no keys, so the model had to guess. Fix: - buildSpawnParameters emits a constraints description that enumerates the actual registry keys ('Keys: modalities') — registry-parametric, so injected test registries get their own keys - guidelines become a registry-aware spawnPromptGuidelines(registry) factory called at registration, adding a line naming the exact key(s) - two tests assert the description lists production + injected keys Metadata/prompt-text only; validation semantics unchanged. Battery: typecheck, unit 699/699, e2e 16/16, snapshots 11/11, compat 169/169, package-host, audit-ci, git diff --check. --- spawn/index.ts | 22 +++++++++++++--------- tests/unit/spawn.test.ts | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/spawn/index.ts b/spawn/index.ts index 241a0c4..281ea76 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -288,13 +288,17 @@ const SPAWN_DESCRIPTION = const SPAWN_PROMPT_SNIPPET = "Spawn a focused subtask agent"; -const SPAWN_PROMPT_GUIDELINES = [ - "Use spawn to delegate isolated work to child agents. They are trusted extensions of you with their own context and the same authority. Only condensed results are returned.", - "If the operator requests a known Model Group confidently, pass its exact name as group. If no known/confident group is requested, omit group so the child inherits the parent model/thinking.", - `Declare constraints when the delegated task needs ${MODEL_GROUP_MODALITY_PROSE} capability; do not work around a missing required modality with third-party tools.`, - `Valid modality values are exactly text, image, or reasoning — do not invent capability names (e.g. \"code\"). Invalid values are rejected before any child is created.`, - `A specified group is binding: if the operator asks for a specific group and it lacks a needed capability, do NOT substitute a different group or inherit the parent model. Stop and report to the operator that the named group cannot satisfy the task, and ask how to proceed.`, -]; +function spawnPromptGuidelines(constraintRegistry: ConstraintRegistry): string[] { + const constraintKeys = constraintRegistry.descriptors.map((descriptor) => descriptor.key); + return [ + "Use spawn to delegate isolated work to child agents. They are trusted extensions of you with their own context and the same authority. Only condensed results are returned.", + "If the operator requests a known Model Group confidently, pass its exact name as group. If no known/confident group is requested, omit group so the child inherits the parent model/thinking.", + `Declare constraints when the delegated task needs ${MODEL_GROUP_MODALITY_PROSE} capability; do not work around a missing required modality with third-party tools.`, + `A constraint is keyed by its exact name (${constraintKeys.join(", ")}); values must match the key's schema — unknown keys or invalid values are rejected before any child is created.`, + "A specified group is binding: if the operator asks for a specific group and it lacks a needed capability, do NOT substitute a different group or inherit the parent model. Stop and report to the operator that the named group cannot satisfy the task, and ask how to proceed.", + `Valid modality values are exactly text, image, or reasoning — do not invent capability names (e.g. \"code\"). Invalid values are rejected before any child is created.`, + ]; +} export function buildSpawnParameters(constraintRegistry: ConstraintRegistry) { return Type.Object({ @@ -307,7 +311,7 @@ export function buildSpawnParameters(constraintRegistry: ConstraintRegistry) { description: "Optional exact Model Group name for child model routing. Omit to inherit the parent model/thinking.", })), constraints: Type.Optional(Type.Object(Object.fromEntries(constraintRegistry.descriptors.map((descriptor) => [descriptor.key, descriptor.requirement.schema])) as any, { - description: "Capability requirements for the delegated task, keyed by constraint name. Keys and values are defined by the constraint registry; unknown keys or invalid values are rejected before a child is created.", + description: `Capability requirements for the delegated task, keyed by constraint name. Keys: ${constraintRegistry.descriptors.map((descriptor) => descriptor.key).join(", ")} — values must match the key's schema; unknown keys or invalid values are rejected before a child is created.`, })), thinking: Type.Optional(StringEnum( ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const, @@ -655,7 +659,7 @@ export function registerSpawnTool( label: "Spawn", description: SPAWN_DESCRIPTION, promptSnippet: SPAWN_PROMPT_SNIPPET, - promptGuidelines: SPAWN_PROMPT_GUIDELINES, + promptGuidelines: spawnPromptGuidelines(constraintRegistry), parameters: buildSpawnParameters(constraintRegistry), renderShell: "self", diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index b307647..9fe1893 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -955,6 +955,22 @@ test("spawn tool schema validates constraints via Value.Check", () => { assert.equal(Value.Check(schema, { prompt: "Do the task", constraints: { modalities: "text" } }), false, "non-object requirement rejected"); }); +test("spawn constraints description enumerates the registry keys (production)", () => { + const pi = createTestPI(); + registerSpawnTool(pi as any, createState()); + const parameters = (pi.tools.get("spawn") as any).parameters; + const constraintsDesc = parameters.properties.constraints.description as string; + assert.ok(constraintsDesc.includes("Keys: modalities"), "description names the production constraint key"); +}); + +test("spawn constraints description enumerates injected registry keys", () => { + const pi = createTestPI(); + registerSpawnTool(pi as any, createState(), undefined, createConstraintRegistry([testMinContext])); + const parameters = (pi.tools.get("spawn") as any).parameters; + const constraintsDesc = parameters.properties.constraints.description as string; + assert.ok(constraintsDesc.includes("Keys: testMinContext"), "description names the injected registry's constraint key"); +}); + test("executeSpawn forwards inherited constraints to routing and succeeds when satisfied", async () => { const pi = createTestPI(); pi.setActiveTools(["read", "spawn"]); From eef188ad12b8826b72a1f560ec40270354ff6b9a Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Mon, 24 Aug 2026 07:08:40 +0000 Subject: [PATCH 33/33] fix(spawn): name the canonical constraints shape (object, required-array) in schema + guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fleet emitted constraints both as a bare-array modalities value ("required" smuggled inside an array) and JSON-stringified. Both were rejected fail-early, but metadata never named the wrapper shape, so the model kept conflating the object/array layers. Describe the exact shape: - requirement schema: object with a single "required" array key, e.g. { "required": ["text", "reasoning"] } — not a bare array - constraints envelope: a JSON object keyed by constraint name, not a JSON string and not an array - guidance: same shape spelled out in the modality bullet Validation semantics unchanged; add rejection asserts for the stringified, bare-array, and array-with-embedded-required shapes. --- model-groups/constraints/modalities.ts | 2 +- spawn/index.ts | 4 ++-- tests/unit/spawn.test.ts | 6 ++++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/model-groups/constraints/modalities.ts b/model-groups/constraints/modalities.ts index 7edbee6..66dba5d 100644 --- a/model-groups/constraints/modalities.ts +++ b/model-groups/constraints/modalities.ts @@ -77,7 +77,7 @@ export const modalitiesConstraint: ConstraintDescriptor<"modalities", ModelGroup }, encode: (value) => ({ required: [...value] }), equals: (left, right) => modalityCodec().equals(left, right), - schema: Type.Object({ required: modalityCodec().schema }, { description: `required input modalities for the delegated task — values must be exactly ${MODEL_GROUP_MODALITIES.join(", ")} (no other capability names)` }), + schema: Type.Object({ required: modalityCodec().schema }, { description: `required input modalities for the delegated task — shape is an object with a single "required" array key, e.g. { "required": ["text", "reasoning"] } (not a bare array). Array entries must be exactly ${MODEL_GROUP_MODALITIES.join(", ")} (no other capability names)` }), }, editor: { kind: "multi-select", label: "Modalities", choices: (evaluation) => evaluation.aggregate.supported, automatic: (evaluation) => `Automatic (${evaluation.aggregate.common.filter((modality) => modality !== "reasoning").join(", ") || "none"})` }, present: { diff --git a/spawn/index.ts b/spawn/index.ts index 281ea76..e1a08b4 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -296,7 +296,7 @@ function spawnPromptGuidelines(constraintRegistry: ConstraintRegistry): string[] `Declare constraints when the delegated task needs ${MODEL_GROUP_MODALITY_PROSE} capability; do not work around a missing required modality with third-party tools.`, `A constraint is keyed by its exact name (${constraintKeys.join(", ")}); values must match the key's schema — unknown keys or invalid values are rejected before any child is created.`, "A specified group is binding: if the operator asks for a specific group and it lacks a needed capability, do NOT substitute a different group or inherit the parent model. Stop and report to the operator that the named group cannot satisfy the task, and ask how to proceed.", - `Valid modality values are exactly text, image, or reasoning — do not invent capability names (e.g. \"code\"). Invalid values are rejected before any child is created.`, + `Pass modality requirements as constraints: { modalities: { required: [\"text\", ...] } } — modalities is an object whose only key is \"required\" (an array of modality names), not a bare array, and constraints itself is a JSON object, not a JSON string. Valid entries are exactly text, image, or reasoning — do not invent capability names (e.g. \"code\"). Invalid shapes are rejected before any child is created.`, ]; } @@ -311,7 +311,7 @@ export function buildSpawnParameters(constraintRegistry: ConstraintRegistry) { description: "Optional exact Model Group name for child model routing. Omit to inherit the parent model/thinking.", })), constraints: Type.Optional(Type.Object(Object.fromEntries(constraintRegistry.descriptors.map((descriptor) => [descriptor.key, descriptor.requirement.schema])) as any, { - description: `Capability requirements for the delegated task, keyed by constraint name. Keys: ${constraintRegistry.descriptors.map((descriptor) => descriptor.key).join(", ")} — values must match the key's schema; unknown keys or invalid values are rejected before a child is created.`, + description: `Capability requirements for the delegated task — a JSON object keyed by constraint name, not a JSON string and not an array. Keys: ${constraintRegistry.descriptors.map((descriptor) => descriptor.key).join(", ")} — each value must match its key's schema; unknown keys or invalid values are rejected before a child is created.`, })), thinking: Type.Optional(StringEnum( ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const, diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index 9fe1893..148c302 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -937,6 +937,9 @@ test("spawn requirements normalize the canonical envelope", () => { assert.deepEqual(normalizeSpawnRequirements({ constraints: { modalities: { required: ["image", "text"] } } }), { modalities: ["text", "image"] }); assert.deepEqual(normalizeSpawnRequirements({ constraints: { modalities: { required: ["image"] } } }), { modalities: ["image"] }); assert.throws(() => normalizeSpawnRequirements({ constraints: { unknown: {} } }), /Unknown spawn constraint/); + assert.throws(() => normalizeSpawnRequirements({ constraints: "{\"modalities\":{\"required\":[\"text\"]}}" as any }), /must be an object/); + assert.throws(() => normalizeSpawnRequirements({ constraints: { modalities: ["text"] } }), /must be an object with required modalities/); + assert.throws(() => normalizeSpawnRequirements({ constraints: { modalities: ["required", ["text"]] } }), /must be an object with required modalities/); assert.deepEqual(normalizeSpawnRequirements({}), normalizeSpawnRequirements({ constraints: {}})); }); @@ -953,6 +956,9 @@ test("spawn tool schema validates constraints via Value.Check", () => { 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"); + assert.equal(Value.Check(schema, { prompt: "Do the task", constraints: "{\"modalities\":{\"required\":[\"text\"]}}" }), false, "stringified constraints rejected"); + assert.equal(Value.Check(schema, { prompt: "Do the task", constraints: { modalities: ["text", "image"] } }), false, "bare-array modalities rejected"); + assert.equal(Value.Check(schema, { prompt: "Do the task", constraints: { modalities: ["required", ["text"]] } }), false, "array-with-embedded-required rejected"); }); test("spawn constraints description enumerates the registry keys (production)", () => {