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 | diff --git a/index.ts b/index.ts index a310ea1..8c7fb42 100644 --- a/index.ts +++ b/index.ts @@ -71,10 +71,12 @@ import { registerSpawnTool } from "./spawn/index.js"; import { registerModelGroupsCommand } from "./model-groups/command.js"; import { resolveSpawnModelRoute, SpawnRouteError } from "./model-groups/router.js"; import { registerModelGroupAutocomplete } from "./model-groups/autocomplete.js"; -import { getEffectiveModelGroupNames } from "./model-groups/router.js"; +import { getEffectiveModelGroups, getEffectiveModelGroupNames } from "./model-groups/router.js"; +import { MODEL_GROUP_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 type { ModelGroupsAccess } from "./model-groups/types.js"; +import { presentConstraintPrompt } from "./model-groups/constraints/presentation.js"; +import { productionConstraintRegistry } from "./model-groups/constraints/registry.js"; import { cacheLookupCommand, cacheLookupCommandExplicitModel, @@ -100,6 +102,7 @@ import { updateIndicators, } from "./tui.js"; import { applyReadonlyBashGuard } from "./readonly-bash.js"; + // ── Helpers ──────────────────────────────────────────────────────────── /** @@ -461,13 +464,14 @@ function refreshModelGroupsState(state: AgenticodingState, ctx: ExtensionContext return state.modelGroups.validation; } -function modelGroupsPromptSection(names: string[]): string | undefined { - if (names.length === 0) return undefined; +function modelGroupsPromptSection(groups: ResolvedModelGroup[]): string | undefined { + if (groups.length === 0) return undefined; + const labels = groups.map((group) => `${escapeDisplayLabel(group.name)} (${(group.evaluations ? presentConstraintPrompt(group.evaluations, productionConstraintRegistry).filter(Boolean).join(", ") : group.modalities?.effective.join(", ")) || "no common modalities"})`); return `\n## Model Groups for spawn\n` + - `Available Model Groups: ${names.join(", ")}\n` + - `When the operator asks to spawn with one of these groups, or mentions #group-name, call spawn with group set to the exact group name only when the mapping is known and confident. ` + - `If no known/confident group is requested, omit group and inherit the parent model/thinking. ` + - `The group list is names-only; do not assume provider/model membership, thinking levels, auth status, validation details, or storage paths from it.`; + `Available Model Groups: ${labels.join(", ")}\n` + + `When the operator asks to spawn with one of these groups, or mentions #group-name, call spawn with group set to the exact group name only when the mapping is known and confident. If a delegated task requires ${MODEL_GROUP_MODALITY_PROSE} capability, pass those requirements as constraints. If no known/confident group is requested, omit group and inherit the parent model/thinking. ` + + `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.`; } export default function (pi: ExtensionAPI): void { @@ -756,7 +760,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 +924,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/autocomplete.ts b/model-groups/autocomplete.ts index deccdae..e83a6e6 100644 --- a/model-groups/autocomplete.ts +++ b/model-groups/autocomplete.ts @@ -1,6 +1,8 @@ -import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +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"; +import { MODALITY_FG, modalityLetterRun } from "./modality.js"; import type { ModelGroupModel, ResolvedModelGroup } from "./types.js"; const registeredUis = new WeakSet(); @@ -20,7 +22,46 @@ function formatModelGroupRouteDetails(group: ResolvedModelGroup): string { .join("; "); } -export function createModelGroupAutocompleteProvider(state: AgenticodingState) { +export type DescriptionColorizer = (color: ThemeColor, text: string) => string; + +/** + * 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 buildCapsLetters(group: ResolvedModelGroup, colorize: DescriptionColorizer): string { + 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, + }); +} + +/** + * 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) { return (current: any) => ({ async getSuggestions(lines: string[], cursorLine: number, cursorCol: number, options: unknown) { const line = lines[cursorLine] ?? ""; @@ -32,13 +73,20 @@ export function createModelGroupAutocompleteProvider(state: AgenticodingState) { 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: formatModelGroupRouteDetails(group), - })); + 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 }; }, @@ -54,10 +102,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)); +} \ No newline at end of file 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..7edbee6 --- /dev/null +++ b/model-groups/constraints/modalities.ts @@ -0,0 +1,113 @@ +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, description: `one of: ${MODEL_GROUP_MODALITIES.join(", ")}` }), + }; +} + +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 }) { + // 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. + // An explicit [] is an intentional text-only ceiling. A non-empty override + // made entirely stale, however, must fall back to the derived union. + 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; + 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() }, + 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 }, { 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: { + 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, + 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, + }, +}; + +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..1299a71 --- /dev/null +++ b/model-groups/constraints/presentation.ts @@ -0,0 +1,65 @@ +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: "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 }; + +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 = 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 (const choice of choices) { + rows.push({ kind: "toggle", label: choice, value: choice, active: effective?.includes(choice) ?? false }); + } + 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..6380b81 --- /dev/null +++ b/model-groups/constraints/types.ts @@ -0,0 +1,69 @@ +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 } + | { kind: "number"; label: string; unit: string; min: number; step: number; automatic(evaluation: ConstraintEvaluation): string; value(evaluation: ConstraintEvaluation): number | null }; + +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 }; + requirement: ConstraintCodec; + editor: ConstraintEditorSpec; + present: { + group(evaluation: ConstraintEvaluation): string; + prompt(evaluation: ConstraintEvaluation): string; + diagnostic(diagnostic: ConstraintDiagnostic): string; + violation(violation: ConstraintViolation): string; + /** Optional child-facing note for an explicit group capability ceiling. */ + ceiling?(evaluation: ConstraintEvaluation): string | undefined; + }; +} + +export type AnyConstraintDescriptor = ConstraintDescriptor; +export type ErasedConstraintEvaluation = ConstraintEvaluation; diff --git a/model-groups/modalities.ts b/model-groups/modalities.ts new file mode 100644 index 0000000..f9c1ab9 --- /dev/null +++ b/model-groups/modalities.ts @@ -0,0 +1,32 @@ +import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { Api, Model } from "@earendil-works/pi-ai"; +import { assertModalitiesOverrideSupported, deriveModalitiesEvaluation, getMissingModalitiesFromModel, getModalitiesModelFact } from "./constraints/modalities.js"; +import { resolveConstraintMembers } from "./constraints/resolution.js"; +import type { ModelGroupDef, ModelGroupModalities, ModelGroupModality } from "./types.js"; + +/** Compatibility façade for the production modalities descriptor. */ +export function getModelModalities(model: Model): ModelGroupModality[] { + return getModalitiesModelFact(model); +} + +export function deriveModelGroupModalities( + group: Pick, + modelRegistry: Pick, +): ModelGroupModalities { + const override = group.constraints?.modalities as ModelGroupModality[] | undefined; + const evaluation = deriveModalitiesEvaluation(resolveConstraintMembers(group.models, modelRegistry).members, override); + return { common: evaluation.aggregate.common, supported: evaluation.aggregate.supported, effective: evaluation.effective }; +} + +export function assertModalityOverrideSupported( + group: Pick, + modelRegistry: Pick, +): void { + const override = group.constraints?.modalities as ModelGroupModality[] | undefined; + const evaluation = deriveModalitiesEvaluation(resolveConstraintMembers(group.models, modelRegistry).members, override); + assertModalitiesOverrideSupported(evaluation, override); +} + +export function getMissingModelModalities(model: Model, required: readonly ModelGroupModality[]): ModelGroupModality[] { + return getMissingModalitiesFromModel(model, required); +} diff --git a/model-groups/modality.ts b/model-groups/modality.ts new file mode 100644 index 0000000..5f2dbfe --- /dev/null +++ b/model-groups/modality.ts @@ -0,0 +1,67 @@ +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. Defaults to false, so capability rows omit R unless requested. */ + 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 " ". */ + 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 hideTextWhenOtherMedia = options.hideTextWhenOtherMedia ?? false; + const render = options.render ?? ((_modality: ModelGroupModality, letter: string) => letter); + const separator = options.separator ?? " "; + const seen = new Set(effective); + const rendered: string[] = []; + let hasOtherMedia = false; + for (const modality of MODEL_GROUP_MODALITIES) { + if (!seen.has(modality)) continue; + if (!includeReasoning && modality === "reasoning") continue; + if (modality !== "text") hasOtherMedia = true; + } + 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/model-groups/router.ts b/model-groups/router.ts index 73a9db3..679bc65 100644 --- a/model-groups/router.ts +++ b/model-groups/router.ts @@ -1,9 +1,12 @@ import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import { clampThinkingLevel, type Api, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai"; -import type { ResolvedModelGroup } from "./types.js"; +import { evaluateConstraint, evaluateGroupRequirement, evaluateModelRequirement } from "./constraints/engine.js"; +import { productionConstraintRegistry, type ConstraintRegistry } from "./constraints/registry.js"; +import { resolveConstraintMembers } from "./constraints/resolution.js"; +import type { AnyConstraintDescriptor, 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; @@ -12,88 +15,133 @@ export interface SpawnModelRoute { provider: string; modelId: string; thinking: ModelThinkingLevel; + /** 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 interface SpawnRouteErrorDetails { + missingModalities?: ModelGroupModality[]; + missingFromGroup?: ModelGroupModality[]; + missingFromModel?: ModelGroupModality[]; + constraintUnsatisfied?: readonly ConstraintViolation[]; + provider?: string; + modelId?: string; + knownGroup?: boolean; } -export type SpawnRouteErrorReason = "empty" | "no-usable-models"; +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) { - const detail = reason === "empty" - ? "has no model entries" - : "has no configured/authenticated usable models"; - super(`Model Group '${group}' ${detail}.`); + 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 : ""; -} - +function parentProvider(model: Model): string { return typeof model.provider === "string" ? model.provider : ""; } function effectiveGroupMap(groups: ResolvedModelGroup[]): Map { - const byName = new Map(); + const map = 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); + const current = map.get(group.name); + if (!current || group.scope === "project") map.set(group.name, group); } - return byName; + 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); } -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: { +type SpawnRouteOptions = { requestedGroup?: string; + constraints?: Readonly>; groups: ResolvedModelGroup[]; parentModel: Model; parentThinking: ModelThinkingLevel; modelRegistry: Pick; + constraintRegistry?: ConstraintRegistry; 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, - }); + routeCursor?: Map; +}; +type DeclaredRequirement = { descriptor: AnyConstraintDescriptor; requirement: unknown }; +type RoutedMember = { entry: ResolvedModelGroup["models"][number]; model: Model }; - if (!requestedGroup) return inherited("inherited"); +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 group = effectiveGroupMap(options.groups).get(requestedGroup); - if (!group) return inherited("unknown-fallback"); - if (group.models.length === 0) throw new SpawnRouteError(group.name, "empty"); +function inheritedRoute(status: "inherited" | "unknown-fallback", requestedGroup: string | undefined, parentModel: Model, parentThinking: ModelThinkingLevel): SpawnModelRoute { + return { + status, + ...(status === "unknown-fallback" && requestedGroup ? { requestedGroup } : {}), + model: parentModel, + provider: parentProvider(parentModel), + modelId: parentModel.id, + thinking: parentThinking, + }; +} - const usable = group.models +function usableMembers(group: ResolvedModelGroup, modelRegistry: SpawnRouteOptions["modelRegistry"]): RoutedMember[] { + return 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; + const model = modelRegistry.find(entry.provider, entry.modelId) as Model | undefined; + return model && modelRegistry.hasConfiguredAuth(model) ? { entry, model } : undefined; }) - .filter((entry): entry is { entry: typeof group.models[number]; model: Model } => Boolean(entry)); + .filter((entry): entry is RoutedMember => Boolean(entry)); +} - if (usable.length === 0) throw new SpawnRouteError(group.name, "no-usable-models"); +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 randomIndex = Math.min(pool.length - 1, Math.max(0, Math.floor((options.rng ?? Math.random)() * pool.length))); + return pool[randomIndex]; +} - 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); +function buildRoutedRoute(group: ResolvedModelGroup, requestedGroup: string, _usable: RoutedMember[], selected: RoutedMember, options: Pick): SpawnModelRoute { return { status: "routed", requestedGroup, @@ -101,6 +149,82 @@ export function resolveSpawnModelRoute(options: { model: selected.model, provider: selected.entry.provider, modelId: selected.entry.modelId, - thinking, + 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 getDeclared()) { + if (group) { + const override = group.constraints?.[descriptor.key]; + const evaluation = evaluateConstraint(descriptor, resolution, override); + const violation = evaluateGroupRequirement(descriptor, evaluation, requirement); + if (violation) violations.push(violation); + } + const violation = evaluateModelRequirement(descriptor, route.model, requirement); + if (violation) violations.push(violation); + } + return 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"); + 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]; + }; + 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), + }); + } + 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; +} diff --git a/model-groups/store.ts b/model-groups/store.ts index ecc2d91..c350c5e 100644 --- a/model-groups/store.ts +++ b/model-groups/store.ts @@ -3,90 +3,105 @@ import path from "node:path"; import * as fs from "node:fs"; import { CONFIG_DIR_NAME, type ModelRegistry } from "@earendil-works/pi-coding-agent"; import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; +import { assertModalityOverrideSupported } from "./modalities.js"; +import { modalitiesConstraint } from "./constraints/modalities.js"; +import { evaluateConstraints } from "./constraints/engine.js"; +import { presentConstraintDiagnosticRecords } from "./constraints/presentation.js"; +import { productionConstraintRegistry } from "./constraints/registry.js"; +import { resolveConstraintMembers } from "./constraints/resolution.js"; import { canonicalizeModelGroupName } from "./names.js"; -import { - ModelGroupsPersistenceError, - type ModelGroupDef, - type ModelGroupModel, - type ModelGroupScope, - type ModelGroupsAccess, - type ModelGroupsBootValidation, - type ModelGroupsConfig, - type ModelGroupsLoadedGroup, - type ModelGroupsLoadIssue, - type ModelGroupsLoadResult, - type ResolvedModelGroup, -} from "./types.js"; +import { ModelGroupsPersistenceError, type ModelGroupDef, type ModelGroupModalities, type ModelGroupModality, type ModelGroupModel, type ModelGroupScope, type ModelGroupsAccess, type ModelGroupsBootValidation, type ModelGroupsConfig, type ModelGroupsLoadedGroup, type ModelGroupsLoadIssue, type ModelGroupsLoadResult, type ResolvedModelGroup } from "./types.js"; -const CURRENT_VERSION = 1; +const CURRENT_VERSION = 2; const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); type FsOps = Pick; let fsOps: FsOps = fs; - export function __setModelGroupsFsForTests(next: Partial | null): void { fsOps = next ? { ...fs, ...next } : fs; } -export function modelGroupsPath(scope: ModelGroupScope, cwd: string, projectConfigDirName = CONFIG_DIR_NAME): string { - return scope === "global" - ? path.join(homedir(), ".pi", "agent", "pi-agenticoding", "model-groups.json") - : path.join(cwd, projectConfigDirName, "pi-agenticoding", "model-groups.json"); -} - +export function modelGroupsPath(scope: ModelGroupScope, cwd: string, projectConfigDirName = CONFIG_DIR_NAME): string { return scope === "global" ? path.join(homedir(), ".pi", "agent", "pi-agenticoding", "model-groups.json") : path.join(cwd, projectConfigDirName, "pi-agenticoding", "model-groups.json"); } function ownGroups(): Record { return Object.create(null) as Record; } -function defineGroup(groups: Record, name: string, def: ModelGroupDef): void { - Object.defineProperty(groups, name, { value: cloneDef(def), enumerable: true, writable: true, configurable: true }); +function cloneDef(def: ModelGroupDef): ModelGroupDef { + const constraints = def.constraints === undefined ? undefined : { ...def.constraints, ...(Array.isArray(def.constraints.modalities) ? { modalities: [...def.constraints.modalities] } : {}) }; + return { ...def, models: def.models.map((model) => ({ ...model })), ...(constraints === undefined ? {} : { constraints }) }; } +function defineGroup(groups: Record, name: string, def: ModelGroupDef): void { Object.defineProperty(groups, name, { value: cloneDef(def), enumerable: true, writable: true, configurable: true }); } function hasOwnGroup(groups: Record, name: string): boolean { return Object.hasOwn(groups, name); } function emptyConfig(): ModelGroupsConfig { return { version: CURRENT_VERSION, groups: ownGroups() }; } -function cloneDef(def: ModelGroupDef): ModelGroupDef { return { models: def.models.map((model) => ({ ...model })) }; } function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function assertScopeAllowed(scope: ModelGroupScope, access: ModelGroupsAccess): void { - if (scope === "project" && access.policy === "global-only") throw new Error("Project Model Groups are unavailable in global-only mode"); -} -function persistenceError(details: ConstructorParameters[0]): ModelGroupsPersistenceError { - return new ModelGroupsPersistenceError(details); -} +function assertScopeAllowed(scope: ModelGroupScope, access: ModelGroupsAccess): void { if (scope === "project" && access.policy === "global-only") throw new Error("Project Model Groups are unavailable in global-only mode"); } +function persistenceError(details: ConstructorParameters[0]): ModelGroupsPersistenceError { return new ModelGroupsPersistenceError(details); } function validateModelEntry(value: unknown, at: string): { ok: true; model: ModelGroupModel } | { ok: false; message: string } { if (!isPlainRecord(value)) return { ok: false, message: `${at} must be an object` }; - if (typeof value.provider !== "string" || value.provider.length === 0) return { ok: false, message: `${at}.provider must be a non-empty string` }; - if (typeof value.modelId !== "string" || value.modelId.length === 0) return { ok: false, message: `${at}.modelId must be a non-empty string` }; + if (typeof value.provider !== "string" || !value.provider) return { ok: false, message: `${at}.provider must be a non-empty string` }; + if (typeof value.modelId !== "string" || !value.modelId) return { ok: false, message: `${at}.modelId must be a non-empty string` }; if (value.thinkingLevel !== undefined && !VALID_THINKING.has(value.thinkingLevel as ModelThinkingLevel)) return { ok: false, message: `${at}.thinkingLevel is invalid` }; - const model: ModelGroupModel = { provider: value.provider, modelId: value.modelId }; - if (value.thinkingLevel !== undefined) model.thinkingLevel = value.thinkingLevel as ModelThinkingLevel; + const model = { ...value, provider: value.provider, modelId: value.modelId } as ModelGroupModel; + if (value.thinkingLevel === undefined) delete (model as any).thinkingLevel; return { ok: true, model }; } -function normalizeGroups(rawGroups: Record): { ok: true; groups: Record } | { ok: false; message: string } { +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 } | { ok: false; message: string } { + if (sourceVersion < 2) { + if (Object.hasOwn(rawDef, "constraints")) return { ok: false, message: `group ${rawName}.constraints is unsupported in legacy config` }; + return { ok: true }; + } + if (rawDef.constraints !== undefined && !isPlainRecord(rawDef.constraints)) return { ok: false, message: `group ${rawName}.constraints must be an object` }; + const constraints = rawDef.constraints === undefined ? undefined : { ...rawDef.constraints as Record }; + if (constraints && Object.hasOwn(constraints, "modalities")) { + const override = validateOverride(constraints.modalities, `group ${rawName}.constraints.modalities`); + if (!override.ok) return override; + constraints.modalities = modalitiesConstraint.persistence.override.encode(override.value!); + } + 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(); 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` }; + 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 index = 0; index < rawDef.models.length; index++) { - const result = validateModelEntry(rawDef.models[index], `group ${rawName}.models[${index}]`); + 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); } - defineGroup(groups, name, { models }); + const envelope = normalizeOverrideEnvelope(rawDef, sourceVersion, rawName); + if (!envelope.ok) return envelope; + // Strip runtime-derived fields while retaining opaque config keys and the v2 envelope. + const { name: _name, scope: _scope, sourcePath: _sourcePath, modalities: _modalities, validation: _validation, models: _rawModels, constraints: _constraints, modalityOverride: _modalityOverride, ...configDef } = rawDef; + const { ok: _ok, ...normalizedEnvelope } = envelope; + defineGroup(groups, name, { + ...configDef, + models, + ...normalizedEnvelope, + ...(sourceVersion < 2 && Object.hasOwn(rawDef, "modalityOverride") ? { modalityOverride: rawDef.modalityOverride } : {}), + }); } return { ok: true, groups }; } function 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; + 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 backupPath = `${sourcePath}.bak`; - const issue: ModelGroupsLoadIssue = { scope, sourcePath, kind, message, backupPath, version }; + const issue: ModelGroupsLoadIssue = { scope, sourcePath, kind, message, backupPath: `${sourcePath}.bak`, version }; if (kind === "unsupported-version") return issue; - try { fsOps.copyFileSync(sourcePath, backupPath); } - catch (cause) { + try { + fsOps.copyFileSync(sourcePath, issue.backupPath!); + } catch (cause) { issue.backupFailed = true; issue.message = `${message}; backup failed: ${cause instanceof Error ? cause.message : String(cause)}`; } @@ -96,116 +111,258 @@ function loadScope(scope: ModelGroupScope, access: ModelGroupsAccess): { config: 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)) }; } + 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) }; + 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 }; + 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 merged: ModelGroupsLoadedGroup[] = []; + const out: 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]) }); + 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 merged; + return out; } export function loadModelGroups(access: ModelGroupsAccess): ModelGroupsLoadResult { const global = loadScope("global", access); const project = access.policy === "global-project" ? loadScope("project", access) : { config: emptyConfig() }; - const configs = { global: global.config, project: project.config }; - return { configs, merged: mergeLoaded(configs, access), issues: [global.issue, project.issue].filter((issue): issue is ModelGroupsLoadIssue => Boolean(issue)) }; + return { + 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 unknown as Record); - if (!normalized.ok) throw persistenceError({ operation: "save", scope, sourcePath, phase: "config-validation", message: normalized.message }); + 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); - 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 */ } + 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 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 }); + + 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) 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 }); + 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); +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`); - defineGroup(config.groups, name, def); saveModelGroups(scope, access, config); + 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): void { - assertScopeAllowed(scope, access); const name = canonicalName(rawName); const config = loadScopeConfig(scope, access); +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`); - defineGroup(config.groups, name, def); saveModelGroups(scope, access, config); + const normalizedDef = normalizeMutationDef(def); + assertModalityOverrideSupported(normalizedDef, modelRegistry); + defineGroup(config.groups, name, normalizedDef); + 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; +export function renameGroup(scope: ModelGroupScope, access: ModelGroupsAccess, old: string, next: string): void { 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); + 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 } { - assertScopeAllowed(scope, access); const name = canonicalName(rawName); const config = loadScopeConfig(scope, access); + 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; } + 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); + 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: modelGroupsPath(newScope, access.cwd), phase: cause.phase, message: `Model group '${name}' was not written to ${newScope}: ${cause.message}`, cause }); throw cause; } + 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: `Model group '${name}' was written to ${newScope} but retained in ${oldScope}: ${cause.message}`, cause }); throw cause; } + 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: 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 } }; + 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, modelRegistry: ModelRegistry): ModelGroupsBootValidation { - const loaded = loadModelGroups(access); return { groups: validateModelGroups(loaded, modelRegistry), loadIssues: loaded.issues }; +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 } { - return { unavailableCount: groups.reduce((sum, group) => sum + group.validation.unavailableRefs.length, 0), overrideCount: groups.filter((group) => group.validation.shadowedByProject).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 }; +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..4692dc2 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -11,11 +11,16 @@ import { summarizeBootValidation, updateGroup, } from "./store.js"; -import { ModelGroupsPersistenceError, type ModelGroupDef, 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 { 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"; -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; @@ -37,6 +42,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); } @@ -44,7 +50,8 @@ function isBackspace(data: string): boolean { return matchesKey(data, Key.backsp function isDeleteChord(data: string): boolean { return data === "D" || matchesKey(data, Key.delete); } function cloneDef(def: ModelGroupDef): ModelGroupDef { - return { models: def.models.map((model) => ({ ...model })) }; + const constraints = def.constraints === undefined ? undefined : { ...def.constraints, ...(Array.isArray(def.constraints.modalities) ? { modalities: [...def.constraints.modalities] } : {}) }; + return { ...def, models: def.models.map((model) => ({ ...model })), ...(constraints === undefined ? {} : { constraints }) }; } function groupKey(group: Pick): string { @@ -107,7 +114,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"; @@ -231,20 +239,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); + 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)); @@ -280,11 +295,41 @@ export function createModelGroupsComponent( return [undefined, ...supported]; } + function activeConstraintEditor(): { descriptor: AnyConstraintDescriptor; evaluation: ErasedConstraintEvaluation } | undefined { + const group = currentEditGroup(); + const evaluation = group?.evaluations?.find((candidate) => productionConstraintRegistry.get(candidate.key)?.editor.kind === "multi-select"); + const descriptor = evaluation && productionConstraintRegistry.get(evaluation.key); + if (descriptor && evaluation) return { descriptor, evaluation }; + // Test/store adapters that predate generic evaluations retain the production descriptor's compatibility projection. + const compatibilityDescriptor = productionConstraintRegistry.descriptors.find((candidate) => candidate.editor.kind === "multi-select"); + if (!group || !compatibilityDescriptor) return undefined; + const reconciled = compatibilityDescriptor.reconcile({ aggregate: group.modalities, override: state.editDraft?.constraints?.modalities }); + return { descriptor: compatibilityDescriptor, evaluation: { key: compatibilityDescriptor.key, aggregate: group.modalities, effective: reconciled.effective, diagnostics: reconciled.diagnostics } }; + } + + function modalityEditorRows(): readonly ConstraintEditorRow[] { + const editor = activeConstraintEditor(); + if (!editor) return []; + // 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", + ); + } + function maxRow(): number { switch (state.screen) { case "LIST": return state.groups.length; case "EDITOR": return modelStartRow() + (state.editDraft?.models.length ?? 0); - case "MODEL_EDIT": return thinkingOptionsFor(modelRegistry.find(state.editDraft?.models[state.modelEditIndex]?.provider ?? "", state.editDraft?.models[state.modelEditIndex]?.modelId ?? "") as Model | undefined).length; + case "MODALITIES": return Math.max(0, modalityEditorRows().length - 1); + 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); @@ -303,7 +348,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 +363,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 +377,42 @@ export function createModelGroupsComponent( } return; } + case "MODALITIES": { + if (!state.editDraft) return; + const editor = activeConstraintEditor(); + const selected = modalityEditorRows()[state.row]; + // No toggle rows (text-only / unresolvable) → Enter/Space are inert. + if (!editor || !selected || selected.kind !== "toggle") return; + const next = cloneDef(state.editDraft); + 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]; + if (next.constraints && Object.keys(next.constraints).length === 0) delete next.constraints; + } else { + (next.constraints ??= {})[editor.descriptor.key] = toggled; + } + 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]; if (!state.editDraft || !model) return; @@ -390,6 +472,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; @@ -440,6 +523,47 @@ export function createModelGroupsComponent( }; } + // 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); + } + + /** 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 { + 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. */ + 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 = { selectedPrefix: (text: string) => theme.fg("accent", text), selectedText: (text: string) => theme.fg("accent", text), @@ -464,7 +588,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(); }; @@ -487,13 +615,21 @@ 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: ")}${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[] = []; if (group.validation.degraded) tags.push("⚠ degraded"); 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"; - return { value: String(index), label: escapeDisplayLabel(group.name), description: `[${group.scope}] ${group.models.length} models ${models}${tags.length ? ` — ${tags.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(", ")}`); + } + 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)); @@ -505,16 +641,82 @@ 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; + 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"; - 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)}`; + 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; + } + + function renderModalitiesComponent(): Component { + activeSelect = null; + 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[]; + 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]")}`)); + 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 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."))); + // 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." + : `Override — media limited to ${effective.filter((modality) => modality !== "reasoning").join(", ") || "none"}.`))); return container; } @@ -577,6 +779,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(); @@ -641,6 +844,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/model-groups/types.ts b/model-groups/types.ts index 9bb9463..3b55c3e 100644 --- a/model-groups/types.ts +++ b/model-groups/types.ts @@ -1,47 +1,39 @@ import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; +import type { ErasedConstraintEvaluation } from "./constraints/types.js"; +export const MODEL_GROUP_MODALITIES = ["text", "image", "reasoning"] as const; +export 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[]; + supported: ModelGroupModality[]; + effective: ModelGroupModality[]; +} export type ModelGroupScope = "project" | "global"; export type ModelGroupsAccessPolicy = "global-project" | "global-only"; export interface ModelGroupsAccess { cwd: string; policy: ModelGroupsAccessPolicy } - -export interface ModelGroupModel { - provider: string; - modelId: string; - thinkingLevel?: ModelThinkingLevel; +export interface ModelGroupModel { provider: string; modelId: string; thinkingLevel?: ModelThinkingLevel } +export interface ModelGroupDef { + models: ModelGroupModel[]; + /** Canonical v2 keyed override envelope. Unknown keys are retained opaquely. */ + constraints?: Record; } -export interface ModelGroupDef { models: ModelGroupModel[] } -export interface ModelGroupsConfig { version: 1; groups: Record } +export interface ModelGroupsConfig { version: 2; groups: Record } export interface ModelGroupValidation { unavailableRefs: Array<{ provider: string; modelId: string }>; shadowedByProject: boolean; degraded: boolean; + emptyCommonModalities: boolean; + unsupportedOverrideModalities: ModelGroupModality[]; } -export interface ModelGroupsLoadedGroup extends ModelGroupDef { - name: string; - scope: ModelGroupScope; - sourcePath: string; -} -export interface ResolvedModelGroup extends ModelGroupsLoadedGroup { validation: ModelGroupValidation } +export interface ModelGroupsLoadedGroup extends ModelGroupDef { name: string; scope: ModelGroupScope; sourcePath: string } +export interface ResolvedModelGroup extends ModelGroupsLoadedGroup { modalities: ModelGroupModalities; validation: ModelGroupValidation; /** Ordered descriptor evaluations; presentation consumers must iterate these. */ evaluations?: readonly ErasedConstraintEvaluation[] } export type ModelGroupsLoadIssueKind = "corrupt-json" | "schema-invalid" | "unsupported-version"; -export interface ModelGroupsLoadIssue { - scope: ModelGroupScope; - sourcePath: string; - kind: ModelGroupsLoadIssueKind; - message: string; - backupPath?: string; - backupFailed?: boolean; - version?: number; -} +export interface ModelGroupsLoadIssue { scope: ModelGroupScope; sourcePath: string; kind: ModelGroupsLoadIssueKind; message: string; backupPath?: string; backupFailed?: boolean; version?: number } export type ModelGroupsPersistenceOperation = "save" | "delete" | "move"; export type ModelGroupsPersistencePhase = "config-validation" | "temp-write" | "rename" | "source-remove" | "load-recovery"; export class ModelGroupsPersistenceError extends Error { - readonly operation!: ModelGroupsPersistenceOperation; - readonly scope?: ModelGroupScope; - readonly sourcePath?: string; - readonly targetPath?: string; - readonly phase!: ModelGroupsPersistencePhase; - readonly partialMove?: "target-written-source-retained"; - readonly cause?: unknown; + 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; @@ -57,9 +49,5 @@ export class ModelGroupsPersistenceError extends Error { Object.assign(this, details); } } -export interface ModelGroupsLoadResult { - configs: Record; - merged: ModelGroupsLoadedGroup[]; - issues: ModelGroupsLoadIssue[]; -} +export interface ModelGroupsLoadResult { configs: Record; merged: ModelGroupsLoadedGroup[]; issues: ModelGroupsLoadIssue[] } export interface ModelGroupsBootValidation { groups: ResolvedModelGroup[]; loadIssues: ModelGroupsLoadIssue[] } diff --git a/spawn/index.ts b/spawn/index.ts index 053c848..281ea76 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -33,6 +33,8 @@ import { abortChildSession, type AgenticodingState } from "../state.js"; import { formatPageList } from "../notebook/store.js"; import { createNotebookToolDefinitions } from "../notebook/tools.js"; import { resolveSpawnModelRoute } from "../model-groups/router.js"; +import { productionConstraintRegistry, type ConstraintRegistry } from "../model-groups/constraints/registry.js"; +import { MODEL_GROUP_MODALITY_PROSE } from "../model-groups/types.js"; import { applyReadonlyBashGuard } from "../readonly-bash.js"; import { renderSpawnCall, @@ -286,28 +288,40 @@ 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.", -]; - -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.", - })), - thinking: Type.Optional(StringEnum( - ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const, - { +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({ + 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(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.`, + })), + 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.", + }, + )), + }); +} /** @@ -342,12 +356,30 @@ export function createChildTools( * - both registries delete(toolCallId) on error and completion paths * */ +export type SpawnConstraintRequirements = Record; +export interface SpawnParameters { prompt: string; group?: string; constraints?: SpawnConstraintRequirements; thinking?: ThinkingValue } + +/** Decode the public constraint envelope once, rejecting unknown keys before routing. */ +export function normalizeSpawnRequirements(params: Pick, registry: ConstraintRegistry = productionConstraintRegistry): SpawnConstraintRequirements { + const raw = params.constraints; + if (raw !== undefined && (!raw || typeof raw !== "object" || Array.isArray(raw))) throw new Error("Spawn constraints must be an object."); + const normalized: SpawnConstraintRequirements = {}; + for (const [key, value] of Object.entries(raw ?? {})) { + const descriptor = registry.get(key); + if (!descriptor) throw new Error(`Unknown spawn constraint requirement '${key}'.`); + const decoded = descriptor.requirement.decode(value, `constraints.${key}`); + if (!decoded.ok) throw new Error(decoded.message); + normalized[key] = decoded.value; + } + return normalized; +} + export function executeSpawn( toolCallId: string, pi: ExtensionAPI, ctx: ExtensionContext, state: AgenticodingState, - params: { prompt: string; group?: string; thinking?: ThinkingValue }, + params: SpawnParameters, signal: AbortSignal | undefined, onUpdate: | ((result: { @@ -357,6 +389,7 @@ export function executeSpawn( | undefined, defaultThinking: ThinkingValue, sessionFactory: typeof createAgentSession = createAgentSession, + constraintRegistry: ConstraintRegistry = productionConstraintRegistry, ): Promise<{ content: TextContent[]; details: SpawnResultDetails }> { let execution!: Promise<{ content: TextContent[]; details: SpawnResultDetails }>; execution = (async () => { @@ -366,12 +399,16 @@ export function executeSpawn( } const inheritedChildThinking: ThinkingValue = params.thinking ?? defaultThinking; + const constraints = normalizeSpawnRequirements(params, constraintRegistry); const route = resolveSpawnModelRoute({ requestedGroup: params.group, + constraints, groups: state.modelGroups.groups, parentModel, parentThinking: inheritedChildThinking, modelRegistry: ctx.modelRegistry, + constraintRegistry, + routeCursor: state.spawnRouteCursors, }); const childModel = route.model; const requestedChildThinking: ThinkingValue = route.thinking; @@ -401,6 +438,12 @@ export function executeSpawn( const authorityNote = state.readonlyEnabled ? READONLY_CHILD_AUTHORITY_NOTE : "You have the same authority as the parent."; + // 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" && 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. ` + `${authorityNote} ` + @@ -409,6 +452,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.`; @@ -608,19 +652,20 @@ export function registerSpawnTool( pi: ExtensionAPI, state: AgenticodingState, sessionFactory: typeof createAgentSession = createAgentSession, + constraintRegistry: ConstraintRegistry = productionConstraintRegistry, ): void { pi.registerTool({ name: "spawn", label: "Spawn", description: SPAWN_DESCRIPTION, promptSnippet: SPAWN_PROMPT_SNIPPET, - promptGuidelines: SPAWN_PROMPT_GUIDELINES, - parameters: SPAWN_PARAMETERS, + promptGuidelines: spawnPromptGuidelines(constraintRegistry), + parameters: buildSpawnParameters(constraintRegistry), renderShell: "self", execute( _toolCallId: string, - params: { prompt: string; group?: string; thinking?: ThinkingValue }, + params: SpawnParameters, signal: AbortSignal | undefined, onUpdate: | ((result: { @@ -641,6 +686,7 @@ export function registerSpawnTool( onUpdate, parentThinking, sessionFactory, + constraintRegistry, ); }, 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-autocomplete.test.ts b/tests/unit/model-groups-autocomplete.test.ts index 9368b58..a7b2b38 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 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, drop reasoning, and consolidate text being implied. + 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; + // 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 gaps 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[] = []; @@ -51,3 +74,24 @@ test("registerModelGroupAutocomplete uses ctx.ui.addAutocompleteProvider once", registerModelGroupAutocomplete(ctx as any, state); assert.equal(providers.length, 1); }); + +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 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; + 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; + // 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-constraints-fixture.ts b/tests/unit/model-groups-constraints-fixture.ts new file mode 100644 index 0000000..13eb037 --- /dev/null +++ b/tests/unit/model-groups-constraints-fixture.ts @@ -0,0 +1,73 @@ +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` }, + encode: (value: number) => value, + equals: (left: number, right: number) => left === right, + schema: Type.Integer({ minimum: 1 }), +}; + +// 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, + 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 }, + requirement: positiveIntegerCodec, + 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`, + 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 }, + requirement: positiveIntegerCodec, + 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`, + 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 new file mode 100644 index 0000000..af5437a --- /dev/null +++ b/tests/unit/model-groups-constraints.test.ts @@ -0,0 +1,125 @@ +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 { 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; +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])); + // 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", () => { + 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("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 (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("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("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 }]); + 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); + 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", () => { + const registry = createConstraintRegistry([modalitiesConstraint as AnyConstraintDescriptor]); + const effective = evaluateConstraints(resolution([{ provider: "p", modelId: "rich", model: rich }]), {}, registry); + const empty = evaluateConstraints(resolution([]), {}, registry); + assert.equal(presentConstraintPrompt(effective, registry).filter(Boolean).join(", ") || "no common modalities", "text, image, reasoning"); + assert.equal(presentConstraintPrompt(empty, registry).filter(Boolean).join(", ") || "no common modalities", "no common modalities"); +}); + +test("generic presentation and number form rows use injected descriptor metadata", () => { + const injected = createConstraintRegistry([testMinContext as AnyConstraintDescriptor]); + const evaluations = evaluateConstraints(resolution([{ provider: "p", modelId: "rich", model: rich }]), { testMinContext: 12 }, injected); + assert.deepEqual(presentConstraintPrompt(evaluations, injected), ["minimum 12 tokens"]); + assert.deepEqual(constraintEditorRows(testMinContext as AnyConstraintDescriptor, evaluations[0]), [ + { kind: "automatic", label: "Automatic" }, + { kind: "number", label: "Test minimum context", value: 12, unit: "tokens", min: 1, step: 1 }, + ]); +}); + +test("engine uses only the supplied resolution and never calls host registry APIs", () => { + const spyResolution = Object.assign(resolution([{ provider: "p", modelId: "rich", model: rich }]), { + find: () => { throw new Error("find must not be called"); }, + hasConfiguredAuth: () => { throw new Error("auth must not be called"); }, + refresh: () => { throw new Error("refresh must not be called"); }, + }); + const result = evaluateConstraints(spyResolution, {}, createConstraintRegistry([modalitiesConstraint as AnyConstraintDescriptor])); + assert.deepEqual(result[0].effective, ["text", "image", "reasoning"]); +}); diff --git a/tests/unit/model-groups-crud.test.ts b/tests/unit/model-groups-crud.test.ts index b6bfbeb..9b09061 100644 --- a/tests/unit/model-groups-crud.test.ts +++ b/tests/unit/model-groups-crud.test.ts @@ -12,6 +12,7 @@ import { moveGroup, renameGroup, saveModelGroups, + summarizeBootValidation, updateGroup, validateModelGroups, } from "../../model-groups/store.js"; @@ -26,8 +27,8 @@ function read(scope: ModelGroupScope, cwd: string): any { function registry(available = new Set(["openai:gpt-5", "anthropic:claude"])): any { const models = [ - { provider: "openai", id: "gpt-5", reasoning: true, thinkingLevelMap: { xhigh: "x" } }, - { provider: "anthropic", id: "claude", reasoning: false }, + { provider: "openai", id: "gpt-5", input: ["text", "image"], reasoning: true, thinkingLevelMap: { xhigh: "x" } }, + { provider: "anthropic", id: "claude", input: ["text"], reasoning: false }, ]; return { getAll: () => models, @@ -39,11 +40,11 @@ function registry(available = new Set(["openai:gpt-5", "anthropic:claude"])): an test("model groups store creates, round-trips, validates, renames, updates, deletes, and moves", () => withTemp(({ cwd }) => { assert.equal(Object.keys(loadModelGroups(access(cwd)).configs.project.groups).length, 0); - createGroup("project", access(cwd), "review", { models: [] }); + createGroup("project", access(cwd), "review", { models: [] }, registry()); assert.deepEqual(read("project", cwd).groups.review.models, []); - assert.throws(() => createGroup("project", access(cwd), "review", { models: [] }), /already exists/); + assert.throws(() => createGroup("project", access(cwd), "review", { models: [] }, registry()), /already exists/); - createGroup("project", access(cwd), "inherit-roundtrip", { models: [{ provider: "anthropic", modelId: "claude" }] }); + createGroup("project", access(cwd), "inherit-roundtrip", { models: [{ provider: "anthropic", modelId: "claude" }] }, registry()); const inheritLoaded = loadModelGroups(access(cwd)).configs.project.groups["inherit-roundtrip"].models[0]; assert.equal(inheritLoaded.thinkingLevel, undefined); assert.equal(Object.prototype.hasOwnProperty.call(inheritLoaded, "thinkingLevel"), false); @@ -51,14 +52,14 @@ test("model groups store creates, round-trips, validates, renames, updates, dele assert.equal(inheritPersisted.thinkingLevel, undefined); assert.equal(Object.prototype.hasOwnProperty.call(inheritPersisted, "thinkingLevel"), false); - updateGroup("project", access(cwd), "review", { models: [{ provider: "openai", modelId: "gpt-5", thinkingLevel: "high" }] }); + updateGroup("project", access(cwd), "review", { models: [{ provider: "openai", modelId: "gpt-5", thinkingLevel: "high" }] }, registry()); renameGroup("project", access(cwd), "review", "reviewers"); assert.equal(read("project", cwd).groups.review, undefined); assert.equal(read("project", cwd).groups.reviewers.models[0].thinkingLevel, "high"); - createGroup("project", access(cwd), "collision", { models: [] }); + createGroup("project", access(cwd), "collision", { models: [] }, registry()); assert.throws(() => renameGroup("project", access(cwd), "reviewers", "collision"), /already exists/); - createGroup("global", access(cwd), "reviewers", { models: [{ provider: "openai", modelId: "gpt-5" }, { provider: "missing", modelId: "nope" }] }); + createGroup("global", access(cwd), "reviewers", { models: [{ provider: "openai", modelId: "gpt-5" }, { provider: "missing", modelId: "nope" }] }, registry()); const loaded = loadModelGroups(access(cwd)); const resolved = validateModelGroups(loaded, registry()); const globalReviewers = resolved.find((g) => g.name === "reviewers" && g.scope === "global"); @@ -66,8 +67,8 @@ test("model groups store creates, round-trips, validates, renames, updates, dele assert.deepEqual(globalReviewers?.validation.unavailableRefs, [{ provider: "missing", modelId: "nope" }]); assert.equal(globalReviewers?.validation.degraded, true); - createGroup("global", access(cwd), "move-collision", { models: [] }); - createGroup("project", access(cwd), "move-collision", { models: [] }); + createGroup("global", access(cwd), "move-collision", { models: [] }, registry()); + createGroup("project", access(cwd), "move-collision", { models: [] }, registry()); assert.throws(() => moveGroup(access(cwd), "move-collision", "project"), /already exists in project scope/); assert.ok(read("global", cwd).groups["move-collision"]); assert.ok(read("project", cwd).groups["move-collision"]); @@ -87,7 +88,7 @@ test("model groups load recovery handles malformed, schema-invalid, unsupported assert.ok(fs.existsSync(`${modelGroupsPath("global", cwd)}.bak`)); fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); - fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 1, groups: { bad: { models: [{ provider: 1 }] } } }), "utf8"); + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 2, groups: { bad: { models: [{ provider: 1 }] } } }), "utf8"); loaded = loadModelGroups(access(cwd)); const schemaIssue = loaded.issues.find((i) => i.scope === "project")!; assert.equal(schemaIssue.kind, "schema-invalid"); @@ -109,7 +110,7 @@ test("model groups load recovery handles malformed, schema-invalid, unsupported const issue = loaded.issues.find((i) => i.scope === "project")!; assert.equal(issue.backupFailed, true); assert.equal(fs.readFileSync(modelGroupsPath("project", cwd), "utf8"), "{bad"); - assert.throws(() => createGroup("project", access(cwd), "must-not-overwrite", { models: [] }), (error) => { + assert.throws(() => createGroup("project", access(cwd), "must-not-overwrite", { models: [] }, registry()), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.operation, "save"); assert.equal(error.phase, "load-recovery"); @@ -120,7 +121,7 @@ test("model groups load recovery handles malformed, schema-invalid, unsupported test("model groups rename failure removes the generated temp file and preserves committed bytes", () => withTemp(({ cwd }) => { const sourcePath = modelGroupsPath("project", cwd); - saveModelGroups("project", access(cwd), { version: 1, groups: { keep: { models: [] } } }); + saveModelGroups("project", access(cwd), { version: 2, groups: { keep: { models: [] } } }); const committedBytes = fs.readFileSync(sourcePath); const renameCause = new Error("rename denied"); let generatedTempPath = ""; @@ -133,7 +134,7 @@ test("model groups rename failure removes the generated temp file and preserves throw renameCause; }, }); - assert.throws(() => saveModelGroups("project", access(cwd), { version: 1, groups: { drop: { models: [] } } }), (error) => { + assert.throws(() => saveModelGroups("project", access(cwd), { version: 2, groups: { drop: { models: [] } } }), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.operation, "save"); assert.equal(error.phase, "rename"); @@ -151,7 +152,7 @@ test("model groups rename failure removes the generated temp file and preserves test("model groups rename cleanup failure remains supplemental to the original typed error", () => withTemp(({ cwd }) => { const sourcePath = modelGroupsPath("project", cwd); - saveModelGroups("project", access(cwd), { version: 1, groups: { keep: { models: [] } } }); + saveModelGroups("project", access(cwd), { version: 2, groups: { keep: { models: [] } } }); const committedBytes = fs.readFileSync(sourcePath); const renameCause = new Error("rename denied"); const cleanupCause = new Error("cleanup denied"); @@ -168,7 +169,7 @@ test("model groups rename cleanup failure remains supplemental to the original t throw cleanupCause; }, }); - assert.throws(() => saveModelGroups("project", access(cwd), { version: 1, groups: { drop: { models: [] } } }), (error) => { + assert.throws(() => saveModelGroups("project", access(cwd), { version: 2, groups: { drop: { models: [] } } }), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.operation, "save"); assert.equal(error.phase, "rename"); @@ -185,9 +186,9 @@ test("model groups rename cleanup failure remains supplemental to the original t })); test("model groups persistence failures throw typed errors and preserve committed state", () => withTemp(({ cwd }) => { - saveModelGroups("project", access(cwd), { version: 1, groups: { keep: { models: [] } } }); + saveModelGroups("project", access(cwd), { version: 2, groups: { keep: { models: [] } } }); __setModelGroupsFsForTests({ writeFileSync: () => { throw new Error("temp denied"); } }); - assert.throws(() => updateGroup("project", access(cwd), "keep", { models: [{ provider: "openai", modelId: "gpt-5" }] }), (error) => { + assert.throws(() => updateGroup("project", access(cwd), "keep", { models: [{ provider: "openai", modelId: "gpt-5" }] }, registry()), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.operation, "save"); assert.equal(error.phase, "temp-write"); @@ -201,7 +202,7 @@ test("model groups persistence failures throw typed errors and preserve committe assert.equal(read("project", cwd).groups.keep.models.length, 0); __setModelGroupsFsForTests({ renameSync: () => { throw new Error("rename denied"); } }); - assert.throws(() => saveModelGroups("project", access(cwd), { version: 1, groups: { drop: { models: [] } } }), (error) => { + assert.throws(() => saveModelGroups("project", access(cwd), { version: 2, groups: { drop: { models: [] } } }), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.phase, "rename"); return true; @@ -218,7 +219,7 @@ test("model groups persistence failures throw typed errors and preserve committe }); __setModelGroupsFsForTests(null); - createGroup("global", access(cwd), "move-target-fails", { models: [] }); + createGroup("global", access(cwd), "move-target-fails", { models: [] }, registry()); __setModelGroupsFsForTests({ renameSync: () => { throw new Error("target denied"); } }); assert.throws(() => moveGroup(access(cwd), "move-target-fails", "project"), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); @@ -228,7 +229,7 @@ test("model groups persistence failures throw typed errors and preserve committe }); __setModelGroupsFsForTests(null); - createGroup("global", access(cwd), "move-me", { models: [] }); + createGroup("global", access(cwd), "move-me", { models: [] }, registry()); let writes = 0; __setModelGroupsFsForTests({ renameSync: (from, to) => { writes++; if (writes === 2) throw new Error("source denied"); fs.renameSync(from, to); } }); assert.throws(() => moveGroup(access(cwd), "move-me", "project"), (error) => { @@ -245,14 +246,14 @@ test("model groups strictly partitions schema and legacy version domains", () => const invalid: Array<[string, unknown, RegExp]> = [ ["root", [], /root/], ["version type", { version: "1", groups: {} }, /version/], ["negative", { version: -1, groups: {} }, /version/], ["fraction low", { version: 0.5, groups: {} }, /version/], - ["fraction high", { version: 1.5, groups: {} }, /version/], ["groups", { version: 1, groups: [] }, /groups/], - ["group", { version: 1, groups: { bad: 1 } }, /group/], - ["provider missing", { version: 1, groups: { bad: { models: [{ modelId: "m" }] } } }, /provider/], - ["provider type", { version: 1, groups: { bad: { models: [{ provider: 1, modelId: "m" }] } } }, /provider/], - ["model missing", { version: 1, groups: { bad: { models: [{ provider: "p" }] } } }, /modelId/], - ["model type", { version: 1, groups: { bad: { models: [{ provider: "p", modelId: 1 }] } } }, /modelId/], - ["models", { version: 1, groups: { bad: { models: 1 } } }, /models/], - ["thinking", { version: 1, groups: { bad: { models: [{ provider: "p", modelId: "m", thinkingLevel: "turbo" }] } } }, /thinkingLevel/], + ["fraction high", { version: 1.5, groups: {} }, /version/], ["groups", { version: 2, groups: [] }, /groups/], + ["group", { version: 2, groups: { bad: 1 } }, /group/], + ["provider missing", { version: 2, groups: { bad: { models: [{ modelId: "m" }] } } }, /provider/], + ["provider type", { version: 2, groups: { bad: { models: [{ provider: 1, modelId: "m" }] } } }, /provider/], + ["model missing", { version: 2, groups: { bad: { models: [{ provider: "p" }] } } }, /modelId/], + ["model type", { version: 2, groups: { bad: { models: [{ provider: "p", modelId: 1 }] } } }, /modelId/], + ["models", { version: 2, groups: { bad: { models: 1 } } }, /models/], + ["thinking", { version: 2, groups: { bad: { models: [{ provider: "p", modelId: "m", thinkingLevel: "turbo" }] } } }, /thinkingLevel/], ]; for (const [label, raw, message] of invalid) { fs.writeFileSync(projectPath, JSON.stringify(raw), "utf8"); @@ -267,12 +268,257 @@ test("model groups strictly partitions schema and legacy version domains", () => fs.writeFileSync(projectPath, JSON.stringify(raw), "utf8"); const loaded = loadModelGroups(access(cwd)); assert.equal(loaded.issues.length, 0); - assert.equal(loaded.configs.project.version, 1); - updateGroup("project", access(cwd), "legacy", { models: [] }); - assert.equal(read("project", cwd).version, 1); + assert.equal(loaded.configs.project.version, 2); + updateGroup("project", access(cwd), "legacy", { models: [] }, registry()); + assert.equal(read("project", cwd).version, 2); } })); +test("legacy constraints recover as schema-invalid instead of crashing load", () => withTemp(({ cwd }) => { + const projectPath = modelGroupsPath("project", cwd); + fs.mkdirSync(path.dirname(projectPath), { recursive: true }); + for (const raw of [{ groups: { legacy: { models: [], constraints: {} } } }, { version: 0, groups: { legacy: { models: [], constraints: {} } } }, { version: 1, groups: { legacy: { models: [], constraints: {} } } }]) { + fs.writeFileSync(projectPath, JSON.stringify(raw), "utf8"); + const loaded = loadModelGroups(access(cwd)); + const issue = loaded.issues.find((candidate) => candidate.scope === "project")!; + assert.equal(issue.kind, "schema-invalid"); + assert.match(issue.message, /constraints/); + assert.ok(fs.existsSync(`${projectPath}.bak`)); + assert.equal(Object.keys(loaded.configs.project.groups).length, 0); + } +})); + +test("store-level validation derives empty-common and stale-override flags and counts them", () => withTemp(({ cwd }) => { + const a = access(cwd); + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 2, groups: { + empty: { models: [] }, + unresolved: { models: [{ provider: "openai", modelId: "gone" }, { provider: "openai", modelId: "gpt-5" }] }, + stale: { models: [{ provider: "anthropic", modelId: "claude" }], constraints: { modalities: ["text", "image"] } }, + } }), "utf8"); + const resolved = validateModelGroups(loadModelGroups(a), registry()); + // claude supports text only, so image is a stale unsupported override entry. + const empty = resolved.find((g) => g.name === "empty"); + assert.equal(empty?.validation.emptyCommonModalities, true, "empty group has no common modalities"); + const unresolved = resolved.find((g) => g.name === "unresolved"); + assert.equal(unresolved?.validation.emptyCommonModalities, true, "unresolved member fails closed"); + const stale = resolved.find((g) => g.name === "stale"); + assert.deepEqual(stale?.validation.unsupportedOverrideModalities, ["image"]); + const summary = summarizeBootValidation(resolved); + assert.equal(summary.emptyModalityCount, 2); + assert.equal(summary.staleModalityOverrideCount, 1); +})); + +test("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)); } }); + assert.throws(() => createGroup("project", a, "claude-only", { models: [{ provider: "anthropic", modelId: "claude" }], constraints: { modalities: ["image"] } }, registry()), /unsupported modalities: image/); + assert.equal(writes, 0); + __setModelGroupsFsForTests(null); + + createGroup("project", a, "rich", { models: [{ provider: "openai", modelId: "gpt-5" }], constraints: { modalities: ["text", "image"] } }, registry()); + const before = fs.readFileSync(modelGroupsPath("project", cwd), "utf8"); + let writes2 = 0; + __setModelGroupsFsForTests({ writeFileSync: (_p: unknown, _d: unknown, _r: unknown) => { writes2++; fs.writeFileSync(_p as any, _d as any, _r as any); } }); + // Combined member change: replacing gpt-5 (text+image+reasoning) with claude (text only) + // makes the retained override's image unsupported → the gate must reject before any write. + assert.throws(() => updateGroup("project", a, "rich", { models: [{ provider: "anthropic", modelId: "claude" }], constraints: { modalities: ["text", "image"] } }, registry()), /unsupported modalities: image/); + assert.equal(writes2, 0, "rejected update must not write"); + assert.equal(fs.readFileSync(modelGroupsPath("project", cwd), "utf8"), before); + __setModelGroupsFsForTests(null); +})); + +test("v2 config load rejects non-array, duplicate, and out-of-vocabulary modality constraints", () => withTemp(({ cwd }) => { + const projectPath = modelGroupsPath("project", cwd); + fs.mkdirSync(path.dirname(projectPath), { recursive: true }); + const cases: Array<[string, unknown, RegExp]> = [ + ["non-array", { version: 2, groups: { g: { models: [], constraints: { modalities: { text: true } } } } }, /modalities/], + ["duplicate", { version: 2, groups: { g: { models: [], constraints: { modalities: ["text", "text"] } } } }, /unique/], + ["out-of-language", { version: 2, groups: { g: { models: [], constraints: { modalities: ["audio"] } } } }, /vocabulary/], + ]; + for (const [label, raw, message] of cases) { + fs.writeFileSync(projectPath, JSON.stringify(raw), "utf8"); + const loaded = loadModelGroups(access(cwd)); + const issue = loaded.issues.find((candidate) => candidate.scope === "project")!; + assert.equal(issue.kind, "schema-invalid", label); + assert.match(issue.message, message, label); + assert.ok(fs.existsSync(`${projectPath}.bak`), label); + } +})); + +test("v1 migration is in-memory until the first successful mutation writes v2 without an invented override", () => withTemp(({ cwd }) => { + const sourcePath = modelGroupsPath("project", cwd); + const v1Bytes = JSON.stringify({ version: 1, groups: { legacy: { models: [{ provider: "openai", modelId: "gpt-5" }] } } }, null, 2) + "\n"; + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, v1Bytes, "utf8"); + + const loaded = loadModelGroups(access(cwd)); + assert.equal(loaded.configs.project.version, 2); + assert.equal(loaded.configs.project.groups.legacy.constraints, undefined); + assert.equal(fs.readFileSync(sourcePath, "utf8"), v1Bytes); + + updateGroup("project", access(cwd), "legacy", { models: [{ provider: "anthropic", modelId: "claude" }] }, registry()); + const persisted = read("project", cwd); + assert.equal(persisted.version, 2); + assert.equal(Object.hasOwn(persisted.groups.legacy, "modalityOverride"), false); +})); + +test("legacy modalityOverride is opaque and not interpreted", () => withTemp(({ cwd }) => { + const sourcePath = modelGroupsPath("project", cwd); + const v1Bytes = JSON.stringify({ version: 1, groups: { legacy: { models: [{ provider: "openai", modelId: "gpt-5" }], modalityOverride: ["image"] } } }, null, 2) + "\n"; + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, v1Bytes, "utf8"); + const loaded = loadModelGroups(access(cwd)); + assert.equal(loaded.configs.project.groups.legacy.constraints, undefined); + assert.equal(Object.hasOwn(loaded.configs.project.groups.legacy, "modalityOverride"), true); + assert.equal(fs.readFileSync(sourcePath, "utf8"), v1Bytes); + saveModelGroups("project", access(cwd), loaded.configs.project); + assert.equal(Object.hasOwn(read("project", cwd).groups.legacy, "modalityOverride"), false); +})); + +test("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 }); + const fixtures: Array<[string, any, string[] | undefined]> = [ + ["generic", { constraints: { modalities: ["reasoning", "text"] } }, ["text", "reasoning"]], + ["empty", { constraints: { modalities: [] } }, []], + ["automatic", {}, undefined], + ]; + fs.writeFileSync(sourcePath, JSON.stringify({ version: 2, groups: Object.fromEntries(fixtures.map(([name, envelope]) => [name, { models: [{ provider: "openai", modelId: "gpt-5" }], ...envelope }])) }), "utf8"); + const loaded = loadModelGroups(access(cwd)); + assert.equal(loaded.issues.length, 0); + for (const [name, _envelope, expected] of fixtures) assert.deepEqual(loaded.configs.project.groups[name].constraints?.modalities, expected, name); + saveModelGroups("project", access(cwd), loaded.configs.project); + const persisted = read("project", cwd); + for (const [name, _envelope, expected] of fixtures) { + const group = persisted.groups[name]; + if (expected === undefined) { + assert.equal(Object.hasOwn(group, "constraints"), false, name); + } else { + assert.deepEqual(group.constraints.modalities, expected, name); + } + } + + fs.writeFileSync(sourcePath, JSON.stringify({ version: 2, groups: { opaque: { models: [{ provider: "openai", modelId: "gpt-5", modelSentinel: true }], groupSentinel: true, constraints: { modalities: ["image"], cost: { future: true } } } } }), "utf8"); + const opaque = loadModelGroups(access(cwd)); + updateGroup("project", access(cwd), "opaque", { ...opaque.configs.project.groups.opaque, models: [{ provider: "openai", modelId: "gpt-5", modelSentinel: true, thinkingLevel: "high" } as any] }, registry()); + const opaquePersisted = read("project", cwd).groups.opaque; + assert.deepEqual(opaquePersisted.constraints, { modalities: ["image"], cost: { future: true } }); + assert.equal(opaquePersisted.groupSentinel, true); + assert.equal(opaquePersisted.models[0].modelSentinel, true); +})); + +test("v2 drops stale modalityOverride rather than reinterpreting it", () => withTemp(({ cwd }) => { + const sourcePath = modelGroupsPath("project", cwd); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, JSON.stringify({ version: 2, groups: { stale: { models: [], modalityOverride: ["image"] } } }), "utf8"); + const loaded = loadModelGroups(access(cwd)); + assert.equal(loaded.issues.length, 0); + assert.equal(loaded.configs.project.groups.stale.constraints, undefined); + saveModelGroups("project", access(cwd), loaded.configs.project); + assert.equal(Object.hasOwn(read("project", cwd).groups.stale, "modalityOverride"), false); +})); + +test("v2 normalization preserves opaque root group and model keys through load save and update", () => withTemp(({ cwd }) => { + const sourcePath = modelGroupsPath("project", cwd); + const raw = { + version: 2, + rootSentinel: { keep: true }, + groups: { + review: { + groupSentinel: "keep", + models: [{ provider: "openai", modelId: "gpt-5", modelSentinel: "keep" }], + }, + }, + }; + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, JSON.stringify(raw), "utf8"); + + const loaded = loadModelGroups(access(cwd)); + saveModelGroups("project", access(cwd), loaded.configs.project); + updateGroup("project", access(cwd), "review", { ...loaded.configs.project.groups.review, models: loaded.configs.project.groups.review.models.map((model) => ({ ...model, thinkingLevel: "high" })) }, registry()); + + const persisted = read("project", cwd); + assert.deepEqual(persisted.rootSentinel, { keep: true }); + assert.equal(persisted.groups.review.groupSentinel, "keep"); + assert.equal(persisted.groups.review.models[0].modelSentinel, "keep"); + assert.equal(persisted.groups.review.models[0].thinkingLevel, "high"); +})); + +test("store normalization strips runtime-derived group keys while preserving opaque keys", () => withTemp(({ cwd }) => { + const a = access(cwd); + createGroup("project", a, "review", { models: [{ provider: "openai", modelId: "gpt-5" }] }, registry()); + updateGroup("project", a, "review", { + models: [{ provider: "openai", modelId: "gpt-5" }], + constraints: { modalities: ["text", "image"] }, + opaqueSentinel: { keep: true }, + name: "review", scope: "project", sourcePath: "/runtime/model-groups.json", + modalities: { common: ["text"], supported: ["text", "image", "reasoning"], effective: ["text", "image"] }, + validation: { unavailableRefs: [], shadowedByProject: false, degraded: false, emptyCommonModalities: false, unsupportedOverrideModalities: [] }, + } as any, registry()); + const persisted = read("project", cwd).groups.review; + assert.deepEqual(Object.keys(persisted).sort(), ["constraints", "models", "opaqueSentinel"]); + assert.deepEqual(persisted.constraints.modalities, ["text", "image"]); + assert.deepEqual(persisted.opaqueSentinel, { keep: true }); + for (const key of ["name", "scope", "sourcePath", "modalities", "validation"]) assert.equal(Object.hasOwn(persisted, key), false); +})); + +test("version-3 mutations refuse before temp write including loadScopeConfig-backed CRUD", () => withTemp(({ cwd }) => { + const sourcePath = modelGroupsPath("project", cwd); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, JSON.stringify({ version: 3, groups: {} }), "utf8"); + let writes = 0; + __setModelGroupsFsForTests({ writeFileSync: () => { writes++; throw new Error("must not write"); } }); + for (const mutate of [ + () => saveModelGroups("project", access(cwd), { version: 2, groups: {} }), + () => createGroup("project", access(cwd), "blocked", { models: [] }, registry()), + ]) { + assert.throws(mutate, (error) => { + assert.ok(error instanceof ModelGroupsPersistenceError); + assert.equal(error.phase, "config-validation"); + return true; + }); + } + assert.equal(writes, 0); + assert.equal(fs.readFileSync(sourcePath, "utf8"), JSON.stringify({ version: 3, groups: {} })); +})); + +test("modality overrides survive CRUD rename and move lifecycle in both scopes", () => withTemp(({ cwd }) => { + const a = access(cwd); + createGroup("project", a, "review", { models: [{ provider: "openai", modelId: "gpt-5" }], constraints: { modalities: ["image"] } }, registry()); + updateGroup("project", a, "review", { models: [{ provider: "openai", modelId: "gpt-5" }], constraints: { modalities: ["text", "image"] } }, registry()); + renameGroup("project", a, "review", "reviewers"); + moveGroup(a, "reviewers", "global"); + saveModelGroups("global", a, loadModelGroups(a).configs.global); + assert.deepEqual(read("global", cwd).groups.reviewers.constraints.modalities, ["text", "image"]); + + renameGroup("global", a, "reviewers", "global-reviewers"); + moveGroup(a, "global-reviewers", "project"); + assert.equal(read("global", cwd).groups["global-reviewers"], undefined); + assert.deepEqual(read("project", cwd).groups["global-reviewers"].constraints.modalities, ["text", "image"]); +})); + test("model groups use branded paths, global-only access, canonical own keys, and native max", () => withTemp(({ cwd }) => { assert.equal(modelGroupsPath("project", cwd, "branded-pi"), path.join(cwd, "branded-pi", "pi-agenticoding", "model-groups.json")); const raw = '{"version":1,"groups":{" __proto__ ":{"models":[{"provider":"p","modelId":"proto","thinkingLevel":"max"}]},"constructor":{"models":[]},"toString":{"models":[]}}}'; @@ -288,8 +534,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 +549,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 +566,7 @@ test("model groups direct save canonicalizes unique keys and rejects empty/colli const before = fs.readFileSync(modelGroupsPath("project", cwd), "utf8"); let writes = 0; __setModelGroupsFsForTests({ writeFileSync: (..._args: any[]) => { writes++; throw new Error("must not write"); } }); - assert.throws(() => saveModelGroups("project", a, { version: 1, groups }), (error) => { + assert.throws(() => saveModelGroups("project", a, { version: 2, groups }), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.operation, "save"); assert.equal(error.phase, "config-validation"); diff --git a/tests/unit/model-groups-helpers.ts b/tests/unit/model-groups-helpers.ts index 1d06411..ec3a3bc 100644 --- a/tests/unit/model-groups-helpers.ts +++ b/tests/unit/model-groups-helpers.ts @@ -26,6 +26,7 @@ export function group( opts: { scope?: "project" | "global"; models?: ResolvedModelGroup["models"]; + constraints?: { modalities: import("../../model-groups/types.js").ModelGroupModality[] } | Record; shadowedByProject?: boolean; unavailableRefs?: ResolvedModelGroup["validation"]["unavailableRefs"]; } = {}, @@ -36,10 +37,14 @@ export function group( scope, sourcePath: `<${scope}>`, models: opts.models ?? [], + ...(opts.constraints === undefined ? {} : { constraints: { ...opts.constraints, ...(Array.isArray(opts.constraints.modalities) ? { modalities: [...opts.constraints.modalities] } : {}) } }), validation: { unavailableRefs: opts.unavailableRefs ?? [], shadowedByProject: opts.shadowedByProject ?? false, degraded: (opts.unavailableRefs?.length ?? 0) > 0, + emptyCommonModalities: false, + unsupportedOverrideModalities: [], }, + modalities: { common: [], supported: [], effective: [] }, }; } diff --git a/tests/unit/model-groups-integration.test.ts b/tests/unit/model-groups-integration.test.ts index c5680e3..e1b7b04 100644 --- a/tests/unit/model-groups-integration.test.ts +++ b/tests/unit/model-groups-integration.test.ts @@ -9,7 +9,7 @@ import { createTestPI, theme } from "./helpers.js"; import { withTemp } from "./model-groups-helpers.js"; function registry(available = new Set(["openai:gpt-5"])): any { - const models = [{ provider: "openai", id: "gpt-5", reasoning: true, thinkingLevelMap: { xhigh: "x" } }]; + const models = [{ provider: "openai", id: "gpt-5", input: ["text", "image"], reasoning: true, thinkingLevelMap: { xhigh: "x" } }]; return { getAll: () => models, getAvailable: () => models.filter((m) => available.has(`${m.provider}:${m.id}`)), @@ -53,7 +53,7 @@ test("/model-groups command registers and opens ctx.ui.custom with live registry assert.equal(customCalled, 1); assert.match(rendered, /Model Groups/); assert.match(rendered, /cwd-sentinel-group/); - assert.deepEqual(findCalls, ["openai:gpt-5"]); + assert.deepEqual(findCalls, ["openai:gpt-5", "openai:gpt-5"]); })); test("index session_start stores model group validation and notifies load and validation issues", async () => withTemp(async ({ cwd }) => { @@ -83,6 +83,31 @@ test("index session_start stores model group validation and notifies load and va assert.ok(notifications.some((m) => /1 unavailable model references · 1 project overrides/.test(m))); })); +test("index session_start notifies empty-common and stale-override boot counts", async () => withTemp(async ({ cwd }) => { + fs.mkdirSync(path.dirname(modelGroupsPath("global", cwd)), { recursive: true }); + // claude is NOT in the registry, so claude-only is unavailable with empty common modalities; its override is stale. + // The registry has only gpt-5 (text+image); an empty group also has empty common modalities. + fs.writeFileSync(modelGroupsPath("global", cwd), JSON.stringify({ version: 2, groups: { + empty: { models: [] }, + "claude-only": { models: [{ provider: "anthropic", modelId: "claude" }], constraints: { modalities: ["text", "image"] } }, + } }), "utf8"); + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notifications: string[] = []; + const ctx = { + hasUI: true, + mode: "tui", + isProjectTrusted: () => true, + cwd, + modelRegistry: registry(), + getContextUsage: () => ({ percent: 10 }), + ui: { theme, notify: (message: string) => notifications.push(message), setStatus: () => {}, setWidget: () => {} }, + }; + const handler = pi.handlers.get("session_start")!.at(-1)!; + await handler({ reason: "load" }, ctx); + assert.ok(notifications.some((m) => /1 unavailable model references · 0 project overrides · 2 groups with no common modalities · 1 stale modality overrides/.test(m)), JSON.stringify(notifications, null, 2)); +})); + test("index session_start notifies corrupt/schema/unsupported load issues", async () => withTemp(async ({ cwd }) => { fs.mkdirSync(path.dirname(modelGroupsPath("global", cwd)), { recursive: true }); fs.writeFileSync(modelGroupsPath("global", cwd), "{bad", "utf8"); @@ -149,7 +174,7 @@ test("index session_start includes backup-failure detail in load issue notificat assert.ok(notifications.some((m) => /corrupt-json/.test(m) && /backup failed.*original file left untouched/.test(m) && m.includes(escapeDisplayLabel(modelGroupsPath("project", cwd))))); })); -test("before_agent_start injects fresh names-only Model Groups guidance", async () => withTemp(async ({ cwd }) => { +test("before_agent_start injects fresh names-and-effective-modalities guidance", async () => withTemp(async ({ cwd }) => { fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 1, groups: { review: { models: [{ provider: "openai", modelId: "gpt-5" }] } } }), "utf8"); const pi = createTestPI(); @@ -157,7 +182,8 @@ test("before_agent_start injects fresh names-only Model Groups guidance", async const handler = pi.handlers.get("before_agent_start")!.at(-1)!; const result = await handler({ systemPrompt: "Base." }, { hasUI: false, isProjectTrusted: () => true, cwd, modelRegistry: registry(), getContextUsage: () => null }); assert.match(result.systemPrompt, /## Model Groups for spawn/); - assert.match(result.systemPrompt, /Available Model Groups: review/); + assert.match(result.systemPrompt, /Available Model Groups: review \(text, image, reasoning\)/); + assert.match(result.systemPrompt, /constraints/); assert.match(result.systemPrompt, /exact group name/); assert.match(result.systemPrompt, /known and confident/); assert.match(result.systemPrompt, /omit group and inherit/); @@ -165,6 +191,57 @@ test("before_agent_start injects fresh names-only Model Groups guidance", async 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"); + const pi = createTestPI(); + registerAgenticoding(pi as any); + const handler = pi.handlers.get("before_agent_start")!.at(-1)!; + const result = await handler({ systemPrompt: "Base." }, { hasUI: false, isProjectTrusted: () => true, cwd, modelRegistry: registry(), getContextUsage: () => null }); + assert.match(result.systemPrompt, /foo \(no common modalities\)/); + assert.match(result.systemPrompt, /foo \(none\) \(no common modalities\)/); + assert.doesNotMatch(result.systemPrompt, /foo \(none\),/); +})); + +test("before_agent_start reinjects updated effective modalities after registry changes", async () => withTemp(async ({ cwd }) => { + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 2, groups: { review: { models: [{ provider: "openai", modelId: "gpt-5" }] } } }), "utf8"); + let model = { provider: "openai", id: "gpt-5", input: ["text", "image"], reasoning: false, thinkingLevelMap: { xhigh: "x" } }; + const changingRegistry = { + getAll: () => [model], + getAvailable: () => [model], + find: () => model, + hasConfiguredAuth: () => true, + }; + const pi = createTestPI(); + registerAgenticoding(pi as any); + const handler = pi.handlers.get("before_agent_start")!.at(-1)!; + const ctx = { hasUI: false, isProjectTrusted: () => true, cwd, modelRegistry: changingRegistry, getContextUsage: () => null }; + const initial = await handler({ systemPrompt: "Base." }, ctx); + assert.match(initial.systemPrompt, /review \(text, image\)/); + + model = { ...model, input: ["text"], reasoning: true }; + const refreshed = await handler({ systemPrompt: "Base." }, ctx); + assert.match(refreshed.systemPrompt, /review \(text, reasoning\)/); + assert.doesNotMatch(refreshed.systemPrompt, /review \(text, image\)/); +})); + test("before_agent_start clears stale Model Groups guidance when registry becomes unavailable", async () => withTemp(async ({ cwd }) => { fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 1, groups: { review: { models: [{ provider: "openai", modelId: "gpt-5" }] } } }), "utf8"); diff --git a/tests/unit/model-groups-modalities.test.ts b/tests/unit/model-groups-modalities.test.ts new file mode 100644 index 0000000..8b5eb7f --- /dev/null +++ b/tests/unit/model-groups-modalities.test.ts @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { assertModalityOverrideSupported, deriveModelGroupModalities, getMissingModelModalities } from "../../model-groups/modalities.js"; +import { deriveModalitiesEvaluation } from "../../model-groups/constraints/modalities.js"; +import { resolveConstraintMembers } from "../../model-groups/constraints/resolution.js"; +import type { ModelGroupDef } from "../../model-groups/types.js"; + +function registry(models: any[]): { find(provider: string, id: string): any } { + return { find: (provider, id) => models.find((model) => model.provider === provider && model.id === id) }; +} + +test("derives ordered common, supported, and override-effective modalities from the live registry", () => { + const models = [ + { provider: "p", id: "rich", input: ["image", "text"], reasoning: true }, + { provider: "p", id: "text", input: ["text"], reasoning: false }, + ]; + const group = { models: [{ provider: "p", modelId: "rich" }, { provider: "p", modelId: "text" }] }; + assert.deepEqual(deriveModelGroupModalities(group, registry(models)), { + common: ["text"], supported: ["text", "image", "reasoning"], effective: ["text", "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"); +}); + +test("compatibility façade and descriptor remain parity-equivalent across modality fixtures", () => { + const models: any[] = [ + { provider: "p", id: "rich", input: ["image", "text"], reasoning: true }, + { provider: "p", id: "text", input: ["text"], reasoning: false }, + ]; + const fixtures: ModelGroupDef[] = [ + { models: [] }, + { models: [{ provider: "p", modelId: "gone" }] }, + { models: [{ provider: "p", modelId: "rich" }], constraints: { modalities: [] } }, + { models: [{ provider: "p", modelId: "text" }], constraints: { modalities: ["image"] } }, + { models: [{ provider: "p", modelId: "rich" }, { provider: "p", modelId: "text" }] }, + ]; + for (const group of fixtures) { + const resolved = resolveConstraintMembers(group.models, registry(models)); + const evaluation = deriveModalitiesEvaluation(resolved.members, group.constraints?.modalities as any); + assert.deepEqual(deriveModelGroupModalities(group, registry(models)), { + common: evaluation.aggregate.common, + supported: evaluation.aggregate.supported, + effective: evaluation.effective, + }); + } +}); + +test("caps stale overrides without mutation and restores them when catalog support returns", () => { + const def: ModelGroupDef = { models: [{ provider: "p", modelId: "m" }], constraints: { modalities: ["text", "image"] } }; + const models: any[] = [{ provider: "p", id: "m", input: ["text"], reasoning: false }]; + const first = deriveModelGroupModalities(def, registry(models)); + assert.deepEqual(first.effective, ["text"]); + assert.deepEqual(def.constraints?.modalities, ["text", "image"]); + models[0].input.push("image"); + assert.deepEqual(deriveModelGroupModalities(def, registry(models)).effective, ["text", "image"]); + assert.throws(() => assertModalityOverrideSupported(def, registry([{ provider: "p", id: "m", input: ["text"], reasoning: false }])), /unsupported modalities: image/); + assert.deepEqual(getMissingModelModalities(models[0], ["text", "reasoning"]), ["reasoning"]); +}); diff --git a/tests/unit/model-groups-modality.test.ts b/tests/unit/model-groups-modality.test.ts new file mode 100644 index 0000000..80e40b2 --- /dev/null +++ b/tests/unit/model-groups-modality.test.ts @@ -0,0 +1,40 @@ +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 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})`, + separator: "·", + }); + assert.equal(rendered, "syntaxKeyword(T)·success(I)"); +}); \ No newline at end of file diff --git a/tests/unit/model-groups-router.test.ts b/tests/unit/model-groups-router.test.ts index 00f8f8d..554f324 100644 --- a/tests/unit/model-groups-router.test.ts +++ b/tests/unit/model-groups-router.test.ts @@ -1,11 +1,13 @@ import test from "node:test"; import assert from "node:assert/strict"; import { getEffectiveModelGroupNames, resolveSpawnModelRoute, SpawnRouteError } from "../../model-groups/router.js"; +import { createConstraintRegistry } from "../../model-groups/constraints/registry.js"; import type { ResolvedModelGroup } from "../../model-groups/types.js"; import { group } from "./model-groups-helpers.js"; +import { testMaxBudget, testMinContext } from "./model-groups-constraints-fixture.js"; function model(provider: string, id: string, overrides: Record = {}): any { - return { provider, id, reasoning: true, ...overrides }; + return { provider, id, reasoning: true, input: ["text"], ...overrides }; } function registry(models: any[], authenticated = new Set(models.map((m) => `${m.provider}:${m.id}`))): any { @@ -16,11 +18,7 @@ function registry(models: any[], authenticated = new Set(models.map((m) => `${m. } test("effective model group names use project-over-global names", () => { - const groups = [ - group("review", { scope: "global", shadowedByProject: true }), - group("review", { scope: "project" }), - group("research", { scope: "global" }), - ]; + const groups = [group("review", { scope: "global", shadowedByProject: true }), group("review", { scope: "project" }), group("research", { scope: "global" })]; assert.deepEqual(getEffectiveModelGroupNames(groups), ["research", "review"]); }); @@ -29,41 +27,173 @@ test("omitted and unknown groups inherit parent route with fallback metadata", ( const reg = registry([parent]); assert.deepEqual(resolveSpawnModelRoute({ groups: [], parentModel: parent, parentThinking: "medium", modelRegistry: reg }).status, "inherited"); const route = resolveSpawnModelRoute({ requestedGroup: "typo", groups: [], parentModel: parent, parentThinking: "medium", modelRegistry: reg }); - assert.equal(route.status, "unknown-fallback"); - assert.equal(route.requestedGroup, "typo"); - assert.equal(route.model, parent); - assert.equal(route.thinking, "medium"); + assert.equal(route.status, "unknown-fallback"); assert.equal(route.requestedGroup, "typo"); assert.equal(route.model, parent); assert.equal(route.thinking, "medium"); }); test("known empty and all-unusable groups fail clearly", () => { const parent = model("openai", "parent"); - assert.throws( - () => resolveSpawnModelRoute({ requestedGroup: "empty", groups: [group("empty", { scope: "project" })], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), - (error: unknown) => error instanceof SpawnRouteError && error.group === "empty" && error.reason === "empty" && /empty/.test(error.message), - ); - assert.throws( - () => resolveSpawnModelRoute({ requestedGroup: "bad", groups: [group("bad", { scope: "project", models: [{ provider: "openai", modelId: "missing" }] })], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), - (error: unknown) => error instanceof SpawnRouteError && error.group === "bad" && error.reason === "no-usable-models" && /configured\/authenticated/.test(error.message), - ); + assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "empty", constraints: { modalities: { required: ["image"] } }, groups: [group("empty", { scope: "project" })], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "empty"); + assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "bad", constraints: { modalities: { required: ["image"] } }, groups: [group("bad", { scope: "project", models: [{ provider: "openai", modelId: "missing" }] })], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "no-usable-models"); }); -test("known usable groups filter registry/auth, draw with rng seam, and clamp thinking", () => { - const parent = model("openai", "parent"); - const usableA = model("openai", "a", { thinkingLevelMap: { xhigh: "x" } }); - const usableB = model("anthropic", "b", { thinkingLevelMap: { xhigh: null } }); - const unauth = model("openai", "unauth"); - const groups = [group("review", { scope: "project", models: [ - { provider: "openai", modelId: "missing" }, - { provider: "openai", modelId: "unauth" }, - { provider: "openai", modelId: "a" }, - { provider: "anthropic", modelId: "b", thinkingLevel: "xhigh" }, - ] })]; - const reg = registry([parent, usableA, usableB, unauth], new Set(["openai:parent", "openai:a", "anthropic:b"])); - const first = resolveSpawnModelRoute({ requestedGroup: "review", groups, parentModel: parent, parentThinking: "low", modelRegistry: reg, rng: () => 0 }); - assert.equal(first.status, "routed"); - assert.equal(first.model, usableA); - assert.equal(first.thinking, "low", "entry without thinking inherits parent"); - const second = resolveSpawnModelRoute({ requestedGroup: "review", groups, parentModel: parent, parentThinking: "low", modelRegistry: reg, rng: () => 0.99 }); - assert.equal(second.model, usableB); - assert.equal(second.thinking, "high", "xhigh clamps when selected model does not support it"); +test("required modalities 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]); + // 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("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.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 }); + 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"] }); + 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", () => { + const parent = model("p", "parent"); const text = model("p", "text"); const g = group("text", { models: [{ provider: "p", modelId: "text" }] }); + assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "text", constraints: { modalities: { required: ["image"] } }, groups: [g], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent, text]) }), (error: unknown) => error instanceof SpawnRouteError && error.missingFromGroup[0] === "image"); + assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "unknown", constraints: { modalities: { required: ["image"] } }, groups: [], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), (error: unknown) => error instanceof SpawnRouteError && error.group === "unknown" && /Spawn model/.test(error.message)); +}); + +test("injected scalar requirements use generic violations, not modality arrays", () => { + const parent = model("p", "parent", { contextWindow: 100 }); const small = model("p", "small", { contextWindow: 10 }); + assert.throws(() => resolveSpawnModelRoute({ requestedGroup: "small", constraints: { testMinContext: 20 }, groups: [group("small", { models: [{ provider: "p", modelId: "small" }] })], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent, small]), constraintRegistry: createConstraintRegistry([testMinContext]) }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "constraint-unsatisfied" && error.constraintUnsatisfied?.length === 2 && error.missingModalities.length === 0 && error.missingFromGroup.length === 0 && error.missingFromModel.length === 0); +}); + +test("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("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"] }); + // Empty array is a no-op: route returns unchanged, no requirement check. + assert.deepEqual(resolveSpawnModelRoute({ constraints: { modalities: { required: [] } }, groups: [], parentModel: text, parentThinking: "medium", modelRegistry: registry([text]) }).status, "inherited"); + // Parent satisfies all requirements → inherited route succeeds. + assert.deepEqual(resolveSpawnModelRoute({ constraints: { modalities: { required: ["text", "image"] } }, groups: [], parentModel: rich, parentThinking: "medium", modelRegistry: registry([rich]) }).status, "inherited"); + // Parent lacks a required modality → missing-modality with the parent model details. + assert.throws(() => resolveSpawnModelRoute({ constraints: { modalities: { required: ["image"] } }, groups: [], parentModel: text, parentThinking: "medium", modelRegistry: registry([text]) }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "missing-modality" && error.group === "" && error.missingFromModel[0] === "image" && error.missingFromGroup.length === 0 && /Spawn model/.test(error.message)); +}); + +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.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", () => { + 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.groupCapabilityCeilings, 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 29d2411..41b4519 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -57,6 +57,15 @@ function rendered(c: { render: (width: number) => string[] }, width = 100): stri return c.render(width).join("\n"); } +function selectRenderedLabel(c: { handleInput?: (data: string) => void; render: (width: number) => string[] }, label: string): void { + for (let i = 0; i < 32; i++) { + const selected = stripAnsi(rendered(c)).split("\n").find((line) => line.includes("→")); + if (selected?.includes(label)) return; + press(c, DOWN); + } + assert.fail(`did not select rendered label: ${label}`); +} + function pressAndRender(c: { handleInput?: (data: string) => void; render: (width: number) => string[] }, ...inputs: string[]): void { for (const input of inputs) { c.render(100); @@ -76,7 +85,7 @@ function catalog(models: any[]): any { function atSearchableModel(models: any[], store?: any) { const c = component({ groups: [group("review", { scope: "project" })], modelRegistry: catalog(models), store }).c; - pressAndRender(c, ENTER, DOWN, DOWN, DOWN, ENTER, ENTER); + pressAndRender(c, ENTER, DOWN, DOWN, DOWN, DOWN, ENTER, ENTER); assert.match(rendered(c), /Add model — Step 2\/3 Model/); return c; } @@ -111,6 +120,224 @@ test("model groups TUI list renders validation summary, health tags, add row, no assert.doesNotMatch(c.render(100).join("\n"), /Delete Model Group/); }); +test("model groups TUI renders modality labels, warnings, and stale override choices", () => { + const review = group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] }); + review.modalities = { common: ["text"], supported: ["text", "image"], effective: ["text", "image"] }; + review.constraints = { modalities: ["text", "image", "reasoning"] }; + review.validation.emptyCommonModalities = true; + review.validation.unsupportedOverrideModalities = ["reasoning"]; + const { c } = component({ groups: [review] }); + 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); + assert.match(rendered(c), /Supported by every model: text/); + 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), /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/); +}); + +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 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 = [ + { 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 }, + ]; + // 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 a dynamic status for Automatic vs Override. + selectRenderedLabel(c, "Modalities:"); + press(c, ENTER); + const modalities = stripAnsi(rendered(c)); + // 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/); + // 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)); + 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", () => { + 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"] }; + 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: reconciledEffective(def, ["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, 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/); + // 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/, "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\)/); +}); + +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", "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"], effective: reconciledEffective(def, ["text", "image"]) }; + }, + 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"]); + assert.match(rendered(c), /Modalities/, "space toggle stays on the modalities screen"); + assert.match(rendered(c), /I image \[off\]/); +}); + +test("model groups TUI modality editor preserves state and notifies on updateGroup failure", () => { + const review = group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] }); + review.modalities = { common: ["text"], supported: ["text", "image"], effective: ["text"] }; + const messages: string[] = []; + let failing = true; + const store = { + updateGroup: (_scope: string, _cwd: string, _name: string, def: any) => { + if (failing) throw new ModelGroupsPersistenceError({ operation: "save", scope: "project", sourcePath: "/tmp/.pi/pi-agenticoding/model-groups.json", phase: "rename", message: "modality write denied" }); + review.constraints = def.constraints ? { ...def.constraints, ...(Array.isArray(def.constraints.modalities) ? { modalities: [...def.constraints.modalities] } : {}) } : undefined; + }, + listResolvedModelGroups: () => boot([review]), + }; + const { c } = component({ groups: [review], store, notify: (message) => messages.push(message) }); + press(c, ENTER, DOWN, DOWN, DOWN, ENTER); // open Modalities + press(c, 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 toggles/); + 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[] = []; @@ -146,7 +373,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 +410,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,22 +468,26 @@ 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 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>/); @@ -272,7 +503,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 +513,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 +522,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 +565,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 +621,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 +646,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 +658,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 +669,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 +721,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 +770,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/); @@ -606,7 +840,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); @@ -685,7 +919,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 +1041,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 +1086,7 @@ test("model groups TUI directly proves every non-Model screen remains search-fre pressAndRender(c, ENTER); assert.match(rendered(c), /provider-11\/only-model/); // EDITOR remains uncapped. assert.equal(rendered(c).includes(CURSOR_MARKER), false); - pressAndRender(c, DOWN, DOWN, DOWN, ENTER); + pressAndRender(c, DOWN, DOWN, DOWN, DOWN, ENTER); assert.match(rendered(c), /Edit model/); // MODEL_EDIT. assert.equal(rendered(c).includes(CURSOR_MARKER), false); pressAndRender(c, ESC, ...Array(20).fill(DOWN), ENTER); diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index 400247f..9fe1893 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -7,11 +7,15 @@ import { buildChildToolNames, createChildTools, executeSpawn, + normalizeSpawnRequirements, registerSpawnTool, truncateText, } from "../../spawn/index.js"; import { renderSpawnResult } from "../../spawn/renderer.js"; import { SpawnRouteError } from "../../model-groups/router.js"; +import { createConstraintRegistry } from "../../model-groups/constraints/registry.js"; +import { Value } from "typebox/value"; +import { testMinContext } from "./model-groups-constraints-fixture.js"; import { createTestPI, createRenderContext, createSession, theme, createDeferred } from "./helpers.js"; import { createTestHarness, type TestHarness } from "../test-utils.js"; @@ -240,6 +244,99 @@ 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"] }; + 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" }, { provider: "openai", modelId: "gpt-text" }], + 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 : 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); + 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"]); + 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 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"]); @@ -266,6 +363,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), "🙂"); @@ -649,6 +777,7 @@ test("spawn execute clears childSessions after successful completion when unrend assert.equal(result.content[0].text, "child result"); assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); }); test("spawn execute fails explicitly without a configured model", async () => { @@ -710,6 +839,160 @@ test("executeSpawn propagates unusable-group errors before creating child work", assert.equal(state.liveChildSessions.size, 0, "no live child session registered"); }); +test("executeSpawn propagates missing modalities before creating child work", async () => { + const pi = createTestPI(); + const state = createState(); + state.modelGroups.groups = [{ + name: "text-only", scope: "project", sourcePath: "", models: [{ provider: "openai", modelId: "text" }], + modalities: { common: ["text"], supported: ["text"], effective: ["text"] }, + validation: { unavailableRefs: [], shadowedByProject: false, degraded: false, emptyCommonModalities: false, unsupportedOverrideModalities: [] }, + }]; + let factoryCalls = 0; + await assert.rejects(() => executeSpawn("missing-modality", pi as any, { + model: { provider: "openai", id: "parent", input: ["text"], reasoning: false }, cwd: "/tmp", + modelRegistry: { find: (_provider: string, id: string) => ({ provider: "openai", id, input: ["text"], reasoning: false }), hasConfiguredAuth: () => true }, + } as any, state, { prompt: "Do the task", group: "text-only", constraints: { modalities: { required: ["image"] } } }, undefined, undefined, "medium", async () => { factoryCalls++; throw new Error("must not create child"); }), (error: unknown) => error instanceof SpawnRouteError && error.reason === "missing-modality"); + assert.equal(factoryCalls, 0); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("executeSpawn rejects unknown requirements before factory or session publication", async () => { + const pi = createTestPI(); const state = createState(); let factoryCalls = 0; + await assert.rejects(() => executeSpawn("unknown-constraint", pi as any, { + model: { provider: "openai", id: "parent", input: ["text"], reasoning: false }, cwd: "/tmp", + modelRegistry: { find: () => undefined, hasConfiguredAuth: () => false }, + } as any, state, { prompt: "Do the task", constraints: { unknown: {} } }, undefined, undefined, "medium", async () => { factoryCalls++; throw new Error("must not create child"); }), /Unknown spawn constraint/); + assert.equal(factoryCalls, 0); assert.equal(state.childSessions.size, 0); assert.equal(state.liveChildSessions.size, 0); +}); + +test("registered spawn tool rejects missing modalities before creating child work", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + state.modelGroups.groups = [{ + name: "text-only", scope: "project", sourcePath: "", models: [{ provider: "openai", modelId: "text" }], + modalities: { common: ["text"], supported: ["text"], effective: ["text"] }, + validation: { unavailableRefs: [], shadowedByProject: false, degraded: false, emptyCommonModalities: false, unsupportedOverrideModalities: [] }, + }]; + let factoryCalls = 0; + registerSpawnTool(pi as any, state, (async () => { factoryCalls++; throw new Error("sessionFactory must not be called"); }) as any); + + await assert.rejects( + () => pi.tools.get("spawn").execute("registered-missing-modality", { prompt: "Do the task", group: "text-only", constraints: { modalities: { required: ["image"] } } }, undefined, undefined, { + model: { provider: "openai", id: "parent", input: ["text"], reasoning: false }, cwd: "/tmp", + modelRegistry: { find: (_provider: string, id: string) => ({ provider: "openai", id, input: ["text"], reasoning: false }), hasConfiguredAuth: () => true }, + } as any), + (error: unknown) => { + assert.ok(error instanceof SpawnRouteError); + assert.equal(error.kind, "unusable-group"); + assert.equal(error.reason, "missing-modality"); + return true; + }, + ); + assert.equal(factoryCalls, 0); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("registered spawn tool rejects injected scalar group and model requirements before publication", async () => { + const pi = createTestPI(); pi.setActiveTools(["spawn"]); + const state = createState(); let factoryCalls = 0; + state.modelGroups.groups = [ + { name: "small", scope: "project", sourcePath: "", models: [{ provider: "openai", modelId: "small" }], modalities: { common: ["text"], supported: ["text"], effective: ["text"] }, validation: { unavailableRefs: [], shadowedByProject: false, degraded: false, emptyCommonModalities: false, unsupportedOverrideModalities: [] } }, + ]; + registerSpawnTool(pi as any, state, (async () => { factoryCalls++; throw new Error("sessionFactory must not be called"); }) as any, createConstraintRegistry([testMinContext])); + await assert.rejects( + () => pi.tools.get("spawn").execute("registered-scalar", { prompt: "Do the task", group: "small", constraints: { testMinContext: 20 } }, undefined, undefined, { + model: { provider: "openai", id: "parent", input: ["text"], reasoning: false, contextWindow: 100 }, cwd: "/tmp", + modelRegistry: { find: (_provider: string, id: string) => ({ provider: "openai", id, input: ["text"], reasoning: false, contextWindow: id === "small" ? 10 : 100 }), hasConfiguredAuth: () => true }, + } as any), + (error: unknown) => error instanceof SpawnRouteError && error.reason === "constraint-unsatisfied" && error.constraintUnsatisfied?.length === 2 && error.missingModalities.length === 0 && error.missingFromGroup.length === 0 && error.missingFromModel.length === 0, + ); + assert.equal(factoryCalls, 0); assert.equal(state.childSessions.size, 0); assert.equal(state.liveChildSessions.size, 0); +}); + +test("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"] }); + assert.throws(() => normalizeSpawnRequirements({ constraints: { unknown: {} } }), /Unknown spawn constraint/); + assert.deepEqual(normalizeSpawnRequirements({}), normalizeSpawnRequirements({ constraints: {}})); +}); + +test("spawn tool schema validates constraints via Value.Check", () => { + const pi = createTestPI(); + const state = createState(); + registerSpawnTool(pi as any, state); + const tool = pi.tools.get("spawn"); + const schema = (tool as any).parameters; + assert.equal(Value.Check(schema, { prompt: "Do the task", constraints: { modalities: { required: ["text", "image"] } } }), true, "valid generic envelope accepted"); + assert.equal(Value.Check(schema, { prompt: "Do the task", constraints: { unknown: {} } }), false, "unknown generic requirement rejected"); + assert.equal(Value.Check(schema, { prompt: "Do the task" }), true, "omitted constraints allowed"); + assert.equal(Value.Check(schema, { prompt: "Do the task", constraints: { modalities: { required: [] } } }), true, "empty array allowed"); + assert.equal(Value.Check(schema, { prompt: "Do the task", constraints: { modalities: { required: ["text", "text"] } } }), false, "duplicates rejected"); + assert.equal(Value.Check(schema, { prompt: "Do the task", constraints: { modalities: { required: ["audio"] } } }), false, "out-of-vocabulary rejected"); + assert.equal(Value.Check(schema, { prompt: "Do the task", constraints: { modalities: "text" } }), false, "non-object requirement rejected"); +}); + +test("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"]); + const state = createState(); + let factoryCalls = 0; + const session = { + messages: [] as any[], + prompt: async () => { + session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; + }, + abort: async () => {}, + getSessionStats: () => undefined, + }; + registerSpawnTool(pi as any, state, (async () => { factoryCalls++; return { session: session as any }; }) as any); + const result = await executeSpawn("spawn-inherited-rm", pi as any, { + model: { provider: "openai", id: "parent", input: ["text", "image"], reasoning: false }, cwd: "/tmp", + modelRegistry: { find: (_p: string, id: string) => ({ provider: "openai", id, input: ["text", "image"], reasoning: false }), hasConfiguredAuth: () => true }, + } as any, state, { prompt: "Do the task", constraints: { modalities: { required: ["text", "image"] } } }, undefined, undefined, "medium", async () => { factoryCalls++; return { session: session as any, extensionsResult: undefined as any }; }); + assert.equal(result.details.outcome, "success"); + assert.deepEqual(result.details.route, { status: "inherited" }); + assert.equal(factoryCalls, 1, "inherited route with satisfied requirements creates one child"); +}); test("spawn renderResult transfers session ownership out of shared state", () => { const state = createState(); @@ -1751,6 +2034,9 @@ test("registerSpawnTool registers a tool with correct name and metadata", () => assert.equal(typeof tool.renderResult, "function"); assert.equal(tool.renderShell, "self"); assert.ok(tool.parameters, "should have parameters"); + const constraints = (tool.parameters as any).properties.constraints; + assert.equal(constraints.type, "object"); + assert.ok(constraints.properties.modalities); assert.equal(tool.executionMode, undefined, "spawn should not be sequential"); }); diff --git a/tests/unit/state-invariants.test.ts b/tests/unit/state-invariants.test.ts index 379355a..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,12 +280,14 @@ 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", 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);