From 907b4e7e6e8b2e955334d2e4ea2b46a75970c15d Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Tue, 16 Jun 2026 04:53:37 +0000 Subject: [PATCH 1/7] Add model groups and spawn routing --- CHANGELOG.md | 9 + index.ts | 43 ++ model-groups/autocomplete.ts | 63 +++ model-groups/command.ts | 22 + model-groups/router.ts | 106 +++++ model-groups/store.ts | 315 +++++++++++++ model-groups/tui.ts | 453 +++++++++++++++++++ model-groups/types.ts | 91 ++++ spawn/index.ts | 44 +- spawn/renderer.ts | 27 +- spawn/shared.ts | 6 + state.ts | 10 + tests/snapshots/spawn-call-collapsed.txt | 2 +- tests/snapshots/spawn-call-long.txt | 2 +- tests/unit/model-groups-autocomplete.test.ts | 66 +++ tests/unit/model-groups-crud.test.ts | 179 ++++++++ tests/unit/model-groups-integration.test.ts | 197 ++++++++ tests/unit/model-groups-router.test.ts | 78 ++++ tests/unit/model-groups-tui.test.ts | 421 +++++++++++++++++ tests/unit/spawn-render.test.ts | 16 + tests/unit/spawn.test.ts | 66 ++- 21 files changed, 2185 insertions(+), 31 deletions(-) create mode 100644 model-groups/autocomplete.ts create mode 100644 model-groups/command.ts create mode 100644 model-groups/router.ts create mode 100644 model-groups/store.ts create mode 100644 model-groups/tui.ts create mode 100644 model-groups/types.ts create mode 100644 tests/unit/model-groups-autocomplete.test.ts create mode 100644 tests/unit/model-groups-crud.test.ts create mode 100644 tests/unit/model-groups-integration.test.ts create mode 100644 tests/unit/model-groups-router.test.ts create mode 100644 tests/unit/model-groups-tui.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 761e65f..e3de9c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **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. + ### Changed - Spawned child agents now inherit active registered parent tools executable in the child session, including MCP/extension tools such as ChunkHound when active and registered, while still excluding spawn and handoff and preserving child-local notebook tools. +### Fixed + +- Model Groups add-model navigation now uses Pi's key matcher for Escape/left-arrow handling and filters provider/model choices to authenticated models. + ## [0.3.0] - 2026-05-23 ### Added diff --git a/index.ts b/index.ts index f6506f0..57b7062 100644 --- a/index.ts +++ b/index.ts @@ -31,6 +31,10 @@ import { registerHandoffTool } from "./handoff/tool.js"; import { registerHandoffCommand } from "./handoff/command.js"; import { registerHandoffCompaction } from "./handoff/compact.js"; import { registerSpawnTool } from "./spawn/index.js"; +import { registerModelGroupsCommand } from "./model-groups/command.js"; +import { registerModelGroupAutocomplete } from "./model-groups/autocomplete.js"; +import { getEffectiveModelGroupNames } from "./model-groups/router.js"; +import { loadModelGroups, summarizeBootValidation, validateModelGroups } from "./model-groups/store.js"; import { STATUS_KEY_HANDOFF, STATUS_KEY_TOPIC, @@ -39,6 +43,24 @@ import { } from "./tui.js"; import { formatPagePreview } from "./notebook/store.js"; +function refreshModelGroupsState(state: AgenticodingState, ctx: ExtensionContext) { + if (!ctx.cwd || !(ctx as any).modelRegistry) return null; + const loadedModelGroups = loadModelGroups(ctx.cwd); + const resolvedModelGroups = validateModelGroups(loadedModelGroups, (ctx as any).modelRegistry); + state.modelGroups.groups = resolvedModelGroups; + state.modelGroups.validation = { groups: resolvedModelGroups, loadIssues: loadedModelGroups.issues }; + return state.modelGroups.validation; +} + +function modelGroupsPromptSection(names: string[]): string | undefined { + if (names.length === 0) return undefined; + 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.`; +} + export default function (pi: ExtensionAPI): void { const state: AgenticodingState = createState(); @@ -55,6 +77,7 @@ export default function (pi: ExtensionAPI): void { // ── Register commands ─────────────────────────────────────────── registerHandoffCommand(pi, state); + registerModelGroupsCommand(pi, state); // ── /notebook command — interactive page selector ──────────────── pi.registerCommand("notebook", { @@ -162,6 +185,7 @@ export default function (pi: ExtensionAPI): void { pi.on("before_agent_start", async (event, ctx: ExtensionContext) => { // Update TUI indicators before each user-prompt agent run updateIndicators(ctx, state); + refreshModelGroupsState(state, ctx); const parts: string[] = [event.systemPrompt]; @@ -181,6 +205,11 @@ export default function (pi: ExtensionAPI): void { ); } + const modelGroupSection = modelGroupsPromptSection(getEffectiveModelGroupNames(state.modelGroups.groups)); + if (modelGroupSection) { + parts.push(modelGroupSection); + } + // Inject notebook listing so the LLM always knows what's available const entryNames = Array.from(state.notebookPages.keys()).sort(); if (entryNames.length > 0) { @@ -239,6 +268,20 @@ export default function (pi: ExtensionAPI): void { ctx.ui.setWidget(WIDGET_KEY_WARNING, undefined); } } + + registerModelGroupAutocomplete(ctx, state); + const validation = refreshModelGroupsState(state, ctx); + if (validation && ctx.hasUI) { + for (const issue of validation.loadIssues) { + const backupNote = issue.backupFailed ? "; backup failed, original file left untouched" : ""; + ctx.ui.notify(`Model Groups config ${issue.kind} in ${issue.scope} scope (${issue.sourcePath}); using empty config for that scope${backupNote}`, "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"); + } + } + updateIndicators(ctx, state); }); diff --git a/model-groups/autocomplete.ts b/model-groups/autocomplete.ts new file mode 100644 index 0000000..deccdae --- /dev/null +++ b/model-groups/autocomplete.ts @@ -0,0 +1,63 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { AgenticodingState } from "../state.js"; +import { getEffectiveModelGroups } from "./router.js"; +import type { ModelGroupModel, ResolvedModelGroup } from "./types.js"; + +const registeredUis = new WeakSet(); + +function isUnavailable(group: ResolvedModelGroup, entry: ModelGroupModel): boolean { + return group.validation.unavailableRefs.some((ref) => ref.provider === entry.provider && ref.modelId === entry.modelId); +} + +function formatModelGroupRouteDetails(group: ResolvedModelGroup): string { + if (group.models.length === 0) return "No models configured"; + return group.models + .map((entry) => { + const thinking = entry.thinkingLevel ?? "inherit"; + const unavailable = isUnavailable(group, entry) ? " (unavailable)" : ""; + return `${entry.provider}/${entry.modelId} • ${thinking}${unavailable}`; + }) + .join("; "); +} + +export function createModelGroupAutocompleteProvider(state: AgenticodingState) { + return (current: any) => ({ + async getSuggestions(lines: string[], cursorLine: number, cursorCol: number, options: unknown) { + const line = lines[cursorLine] ?? ""; + const beforeCursor = line.slice(0, cursorCol); + const match = beforeCursor.match(/(?:^|[\t ])#([^\s#]*)$/); + if (!match) { + return current.getSuggestions(lines, cursorLine, cursorCol, options); + } + + 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), + })); + return { prefix: `#${match[1] ?? ""}`, items }; + }, + + applyCompletion(lines: string[], cursorLine: number, cursorCol: number, item: unknown, prefix: string) { + return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + }, + + shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number) { + return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true; + }, + }); +} + +export function registerModelGroupAutocomplete(ctx: ExtensionContext, state: AgenticodingState): void { + if (!ctx.hasUI) return; + const ui = ctx.ui as unknown as { addAutocompleteProvider?: (factory: ReturnType) => void }; + if (typeof ui.addAutocompleteProvider !== "function") return; + const key = ui as object; + if (registeredUis.has(key)) return; + registeredUis.add(key); + ui.addAutocompleteProvider(createModelGroupAutocompleteProvider(state)); +} diff --git a/model-groups/command.ts b/model-groups/command.ts new file mode 100644 index 0000000..c8b6c8d --- /dev/null +++ b/model-groups/command.ts @@ -0,0 +1,22 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { AgenticodingState } from "../state.js"; +import { createModelGroupsComponent } from "./tui.js"; + +export function registerModelGroupsCommand(pi: ExtensionAPI, state: AgenticodingState): void { + pi.registerCommand("model-groups", { + description: "Manage Model Groups", + handler: async (_args, ctx) => { + if (!ctx.hasUI) return; + await ctx.ui.custom((tui, theme, _keybindings, done) => + createModelGroupsComponent(tui, theme, ctx.modelRegistry, ctx.cwd, done, { + initialValidation: state.modelGroups.validation, + notify: (message, type) => ctx.ui.notify(message, type), + onRefresh: (validation) => { + state.modelGroups.groups = validation.groups; + state.modelGroups.validation = validation; + }, + }), + ); + }, + }); +} diff --git a/model-groups/router.ts b/model-groups/router.ts new file mode 100644 index 0000000..73a9db3 --- /dev/null +++ b/model-groups/router.ts @@ -0,0 +1,106 @@ +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"; + +export type SpawnRouteStatus = "inherited" | "routed" | "unknown-fallback"; + +export interface SpawnModelRoute { + status: SpawnRouteStatus; + requestedGroup?: string; + groupName?: string; + model: Model; + provider: string; + modelId: string; + thinking: ModelThinkingLevel; +} + +export type SpawnRouteErrorReason = "empty" | "no-usable-models"; + +export class SpawnRouteError extends Error { + readonly kind = "unusable-group" as const; + readonly group: string; + readonly reason: SpawnRouteErrorReason; + + constructor(group: string, reason: SpawnRouteErrorReason) { + const detail = reason === "empty" + ? "has no model entries" + : "has no configured/authenticated usable models"; + super(`Model Group '${group}' ${detail}.`); + this.name = "SpawnRouteError"; + this.group = group; + this.reason = reason; + } +} + +function parentProvider(model: Model): string { + return typeof model.provider === "string" ? model.provider : ""; +} + +function effectiveGroupMap(groups: ResolvedModelGroup[]): Map { + const byName = new Map(); + for (const group of groups) { + if (group.validation?.shadowedByProject) continue; + const existing = byName.get(group.name); + if (!existing || group.scope === "project") byName.set(group.name, group); + } + return byName; +} + +export function getEffectiveModelGroups(groups: ResolvedModelGroup[]): ResolvedModelGroup[] { + return [...effectiveGroupMap(groups).values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +export function getEffectiveModelGroupNames(groups: ResolvedModelGroup[]): string[] { + return getEffectiveModelGroups(groups).map((group) => group.name); +} + +export function resolveSpawnModelRoute(options: { + requestedGroup?: string; + groups: ResolvedModelGroup[]; + parentModel: Model; + parentThinking: ModelThinkingLevel; + modelRegistry: Pick; + rng?: () => number; +}): SpawnModelRoute { + const requestedGroup = options.requestedGroup?.trim(); + const inherited = (status: "inherited" | "unknown-fallback"): SpawnModelRoute => ({ + status, + ...(status === "unknown-fallback" && requestedGroup ? { requestedGroup } : {}), + model: options.parentModel, + provider: parentProvider(options.parentModel), + modelId: options.parentModel.id, + thinking: options.parentThinking, + }); + + if (!requestedGroup) return inherited("inherited"); + + const group = effectiveGroupMap(options.groups).get(requestedGroup); + if (!group) return inherited("unknown-fallback"); + if (group.models.length === 0) throw new SpawnRouteError(group.name, "empty"); + + const usable = group.models + .map((entry) => { + const model = options.modelRegistry.find(entry.provider, entry.modelId) as Model | undefined; + return model && options.modelRegistry.hasConfiguredAuth(model) + ? { entry, model } + : undefined; + }) + .filter((entry): entry is { entry: typeof group.models[number]; model: Model } => Boolean(entry)); + + if (usable.length === 0) throw new SpawnRouteError(group.name, "no-usable-models"); + + const rng = options.rng ?? Math.random; + const index = Math.min(usable.length - 1, Math.max(0, Math.floor(rng() * usable.length))); + const selected = usable[index]; + const requestedThinking = selected.entry.thinkingLevel ?? options.parentThinking; + const thinking = clampThinkingLevel(selected.model, requestedThinking); + return { + status: "routed", + requestedGroup, + groupName: group.name, + model: selected.model, + provider: selected.entry.provider, + modelId: selected.entry.modelId, + thinking, + }; +} diff --git a/model-groups/store.ts b/model-groups/store.ts new file mode 100644 index 0000000..cba39ff --- /dev/null +++ b/model-groups/store.ts @@ -0,0 +1,315 @@ +import { homedir } from "node:os"; +import path from "node:path"; +import * as fs from "node:fs"; +import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; +import { + ModelGroupsPersistenceError, + type ModelGroupDef, + type ModelGroupModel, + type ModelGroupScope, + type ModelGroupsBootValidation, + type ModelGroupsConfig, + type ModelGroupsLoadedGroup, + type ModelGroupsLoadIssue, + type ModelGroupsLoadResult, + type ResolvedModelGroup, +} from "./types.js"; + +const CURRENT_VERSION = 1; +const EMPTY_CONFIG: ModelGroupsConfig = { version: CURRENT_VERSION, groups: {} }; +const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh"]); + +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): string { + return scope === "global" + ? path.join(homedir(), ".pi", "agent", "pi-agenticoding", "model-groups.json") + : path.join(cwd, ".pi", "pi-agenticoding", "model-groups.json"); +} + +function emptyConfig(): ModelGroupsConfig { + return { version: CURRENT_VERSION, groups: {} }; +} + +function cloneDef(def: ModelGroupDef): ModelGroupDef { + return { models: def.models.map((m) => ({ ...m })) }; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function validateModelEntry(value: unknown): value is ModelGroupModel { + if (!isPlainRecord(value)) return false; + if (typeof value.provider !== "string" || value.provider.length === 0) return false; + if (typeof value.modelId !== "string" || value.modelId.length === 0) return false; + if (value.thinkingLevel !== undefined && !VALID_THINKING.has(value.thinkingLevel as ModelThinkingLevel)) return false; + return true; +} + +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") return { ok: false, message: "version must be a number" }; + 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 groups: Record = {}; + for (const [name, def] of Object.entries(raw.groups)) { + if (!isPlainRecord(def)) return { ok: false, message: `group ${name} must be an object` }; + if (!Array.isArray(def.models)) return { ok: false, message: `group ${name}.models must be an array` }; + if (!def.models.every(validateModelEntry)) return { ok: false, message: `group ${name}.models contains invalid model entry` }; + groups[name] = { models: def.models.map((m) => ({ ...m })) }; + } + return { ok: true, config: { version: CURRENT_VERSION, groups } }; +} + +function backupAndIssue(scope: ModelGroupScope, sourcePath: string, kind: ModelGroupsLoadIssue["kind"], message: string, version?: number): ModelGroupsLoadIssue { + const backupPath = `${sourcePath}.bak`; + const issue: ModelGroupsLoadIssue = { scope, sourcePath, kind, message, backupPath, version }; + if (kind === "unsupported-version") return issue; + try { + fsOps.copyFileSync(sourcePath, backupPath); + } catch (cause) { + issue.backupFailed = true; + issue.message = `${message}; backup failed: ${cause instanceof Error ? cause.message : String(cause)}`; + } + return issue; +} + +function loadScope(scope: ModelGroupScope, cwd: string): { config: ModelGroupsConfig; issue?: ModelGroupsLoadIssue } { + const sourcePath = modelGroupsPath(scope, cwd); + if (!fsOps.existsSync(sourcePath)) return { config: emptyConfig() }; + let parsed: unknown; + try { + parsed = JSON.parse(String(fsOps.readFileSync(sourcePath, "utf8"))); + } catch (cause) { + return { + config: emptyConfig(), + issue: backupAndIssue(scope, sourcePath, "corrupt-json", cause instanceof Error ? cause.message : String(cause)), + }; + } + if (isPlainRecord(parsed)) { + const version = parsed.version === undefined || parsed.version === 0 ? CURRENT_VERSION : parsed.version; + if (typeof version === "number" && version > CURRENT_VERSION) { + return { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "unsupported-version", `unsupported version ${version}`, version) }; + } + } + const validated = validateConfig(parsed); + if (!validated.ok) { + return { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "schema-invalid", validated.message) }; + } + return { config: validated.config }; +} + +function mergeLoaded(configs: Record, cwd: string): ModelGroupsLoadedGroup[] { + const globalPath = modelGroupsPath("global", cwd); + const projectPath = modelGroupsPath("project", cwd); + const names = new Set([...Object.keys(configs.global.groups), ...Object.keys(configs.project.groups)]); + const merged: ModelGroupsLoadedGroup[] = []; + for (const name of [...names].sort()) { + const globalDef = configs.global.groups[name]; + if (globalDef) merged.push({ name, scope: "global", sourcePath: globalPath, ...cloneDef(globalDef) }); + const projectDef = configs.project.groups[name]; + if (projectDef) merged.push({ name, scope: "project", sourcePath: projectPath, ...cloneDef(projectDef) }); + } + return merged; +} + +export function loadModelGroups(cwd: string): ModelGroupsLoadResult { + const global = loadScope("global", cwd); + const project = loadScope("project", cwd); + const configs = { global: global.config, project: project.config }; + return { + configs, + merged: mergeLoaded(configs, cwd), + issues: [global.issue, project.issue].filter((issue): issue is ModelGroupsLoadIssue => Boolean(issue)), + }; +} + +function persistenceError(details: ConstructorParameters[0]): ModelGroupsPersistenceError { + return new ModelGroupsPersistenceError(details); +} + +export function saveModelGroups(scope: ModelGroupScope, cwd: string, config: ModelGroupsConfig): void { + const sourcePath = modelGroupsPath(scope, cwd); + 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 { + // Preserve nothing from malformed content; load recovery owns malformed-file handling. + } + } + const body = JSON.stringify({ ...raw, version: CURRENT_VERSION, groups: config.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) { + 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)}`, cause }); + } +} + +function loadScopeConfig(scope: ModelGroupScope, cwd: string): ModelGroupsConfig { + const loaded = loadScope(scope, cwd); + if (loaded.issue?.backupFailed) { + throw persistenceError({ + operation: "save", + scope, + sourcePath: loaded.issue.sourcePath, + targetPath: loaded.issue.backupPath, + phase: "load-recovery", + message: `Refusing to overwrite ${scope} model-groups config after ${loaded.issue.kind} recovery because backup failed: ${loaded.issue.message}`, + cause: loaded.issue, + }); + } + return loaded.config; +} + +function ensureGroupName(name: string): void { + if (!name.trim()) throw new Error("Model group name is required"); +} + +export function createGroup(scope: ModelGroupScope, cwd: string, name: string, def: ModelGroupDef): void { + ensureGroupName(name); + const config = loadScopeConfig(scope, cwd); + if (config.groups[name]) throw new Error(`Model group '${name}' already exists in ${scope} scope`); + config.groups[name] = cloneDef(def); + saveModelGroups(scope, cwd, config); +} + +export function updateGroup(scope: ModelGroupScope, cwd: string, name: string, def: ModelGroupDef): void { + const config = loadScopeConfig(scope, cwd); + if (!config.groups[name]) throw new Error(`Model group '${name}' does not exist in ${scope} scope`); + config.groups[name] = cloneDef(def); + saveModelGroups(scope, cwd, config); +} + +export function renameGroup(scope: ModelGroupScope, cwd: string, oldName: string, newName: string): void { + ensureGroupName(newName); + if (oldName === newName) return; + const config = loadScopeConfig(scope, cwd); + const existing = config.groups[oldName]; + if (!existing) throw new Error(`Model group '${oldName}' does not exist in ${scope} scope`); + if (config.groups[newName]) throw new Error(`Model group '${newName}' already exists in ${scope} scope`); + delete config.groups[oldName]; + config.groups[newName] = cloneDef(existing); + saveModelGroups(scope, cwd, config); +} + +export function deleteGroup(scope: ModelGroupScope, cwd: string, name: string): { otherScopeHasOverride: boolean } { + const config = loadScopeConfig(scope, cwd); + if (!config.groups[name]) throw new Error(`Model group '${name}' does not exist in ${scope} scope`); + delete config.groups[name]; + try { + saveModelGroups(scope, cwd, 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; + } + const other = loadScopeConfig(scope === "global" ? "project" : "global", cwd); + return { otherScopeHasOverride: Boolean(other.groups[name]) }; +} + +export function moveGroup(cwd: string, name: string, newScope: ModelGroupScope): void { + const oldScope: ModelGroupScope = newScope === "project" ? "global" : "project"; + const source = loadScopeConfig(oldScope, cwd); + const target = loadScopeConfig(newScope, cwd); + const def = source.groups[name]; + if (!def) throw new Error(`Model group '${name}' does not exist in ${oldScope} scope`); + if (target.groups[name]) throw new Error(`Model group '${name}' already exists in ${newScope} scope`); + const nextTarget: ModelGroupsConfig = { version: CURRENT_VERSION, groups: { ...target.groups, [name]: cloneDef(def) } }; + try { + saveModelGroups(newScope, cwd, nextTarget); + } catch (cause) { + if (cause instanceof ModelGroupsPersistenceError) { + throw new ModelGroupsPersistenceError({ + operation: "move", + scope: newScope, + sourcePath: modelGroupsPath(oldScope, cwd), + targetPath: modelGroupsPath(newScope, cwd), + phase: cause.phase, + message: `Model group '${name}' was not written to ${newScope}: ${cause.message}`, + cause, + }); + } + throw cause; + } + const nextSource: ModelGroupsConfig = { version: CURRENT_VERSION, groups: { ...source.groups } }; + delete nextSource.groups[name]; + try { + saveModelGroups(oldScope, cwd, nextSource); + } catch (cause) { + if (cause instanceof ModelGroupsPersistenceError) { + throw new ModelGroupsPersistenceError({ + operation: "move", + scope: oldScope, + sourcePath: modelGroupsPath(oldScope, cwd), + targetPath: modelGroupsPath(newScope, cwd), + phase: "source-remove", + partialMove: "target-written-source-retained", + message: `Model group '${name}' was written to ${newScope} but retained in ${oldScope}: ${cause.message}`, + cause, + }); + } + throw cause; + } +} + +export function validateModelGroups(loadResult: ModelGroupsLoadResult, modelRegistry: ModelRegistry): ResolvedModelGroup[] { + const projectNames = new Set(Object.keys(loadResult.configs.project.groups)); + return loadResult.merged.map((group) => { + const unavailableRefs: Array<{ provider: string; modelId: string }> = []; + for (const modelRef of group.models) { + const model = modelRegistry.find(modelRef.provider, modelRef.modelId); + if (!model || !modelRegistry.hasConfiguredAuth(model)) unavailableRefs.push({ provider: modelRef.provider, modelId: modelRef.modelId }); + } + const unavailableCount = unavailableRefs.length; + const availableCount = group.models.length - unavailableCount; + return { + ...group, + validation: { + unavailableRefs, + shadowedByProject: group.scope === "global" && projectNames.has(group.name), + degraded: unavailableCount > 0 && availableCount > 0, + }, + }; + }); +} + +export function listResolvedModelGroups(cwd: string, modelRegistry: ModelRegistry): ModelGroupsBootValidation { + const loaded = loadModelGroups(cwd); + return { groups: validateModelGroups(loaded, modelRegistry), loadIssues: loaded.issues }; +} + +export function summarizeBootValidation(groups: ResolvedModelGroup[]): { unavailableCount: number; overrideCount: number } { + return { + unavailableCount: groups.reduce((sum, group) => sum + group.validation.unavailableRefs.length, 0), + overrideCount: groups.filter((group) => group.validation.shadowedByProject).length, + }; +} + +export { CURRENT_VERSION as MODEL_GROUPS_CONFIG_VERSION, EMPTY_CONFIG as EMPTY_MODEL_GROUPS_CONFIG }; diff --git a/model-groups/tui.ts b/model-groups/tui.ts new file mode 100644 index 0000000..6b0cbdf --- /dev/null +++ b/model-groups/tui.ts @@ -0,0 +1,453 @@ +import type { Theme } from "@earendil-works/pi-coding-agent"; +import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { getSupportedThinkingLevels, type Model, type ModelThinkingLevel, type Api } from "@earendil-works/pi-ai"; +import { Key, matchesKey, type Component, type TUI } from "@earendil-works/pi-tui"; +import { + createGroup, + deleteGroup, + listResolvedModelGroups, + moveGroup, + renameGroup, + summarizeBootValidation, + updateGroup, +} from "./store.js"; +import { ModelGroupsPersistenceError, type ModelGroupDef, type ModelGroupScope, type ModelGroupsBootValidation, type ResolvedModelGroup } from "./types.js"; + +export type ModelGroupsScreen = "LIST" | "EDITOR" | "MODEL_EDIT" | "WIZARD_PROVIDER" | "WIZARD_MODEL" | "WIZARD_THINKING" | "DELETE_CONFIRM"; + +export interface ModelGroupsStoreOps { + listResolvedModelGroups: typeof listResolvedModelGroups; + createGroup: typeof createGroup; + updateGroup: typeof updateGroup; + renameGroup: typeof renameGroup; + deleteGroup: typeof deleteGroup; + moveGroup: typeof moveGroup; +} + +export interface ModelGroupsComponentOptions { + notify?: (message: string, type?: "info" | "warning" | "error") => void; + initialValidation?: ModelGroupsBootValidation | null; + onRefresh?: (validation: ModelGroupsBootValidation) => void; + store?: Partial; +} + +const defaultStore: ModelGroupsStoreOps = { listResolvedModelGroups, createGroup, updateGroup, renameGroup, deleteGroup, moveGroup }; + +function isEnter(data: string): boolean { return matchesKey(data, Key.enter) || data === "\n"; } +function isEsc(data: string): boolean { return matchesKey(data, Key.escape); } +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); } +function isBackspace(data: string): boolean { return matchesKey(data, Key.backspace); } +function isDeleteChord(data: string): boolean { return data === "D" || matchesKey(data, Key.delete); } +function isPrintable(data: string): boolean { return data.length === 1 && data >= " " && data !== "\u007f"; } + +function cloneDef(def: ModelGroupDef): ModelGroupDef { + return { models: def.models.map((model) => ({ ...model })) }; +} + +function groupKey(group: Pick): string { + return `${group.scope}:${group.name}`; +} + +function thinkingLabel(level: ModelThinkingLevel | undefined): string { + return level ?? "inherit"; +} + +function modelAvailable(registry: ModelRegistry, provider: string, modelId: string): boolean { + const model = registry.find(provider, modelId); + return Boolean(model && registry.hasConfiguredAuth(model)); +} + +function modelDisplay(model: Model): string { + return `${model.provider}/${model.id}`; +} + +function toPersistenceMessage(error: unknown): string { + if (error instanceof ModelGroupsPersistenceError) { + const scope = error.scope ? ` for ${error.scope} scope` : ""; + const paths = [error.sourcePath ? `source: ${error.sourcePath}` : "", error.targetPath ? `target: ${error.targetPath}` : ""].filter(Boolean).join("; "); + const pathDetails = paths ? ` (${paths})` : ""; + return `${error.operation} failed at ${error.phase}${scope}${pathDetails}: ${error.message}`; + } + return error instanceof Error ? error.message : String(error); +} + +export function createModelGroupsComponent( + tui: TUI, + theme: Theme, + modelRegistry: ModelRegistry, + cwd: string, + done: (result: void) => void, + options: ModelGroupsComponentOptions = {}, +): Component { + const store = { ...defaultStore, ...options.store }; + const notify = options.notify ?? (() => {}); + const state = { + screen: "LIST" as ModelGroupsScreen, + row: 0, + groups: [] as ResolvedModelGroup[], + loadIssues: [] as ModelGroupsBootValidation["loadIssues"], + editKey: null as string | null, + editName: "", + editScope: "project" as ModelGroupScope, + editDraft: null as ModelGroupDef | null, + activeTextInput: null as null | "group-name" | "wizard-filter", + modelEditIndex: 0, + wizardProvider: "", + wizardModelId: "", + wizardThinking: undefined as ModelThinkingLevel | undefined, + deleteKey: null as string | null, + finished: false, + }; + + function refresh(): void { + const boot = store.listResolvedModelGroups(cwd, modelRegistry); + state.groups = boot.groups; + state.loadIssues = boot.loadIssues; + options.onRefresh?.(boot); + for (const issue of boot.loadIssues) { + const backup = issue.backupFailed ? "; backup failed, original file left untouched" : ""; + notify(`Model Groups config ${issue.kind} in ${issue.scope} scope (${issue.sourcePath}); using empty config for that scope${backup}`, "warning"); + } + } + + if (options.initialValidation) { + state.groups = options.initialValidation.groups; + state.loadIssues = options.initialValidation.loadIssues; + } else { + refresh(); + } + + function selectedGroup(): ResolvedModelGroup | undefined { + return state.groups[state.row]; + } + + function openEditor(group: ResolvedModelGroup): void { + state.screen = "EDITOR"; + state.row = 0; + state.editKey = groupKey(group); + state.editName = group.name; + state.editScope = group.scope; + state.editDraft = cloneDef(group); + state.activeTextInput = null; + } + + function currentEditGroup(): ResolvedModelGroup | undefined { + return state.groups.find((group) => groupKey(group) === state.editKey) ?? state.groups.find((group) => group.name === state.editName && group.scope === state.editScope); + } + + function uniqueNewGroupName(): string { + const existing = new Set(state.groups.map((group) => group.name)); + if (!existing.has("new-group")) return "new-group"; + let index = 2; + while (existing.has(`new-group-${index}`)) index++; + return `new-group-${index}`; + } + + function notifyError(error: unknown): void { + notify(toPersistenceMessage(error), "error"); + } + + function commitName(): boolean { + const group = currentEditGroup(); + if (!group) return false; + const nextName = state.editName.trim(); + if (!nextName || nextName === group.name) { + state.editName = group.name; + return true; + } + try { + store.renameGroup(group.scope, cwd, group.name, nextName); + refresh(); + const renamed = state.groups.find((candidate) => candidate.name === nextName && candidate.scope === group.scope); + if (renamed) openEditor(renamed); + return true; + } catch (error) { + notifyError(error); + state.editName = group.name; + state.activeTextInput = null; + return false; + } + } + + function switchScope(newScope: ModelGroupScope): void { + const group = currentEditGroup(); + if (!group || newScope === group.scope) return; + if (!commitName()) return; + const confirmed = currentEditGroup(); + if (!confirmed) return; + try { + store.moveGroup(cwd, confirmed.name, newScope); + refresh(); + const moved = state.groups.find((candidate) => candidate.name === confirmed.name && candidate.scope === newScope); + if (moved) openEditor(moved); + } catch (error) { + notifyError(error); + } + } + + function updateDraft(def: ModelGroupDef, afterSuccess: () => void): void { + const group = currentEditGroup(); + if (!group) return; + try { + store.updateGroup(group.scope, cwd, group.name, def); + refresh(); + const updated = state.groups.find((candidate) => candidate.name === group.name && candidate.scope === group.scope); + if (updated) openEditor(updated); + afterSuccess(); + } catch (error) { + notifyError(error); + } + } + + function availableModels(): Model[] { + return modelRegistry.getAvailable() + .filter((model) => modelRegistry.hasConfiguredAuth(model)); + } + + function allProviders(): string[] { + return [...new Set(availableModels().map((model) => model.provider))].sort(); + } + + function modelsForProvider(provider: string): Model[] { + return availableModels() + .filter((model) => model.provider === provider) + .sort((a, b) => a.id.localeCompare(b.id)); + } + + function currentWizardModel(): Model | undefined { + return modelRegistry.find(state.wizardProvider, state.wizardModelId) as Model | undefined; + } + + function thinkingOptionsFor(model: Model | undefined): Array { + if (!model) return [undefined]; + const supported = getSupportedThinkingLevels(model).filter((level) => model.reasoning || level !== "off"); + return [undefined, ...supported]; + } + + function maxRow(): number { + switch (state.screen) { + case "LIST": return state.groups.length; + case "EDITOR": return 3 + (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 "WIZARD_PROVIDER": return Math.max(0, allProviders().length - 1); + case "WIZARD_MODEL": return Math.max(0, modelsForProvider(state.wizardProvider).length - 1); + case "WIZARD_THINKING": return Math.max(0, thinkingOptionsFor(currentWizardModel()).length - 1); + case "DELETE_CONFIRM": return 1; + } + } + + function clampRow(): void { + state.row = Math.max(0, Math.min(state.row, maxRow())); + } + + function activate(): void { + switch (state.screen) { + case "LIST": { + if (state.row === state.groups.length) { + const name = uniqueNewGroupName(); + try { + store.createGroup("project", cwd, name, { models: [] }); + refresh(); + const created = state.groups.find((group) => group.name === name && group.scope === "project"); + if (created) openEditor(created); + } catch (error) { notifyError(error); } + return; + } + const group = selectedGroup(); + if (group) openEditor(group); + return; + } + case "EDITOR": { + if (state.row === 0) { switchScope("project"); return; } + if (state.row === 1) { switchScope("global"); return; } + if (state.row === 2) { state.activeTextInput = "group-name"; return; } + if (!commitName()) return; + const modelIndex = state.row - 3; + if (state.editDraft && modelIndex < state.editDraft.models.length) { + state.modelEditIndex = modelIndex; + state.screen = "MODEL_EDIT"; + state.row = 0; + } else { + state.screen = "WIZARD_PROVIDER"; + state.row = 0; + } + return; + } + case "MODEL_EDIT": { + const model = state.editDraft?.models[state.modelEditIndex]; + if (!state.editDraft || !model) return; + const found = modelRegistry.find(model.provider, model.modelId) as Model | undefined; + const options = thinkingOptionsFor(found); + if (state.row >= options.length) { + const next = cloneDef(state.editDraft); + next.models.splice(state.modelEditIndex, 1); + updateDraft(next, () => { state.screen = "EDITOR"; state.row = 0; }); + return; + } + const next = cloneDef(state.editDraft); + const level = options[state.row]; + if (level === undefined) delete next.models[state.modelEditIndex].thinkingLevel; + else next.models[state.modelEditIndex].thinkingLevel = level; + updateDraft(next, () => { state.screen = "EDITOR"; state.row = 0; }); + return; + } + case "WIZARD_PROVIDER": { + const provider = allProviders()[state.row]; + if (!provider) return; + state.wizardProvider = provider; + state.screen = "WIZARD_MODEL"; + state.row = 0; + return; + } + case "WIZARD_MODEL": { + const model = modelsForProvider(state.wizardProvider)[state.row]; + if (!model) return; + state.wizardModelId = model.id; + state.screen = "WIZARD_THINKING"; + state.row = 0; + return; + } + case "WIZARD_THINKING": { + if (!state.editDraft) return; + const level = thinkingOptionsFor(currentWizardModel())[state.row]; + const next = cloneDef(state.editDraft); + const entry = { provider: state.wizardProvider, modelId: state.wizardModelId } as { provider: string; modelId: string; thinkingLevel?: ModelThinkingLevel }; + if (level !== undefined) entry.thinkingLevel = level; + next.models.push(entry); + updateDraft(next, () => { state.screen = "EDITOR"; state.row = 0; }); + return; + } + case "DELETE_CONFIRM": { + if (state.row === 0) { state.screen = "LIST"; state.row = 0; return; } + const group = state.groups.find((candidate) => groupKey(candidate) === state.deleteKey); + if (!group) { state.screen = "LIST"; return; } + try { + store.deleteGroup(group.scope, cwd, group.name); + refresh(); + state.screen = "LIST"; + state.row = 0; + } catch (error) { notifyError(error); } + return; + } + } + } + + function goBack(): void { + if (state.activeTextInput === "group-name") { commitName(); state.activeTextInput = null; return; } + switch (state.screen) { + case "LIST": state.finished = true; done(); return; + case "EDITOR": commitName(); state.screen = "LIST"; state.row = 0; return; + case "MODEL_EDIT": state.screen = "EDITOR"; state.row = 0; return; + case "WIZARD_PROVIDER": state.screen = "EDITOR"; state.row = 0; return; + case "WIZARD_MODEL": state.screen = "WIZARD_PROVIDER"; state.row = 0; return; + case "WIZARD_THINKING": state.screen = "WIZARD_MODEL"; state.row = 0; return; + case "DELETE_CONFIRM": state.screen = "LIST"; state.row = 0; return; + } + } + + function deleteAction(): void { + if (state.screen === "LIST") { + const group = selectedGroup(); + if (!group) return; + state.deleteKey = groupKey(group); + state.screen = "DELETE_CONFIRM"; + state.row = 0; + } else if (state.screen === "MODEL_EDIT") { + state.row = maxRow(); + activate(); + } + } + + function selectableLine(selected: boolean, primary: string, suffix = ""): string { + if (!selected) return ` ${primary}${suffix}`; + return `${theme.fg("accent", "→")} ${theme.fg("accent", primary)}${suffix}`; + } + + function renderList(): string[] { + const summary = summarizeBootValidation(state.groups); + const lines = [theme.fg("accent", "Model Groups"), theme.fg("dim", `Boot validation: ${summary.unavailableCount} unavailable model references · ${summary.overrideCount} project overrides`)]; + state.groups.forEach((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"; + lines.push(selectableLine(index === state.row, group.name, ` [${group.scope}] ${group.models.length} models ${models}${tags.length ? ` — ${tags.join(" · ")}` : ""}`)); + }); + lines.push(selectableLine(state.row === state.groups.length, "+ Add group")); + lines.push(theme.fg("dim", "↑↓ navigate • Enter open/add • D delete • Esc close")); + return lines; + } + + function renderEditor(): string[] { + const lines = [theme.fg("accent", `Model Group: ${state.editName}`)]; + lines.push(selectableLine(state.row === 0, "Location: project", state.editScope === "project" ? " ✓" : "")); + lines.push(selectableLine(state.row === 1, "Location: global", state.editScope === "global" ? " ✓" : "")); + lines.push(selectableLine(state.row === 2, `Name: ${state.editName}${state.activeTextInput === "group-name" ? "_" : ""}`)); + state.editDraft?.models.forEach((model, index) => { + const available = modelAvailable(modelRegistry, model.provider, model.modelId) ? "available" : "unavailable"; + lines.push(selectableLine(state.row === index + 3, `${model.provider}/${model.modelId}`, ` (${available}, thinking ${thinkingLabel(model.thinkingLevel)})`)); + }); + const addRow = 3 + (state.editDraft?.models.length ?? 0); + lines.push(selectableLine(state.row === addRow, "+ Add model…")); + return lines; + } + + function renderModelEdit(): string[] { + const model = state.editDraft?.models[state.modelEditIndex]; + if (!model) return ["Model not found"]; + const found = modelRegistry.find(model.provider, model.modelId) as Model | undefined; + const lines = [theme.fg("accent", "Edit model"), `Provider: ${model.provider}`, `Model ID: ${model.modelId}`, `Status: ${found && modelRegistry.hasConfiguredAuth(found) ? "available" : "unavailable"}`]; + thinkingOptionsFor(found).forEach((level, index) => lines.push(selectableLine(state.row === index, `Thinking: ${thinkingLabel(level)}`))); + lines.push(selectableLine(state.row === thinkingOptionsFor(found).length, "Remove model")); + return lines; + } + + function renderWizard(): string[] { + if (state.screen === "WIZARD_PROVIDER") return [theme.fg("accent", "Add model — Step 1/3 Provider"), ...allProviders().map((provider, index) => selectableLine(state.row === index, provider))]; + if (state.screen === "WIZARD_MODEL") return [theme.fg("accent", "Add model — Step 2/3 Model"), ...modelsForProvider(state.wizardProvider).map((model, index) => selectableLine(state.row === index, modelDisplay(model)))]; + return [theme.fg("accent", "Add model — Step 3/3 Thinking"), ...thinkingOptionsFor(currentWizardModel()).map((level, index) => selectableLine(state.row === index, thinkingLabel(level)))]; + } + + function renderDelete(): string[] { + const group = state.groups.find((candidate) => groupKey(candidate) === state.deleteKey); + const otherScope = group ? state.groups.some((candidate) => candidate.name === group.name && candidate.scope !== group.scope) : false; + return [theme.fg("warning", "Delete Model Group?"), group ? `${group.name} [${group.scope}] with ${group.models.length} models` : "Missing group", otherScope ? "Same-name group in the other scope remains unaffected." : "", selectableLine(state.row === 0, "Keep group"), selectableLine(state.row === 1, "Delete group")].filter(Boolean); + } + + return { + render: (_width: number) => { + if (state.screen === "LIST") return renderList(); + if (state.screen === "EDITOR") return renderEditor(); + if (state.screen === "MODEL_EDIT") return renderModelEdit(); + if (state.screen === "DELETE_CONFIRM") return renderDelete(); + return renderWizard(); + }, + invalidate: () => {}, + handleInput: (data: string) => { + if (state.finished) return; + if (state.activeTextInput === "group-name") { + if (isUp(data) || isDown(data)) { + const previousRow = state.row; + if (commitName()) { + state.activeTextInput = null; + state.row = previousRow + (isDown(data) ? 1 : -1); + clampRow(); + } + } else if (isEnter(data) || isEsc(data)) { commitName(); state.activeTextInput = null; } + else if (isBackspace(data)) state.editName = state.editName.slice(0, -1); + else if (isPrintable(data)) state.editName += data; + tui.requestRender(); + return; + } + if (isDeleteChord(data) && (state.screen === "LIST" || state.screen === "MODEL_EDIT")) deleteAction(); + else if (isUp(data)) { state.row--; clampRow(); } + else if (isDown(data)) { state.row++; clampRow(); } + else if (isLeft(data) || isEsc(data)) goBack(); + else if (isEnter(data)) activate(); + tui.requestRender(); + }, + }; +} diff --git a/model-groups/types.ts b/model-groups/types.ts new file mode 100644 index 0000000..6eebe0d --- /dev/null +++ b/model-groups/types.ts @@ -0,0 +1,91 @@ +import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; + +export type ModelGroupScope = "project" | "global"; + +export interface ModelGroupModel { + provider: string; + modelId: string; + thinkingLevel?: ModelThinkingLevel; +} + +export interface ModelGroupDef { + models: ModelGroupModel[]; +} + +export interface ModelGroupsConfig { + version: 1; + groups: Record; +} + +export interface ModelGroupValidation { + unavailableRefs: Array<{ provider: string; modelId: string }>; + shadowedByProject: boolean; + degraded: boolean; +} + +export interface ModelGroupsLoadedGroup extends ModelGroupDef { + name: string; + scope: ModelGroupScope; + sourcePath: string; +} + +export interface ResolvedModelGroup extends ModelGroupsLoadedGroup { + validation: ModelGroupValidation; +} + +export type ModelGroupsLoadIssueKind = "corrupt-json" | "schema-invalid" | "unsupported-version"; + +export interface ModelGroupsLoadIssue { + scope: ModelGroupScope; + sourcePath: string; + kind: ModelGroupsLoadIssueKind; + message: string; + backupPath?: string; + backupFailed?: boolean; + version?: number; +} + +export type ModelGroupsPersistenceOperation = "save" | "delete" | "move"; +export type ModelGroupsPersistencePhase = "temp-write" | "rename" | "source-remove" | "load-recovery"; + +export class ModelGroupsPersistenceError extends Error { + readonly operation: ModelGroupsPersistenceOperation; + readonly scope?: ModelGroupScope; + readonly sourcePath?: string; + readonly targetPath?: string; + readonly phase: ModelGroupsPersistencePhase; + readonly partialMove?: "target-written-source-retained"; + readonly cause?: unknown; + + constructor(details: { + operation: ModelGroupsPersistenceOperation; + scope?: ModelGroupScope; + sourcePath?: string; + targetPath?: string; + phase: ModelGroupsPersistencePhase; + partialMove?: "target-written-source-retained"; + message: string; + cause?: unknown; + }) { + super(details.message); + this.name = "ModelGroupsPersistenceError"; + this.operation = details.operation; + this.scope = details.scope; + this.sourcePath = details.sourcePath; + this.targetPath = details.targetPath; + this.phase = details.phase; + this.partialMove = details.partialMove; + this.cause = details.cause; + } +} + +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 2353c03..a4a4686 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -23,11 +23,11 @@ import { ModelRegistry, SessionManager, } from "@earendil-works/pi-coding-agent"; -import { StringEnum } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import type { AgenticodingState } from "../state.js"; import { formatPageList } from "../notebook/store.js"; import { createNotebookToolDefinitions } from "../notebook/tools.js"; +import { resolveSpawnModelRoute } from "../model-groups/router.js"; import { renderSpawnCall, renderSpawnResult, @@ -142,13 +142,14 @@ export function buildChildToolNames( const SPAWN_DESCRIPTION = "Spawn an isolated child agent for a focused subtask. " + - "Child inherits parent model, thinking level, cwd, active registered tools executable in the child session, and shared notebook tools; children cannot spawn or handoff. " + + "Child inherits parent model, thinking level, cwd, active registered tools executable in the child session, and shared notebook tools unless an optional Model Group routes its model/thinking; children cannot spawn or handoff. " + "Reference notebook pages by name — child will notebook_read them on demand."; 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({ @@ -157,13 +158,9 @@ const SPAWN_PARAMETERS = Type.Object({ "Self-contained task description. Reference notebook pages by name — " + "child will notebook_read them on demand.", }), - thinking: StringEnum( - ["off", "minimal", "low", "medium", "high", "xhigh"] as const, - { - description: - "Override child thinking level. Inherits parent by default.", - }, - ), + group: Type.Optional(Type.String({ + description: "Optional exact Model Group name for child model routing. Omit to inherit the parent model/thinking.", + })), }); @@ -207,7 +204,7 @@ export async function executeSpawn( pi: ExtensionAPI, ctx: ExtensionContext, state: AgenticodingState, - params: { prompt: string; thinking?: ThinkingValue }, + params: { prompt: string; group?: string; thinking?: unknown }, signal: AbortSignal | undefined, onUpdate: | ((result: { @@ -219,12 +216,27 @@ export async function executeSpawn( sessionFactory: typeof createAgentSession = createAgentSession, ) { - const childModel = ctx.model; - if (!childModel) { + const parentModel = ctx.model; + if (!parentModel) { throw new Error("No model configured. Cannot spawn child agent."); } - const childThinking: ThinkingValue = params.thinking ?? defaultThinking; + const authStorage = (ctx as any).modelRegistry?.authStorage ?? AuthStorage.create(); + const modelRegistry = (ctx as any).modelRegistry ?? ModelRegistry.create(authStorage); + const route = resolveSpawnModelRoute({ + requestedGroup: params.group, + groups: state.modelGroups.groups, + parentModel: parentModel as any, + parentThinking: defaultThinking, + modelRegistry, + }); + const childModel = route.model; + const childThinking: ThinkingValue = route.thinking; + const routeDetails: SpawnResultDetails["route"] = route.status === "routed" + ? { status: "routed", group: route.groupName ?? route.requestedGroup ?? params.group?.trim() ?? "", provider: route.provider, modelId: route.modelId } + : route.status === "unknown-fallback" + ? { status: "unknown-fallback", requestedGroup: route.requestedGroup ?? params.group?.trim() ?? "", provider: route.provider, modelId: route.modelId } + : { status: "inherited" }; const listing = formatPageList(state); const notebookListing = listing @@ -242,8 +254,6 @@ export async function executeSpawn( `When complete, provide a concise summary of findings. ` + `Keep the result under ${CHILD_MAX_LINES} lines / ${(CHILD_MAX_BYTES / 1024).toFixed(0)}KB.`; - const authStorage = AuthStorage.create(); - const modelRegistry = ModelRegistry.create(authStorage); const childSessionEpoch = state.childSessionEpoch; const isStale = () => state.childSessionEpoch !== childSessionEpoch; const childTools = createChildTools(pi, state, { isStale }); @@ -310,6 +320,7 @@ export async function executeSpawn( thinking: childThinking, truncated: false, outcome: "running", + route: routeDetails, } satisfies SpawnResultDetails, }); @@ -373,6 +384,7 @@ export async function executeSpawn( thinking: childThinking, truncated, outcome, + route: routeDetails, }; if (stats) { details.stats = stats; @@ -413,7 +425,7 @@ export function registerSpawnTool( async execute( _toolCallId: string, - params: { prompt: string; thinking?: ThinkingValue }, + params: { prompt: string; group?: string; thinking?: unknown }, signal: AbortSignal | undefined, onUpdate: | ((result: { diff --git a/spawn/renderer.ts b/spawn/renderer.ts index 5c837a2..f002754 100644 --- a/spawn/renderer.ts +++ b/spawn/renderer.ts @@ -167,6 +167,20 @@ function equalStats(a?: Record, b?: Record): boo return keys.length === Object.keys(b).length && keys.every(key => a[key] === b[key]); } +function equalRoute(a: SpawnResultDetails["route"], b: SpawnResultDetails["route"]): boolean { + return JSON.stringify(a ?? null) === JSON.stringify(b ?? null); +} + +function formatSpawnIdentity(details: Pick): string { + if (details.route?.status === "routed") { + return `${details.route.group} → ${details.route.provider}/${details.route.modelId} • ${details.thinking}`; + } + if (details.route?.status === "unknown-fallback") { + return `${details.route.requestedGroup}? fallback → ${details.route.provider}/${details.route.modelId} • ${details.thinking}`; + } + return `${details.model} • ${details.thinking}`; +} + function padVisibleWidth(text: string, width: number): string { const vw = visibleWidth(text); return vw >= width ? text : text + " ".repeat(width - vw); @@ -493,6 +507,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget || prior.thinking !== details.thinking || prior.truncated !== details.truncated || prior.outcome !== details.outcome + || !equalRoute(prior.route, details.route) || !equalStats(prior.stats, details.stats) || prior.statsUnavailable !== details.statsUnavailable || this.nestTheme !== theme; @@ -854,7 +869,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget // Identity line — distinguishes nested spawns in collapsed view if (details) { lines.push(truncateAndColor( - `${getOutcomeMarker(outcome)}${details.model} • ${details.thinking}`, + `${getOutcomeMarker(outcome)}${formatSpawnIdentity(details)}`, width, color, "dim", @@ -908,7 +923,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget const colorExpanded = (name: ThemeColor, text: string) => this.nestTheme ? this.nestTheme.fg(name, text) : text; // Expanded mode has no shell background — safe to color before truncation if (this.details) { - const header = `${getOutcomeMarker(this.liveOutcome)}${this.details.model} • ${this.details.thinking}`; + const header = `${getOutcomeMarker(this.liveOutcome)}${formatSpawnIdentity(this.details)}`; lines.push(leftPad + truncateToWidth( colorExpanded("dim", header), childWidth, @@ -1163,8 +1178,8 @@ function renderSpawnCall(args: any, theme: Theme, context: { expanded: boolean } const prompt = typeof args.prompt === "string" ? args.prompt : "..."; const { shown, remaining } = renderPromptPreview(prompt, context.expanded); let text = theme.fg("toolTitle", theme.bold("spawn ")) + theme.fg("accent", "child"); - if (typeof args.thinking === "string") { - text += theme.fg("dim", ` [${args.thinking}]`); + if (typeof args.group === "string" && args.group.trim()) { + text += theme.fg("dim", ` [${args.group.trim()}]`); } text += `\n${theme.fg("dim", shown)}`; if (remaining > 0) { @@ -1224,7 +1239,7 @@ function renderSpawnResult( .trim(); const summary = output || "(no output)"; const outcome = details?.outcome ?? "running"; - const meta = details ? `${getOutcomeMarker(outcome)}${details.model} • ${details.thinking}` : ""; + const meta = details ? `${getOutcomeMarker(outcome)}${formatSpawnIdentity(details)}` : ""; const status = getOutcomeStatusText(outcome); const text = [ meta ? theme.fg("dim", meta) : "", @@ -1234,7 +1249,7 @@ function renderSpawnResult( return new Text(text, SPAWN_SHELL_PADDING_X, SPAWN_SHELL_PADDING_Y, getShellBackground(theme, outcome)); } -export { NestedAgentSessionComponent, renderSpawnCall, renderSpawnResult }; +export { NestedAgentSessionComponent, formatSpawnIdentity, renderSpawnCall, renderSpawnResult }; // ── Test support ────────────────────────────────────────────────────── diff --git a/spawn/shared.ts b/spawn/shared.ts index a38fec0..b0c2ec1 100644 --- a/spawn/shared.ts +++ b/spawn/shared.ts @@ -1,11 +1,17 @@ export type ThinkingValue = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; export type SpawnOutcome = "running" | "success" | "aborted" | "error"; +export type SpawnRouteDetails = + | { status: "inherited" } + | { status: "routed"; group: string; provider: string; modelId: string } + | { status: "unknown-fallback"; requestedGroup: string; provider: string; modelId: string }; + export type SpawnResultDetails = { model: string; thinking: ThinkingValue; truncated: boolean; outcome: SpawnOutcome; + route?: SpawnRouteDetails; stats?: Record; statsUnavailable?: boolean; }; diff --git a/state.ts b/state.ts index 626c696..a5d495f 100644 --- a/state.ts +++ b/state.ts @@ -6,6 +6,7 @@ */ import type { AgentSession } from "@earendil-works/pi-coding-agent"; +import type { ModelGroupsBootValidation, ResolvedModelGroup } from "./model-groups/types.js"; export interface AgenticodingState { /** Compact notebook pages keyed by kebab-case name */ @@ -40,6 +41,12 @@ export interface AgenticodingState { toolCalled: boolean; } | null; + /** Boot-time Model Groups validation snapshot used by /model-groups. */ + modelGroups: { + groups: ResolvedModelGroup[]; + validation: ModelGroupsBootValidation | null; + }; + /** * Published child agent sessions keyed by toolCallId. * Lifecycle: executeSpawn publishes → renderSpawnResult claims via get+delete. @@ -78,6 +85,7 @@ export function createState(): AgenticodingState { lastContextPercent: null, pendingHandoff: null, pendingRequestedHandoff: null, + modelGroups: { groups: [], validation: null }, childSessions, liveChildSessions, childSessionEpoch: 0, @@ -111,6 +119,8 @@ export function resetState(state: AgenticodingState): void { state.lastContextPercent = null; state.pendingHandoff = null; state.pendingRequestedHandoff = null; + state.modelGroups.groups = []; + state.modelGroups.validation = null; abortAndClearChildSessions(state); } diff --git a/tests/snapshots/spawn-call-collapsed.txt b/tests/snapshots/spawn-call-collapsed.txt index cea6994..d543f5a 100644 --- a/tests/snapshots/spawn-call-collapsed.txt +++ b/tests/snapshots/spawn-call-collapsed.txt @@ -1,4 +1,4 @@ - spawn child [medium] + spawn child Research the rate limits for the OpenAI API and document the results. \ No newline at end of file diff --git a/tests/snapshots/spawn-call-long.txt b/tests/snapshots/spawn-call-long.txt index fe95eb9..cd0c6ec 100644 --- a/tests/snapshots/spawn-call-long.txt +++ b/tests/snapshots/spawn-call-long.txt @@ -1,5 +1,5 @@ - spawn child [high] + spawn child Line 1: Initialize the project structure Line 2: Set up TypeScript configuration Line 3: Create the main entry point diff --git a/tests/unit/model-groups-autocomplete.test.ts b/tests/unit/model-groups-autocomplete.test.ts new file mode 100644 index 0000000..70790f6 --- /dev/null +++ b/tests/unit/model-groups-autocomplete.test.ts @@ -0,0 +1,66 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createModelGroupAutocompleteProvider, registerModelGroupAutocomplete } from "../../model-groups/autocomplete.js"; +import { createState } from "../../state.js"; +import type { ResolvedModelGroup } from "../../model-groups/types.js"; + +function group( + name: string, + models: ResolvedModelGroup["models"] = [], + unavailableRefs: ResolvedModelGroup["validation"]["unavailableRefs"] = [], +): ResolvedModelGroup { + return { + name, + scope: "project", + sourcePath: "", + models, + validation: { unavailableRefs, shadowedByProject: false, degraded: unavailableRefs.length > 0 }, + }; +} + +test("#group autocomplete suggests effective live group names and delegates elsewhere", async () => { + const state = createState(); + state.modelGroups.groups = [ + group("review", [ + { provider: "openai", modelId: "gpt-5", thinkingLevel: "high" }, + { provider: "anthropic", modelId: "claude-sonnet-4" }, + ]), + group("research", [{ provider: "google", modelId: "gemini-2.5-pro", thinkingLevel: "xhigh" }]), + ]; + let delegated = 0; + const current = { + getSuggestions: async () => { delegated++; return { prefix: "", items: [{ value: "delegated" }] }; }, + applyCompletion: () => "applied", + shouldTriggerFileCompletion: () => false, + }; + const provider = createModelGroupAutocompleteProvider(state)(current as any); + + const suggestions = await provider.getSuggestions(["spawn #re"], 0, "spawn #re".length, {}); + assert.equal(suggestions.prefix, "#re"); + assert.deepEqual(suggestions.items.map((item: any) => item.value), ["#research", "#review"]); + assert.deepEqual(suggestions.items.map((item: any) => item.description), [ + "google/gemini-2.5-pro • xhigh", + "openai/gpt-5 • high; anthropic/claude-sonnet-4 • inherit", + ]); + assert.equal(delegated, 0); + + state.modelGroups.groups = [group("reviewers", [{ provider: "openai", modelId: "gpt-5" }], [{ provider: "openai", modelId: "gpt-5" }])]; + const fresh = await provider.getSuggestions(["#rev"], 0, 4, {}); + assert.deepEqual(fresh.items.map((item: any) => item.value), ["#reviewers"]); + assert.equal(fresh.items[0].description, "openai/gpt-5 • inherit (unavailable)"); + + const other = await provider.getSuggestions(["no hash"], 0, 7, {}); + assert.equal(delegated, 1); + assert.deepEqual(other.items.map((item: any) => item.value), ["delegated"]); + assert.equal(provider.applyCompletion([], 0, 0, {}, "#re"), "applied"); + assert.equal(provider.shouldTriggerFileCompletion([], 0, 0), false); +}); + +test("registerModelGroupAutocomplete uses ctx.ui.addAutocompleteProvider once", () => { + const state = createState(); + const providers: any[] = []; + const ctx = { hasUI: true, ui: { addAutocompleteProvider: (factory: any) => providers.push(factory) } }; + registerModelGroupAutocomplete(ctx as any, state); + registerModelGroupAutocomplete(ctx as any, state); + assert.equal(providers.length, 1); +}); diff --git a/tests/unit/model-groups-crud.test.ts b/tests/unit/model-groups-crud.test.ts new file mode 100644 index 0000000..0f08c07 --- /dev/null +++ b/tests/unit/model-groups-crud.test.ts @@ -0,0 +1,179 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + __setModelGroupsFsForTests, + createGroup, + deleteGroup, + loadModelGroups, + modelGroupsPath, + moveGroup, + renameGroup, + saveModelGroups, + updateGroup, + validateModelGroups, +} from "../../model-groups/store.js"; +import { ModelGroupsPersistenceError, type ModelGroupScope } from "../../model-groups/types.js"; + +function withTemp(fn: (ctx: { cwd: string; home: string }) => void): void { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "model-groups-")); + const oldHome = process.env.HOME; + process.env.HOME = path.join(root, "home"); + try { fn({ cwd: path.join(root, "project"), home: process.env.HOME }); } + finally { process.env.HOME = oldHome; __setModelGroupsFsForTests(null); fs.rmSync(root, { recursive: true, force: true }); } +} + +function read(scope: ModelGroupScope, cwd: string): any { + return JSON.parse(fs.readFileSync(modelGroupsPath(scope, cwd), "utf8")); +} + +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 }, + ]; + return { + getAll: () => models, + getAvailable: () => models.filter((m) => available.has(`${m.provider}:${m.id}`)), + find: (provider: string, id: string) => models.find((m) => m.provider === provider && m.id === id), + hasConfiguredAuth: (model: any) => available.has(`${model.provider}:${model.id}`), + }; +} + +test("model groups store creates, round-trips, validates, renames, updates, deletes, and moves", () => withTemp(({ cwd }) => { + assert.deepEqual(loadModelGroups(cwd).configs.project.groups, {}); + createGroup("project", cwd, "review", { models: [] }); + assert.deepEqual(read("project", cwd).groups.review.models, []); + assert.throws(() => createGroup("project", cwd, "review", { models: [] }), /already exists/); + + createGroup("project", cwd, "inherit-roundtrip", { models: [{ provider: "anthropic", modelId: "claude" }] }); + const inheritLoaded = loadModelGroups(cwd).configs.project.groups["inherit-roundtrip"].models[0]; + assert.equal(inheritLoaded.thinkingLevel, undefined); + assert.equal(Object.prototype.hasOwnProperty.call(inheritLoaded, "thinkingLevel"), false); + const inheritPersisted = read("project", cwd).groups["inherit-roundtrip"].models[0]; + assert.equal(inheritPersisted.thinkingLevel, undefined); + assert.equal(Object.prototype.hasOwnProperty.call(inheritPersisted, "thinkingLevel"), false); + + updateGroup("project", cwd, "review", { models: [{ provider: "openai", modelId: "gpt-5", thinkingLevel: "high" }] }); + renameGroup("project", cwd, "review", "reviewers"); + assert.equal(read("project", cwd).groups.review, undefined); + assert.equal(read("project", cwd).groups.reviewers.models[0].thinkingLevel, "high"); + + createGroup("project", cwd, "collision", { models: [] }); + assert.throws(() => renameGroup("project", cwd, "reviewers", "collision"), /already exists/); + createGroup("global", cwd, "reviewers", { models: [{ provider: "openai", modelId: "gpt-5" }, { provider: "missing", modelId: "nope" }] }); + const loaded = loadModelGroups(cwd); + const resolved = validateModelGroups(loaded, registry()); + const globalReviewers = resolved.find((g) => g.name === "reviewers" && g.scope === "global"); + assert.equal(globalReviewers?.validation.shadowedByProject, true); + assert.deepEqual(globalReviewers?.validation.unavailableRefs, [{ provider: "missing", modelId: "nope" }]); + assert.equal(globalReviewers?.validation.degraded, true); + + createGroup("global", cwd, "move-collision", { models: [] }); + createGroup("project", cwd, "move-collision", { models: [] }); + assert.throws(() => moveGroup(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"]); + + const deleted = deleteGroup("global", cwd, "reviewers"); + assert.equal(deleted.otherScopeHasOverride, true); + moveGroup(cwd, "reviewers", "global"); + assert.ok(read("global", cwd).groups.reviewers); + assert.equal(read("project", cwd).groups.reviewers, undefined); +})); + +test("model groups load recovery handles malformed, schema-invalid, unsupported version, and backup failure", () => withTemp(({ cwd }) => { + fs.mkdirSync(path.dirname(modelGroupsPath("global", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("global", cwd), "{not json", "utf8"); + let loaded = loadModelGroups(cwd); + assert.equal(loaded.issues[0].kind, "corrupt-json"); + 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"); + loaded = loadModelGroups(cwd); + const schemaIssue = loaded.issues.find((i) => i.scope === "project")!; + assert.equal(schemaIssue.kind, "schema-invalid"); + assert.equal(schemaIssue.scope, "project"); + assert.equal(schemaIssue.sourcePath, modelGroupsPath("project", cwd)); + assert.equal(schemaIssue.backupPath, `${modelGroupsPath("project", cwd)}.bak`); + assert.match(schemaIssue.message, /invalid model entry/); + assert.ok(fs.existsSync(schemaIssue.backupPath!)); + assert.deepEqual(loaded.configs.project.groups, {}); + + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 99, groups: {} }), "utf8"); + loaded = loadModelGroups(cwd); + assert.equal(loaded.issues.find((i) => i.scope === "project")?.kind, "unsupported-version"); + assert.equal(loaded.issues.find((i) => i.scope === "project")?.version, 99); + + fs.writeFileSync(modelGroupsPath("project", cwd), "{bad", "utf8"); + __setModelGroupsFsForTests({ copyFileSync: () => { throw new Error("denied"); } }); + loaded = loadModelGroups(cwd); + 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", cwd, "must-not-overwrite", { models: [] }), (error) => { + assert.ok(error instanceof ModelGroupsPersistenceError); + assert.equal(error.operation, "save"); + assert.equal(error.phase, "load-recovery"); + return true; + }); + assert.equal(fs.readFileSync(modelGroupsPath("project", cwd), "utf8"), "{bad"); +})); + +test("model groups persistence failures throw typed errors and preserve committed state", () => withTemp(({ cwd }) => { + saveModelGroups("project", cwd, { version: 1, groups: { keep: { models: [] } } }); + __setModelGroupsFsForTests({ writeFileSync: () => { throw new Error("temp denied"); } }); + assert.throws(() => updateGroup("project", cwd, "keep", { models: [{ provider: "openai", modelId: "gpt-5" }] }), (error) => { + assert.ok(error instanceof ModelGroupsPersistenceError); + assert.equal(error.operation, "save"); + assert.equal(error.phase, "temp-write"); + assert.equal(error.scope, "project"); + assert.equal(error.sourcePath, modelGroupsPath("project", cwd)); + assert.match(error.targetPath ?? "", /model-groups\.json\..+\.tmp$/); + return true; + }); + __setModelGroupsFsForTests(null); + assert.ok(read("project", cwd).groups.keep); + assert.equal(read("project", cwd).groups.keep.models.length, 0); + + __setModelGroupsFsForTests({ renameSync: () => { throw new Error("rename denied"); } }); + assert.throws(() => saveModelGroups("project", cwd, { version: 1, groups: { drop: { models: [] } } }), (error) => { + assert.ok(error instanceof ModelGroupsPersistenceError); + assert.equal(error.phase, "rename"); + return true; + }); + __setModelGroupsFsForTests(null); + assert.ok(read("project", cwd).groups.keep); + assert.equal(read("project", cwd).groups.drop, undefined); + + __setModelGroupsFsForTests({ renameSync: () => { throw new Error("delete denied"); } }); + assert.throws(() => deleteGroup("project", cwd, "keep"), (error) => { + assert.ok(error instanceof ModelGroupsPersistenceError); + assert.equal(error.operation, "delete"); + return true; + }); + __setModelGroupsFsForTests(null); + + createGroup("global", cwd, "move-target-fails", { models: [] }); + __setModelGroupsFsForTests({ renameSync: () => { throw new Error("target denied"); } }); + assert.throws(() => moveGroup(cwd, "move-target-fails", "project"), (error) => { + assert.ok(error instanceof ModelGroupsPersistenceError); + assert.equal(error.operation, "move"); + assert.equal(error.partialMove, undefined); + return true; + }); + __setModelGroupsFsForTests(null); + + createGroup("global", cwd, "move-me", { models: [] }); + let writes = 0; + __setModelGroupsFsForTests({ renameSync: (from, to) => { writes++; if (writes === 2) throw new Error("source denied"); fs.renameSync(from, to); } }); + assert.throws(() => moveGroup(cwd, "move-me", "project"), (error) => { + assert.ok(error instanceof ModelGroupsPersistenceError); + assert.equal(error.partialMove, "target-written-source-retained"); + assert.equal(error.phase, "source-remove"); + return true; + }); +})); diff --git a/tests/unit/model-groups-integration.test.ts b/tests/unit/model-groups-integration.test.ts new file mode 100644 index 0000000..fe7abf2 --- /dev/null +++ b/tests/unit/model-groups-integration.test.ts @@ -0,0 +1,197 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import registerAgenticoding from "../../index.js"; +import { registerModelGroupsCommand } from "../../model-groups/command.js"; +import { __setModelGroupsFsForTests, modelGroupsPath } from "../../model-groups/store.js"; +import { createState } from "../../state.js"; +import { createTestPI, theme } from "./helpers.js"; + +function registry(available = new Set(["openai:gpt-5"])): any { + const models = [{ provider: "openai", id: "gpt-5", reasoning: true, thinkingLevelMap: { xhigh: "x" } }]; + return { + getAll: () => models, + getAvailable: () => models.filter((m) => available.has(`${m.provider}:${m.id}`)), + find: (provider: string, id: string) => models.find((m) => m.provider === provider && m.id === id), + hasConfiguredAuth: (model: any) => available.has(`${model.provider}:${model.id}`), + }; +} + +async function withTemp(fn: (cwd: string) => Promise | void): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "model-groups-int-")); + const oldHome = process.env.HOME; + process.env.HOME = path.join(root, "home"); + try { await fn(path.join(root, "project")); } + finally { process.env.HOME = oldHome; __setModelGroupsFsForTests(null); fs.rmSync(root, { recursive: true, force: true }); } +} + +test("/model-groups command registers and opens ctx.ui.custom with live registry/cwd", async () => withTemp(async (cwd) => { + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 1, groups: { "cwd-sentinel-group": { models: [{ provider: "openai", modelId: "gpt-5" }] } } }), "utf8"); + const pi = createTestPI(); + const state = createState(); + const findCalls: string[] = []; + const registrySentinel = { + ...registry(), + find: (provider: string, id: string) => { + findCalls.push(`${provider}:${id}`); + return { provider, id, reasoning: true, thinkingLevelMap: { xhigh: "x" } }; + }, + hasConfiguredAuth: () => true, + }; + registerModelGroupsCommand(pi as any, state); + assert.ok(pi.commands.has("model-groups")); + let customCalled = 0; + let rendered = ""; + await pi.commands.get("model-groups")!.handler("", { + hasUI: true, + cwd, + modelRegistry: registrySentinel, + ui: { + notify: () => {}, + custom: async (factory: any) => { + customCalled++; + const component = factory({ requestRender: () => {} }, theme, {}, () => {}); + rendered = component.render(80).join("\n"); + }, + }, + }); + assert.equal(customCalled, 1); + assert.match(rendered, /Model Groups/); + assert.match(rendered, /cwd-sentinel-group/); + assert.deepEqual(findCalls, ["openai:gpt-5"]); +})); + +test("index session_start stores model group validation and notifies load and validation issues", async () => withTemp(async (cwd) => { + fs.mkdirSync(path.dirname(modelGroupsPath("global", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("global", cwd), JSON.stringify({ version: 1, groups: { bad: { models: [{ provider: "missing", modelId: "nope" }] }, shadow: { models: [] } } }), "utf8"); + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 1, groups: { shadow: { models: [] } } }), "utf8"); + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notifications: string[] = []; + const ctx = { + hasUI: 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 · 1 project overrides/.test(m))); +})); + +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"); + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 99, groups: {} }), "utf8"); + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notifications: string[] = []; + const ctx = { + hasUI: 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) => /corrupt-json/.test(m))); + assert.ok(notifications.some((m) => /unsupported-version/.test(m))); +})); + +test("index session_start notifies schema-invalid load issues", async () => withTemp(async (cwd) => { + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 1, groups: { broken: { models: [{ provider: 1 }] } } }), "utf8"); + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notifications: string[] = []; + const ctx = { + hasUI: 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) => /schema-invalid/.test(m))); + assert.ok(notifications.some((m) => /project scope/.test(m))); + assert.ok(notifications.some((m) => m.includes(modelGroupsPath("project", cwd)))); +})); + +test("index session_start includes backup-failure detail in load issue notifications", async () => withTemp(async (cwd) => { + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), "{bad", "utf8"); + __setModelGroupsFsForTests({ copyFileSync: () => { throw new Error("backup denied"); } }); + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notifications: string[] = []; + const ctx = { + hasUI: 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) => /corrupt-json/.test(m) && /backup failed, original file left untouched/.test(m) && m.includes(modelGroupsPath("project", cwd)))); +})); + +test("before_agent_start injects fresh names-only Model Groups 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(); + registerAgenticoding(pi as any); + const handler = pi.handlers.get("before_agent_start")!.at(-1)!; + const result = await handler({ systemPrompt: "Base." }, { hasUI: false, 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, /exact group name/); + assert.match(result.systemPrompt, /known and confident/); + assert.match(result.systemPrompt, /omit group and inherit/); + assert.doesNotMatch(result.systemPrompt, /gpt-5/); + assert.doesNotMatch(result.systemPrompt, /model-groups\.json/); +})); + +test("session_start registers Model Groups autocomplete provider when UI supports it", async () => withTemp(async (cwd) => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const providers: any[] = []; + const handler = pi.handlers.get("session_start")!.at(-1)!; + await handler({ reason: "load" }, { + hasUI: true, + cwd, + modelRegistry: registry(), + getContextUsage: () => ({ percent: 10 }), + ui: { theme, notify: () => {}, setStatus: () => {}, setWidget: () => {}, addAutocompleteProvider: (factory: any) => providers.push(factory) }, + }); + assert.equal(providers.length, 1); +})); + +test("index session_start does not notify when load and validation issues are absent", async () => withTemp(async (cwd) => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notifications: string[] = []; + const ctx = { + hasUI: 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.deepEqual(notifications, []); +})); diff --git a/tests/unit/model-groups-router.test.ts b/tests/unit/model-groups-router.test.ts new file mode 100644 index 0000000..a4c62de --- /dev/null +++ b/tests/unit/model-groups-router.test.ts @@ -0,0 +1,78 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getEffectiveModelGroupNames, resolveSpawnModelRoute, SpawnRouteError } from "../../model-groups/router.js"; +import type { ResolvedModelGroup } from "../../model-groups/types.js"; + +function model(provider: string, id: string, overrides: Record = {}): any { + return { provider, id, reasoning: true, ...overrides }; +} + +function group(name: string, scope: "project" | "global", models: any[], shadowedByProject = false): ResolvedModelGroup { + return { + name, + scope, + sourcePath: `<${scope}>`, + models, + validation: { unavailableRefs: [], shadowedByProject, degraded: false }, + }; +} + +function registry(models: any[], authenticated = new Set(models.map((m) => `${m.provider}:${m.id}`))): any { + return { + find: (provider: string, id: string) => models.find((m) => m.provider === provider && m.id === id), + hasConfiguredAuth: (m: any) => authenticated.has(`${m.provider}:${m.id}`), + }; +} + +test("effective model group names use project-over-global names", () => { + const groups = [ + group("review", "global", [], true), + group("review", "project", []), + group("research", "global", []), + ]; + assert.deepEqual(getEffectiveModelGroupNames(groups), ["research", "review"]); +}); + +test("omitted and unknown groups inherit parent route with fallback metadata", () => { + const parent = model("openai", "gpt-parent"); + 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"); +}); + +test("known empty and all-unusable groups fail clearly", () => { + const parent = model("openai", "parent"); + assert.throws( + () => resolveSpawnModelRoute({ requestedGroup: "empty", groups: [group("empty", "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", "project", [{ 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), + ); +}); + +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", "project", [ + { 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"); +}); diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts new file mode 100644 index 0000000..98a1c75 --- /dev/null +++ b/tests/unit/model-groups-tui.test.ts @@ -0,0 +1,421 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createModelGroupsComponent } from "../../model-groups/tui.js"; +import { ModelGroupsPersistenceError, type ModelGroupsBootValidation, type ResolvedModelGroup } from "../../model-groups/types.js"; +import { theme } from "./helpers.js"; + +function registry(): any { + const models = [ + { provider: "anthropic", id: "claude", reasoning: false }, + { provider: "google", id: "gemini-no-auth", reasoning: true, configuredAuth: false }, + { provider: "openai", id: "gpt-5", reasoning: true, thinkingLevelMap: { xhigh: "x" } }, + { provider: "openai", id: "gpt-no-auth", reasoning: true, configuredAuth: false }, + ]; + return { + getAll: () => models, + getAvailable: () => models, + find: (provider: string, id: string) => models.find((m) => m.provider === provider && m.id === id), + hasConfiguredAuth: (model: any) => model.provider !== "missing" && model.configuredAuth !== false, + }; +} + +function group(name: string, scope: "project" | "global", models: any[] = []): ResolvedModelGroup { + return { name, scope, sourcePath: `/tmp/${scope}.json`, models, validation: { unavailableRefs: [], shadowedByProject: false, degraded: false } }; +} + +function boot(groups: ResolvedModelGroup[]): ModelGroupsBootValidation { return { groups, loadIssues: [] }; } + +function component(args: { groups?: ResolvedModelGroup[]; store?: any; notify?: (m: string, t?: any) => void; renderTheme?: any } = {}) { + let renders = 0; + const c = createModelGroupsComponent( + { requestRender: () => { renders++; } } as any, + args.renderTheme ?? theme, + registry(), + "/tmp/project", + () => {}, + { initialValidation: boot(args.groups ?? []), store: args.store, notify: args.notify }, + ); + return { c, get renders() { return renders; } }; +} + +const ENTER = "\r"; +const ESC = "\u001b"; +const ESC_KITTY = "\u001b[27u"; +const DOWN = "\u001b[B"; +const LEFT = "\u001b[D"; +const LEFT_SS3 = "\u001bOD"; + +function press(c: { handleInput?: (data: string) => void }, ...inputs: string[]): void { + for (const input of inputs) c.handleInput?.(input); +} + +function rendered(c: { render: (width: number) => string[] }): string { + return c.render(100).join("\n"); +} + +test("model groups TUI list renders validation summary, health tags, add row, no Validate row, and confirmed D delete", () => { + const override = group("review", "global"); + override.validation.shadowedByProject = true; + const degraded = group("mixed", "project", [{ provider: "openai", modelId: "gpt-5" }, { provider: "missing", modelId: "nope" }]); + degraded.validation.degraded = true; + degraded.validation.unavailableRefs = [{ provider: "missing", modelId: "nope" }]; + let groups = [override, group("review", "project"), degraded]; + const deleteCalls: string[] = []; + const store = { + deleteGroup: (scope: string, _cwd: string, name: string) => { deleteCalls.push(`${scope}:${name}`); groups = groups.filter((candidate) => !(candidate.scope === scope && candidate.name === name)); return { otherScopeHasOverride: true }; }, + listResolvedModelGroups: () => boot(groups), + }; + const { c } = component({ groups, store }); + let lines = c.render(100).join("\n"); + assert.match(lines, /Boot validation: 1 unavailable model references · 1 project overrides/); + assert.match(lines, /project override/); + assert.match(lines, /⚠ degraded/); + assert.match(lines, /✗ unavailable/); + assert.match(lines, /\+ Add group/); + assert.doesNotMatch(lines, /Validate/); + c.handleInput?.("D"); + lines = c.render(100).join("\n"); + assert.match(lines, /Delete Model Group/); + assert.match(lines, /Same-name group in the other scope remains unaffected/); + c.handleInput?.("\u001b[B"); + c.handleInput?.("\r"); + assert.deepEqual(deleteCalls, ["global:review"]); + assert.doesNotMatch(c.render(100).join("\n"), /Delete Model Group/); +}); + +test("model groups TUI computes unique new-group names and opens editor after create", () => { + let groups = [group("new-group", "project")]; + const calls: string[] = []; + const store = { + createGroup: (scope: string, _cwd: string, name: string, def: any) => { + calls.push(`${scope}:${name}:${def.models.length}`); + groups = [...groups, group(name, "project")]; + }, + listResolvedModelGroups: () => boot(groups), + }; + const { c } = component({ groups, store }); + c.handleInput?.("\u001b[B"); // + Add group + c.handleInput?.("\r"); + assert.deepEqual(calls, ["project:new-group-2:0"]); + assert.match(c.render(100).join("\n"), /Model Group: new-group-2/); +}); + +test("model groups TUI wizard renders provider/model/thinking steps and preserves state on add failure", () => { + const messages: string[] = []; + let updateCalls = 0; + const groups = [group("review", "project")]; + const store = { + updateGroup: () => { + updateCalls++; + throw new ModelGroupsPersistenceError({ + operation: "save", + scope: "project", + sourcePath: "/tmp/project/.pi/pi-agenticoding/model-groups.json", + phase: "rename", + message: "add failed", + }); + }, + listResolvedModelGroups: () => boot(groups), + }; + const { c } = component({ groups, store, notify: (message) => messages.push(message) }); + press(c, ENTER, DOWN, DOWN, DOWN, ENTER); + let text = rendered(c); + assert.match(text, /Add model — Step 1\/3 Provider/); + assert.match(text, /anthropic/); + assert.match(text, /openai/); + assert.doesNotMatch(text, /google/); + assert.doesNotMatch(text, /Step 4/); + + press(c, DOWN, ENTER); + text = rendered(c); + assert.match(text, /Add model — Step 2\/3 Model/); + assert.match(text, /openai\/gpt-5/); + assert.doesNotMatch(text, /openai\/gpt-no-auth/); + assert.doesNotMatch(text, /anthropic\/claude/); + assert.doesNotMatch(text, /Step 4/); + + press(c, ENTER); + text = rendered(c); + assert.match(text, /Add model — Step 3\/3 Thinking/); + for (const option of ["inherit", "off", "minimal", "low", "medium", "high", "xhigh"]) { + assert.match(text, new RegExp(`\\b${option}\\b`)); + } + assert.doesNotMatch(text, /Step 4/); + + press(c, ENTER); + assert.equal(updateCalls, 1); + assert.equal(messages.length, 1); + assert.match(messages[0], /save failed at rename for project scope/); + assert.match(messages[0], /add failed/); + text = rendered(c); + assert.match(text, /Add model — Step 3\/3 Thinking/); + assert.doesNotMatch(text, /Model Group: review/); +}); + +test("model groups TUI Esc and left-arrow share wizard back-step behavior", () => { + function atProvider() { + const { c } = component({ groups: [group("review", "project")] }); + press(c, ENTER, DOWN, DOWN, DOWN, ENTER); + return c; + } + function atModel() { + const c = atProvider(); + press(c, DOWN, ENTER); + return c; + } + function atThinking() { + const c = atModel(); + press(c, ENTER); + return c; + } + + const providerEsc = atProvider(); + const providerEscKitty = atProvider(); + const providerLeft = atProvider(); + press(providerEsc, ESC); + press(providerEscKitty, ESC_KITTY); + press(providerLeft, LEFT); + assert.equal(rendered(providerEsc), rendered(providerLeft)); + assert.equal(rendered(providerEscKitty), rendered(providerLeft)); + assert.match(rendered(providerEsc), /Model Group: review/); + + const modelEsc = atModel(); + const modelLeft = atModel(); + press(modelEsc, ESC); + press(modelLeft, LEFT); + assert.equal(rendered(modelEsc), rendered(modelLeft)); + assert.match(rendered(modelEsc), /Add model — Step 1\/3 Provider/); + + const thinkingEsc = atThinking(); + const thinkingLeft = atThinking(); + const thinkingLeftSs3 = atThinking(); + press(thinkingEsc, ESC); + press(thinkingLeft, LEFT); + press(thinkingLeftSs3, LEFT_SS3); + assert.equal(rendered(thinkingEsc), rendered(thinkingLeft)); + assert.equal(rendered(thinkingLeftSs3), rendered(thinkingLeft)); + assert.match(rendered(thinkingEsc), /Add model — Step 2\/3 Model/); +}); + +test("model groups TUI selected markers and primary labels use accent token", () => { + const accentTheme = { + fg: (name: string, text: string) => name === "accent" ? `${text}` : text, + bold: (text: string) => text, + }; + const list = component({ groups: [group("review", "project", [{ provider: "openai", modelId: "gpt-5" }])], renderTheme: accentTheme }).c; + + let text = rendered(list); + assert.match(text, /→<\/accent> review<\/accent> \[project\]/); + press(list, DOWN); + assert.match(rendered(list), /→<\/accent> \+ Add group<\/accent>/); + + const editor = component({ groups: [group("review", "project", [{ provider: "openai", modelId: "gpt-5" }])], renderTheme: accentTheme }).c; + press(editor, ENTER); + text = rendered(editor); + assert.match(text, /→<\/accent> Location: project<\/accent> ✓/); + press(editor, 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), /→<\/accent> anthropic<\/accent>/); + press(editor, DOWN, ENTER); + assert.match(rendered(editor), /→<\/accent> openai\/gpt-5<\/accent>/); + press(editor, ENTER); + assert.match(rendered(editor), /→<\/accent> inherit<\/accent>/); + + const modelEdit = component({ groups: [group("review", "project", [{ provider: "openai", modelId: "gpt-5" }])], renderTheme: accentTheme }).c; + press(modelEdit, ENTER, DOWN, DOWN, DOWN, ENTER); + assert.match(rendered(modelEdit), /→<\/accent> Thinking: inherit<\/accent>/); + + const deleteConfirm = component({ groups: [group("review", "project")], renderTheme: accentTheme }).c; + press(deleteConfirm, "D"); + assert.match(rendered(deleteConfirm), /→<\/accent> Keep group<\/accent>/); + press(deleteConfirm, DOWN); + assert.match(rendered(deleteConfirm), /→<\/accent> Delete group<\/accent>/); +}); + +test("model groups TUI model edit renders identity/status and filters thinking options", () => { + const groups = [group("review", "project", [ + { provider: "anthropic", modelId: "claude" }, + { provider: "openai", modelId: "gpt-5" }, + { provider: "missing", modelId: "nope" }, + ])]; + const { c } = component({ groups }); + + press(c, ENTER, DOWN, DOWN, DOWN, ENTER); + let text = rendered(c); + assert.match(text, /Provider: anthropic/); + assert.match(text, /Model ID: claude/); + assert.match(text, /Status: available/); + assert.match(text, /Thinking: inherit/); + assert.equal(text.match(/Thinking:/g)?.length, 1); + assert.doesNotMatch(text, /Thinking: off/); + assert.doesNotMatch(text, /Thinking: (minimal|low|medium|high|xhigh)/); + + press(c, ESC, DOWN, DOWN, DOWN, DOWN, ENTER); + text = rendered(c); + assert.match(text, /Provider: openai/); + assert.match(text, /Model ID: gpt-5/); + assert.match(text, /Status: available/); + for (const option of ["inherit", "off", "minimal", "low", "medium", "high", "xhigh"]) { + assert.match(text, new RegExp(`Thinking: ${option}`)); + } + + press(c, ESC, DOWN, DOWN, DOWN, DOWN, DOWN, ENTER); + text = rendered(c); + assert.match(text, /Provider: missing/); + assert.match(text, /Model ID: nope/); + assert.match(text, /Status: unavailable/); + assert.match(text, /Thinking: inherit/); + assert.equal(text.match(/Thinking:/g)?.length, 1); +}); + +test("model groups TUI notifies and preserves location on move collision", () => { + const messages: string[] = []; + const groups = [group("review", "project")]; + const store = { + moveGroup: () => { throw new Error("target scope already contains review"); }, + listResolvedModelGroups: () => boot(groups), + }; + const { c } = component({ groups, store, notify: (message) => messages.push(message) }); + press(c, ENTER, DOWN, ENTER); + assert.deepEqual(messages, ["target scope already contains review"]); + const text = rendered(c); + assert.match(text, /Model Group: review/); + assert.match(text, /Location: project ✓/); + assert.doesNotMatch(text, /Location: global ✓/); +}); + +test("model groups TUI notifies and preserves model edit state when updateGroup fails", () => { + const messages: string[] = []; + const attemptedModels: string[][] = []; + const groups = [group("review", "project", [{ provider: "openai", modelId: "gpt-5" }])]; + const store = { + updateGroup: (_scope: string, _cwd: string, _name: string, def: any) => { + attemptedModels.push(def.models.map((model: any) => `${model.provider}/${model.modelId}/${model.thinkingLevel ?? "inherit"}`)); + throw new ModelGroupsPersistenceError({ + operation: "save", + scope: "project", + sourcePath: "/tmp/project/.pi/pi-agenticoding/model-groups.json", + phase: "temp-write", + message: `update failed ${attemptedModels.length}`, + }); + }, + listResolvedModelGroups: () => boot(groups), + }; + const { c } = component({ groups, store, notify: (message) => messages.push(message) }); + press(c, ENTER, 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); + assert.match(text, /Edit model/); + assert.match(text, /Provider: openai/); + assert.match(text, /Model ID: gpt-5/); + assert.doesNotMatch(text, /Model Group: review/); + + press(c, "D"); + assert.deepEqual(attemptedModels[1], []); + assert.match(messages[1], /update failed 2/); + text = rendered(c); + assert.match(text, /Edit model/); + assert.match(text, /Provider: openai/); + assert.match(text, /Remove model/); +}); + +test("model groups TUI name edit commits through renameGroup on row-change and D in text input types literally", () => { + let groups = [group("abc", "project")]; + const calls: string[] = []; + const store = { + renameGroup: (_scope: string, _cwd: string, oldName: string, newName: string) => { calls.push(`${oldName}->${newName}`); groups = [group(newName, "project")]; }, + listResolvedModelGroups: () => boot(groups), + }; + const { c } = component({ groups, store }); + c.handleInput?.("\r"); // open editor + c.handleInput?.("\u001b[B"); + c.handleInput?.("\u001b[B"); // name row + c.handleInput?.("\r"); // focus name + c.handleInput?.("d"); + assert.match(c.render(100).join("\n"), /Name: abcd_/); + c.handleInput?.("\u001b[B"); // row-change flushes the pending rename before moving to + Add model + assert.deepEqual(calls, ["abc->abcd"]); + const rendered = c.render(100).join("\n"); + assert.match(rendered, /Model Group: abcd/); + assert.match(rendered, /→ \+ Add model/); + assert.doesNotMatch(rendered, /Name: abcd_/); + assert.doesNotMatch(rendered, /Delete Model Group/); +}); + +test("model groups TUI move, wizard add, model thinking, and remove persist through store calls", () => { + let groups = [group("review", "project", [{ provider: "openai", modelId: "gpt-5" }])]; + const calls: string[] = []; + const store = { + moveGroup: (_cwd: string, name: string, scope: string) => { calls.push(`move:${name}:${scope}`); groups = [group(name, "global", groups[0].models)]; }, + updateGroup: (scope: string, _cwd: string, name: string, def: any) => { calls.push(`update:${scope}:${name}:${def.models.map((m: any) => `${m.provider}/${m.modelId}/${m.thinkingLevel ?? "inherit"}`).join(",")}`); groups = [group(name, scope as any, def.models)]; }, + listResolvedModelGroups: () => boot(groups), + }; + const { c } = component({ groups, store }); + c.handleInput?.("\r"); // editor + c.handleInput?.("\u001b[B"); + c.handleInput?.("\r"); // switch global + assert.equal(calls[0], "move:review:global"); + + c.handleInput?.("\u001b[B"); + c.handleInput?.("\u001b[B"); + c.handleInput?.("\u001b[B"); // first model row + c.handleInput?.("\r"); // model edit + c.handleInput?.("\u001b[B"); // off + c.handleInput?.("\r"); + assert.match(calls.at(-1)!, /update:global:review:openai\/gpt-5\/off/); + + c.handleInput?.("\u001b[B"); + c.handleInput?.("\u001b[B"); + c.handleInput?.("\u001b[B"); + c.handleInput?.("\u001b[B"); // + add model + c.handleInput?.("\r"); // provider step + c.handleInput?.("\r"); // anthropic provider (sorted first) + c.handleInput?.("\r"); // claude model + c.handleInput?.("\r"); // inherit thinking + assert.match(calls.at(-1)!, /anthropic\/claude\/inherit/); + + c.handleInput?.("\u001b[B"); + c.handleInput?.("\u001b[B"); + c.handleInput?.("\u001b[B"); // first model row after refresh + c.handleInput?.("\r"); + c.handleInput?.("D"); + assert.ok(calls.at(-1)!.startsWith("update:global:review:")); +}); + +test("model groups TUI notifies and keeps visible state on persistence errors", () => { + const messages: string[] = []; + const { c } = component({ + groups: [group("review", "project")], + notify: (message) => messages.push(message), + store: { + renameGroup: () => { + throw new ModelGroupsPersistenceError({ + operation: "save", + scope: "project", + sourcePath: "/tmp/project/.pi/pi-agenticoding/model-groups.json", + targetPath: "/tmp/project/.pi/pi-agenticoding/model-groups.json.123.tmp", + phase: "temp-write", + message: "collision", + }); + }, + listResolvedModelGroups: () => boot([group("review", "project")]), + }, + }); + c.handleInput?.("\r"); + c.handleInput?.("\u001b[B"); + c.handleInput?.("\u001b[B"); + c.handleInput?.("\r"); + c.handleInput?.("2"); + c.handleInput?.("\r"); + assert.equal(messages.length, 1); + assert.match(messages[0], /save failed at temp-write for project scope/); + assert.match(messages[0], /source: \/tmp\/project\/\.pi\/pi-agenticoding\/model-groups\.json/); + assert.match(messages[0], /target: \/tmp\/project\/\.pi\/pi-agenticoding\/model-groups\.json\.123\.tmp/); + assert.match(messages[0], /collision/); + assert.match(c.render(100).join("\n"), /Model Group: review/); +}); diff --git a/tests/unit/spawn-render.test.ts b/tests/unit/spawn-render.test.ts index c4c4eba..6634c00 100644 --- a/tests/unit/spawn-render.test.ts +++ b/tests/unit/spawn-render.test.ts @@ -75,6 +75,22 @@ test("collapsed nested spawn render shows preview and stats", () => { assert.ok(lines.some((l: string) => l.includes("trunc"))); }); +test("spawn result identity formats default routed and unknown fallback", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const routed = childSpawnTool.renderResult( + { content: [{ type: "text", text: "done" }], details: { model: "gpt-routed", thinking: "high", truncated: false, outcome: "success", route: { status: "routed", group: "review", provider: "openai", modelId: "gpt-routed" } } }, + { expanded: false }, theme, createRenderContext(), + ) as any; + assert.ok(routed.render(120).some((l: string) => l.includes("review → openai/gpt-routed • high"))); + + const fallback = childSpawnTool.renderResult( + { content: [{ type: "text", text: "done" }], details: { model: "parent", thinking: "medium", truncated: false, outcome: "success", route: { status: "unknown-fallback", requestedGroup: "rev", provider: "openai", modelId: "parent" } } }, + { expanded: false }, theme, createRenderContext(), + ) as any; + assert.ok(fallback.render(120).some((l: string) => l.includes("rev? fallback → openai/parent • medium"))); +}); + test("collapsed nested spawn render keeps all text blocks from the last assistant message", () => { const state = createState(); const childSpawnTool = makeChildSpawnTool(state); diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index 817b3d4..60f357c 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -178,7 +178,7 @@ test("spawn execute passes broad active registered tool formula to child session ); assert.equal(seenConfig.model.id, "mock-model"); - assert.equal(seenConfig.thinkingLevel, "high"); + assert.equal(seenConfig.thinkingLevel, "medium"); assert.equal(seenConfig.cwd, "/tmp"); assert.deepEqual( new Set(seenConfig.tools), @@ -187,6 +187,52 @@ test("spawn execute passes broad active registered tool formula to child session assert.deepEqual(seenConfig.customTools.map((tool: any) => tool.name), ["notebook_write", "notebook_read", "notebook_index"]); }); +test("spawn execute routes Model Group with parent registry/auth and ignores stale thinking", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "spawn"]); + const state = createState(); + const routedModel = { provider: "openai", id: "gpt-routed", reasoning: true }; + state.modelGroups.groups = [{ + name: "review", + scope: "project", + sourcePath: "", + models: [{ provider: "openai", modelId: "gpt-routed", thinkingLevel: "low" }], + validation: { unavailableRefs: [], shadowedByProject: false, degraded: false }, + } as any]; + const parentAuth = { sentinel: true }; + const parentRegistry = { + authStorage: parentAuth, + find: (provider: string, modelId: string) => provider === "openai" && modelId === "gpt-routed" ? routedModel : undefined, + hasConfiguredAuth: (model: any) => model === routedModel, + }; + let seenConfig: any; + const mockFactory = async (config: any) => { + seenConfig = config; + const session = { + messages: [] as any[], + prompt: async () => { session.messages = [{ role: "assistant", content: [{ type: "text", text: "routed result" }] }]; }, + abort: async () => {}, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + registerSpawnTool(pi as any, state, mockFactory as any); + + const result = await pi.tools.get("spawn").execute( + "spawn-routed", + { prompt: "Do the task", group: "review", thinking: "xhigh" }, + undefined, + undefined, + { model: { provider: "openai", id: "parent" }, cwd: "/tmp", modelRegistry: parentRegistry }, + ); + + assert.equal(seenConfig.model, routedModel); + assert.equal(seenConfig.thinkingLevel, "low"); + assert.equal(seenConfig.modelRegistry, parentRegistry); + assert.equal(seenConfig.authStorage, parentAuth); + assert.deepEqual(result.details.route, { status: "routed", group: "review", provider: "openai", modelId: "gpt-routed" }); +}); + test("spawn execute builds prompt with notebook pages and task", async () => { const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); @@ -308,7 +354,7 @@ test("spawn execute returns result and stats", async () => { assert.deepEqual(updates, [{ content: [], - details: { model: "mock-model", thinking: "high", truncated: false, outcome: "running" }, + details: { model: "mock-model", thinking: "medium", truncated: false, outcome: "running", route: { status: "inherited" } }, }]); assert.equal(result.content[0].text, "child result"); assert.equal(result.details.outcome, "success"); @@ -953,7 +999,7 @@ test("spawn execute aborts child session when signal fires during execution", as assert.equal(result.details.outcome, "aborted"); }); -test("spawn renderCall shows prompt preview and thinking level", () => { +test("spawn renderCall shows prompt preview and optional group", () => { const state = createState(); const pi = createTestPI(); registerSpawnTool(pi as any, state); @@ -972,10 +1018,13 @@ test("spawn renderCall shows prompt preview and thinking level", () => { const truncatedLines = truncated.render(120); assert.ok(truncatedLines.some((l: string) => l.includes("more lines"))); - // With thinking level - const withThinking = tool.renderCall({ prompt: "Do X", thinking: "high" }, theme, { expanded: false }); - const thinkingLines = withThinking.render(120); - assert.ok(thinkingLines.some((l: string) => l.includes("high"))); + // Stale thinking is not rendered; optional group is rendered as routing context + const staleThinking = tool.renderCall({ prompt: "Do X", thinking: "high" }, theme, { expanded: false }); + const staleThinkingLines = staleThinking.render(120); + assert.ok(!staleThinkingLines.some((l: string) => l.includes("high"))); + const withGroup = tool.renderCall({ prompt: "Do X", group: "review" }, theme, { expanded: false }); + const groupLines = withGroup.render(120); + assert.ok(groupLines.some((l: string) => l.includes("review"))); // Expanded: shows full prompt const expanded = tool.renderCall({ prompt: longPrompt }, theme, { expanded: true }); @@ -1209,6 +1258,9 @@ test("spawn tool definitions include prompt hints when registered", () => { registerSpawnTool(pi as any, state); const spawnTool = pi.tools.get("spawn")!; + assert.ok(spawnTool.parameters.properties.prompt, "spawn should advertise prompt"); + assert.ok(spawnTool.parameters.properties.group, "spawn should advertise optional group"); + assert.equal(spawnTool.parameters.properties.thinking, undefined, "spawn should not advertise thinking"); assert.ok(typeof spawnTool.promptSnippet === "string", "spawn should have promptSnippet"); assert.ok(spawnTool.promptSnippet!.length > 10, "spawn promptSnippet should be non-trivial"); assert.ok(Array.isArray(spawnTool.promptGuidelines), "spawn should have promptGuidelines"); From 6733bfd4dc5eeb4a822571ef359fdcd0a45c2dcb Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Tue, 16 Jun 2026 07:15:58 +0000 Subject: [PATCH 2/7] fix: stabilize model groups CI checks --- .github/workflows/test.yml | 14 ++++++++++++-- tests/unit/helpers.ts | 17 +++++++++++++++++ tests/unit/model-groups-crud.test.ts | 9 +++++---- tests/unit/model-groups-integration.test.ts | 7 +++---- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75c945f..c38b300 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -52,12 +52,22 @@ jobs: - run: npm ci - # Uniform pre-flight checks — type errors and security issues on every platform + # Uniform pre-flight checks — type errors and runtime dependency security issues on every platform. + # npm audit includes devDependencies by default. Here the pi packages are peerDependencies for + # runtime/library use and devDependencies only so CI can typecheck and test against the Pi SDK. + # The current full dev audit is blocked by @earendil-works/pi-coding-agent's published + # npm-shrinkwrap.json pinning nested protobufjs/ws versions. Root npm overrides do not override + # those shrinkwrapped nested packages, and the broader upstream packaging discussion is tracked + # in earendil-works/pi#5653 ("Move off Shrinkwrap"). The relevant GitHub advisories were + # published to the audit DB on 2026-06-15, after PR #13 introduced this workflow, so enforcing + # full dev audit now would fail unrelated PRs until upstream publishes a fixed shrinkwrap. + # Keep the runtime dependency audit strict here; restore full dev audit after the upstream fix + # is released and consumed by package.json/package-lock.json. - name: Type check run: npx tsc --noEmit - name: Security audit - run: npm audit --audit-level=moderate + run: npm audit --omit=dev --audit-level=moderate # Unit suite (unit tests + snapshot tests + property-based tests) - name: Unit tests diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index 59ae5f2..f90812a 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -16,6 +16,23 @@ export const ansiTheme = { bold: (text: string) => text, } as unknown as Theme; +const HOME_ENV_KEYS = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"] as const; + +export function setTempHome(home: string): () => void { + const snapshot = Object.fromEntries(HOME_ENV_KEYS.map((key) => [key, process.env[key]])) as Record; + process.env.HOME = home; + process.env.USERPROFILE = home; + delete process.env.HOMEDRIVE; + delete process.env.HOMEPATH; + return () => { + for (const key of HOME_ENV_KEYS) { + const value = snapshot[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; +} + export function createRenderContext(overrides: Record = {}): Record { return { expanded: false, diff --git a/tests/unit/model-groups-crud.test.ts b/tests/unit/model-groups-crud.test.ts index 0f08c07..df5fcb1 100644 --- a/tests/unit/model-groups-crud.test.ts +++ b/tests/unit/model-groups-crud.test.ts @@ -16,13 +16,14 @@ import { validateModelGroups, } from "../../model-groups/store.js"; import { ModelGroupsPersistenceError, type ModelGroupScope } from "../../model-groups/types.js"; +import { setTempHome } from "./helpers.js"; function withTemp(fn: (ctx: { cwd: string; home: string }) => void): void { const root = fs.mkdtempSync(path.join(os.tmpdir(), "model-groups-")); - const oldHome = process.env.HOME; - process.env.HOME = path.join(root, "home"); - try { fn({ cwd: path.join(root, "project"), home: process.env.HOME }); } - finally { process.env.HOME = oldHome; __setModelGroupsFsForTests(null); fs.rmSync(root, { recursive: true, force: true }); } + const home = path.join(root, "home"); + const restoreHome = setTempHome(home); + try { fn({ cwd: path.join(root, "project"), home }); } + finally { restoreHome(); __setModelGroupsFsForTests(null); fs.rmSync(root, { recursive: true, force: true }); } } function read(scope: ModelGroupScope, cwd: string): any { diff --git a/tests/unit/model-groups-integration.test.ts b/tests/unit/model-groups-integration.test.ts index fe7abf2..a26ae8c 100644 --- a/tests/unit/model-groups-integration.test.ts +++ b/tests/unit/model-groups-integration.test.ts @@ -7,7 +7,7 @@ import registerAgenticoding from "../../index.js"; import { registerModelGroupsCommand } from "../../model-groups/command.js"; import { __setModelGroupsFsForTests, modelGroupsPath } from "../../model-groups/store.js"; import { createState } from "../../state.js"; -import { createTestPI, theme } from "./helpers.js"; +import { createTestPI, setTempHome, theme } from "./helpers.js"; function registry(available = new Set(["openai:gpt-5"])): any { const models = [{ provider: "openai", id: "gpt-5", reasoning: true, thinkingLevelMap: { xhigh: "x" } }]; @@ -21,10 +21,9 @@ function registry(available = new Set(["openai:gpt-5"])): any { async function withTemp(fn: (cwd: string) => Promise | void): Promise { const root = fs.mkdtempSync(path.join(os.tmpdir(), "model-groups-int-")); - const oldHome = process.env.HOME; - process.env.HOME = path.join(root, "home"); + const restoreHome = setTempHome(path.join(root, "home")); try { await fn(path.join(root, "project")); } - finally { process.env.HOME = oldHome; __setModelGroupsFsForTests(null); fs.rmSync(root, { recursive: true, force: true }); } + finally { restoreHome(); __setModelGroupsFsForTests(null); fs.rmSync(root, { recursive: true, force: true }); } } test("/model-groups command registers and opens ctx.ui.custom with live registry/cwd", async () => withTemp(async (cwd) => { From 5b8a7a4ae46b79342d4eb41be85144c56efa17de Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Wed, 24 Jun 2026 13:02:07 +0000 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20address=20PR=20#14=20review=20?= =?UTF-8?q?=E2=80=94=20guard=20missing=20context=20in=20/model-groups,=20f?= =?UTF-8?q?ix=20deleteGroup=20error-after-commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model-groups/command.ts: add guard for missing ctx.cwd or ctx.modelRegistry before calling createModelGroupsComponent; surface friendly notification instead of crashing. Matches refreshModelGroupsState pattern in index.ts. - model-groups/store.ts deleteGroup(): load opposite-scope config before saving the delete so a load failure doesn't throw after the delete is already persisted on disk. Follows moveGroup's pre-validation pattern. --- model-groups/command.ts | 4 ++++ model-groups/store.ts | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/model-groups/command.ts b/model-groups/command.ts index c8b6c8d..74ea527 100644 --- a/model-groups/command.ts +++ b/model-groups/command.ts @@ -7,6 +7,10 @@ export function registerModelGroupsCommand(pi: ExtensionAPI, state: Agenticoding description: "Manage Model Groups", handler: async (_args, ctx) => { if (!ctx.hasUI) return; + if (!ctx.cwd || !ctx.modelRegistry) { + ctx.ui.notify("Cannot manage model groups: missing working directory or model registry", "error"); + return; + } await ctx.ui.custom((tui, theme, _keybindings, done) => createModelGroupsComponent(tui, theme, ctx.modelRegistry, ctx.cwd, done, { initialValidation: state.modelGroups.validation, diff --git a/model-groups/store.ts b/model-groups/store.ts index cba39ff..5e6c043 100644 --- a/model-groups/store.ts +++ b/model-groups/store.ts @@ -214,6 +214,10 @@ export function deleteGroup(scope: ModelGroupScope, cwd: string, name: string): const config = loadScopeConfig(scope, cwd); if (!config.groups[name]) throw new Error(`Model group '${name}' does not exist in ${scope} scope`); delete config.groups[name]; + + // Load the opposite scope before saving — if this throws, nothing was persisted yet. + const other = loadScopeConfig(scope === "global" ? "project" : "global", cwd); + try { saveModelGroups(scope, cwd, config); } catch (cause) { @@ -230,7 +234,6 @@ export function deleteGroup(scope: ModelGroupScope, cwd: string, name: string): } throw cause; } - const other = loadScopeConfig(scope === "global" ? "project" : "global", cwd); return { otherScopeHasOverride: Boolean(other.groups[name]) }; } From 59370bdc23f8faafc59ff822abafe70e0c2d058c Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Wed, 24 Jun 2026 13:21:08 +0000 Subject: [PATCH 4/7] refactor(tests): extract shared model-groups test helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract duplicated withTemp() and group() into tests/unit/model-groups-helpers.ts - withTemp: unified async version replacing 2 local variants (crud + integration) - group: unified opts-object signature replacing 3 local variants (tui, router, autocomplete) - registry() kept per-file — each test file needs different model sets --- tests/unit/model-groups-autocomplete.test.ts | 23 ++------- tests/unit/model-groups-crud.test.ts | 10 +--- tests/unit/model-groups-helpers.ts | 45 ++++++++++++++++++ tests/unit/model-groups-integration.test.ts | 27 ++++------- tests/unit/model-groups-router.test.ts | 25 ++++------ tests/unit/model-groups-tui.test.ts | 49 +++++++++----------- 6 files changed, 92 insertions(+), 87 deletions(-) create mode 100644 tests/unit/model-groups-helpers.ts diff --git a/tests/unit/model-groups-autocomplete.test.ts b/tests/unit/model-groups-autocomplete.test.ts index 70790f6..9368b58 100644 --- a/tests/unit/model-groups-autocomplete.test.ts +++ b/tests/unit/model-groups-autocomplete.test.ts @@ -3,29 +3,16 @@ import assert from "node:assert/strict"; import { createModelGroupAutocompleteProvider, registerModelGroupAutocomplete } from "../../model-groups/autocomplete.js"; import { createState } from "../../state.js"; import type { ResolvedModelGroup } from "../../model-groups/types.js"; - -function group( - name: string, - models: ResolvedModelGroup["models"] = [], - unavailableRefs: ResolvedModelGroup["validation"]["unavailableRefs"] = [], -): ResolvedModelGroup { - return { - name, - scope: "project", - sourcePath: "", - models, - validation: { unavailableRefs, shadowedByProject: false, degraded: unavailableRefs.length > 0 }, - }; -} +import { group } from "./model-groups-helpers.js"; test("#group autocomplete suggests effective live group names and delegates elsewhere", async () => { const state = createState(); state.modelGroups.groups = [ - group("review", [ + group("review", { models: [ { provider: "openai", modelId: "gpt-5", thinkingLevel: "high" }, { provider: "anthropic", modelId: "claude-sonnet-4" }, - ]), - group("research", [{ provider: "google", modelId: "gemini-2.5-pro", thinkingLevel: "xhigh" }]), + ]}), + group("research", { models: [{ provider: "google", modelId: "gemini-2.5-pro", thinkingLevel: "xhigh" }] }), ]; let delegated = 0; const current = { @@ -44,7 +31,7 @@ test("#group autocomplete suggests effective live group names and delegates else ]); assert.equal(delegated, 0); - state.modelGroups.groups = [group("reviewers", [{ provider: "openai", modelId: "gpt-5" }], [{ provider: "openai", modelId: "gpt-5" }])]; + state.modelGroups.groups = [group("reviewers", { models: [{ provider: "openai", modelId: "gpt-5" }], unavailableRefs: [{ provider: "openai", modelId: "gpt-5" }] })]; const fresh = await provider.getSuggestions(["#rev"], 0, 4, {}); assert.deepEqual(fresh.items.map((item: any) => item.value), ["#reviewers"]); assert.equal(fresh.items[0].description, "openai/gpt-5 • inherit (unavailable)"); diff --git a/tests/unit/model-groups-crud.test.ts b/tests/unit/model-groups-crud.test.ts index df5fcb1..62c7b8c 100644 --- a/tests/unit/model-groups-crud.test.ts +++ b/tests/unit/model-groups-crud.test.ts @@ -16,15 +16,7 @@ import { validateModelGroups, } from "../../model-groups/store.js"; import { ModelGroupsPersistenceError, type ModelGroupScope } from "../../model-groups/types.js"; -import { setTempHome } from "./helpers.js"; - -function withTemp(fn: (ctx: { cwd: string; home: string }) => void): void { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "model-groups-")); - const home = path.join(root, "home"); - const restoreHome = setTempHome(home); - try { fn({ cwd: path.join(root, "project"), home }); } - finally { restoreHome(); __setModelGroupsFsForTests(null); fs.rmSync(root, { recursive: true, force: true }); } -} +import { withTemp } from "./model-groups-helpers.js"; function read(scope: ModelGroupScope, cwd: string): any { return JSON.parse(fs.readFileSync(modelGroupsPath(scope, cwd), "utf8")); diff --git a/tests/unit/model-groups-helpers.ts b/tests/unit/model-groups-helpers.ts new file mode 100644 index 0000000..1d06411 --- /dev/null +++ b/tests/unit/model-groups-helpers.ts @@ -0,0 +1,45 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { ResolvedModelGroup } from "../../model-groups/types.js"; +import { __setModelGroupsFsForTests } from "../../model-groups/store.js"; +import { setTempHome } from "./helpers.js"; + +export async function withTemp( + fn: (ctx: { cwd: string; home: string }) => Promise | void, +): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "model-groups-")); + const home = path.join(root, "home"); + const cwd = path.join(root, "project"); + const restoreHome = setTempHome(home); + try { + await fn({ cwd, home }); + } finally { + restoreHome(); + __setModelGroupsFsForTests(null); + fs.rmSync(root, { recursive: true, force: true }); + } +} + +export function group( + name: string, + opts: { + scope?: "project" | "global"; + models?: ResolvedModelGroup["models"]; + shadowedByProject?: boolean; + unavailableRefs?: ResolvedModelGroup["validation"]["unavailableRefs"]; + } = {}, +): ResolvedModelGroup { + const scope = opts.scope ?? "project"; + return { + name, + scope, + sourcePath: `<${scope}>`, + models: opts.models ?? [], + validation: { + unavailableRefs: opts.unavailableRefs ?? [], + shadowedByProject: opts.shadowedByProject ?? false, + degraded: (opts.unavailableRefs?.length ?? 0) > 0, + }, + }; +} diff --git a/tests/unit/model-groups-integration.test.ts b/tests/unit/model-groups-integration.test.ts index a26ae8c..bbcf5d1 100644 --- a/tests/unit/model-groups-integration.test.ts +++ b/tests/unit/model-groups-integration.test.ts @@ -1,13 +1,13 @@ import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import registerAgenticoding from "../../index.js"; import { registerModelGroupsCommand } from "../../model-groups/command.js"; import { __setModelGroupsFsForTests, modelGroupsPath } from "../../model-groups/store.js"; import { createState } from "../../state.js"; -import { createTestPI, setTempHome, theme } from "./helpers.js"; +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" } }]; @@ -19,14 +19,7 @@ function registry(available = new Set(["openai:gpt-5"])): any { }; } -async function withTemp(fn: (cwd: string) => Promise | void): Promise { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "model-groups-int-")); - const restoreHome = setTempHome(path.join(root, "home")); - try { await fn(path.join(root, "project")); } - finally { restoreHome(); __setModelGroupsFsForTests(null); fs.rmSync(root, { recursive: true, force: true }); } -} - -test("/model-groups command registers and opens ctx.ui.custom with live registry/cwd", async () => withTemp(async (cwd) => { +test("/model-groups command registers and opens ctx.ui.custom with live registry/cwd", async () => withTemp(async ({ cwd }) => { fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 1, groups: { "cwd-sentinel-group": { models: [{ provider: "openai", modelId: "gpt-5" }] } } }), "utf8"); const pi = createTestPI(); @@ -63,7 +56,7 @@ test("/model-groups command registers and opens ctx.ui.custom with live registry assert.deepEqual(findCalls, ["openai:gpt-5"]); })); -test("index session_start stores model group validation and notifies load and validation issues", async () => withTemp(async (cwd) => { +test("index session_start stores model group validation and notifies load and validation issues", async () => withTemp(async ({ cwd }) => { fs.mkdirSync(path.dirname(modelGroupsPath("global", cwd)), { recursive: true }); fs.writeFileSync(modelGroupsPath("global", cwd), JSON.stringify({ version: 1, groups: { bad: { models: [{ provider: "missing", modelId: "nope" }] }, shadow: { models: [] } } }), "utf8"); fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); @@ -88,7 +81,7 @@ 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 corrupt/schema/unsupported load issues", async () => withTemp(async (cwd) => { +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"); fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); @@ -109,7 +102,7 @@ test("index session_start notifies corrupt/schema/unsupported load issues", asyn assert.ok(notifications.some((m) => /unsupported-version/.test(m))); })); -test("index session_start notifies schema-invalid load issues", async () => withTemp(async (cwd) => { +test("index session_start notifies schema-invalid load issues", async () => withTemp(async ({ cwd }) => { fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 1, groups: { broken: { models: [{ provider: 1 }] } } }), "utf8"); const pi = createTestPI(); @@ -129,7 +122,7 @@ test("index session_start notifies schema-invalid load issues", async () => with assert.ok(notifications.some((m) => m.includes(modelGroupsPath("project", cwd)))); })); -test("index session_start includes backup-failure detail in load issue notifications", async () => withTemp(async (cwd) => { +test("index session_start includes backup-failure detail in load issue notifications", async () => withTemp(async ({ cwd }) => { fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); fs.writeFileSync(modelGroupsPath("project", cwd), "{bad", "utf8"); __setModelGroupsFsForTests({ copyFileSync: () => { throw new Error("backup denied"); } }); @@ -148,7 +141,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(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-only Model Groups 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(); @@ -164,7 +157,7 @@ test("before_agent_start injects fresh names-only Model Groups guidance", async assert.doesNotMatch(result.systemPrompt, /model-groups\.json/); })); -test("session_start registers Model Groups autocomplete provider when UI supports it", async () => withTemp(async (cwd) => { +test("session_start registers Model Groups autocomplete provider when UI supports it", async () => withTemp(async ({ cwd }) => { const pi = createTestPI(); registerAgenticoding(pi as any); const providers: any[] = []; @@ -179,7 +172,7 @@ test("session_start registers Model Groups autocomplete provider when UI support assert.equal(providers.length, 1); })); -test("index session_start does not notify when load and validation issues are absent", async () => withTemp(async (cwd) => { +test("index session_start does not notify when load and validation issues are absent", async () => withTemp(async ({ cwd }) => { const pi = createTestPI(); registerAgenticoding(pi as any); const notifications: string[] = []; diff --git a/tests/unit/model-groups-router.test.ts b/tests/unit/model-groups-router.test.ts index a4c62de..00f8f8d 100644 --- a/tests/unit/model-groups-router.test.ts +++ b/tests/unit/model-groups-router.test.ts @@ -2,21 +2,12 @@ import test from "node:test"; import assert from "node:assert/strict"; import { getEffectiveModelGroupNames, resolveSpawnModelRoute, SpawnRouteError } from "../../model-groups/router.js"; import type { ResolvedModelGroup } from "../../model-groups/types.js"; +import { group } from "./model-groups-helpers.js"; function model(provider: string, id: string, overrides: Record = {}): any { return { provider, id, reasoning: true, ...overrides }; } -function group(name: string, scope: "project" | "global", models: any[], shadowedByProject = false): ResolvedModelGroup { - return { - name, - scope, - sourcePath: `<${scope}>`, - models, - validation: { unavailableRefs: [], shadowedByProject, degraded: false }, - }; -} - function registry(models: any[], authenticated = new Set(models.map((m) => `${m.provider}:${m.id}`))): any { return { find: (provider: string, id: string) => models.find((m) => m.provider === provider && m.id === id), @@ -26,9 +17,9 @@ 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", "global", [], true), - group("review", "project", []), - group("research", "global", []), + group("review", { scope: "global", shadowedByProject: true }), + group("review", { scope: "project" }), + group("research", { scope: "global" }), ]; assert.deepEqual(getEffectiveModelGroupNames(groups), ["research", "review"]); }); @@ -47,11 +38,11 @@ test("omitted and unknown groups inherit parent route with fallback metadata", ( test("known empty and all-unusable groups fail clearly", () => { const parent = model("openai", "parent"); assert.throws( - () => resolveSpawnModelRoute({ requestedGroup: "empty", groups: [group("empty", "project", [])], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), + () => 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", "project", [{ provider: "openai", modelId: "missing" }])], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), + () => 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), ); }); @@ -61,12 +52,12 @@ test("known usable groups filter registry/auth, draw with rng seam, and clamp th 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", "project", [ + 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"); diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index 98a1c75..28fc89f 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { createModelGroupsComponent } from "../../model-groups/tui.js"; import { ModelGroupsPersistenceError, type ModelGroupsBootValidation, type ResolvedModelGroup } from "../../model-groups/types.js"; import { theme } from "./helpers.js"; +import { group } from "./model-groups-helpers.js"; function registry(): any { const models = [ @@ -19,10 +20,6 @@ function registry(): any { }; } -function group(name: string, scope: "project" | "global", models: any[] = []): ResolvedModelGroup { - return { name, scope, sourcePath: `/tmp/${scope}.json`, models, validation: { unavailableRefs: [], shadowedByProject: false, degraded: false } }; -} - function boot(groups: ResolvedModelGroup[]): ModelGroupsBootValidation { return { groups, loadIssues: [] }; } function component(args: { groups?: ResolvedModelGroup[]; store?: any; notify?: (m: string, t?: any) => void; renderTheme?: any } = {}) { @@ -54,12 +51,12 @@ function rendered(c: { render: (width: number) => string[] }): string { } test("model groups TUI list renders validation summary, health tags, add row, no Validate row, and confirmed D delete", () => { - const override = group("review", "global"); + const override = group("review", { scope: "global" }); override.validation.shadowedByProject = true; - const degraded = group("mixed", "project", [{ provider: "openai", modelId: "gpt-5" }, { provider: "missing", modelId: "nope" }]); + const degraded = group("mixed", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }, { provider: "missing", modelId: "nope" }] }); degraded.validation.degraded = true; degraded.validation.unavailableRefs = [{ provider: "missing", modelId: "nope" }]; - let groups = [override, group("review", "project"), degraded]; + let groups = [override, group("review", { scope: "project" }), degraded]; const deleteCalls: string[] = []; const store = { deleteGroup: (scope: string, _cwd: string, name: string) => { deleteCalls.push(`${scope}:${name}`); groups = groups.filter((candidate) => !(candidate.scope === scope && candidate.name === name)); return { otherScopeHasOverride: true }; }, @@ -84,12 +81,12 @@ test("model groups TUI list renders validation summary, health tags, add row, no }); test("model groups TUI computes unique new-group names and opens editor after create", () => { - let groups = [group("new-group", "project")]; + let groups = [group("new-group", { scope: "project" })]; const calls: string[] = []; const store = { createGroup: (scope: string, _cwd: string, name: string, def: any) => { calls.push(`${scope}:${name}:${def.models.length}`); - groups = [...groups, group(name, "project")]; + groups = [...groups, group(name, { scope: "project" })]; }, listResolvedModelGroups: () => boot(groups), }; @@ -103,7 +100,7 @@ test("model groups TUI computes unique new-group names and opens editor after cr test("model groups TUI wizard renders provider/model/thinking steps and preserves state on add failure", () => { const messages: string[] = []; let updateCalls = 0; - const groups = [group("review", "project")]; + const groups = [group("review", { scope: "project" })]; const store = { updateGroup: () => { updateCalls++; @@ -154,7 +151,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", "project")] }); + const { c } = component({ groups: [group("review", { scope: "project" })] }); press(c, ENTER, DOWN, DOWN, DOWN, ENTER); return c; } @@ -202,14 +199,14 @@ test("model groups TUI selected markers and primary labels use accent token", () fg: (name: string, text: string) => name === "accent" ? `${text}` : text, bold: (text: string) => text, }; - const list = component({ groups: [group("review", "project", [{ provider: "openai", modelId: "gpt-5" }])], renderTheme: accentTheme }).c; + const list = component({ groups: [group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] })], renderTheme: accentTheme }).c; let text = rendered(list); assert.match(text, /→<\/accent> review<\/accent> \[project\]/); press(list, DOWN); assert.match(rendered(list), /→<\/accent> \+ Add group<\/accent>/); - const editor = component({ groups: [group("review", "project", [{ provider: "openai", modelId: "gpt-5" }])], renderTheme: accentTheme }).c; + const editor = component({ groups: [group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] })], renderTheme: accentTheme }).c; press(editor, ENTER); text = rendered(editor); assert.match(text, /→<\/accent> Location: project<\/accent> ✓/); @@ -225,11 +222,11 @@ test("model groups TUI selected markers and primary labels use accent token", () press(editor, ENTER); assert.match(rendered(editor), /→<\/accent> inherit<\/accent>/); - const modelEdit = component({ groups: [group("review", "project", [{ provider: "openai", modelId: "gpt-5" }])], renderTheme: accentTheme }).c; + const modelEdit = component({ groups: [group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] })], renderTheme: accentTheme }).c; press(modelEdit, ENTER, DOWN, DOWN, DOWN, ENTER); assert.match(rendered(modelEdit), /→<\/accent> Thinking: inherit<\/accent>/); - const deleteConfirm = component({ groups: [group("review", "project")], renderTheme: accentTheme }).c; + const deleteConfirm = component({ groups: [group("review", { scope: "project" })], renderTheme: accentTheme }).c; press(deleteConfirm, "D"); assert.match(rendered(deleteConfirm), /→<\/accent> Keep group<\/accent>/); press(deleteConfirm, DOWN); @@ -237,11 +234,11 @@ test("model groups TUI selected markers and primary labels use accent token", () }); test("model groups TUI model edit renders identity/status and filters thinking options", () => { - const groups = [group("review", "project", [ + const groups = [group("review", { scope: "project", models: [ { provider: "anthropic", modelId: "claude" }, { provider: "openai", modelId: "gpt-5" }, { provider: "missing", modelId: "nope" }, - ])]; + ] })]; const { c } = component({ groups }); press(c, ENTER, DOWN, DOWN, DOWN, ENTER); @@ -274,7 +271,7 @@ test("model groups TUI model edit renders identity/status and filters thinking o test("model groups TUI notifies and preserves location on move collision", () => { const messages: string[] = []; - const groups = [group("review", "project")]; + const groups = [group("review", { scope: "project" })]; const store = { moveGroup: () => { throw new Error("target scope already contains review"); }, listResolvedModelGroups: () => boot(groups), @@ -291,7 +288,7 @@ test("model groups TUI notifies and preserves location on move collision", () => test("model groups TUI notifies and preserves model edit state when updateGroup fails", () => { const messages: string[] = []; const attemptedModels: string[][] = []; - const groups = [group("review", "project", [{ provider: "openai", modelId: "gpt-5" }])]; + const groups = [group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] })]; const store = { updateGroup: (_scope: string, _cwd: string, _name: string, def: any) => { attemptedModels.push(def.models.map((model: any) => `${model.provider}/${model.modelId}/${model.thinkingLevel ?? "inherit"}`)); @@ -325,10 +322,10 @@ test("model groups TUI notifies and preserves model edit state when updateGroup }); test("model groups TUI name edit commits through renameGroup on row-change and D in text input types literally", () => { - let groups = [group("abc", "project")]; + let groups = [group("abc", { scope: "project" })]; const calls: string[] = []; const store = { - renameGroup: (_scope: string, _cwd: string, oldName: string, newName: string) => { calls.push(`${oldName}->${newName}`); groups = [group(newName, "project")]; }, + renameGroup: (_scope: string, _cwd: string, oldName: string, newName: string) => { calls.push(`${oldName}->${newName}`); groups = [group(newName, { scope: "project" })]; }, listResolvedModelGroups: () => boot(groups), }; const { c } = component({ groups, store }); @@ -348,11 +345,11 @@ test("model groups TUI name edit commits through renameGroup on row-change and D }); test("model groups TUI move, wizard add, model thinking, and remove persist through store calls", () => { - let groups = [group("review", "project", [{ provider: "openai", modelId: "gpt-5" }])]; + let groups = [group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] })]; const calls: string[] = []; const store = { - moveGroup: (_cwd: string, name: string, scope: string) => { calls.push(`move:${name}:${scope}`); groups = [group(name, "global", groups[0].models)]; }, - updateGroup: (scope: string, _cwd: string, name: string, def: any) => { calls.push(`update:${scope}:${name}:${def.models.map((m: any) => `${m.provider}/${m.modelId}/${m.thinkingLevel ?? "inherit"}`).join(",")}`); groups = [group(name, scope as any, def.models)]; }, + moveGroup: (_cwd: string, name: string, scope: string) => { calls.push(`move:${name}:${scope}`); groups = [group(name, { scope: "global", models: groups[0].models })]; }, + updateGroup: (scope: string, _cwd: string, name: string, def: any) => { calls.push(`update:${scope}:${name}:${def.models.map((m: any) => `${m.provider}/${m.modelId}/${m.thinkingLevel ?? "inherit"}`).join(",")}`); groups = [group(name, { scope: scope as "project" | "global", models: def.models })]; }, listResolvedModelGroups: () => boot(groups), }; const { c } = component({ groups, store }); @@ -390,7 +387,7 @@ test("model groups TUI move, wizard add, model thinking, and remove persist thro test("model groups TUI notifies and keeps visible state on persistence errors", () => { const messages: string[] = []; const { c } = component({ - groups: [group("review", "project")], + groups: [group("review", { scope: "project" })], notify: (message) => messages.push(message), store: { renameGroup: () => { @@ -403,7 +400,7 @@ test("model groups TUI notifies and keeps visible state on persistence errors", message: "collision", }); }, - listResolvedModelGroups: () => boot([group("review", "project")]), + listResolvedModelGroups: () => boot([group("review", { scope: "project" })]), }, }); c.handleInput?.("\r"); From 30c0f89a78bede7ecab1949f2927e30a62e8c27f Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Thu, 16 Jul 2026 14:28:21 +0000 Subject: [PATCH 5/7] feat(model-groups): complete data layer and TUI --- index.ts | 15 +- model-groups/command.ts | 9 +- model-groups/display.ts | 53 +++ model-groups/names.ts | 3 + model-groups/store.ts | 370 +++++++------------- model-groups/tui.ts | 254 ++++++++++---- model-groups/types.ts | 46 +-- package-lock.json | 200 +++++++---- package.json | 12 +- tests/typecheck/model-groups-producer.ts | 16 + tests/unit/model-groups-crud.test.ts | 159 +++++++-- tests/unit/model-groups-display.test.ts | 29 ++ tests/unit/model-groups-integration.test.ts | 88 ++++- tests/unit/model-groups-tui.test.ts | 157 ++++++++- tsconfig.model-groups-producer.json | 14 + 15 files changed, 952 insertions(+), 473 deletions(-) create mode 100644 model-groups/display.ts create mode 100644 model-groups/names.ts create mode 100644 tests/typecheck/model-groups-producer.ts create mode 100644 tests/unit/model-groups-display.test.ts create mode 100644 tsconfig.model-groups-producer.json diff --git a/index.ts b/index.ts index 57b7062..dcf833b 100644 --- a/index.ts +++ b/index.ts @@ -35,6 +35,8 @@ import { registerModelGroupsCommand } from "./model-groups/command.js"; import { registerModelGroupAutocomplete } from "./model-groups/autocomplete.js"; import { getEffectiveModelGroupNames } from "./model-groups/router.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 { STATUS_KEY_HANDOFF, STATUS_KEY_TOPIC, @@ -43,9 +45,13 @@ import { } from "./tui.js"; import { formatPagePreview } from "./notebook/store.js"; +function modelGroupsAccess(ctx: ExtensionContext): ModelGroupsAccess { + return { cwd: ctx.cwd, policy: ctx.isProjectTrusted() ? "global-project" : "global-only" }; +} + function refreshModelGroupsState(state: AgenticodingState, ctx: ExtensionContext) { if (!ctx.cwd || !(ctx as any).modelRegistry) return null; - const loadedModelGroups = loadModelGroups(ctx.cwd); + const loadedModelGroups = loadModelGroups(modelGroupsAccess(ctx)); const resolvedModelGroups = validateModelGroups(loadedModelGroups, (ctx as any).modelRegistry); state.modelGroups.groups = resolvedModelGroups; state.modelGroups.validation = { groups: resolvedModelGroups, loadIssues: loadedModelGroups.issues }; @@ -273,8 +279,11 @@ export default function (pi: ExtensionAPI): void { const validation = refreshModelGroupsState(state, ctx); if (validation && ctx.hasUI) { for (const issue of validation.loadIssues) { - const backupNote = issue.backupFailed ? "; backup failed, original file left untouched" : ""; - ctx.ui.notify(`Model Groups config ${issue.kind} in ${issue.scope} scope (${issue.sourcePath}); using empty config for that scope${backupNote}`, "warning"); + const sourcePath = escapeDisplayLabel(issue.sourcePath); + const backupPath = issue.backupPath ? escapeDisplayLabel(issue.backupPath) : undefined; + const detail = escapeDisplayLabel(issue.message); + 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) { diff --git a/model-groups/command.ts b/model-groups/command.ts index 74ea527..24e5b6b 100644 --- a/model-groups/command.ts +++ b/model-groups/command.ts @@ -1,18 +1,23 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "../state.js"; +import type { ModelGroupsAccess } from "./types.js"; import { createModelGroupsComponent } from "./tui.js"; export function registerModelGroupsCommand(pi: ExtensionAPI, state: AgenticodingState): void { pi.registerCommand("model-groups", { description: "Manage Model Groups", handler: async (_args, ctx) => { - if (!ctx.hasUI) return; + if (ctx.mode !== "tui") { + if (ctx.mode === "rpc") ctx.ui.notify("/model-groups requires TUI mode"); + return; + } if (!ctx.cwd || !ctx.modelRegistry) { ctx.ui.notify("Cannot manage model groups: missing working directory or model registry", "error"); return; } + const access: ModelGroupsAccess = { cwd: ctx.cwd, policy: ctx.isProjectTrusted() ? "global-project" : "global-only" }; await ctx.ui.custom((tui, theme, _keybindings, done) => - createModelGroupsComponent(tui, theme, ctx.modelRegistry, ctx.cwd, done, { + createModelGroupsComponent(tui, theme, ctx.modelRegistry, access, done, { initialValidation: state.modelGroups.validation, notify: (message, type) => ctx.ui.notify(message, type), onRefresh: (validation) => { diff --git a/model-groups/display.ts b/model-groups/display.ts new file mode 100644 index 0000000..cf0ad11 --- /dev/null +++ b/model-groups/display.ts @@ -0,0 +1,53 @@ +export type DisplayDecodeResult = { ok: true; value: string } | { ok: false; message: string }; + +function hex(value: number): string { + return value.toString(16).toUpperCase().padStart(2, "0"); +} + +export function escapeDisplayLabel(raw: string): string { + let encoded = ""; + for (const char of raw) { + const code = char.codePointAt(0)!; + if (char === "\\") encoded += "\\\\"; + else if (char === "\n") encoded += "\\n"; + else if (char === "\r") encoded += "\\r"; + else if (char === "\t") encoded += "\\t"; + else if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) encoded += `\\x${hex(code)}`; + else if (code === 0x2028 || code === 0x2029) encoded += `\\u${code.toString(16).toUpperCase()}`; + else encoded += char; + } + return encoded; +} + +export function decodeDisplayLabel(encoded: string): DisplayDecodeResult { + let value = ""; + for (let index = 0; index < encoded.length; index++) { + const char = encoded[index]; + if (char !== "\\") { + value += char; + continue; + } + const escape = encoded[++index]; + if (escape === undefined) return { ok: false, message: "Incomplete display escape" }; + if (escape === "\\") value += "\\"; + else if (escape === "n") value += "\n"; + else if (escape === "r") value += "\r"; + else if (escape === "t") value += "\t"; + else if (escape === "x") { + const digits = encoded.slice(index + 1, index + 3); + if (!/^[0-9A-F]{2}$/.test(digits)) return { ok: false, message: "Invalid or non-canonical \\xHH display escape" }; + const code = Number.parseInt(digits, 16); + if (!(code < 0x20 || (code >= 0x7f && code <= 0x9f)) || code === 0x09 || code === 0x0a || code === 0x0d) { + return { ok: false, message: "Display escape does not encode a canonical control" }; + } + value += String.fromCharCode(code); + index += 2; + } else if (escape === "u") { + const digits = encoded.slice(index + 1, index + 5); + if (digits !== "2028" && digits !== "2029") return { ok: false, message: "Invalid canonical Unicode display escape" }; + value += String.fromCharCode(Number.parseInt(digits, 16)); + index += 4; + } else return { ok: false, message: `Unknown display escape \\${escape}` }; + } + return { ok: true, value }; +} diff --git a/model-groups/names.ts b/model-groups/names.ts new file mode 100644 index 0000000..a2bc891 --- /dev/null +++ b/model-groups/names.ts @@ -0,0 +1,3 @@ +export function canonicalizeModelGroupName(raw: string): string { + return raw.trim(); +} diff --git a/model-groups/store.ts b/model-groups/store.ts index 5e6c043..56548c8 100644 --- a/model-groups/store.ts +++ b/model-groups/store.ts @@ -1,13 +1,15 @@ import { homedir } from "node:os"; import path from "node:path"; import * as fs from "node:fs"; -import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { CONFIG_DIR_NAME, type ModelRegistry } from "@earendil-works/pi-coding-agent"; import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; +import { canonicalizeModelGroupName } from "./names.js"; import { ModelGroupsPersistenceError, type ModelGroupDef, type ModelGroupModel, type ModelGroupScope, + type ModelGroupsAccess, type ModelGroupsBootValidation, type ModelGroupsConfig, type ModelGroupsLoadedGroup, @@ -17,302 +19,188 @@ import { } from "./types.js"; const CURRENT_VERSION = 1; -const EMPTY_CONFIG: ModelGroupsConfig = { version: CURRENT_VERSION, groups: {} }; -const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh"]); - +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): string { +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, ".pi", "pi-agenticoding", "model-groups.json"); + : path.join(cwd, projectConfigDirName, "pi-agenticoding", "model-groups.json"); } -function emptyConfig(): ModelGroupsConfig { - return { version: CURRENT_VERSION, groups: {} }; +function ownGroups(): Record { return Object.create(null) as Record; } +function defineGroup(groups: Record, name: string, def: ModelGroupDef): void { + Object.defineProperty(groups, name, { value: cloneDef(def), enumerable: true, writable: true, configurable: true }); } - -function cloneDef(def: ModelGroupDef): ModelGroupDef { - return { models: def.models.map((m) => ({ ...m })) }; +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 isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +function persistenceError(details: ConstructorParameters[0]): ModelGroupsPersistenceError { + return new ModelGroupsPersistenceError(details); } - -function validateModelEntry(value: unknown): value is ModelGroupModel { - if (!isPlainRecord(value)) return false; - if (typeof value.provider !== "string" || value.provider.length === 0) return false; - if (typeof value.modelId !== "string" || value.modelId.length === 0) return false; - if (value.thinkingLevel !== undefined && !VALID_THINKING.has(value.thinkingLevel as ModelThinkingLevel)) return false; - return true; +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 (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; + return { ok: true, model }; +} +function normalizeGroups(rawGroups: Record): { ok: true; groups: Record } | { ok: false; message: string } { + const groups = ownGroups(); + for (const rawName of Object.keys(rawGroups)) { + const name = canonicalizeModelGroupName(rawName); + if (!name) return { ok: false, message: "group name must not be empty after trimming" }; + if (hasOwnGroup(groups, name)) return { ok: false, message: `group keys collide after trimming at '${name}'` }; + const rawDef = rawGroups[rawName]; + if (!isPlainRecord(rawDef)) return { ok: false, message: `group ${rawName} must be an object` }; + if (!Array.isArray(rawDef.models)) return { ok: false, message: `group ${rawName}.models must be an array` }; + const models: ModelGroupModel[] = []; + for (let index = 0; index < rawDef.models.length; index++) { + const result = validateModelEntry(rawDef.models[index], `group ${rawName}.models[${index}]`); + if (!result.ok) return result; + models.push(result.model); + } + defineGroup(groups, name, { models }); + } + return { ok: true, groups }; } - function validateConfig(raw: unknown): { ok: true; config: ModelGroupsConfig } | { ok: false; message: string } { if (!isPlainRecord(raw)) return { ok: false, message: "config root must be an object" }; const version = raw.version === undefined || raw.version === 0 ? CURRENT_VERSION : raw.version; - if (typeof version !== "number") return { ok: false, message: "version must be a number" }; + 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 groups: Record = {}; - for (const [name, def] of Object.entries(raw.groups)) { - if (!isPlainRecord(def)) return { ok: false, message: `group ${name} must be an object` }; - if (!Array.isArray(def.models)) return { ok: false, message: `group ${name}.models must be an array` }; - if (!def.models.every(validateModelEntry)) return { ok: false, message: `group ${name}.models contains invalid model entry` }; - groups[name] = { models: def.models.map((m) => ({ ...m })) }; - } - return { ok: true, config: { version: CURRENT_VERSION, groups } }; + const normalized = normalizeGroups(raw.groups); + return normalized.ok ? { ok: true, config: { version: CURRENT_VERSION, groups: normalized.groups } } : normalized; } - function backupAndIssue(scope: ModelGroupScope, sourcePath: string, kind: ModelGroupsLoadIssue["kind"], message: string, version?: number): ModelGroupsLoadIssue { const backupPath = `${sourcePath}.bak`; const issue: ModelGroupsLoadIssue = { scope, sourcePath, kind, message, backupPath, version }; if (kind === "unsupported-version") return issue; - try { - fsOps.copyFileSync(sourcePath, backupPath); - } catch (cause) { + try { fsOps.copyFileSync(sourcePath, backupPath); } + catch (cause) { issue.backupFailed = true; issue.message = `${message}; backup failed: ${cause instanceof Error ? cause.message : String(cause)}`; } return issue; } - -function loadScope(scope: ModelGroupScope, cwd: string): { config: ModelGroupsConfig; issue?: ModelGroupsLoadIssue } { - const sourcePath = modelGroupsPath(scope, cwd); +function loadScope(scope: ModelGroupScope, access: ModelGroupsAccess): { config: ModelGroupsConfig; issue?: ModelGroupsLoadIssue } { + assertScopeAllowed(scope, access); + const sourcePath = modelGroupsPath(scope, access.cwd); if (!fsOps.existsSync(sourcePath)) return { config: emptyConfig() }; let parsed: unknown; - try { - parsed = JSON.parse(String(fsOps.readFileSync(sourcePath, "utf8"))); - } catch (cause) { - return { - config: emptyConfig(), - issue: backupAndIssue(scope, sourcePath, "corrupt-json", cause instanceof Error ? cause.message : String(cause)), - }; - } - if (isPlainRecord(parsed)) { - const version = parsed.version === undefined || parsed.version === 0 ? CURRENT_VERSION : parsed.version; - if (typeof version === "number" && version > CURRENT_VERSION) { - return { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "unsupported-version", `unsupported version ${version}`, version) }; - } + try { parsed = JSON.parse(String(fsOps.readFileSync(sourcePath, "utf8"))); } + catch (cause) { return { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "corrupt-json", cause instanceof Error ? cause.message : String(cause)) }; } + if (isPlainRecord(parsed) && typeof parsed.version === "number" && Number.isInteger(parsed.version) && parsed.version > CURRENT_VERSION) { + return { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "unsupported-version", `unsupported version ${parsed.version}`, parsed.version) }; } const validated = validateConfig(parsed); - if (!validated.ok) { - return { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "schema-invalid", validated.message) }; - } + if (!validated.ok) return { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "schema-invalid", validated.message) }; return { config: validated.config }; } - -function mergeLoaded(configs: Record, cwd: string): ModelGroupsLoadedGroup[] { - const globalPath = modelGroupsPath("global", cwd); - const projectPath = modelGroupsPath("project", cwd); +function mergeLoaded(configs: Record, access: ModelGroupsAccess): ModelGroupsLoadedGroup[] { const names = new Set([...Object.keys(configs.global.groups), ...Object.keys(configs.project.groups)]); const merged: ModelGroupsLoadedGroup[] = []; for (const name of [...names].sort()) { - const globalDef = configs.global.groups[name]; - if (globalDef) merged.push({ name, scope: "global", sourcePath: globalPath, ...cloneDef(globalDef) }); - const projectDef = configs.project.groups[name]; - if (projectDef) merged.push({ name, scope: "project", sourcePath: projectPath, ...cloneDef(projectDef) }); + if (hasOwnGroup(configs.global.groups, name)) merged.push({ name, scope: "global", sourcePath: modelGroupsPath("global", access.cwd), ...cloneDef(configs.global.groups[name]) }); + if (access.policy === "global-project" && hasOwnGroup(configs.project.groups, name)) merged.push({ name, scope: "project", sourcePath: modelGroupsPath("project", access.cwd), ...cloneDef(configs.project.groups[name]) }); } return merged; } - -export function loadModelGroups(cwd: string): ModelGroupsLoadResult { - const global = loadScope("global", cwd); - const project = loadScope("project", cwd); +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, cwd), - issues: [global.issue, project.issue].filter((issue): issue is ModelGroupsLoadIssue => Boolean(issue)), - }; + return { configs, merged: mergeLoaded(configs, access), issues: [global.issue, project.issue].filter((issue): issue is ModelGroupsLoadIssue => Boolean(issue)) }; } - -function persistenceError(details: ConstructorParameters[0]): ModelGroupsPersistenceError { - return new ModelGroupsPersistenceError(details); +function normalizeSaveConfig(scope: ModelGroupScope, sourcePath: string, config: ModelGroupsConfig): ModelGroupsConfig { + const normalized = normalizeGroups(config.groups as unknown as Record); + if (!normalized.ok) throw persistenceError({ operation: "save", scope, sourcePath, phase: "config-validation", message: normalized.message }); + return { version: CURRENT_VERSION, groups: normalized.groups }; } - -export function saveModelGroups(scope: ModelGroupScope, cwd: string, config: ModelGroupsConfig): void { - const sourcePath = modelGroupsPath(scope, cwd); +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 { - // Preserve nothing from malformed content; load recovery owns malformed-file handling. - } - } - const body = JSON.stringify({ ...raw, version: CURRENT_VERSION, groups: config.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) { - 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)}`, cause }); - } -} - -function loadScopeConfig(scope: ModelGroupScope, cwd: string): ModelGroupsConfig { - const loaded = loadScope(scope, cwd); - 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, - }); + try { const parsed = JSON.parse(String(fsOps.readFileSync(sourcePath, "utf8"))); if (isPlainRecord(parsed)) raw = parsed; } + catch { /* load recovery owns malformed content */ } } + const body = JSON.stringify({ ...raw, version: CURRENT_VERSION, groups: normalized.groups }, null, 2) + "\n"; + try { fsOps.mkdirSync(dir, { recursive: true }); fsOps.writeFileSync(tempPath, body, "utf8"); } + catch (cause) { throw persistenceError({ operation: "save", scope, sourcePath, targetPath: tempPath, phase: "temp-write", message: `Failed to write temp model-groups file for ${scope}: ${cause instanceof Error ? cause.message : String(cause)}`, cause }); } + try { fsOps.renameSync(tempPath, sourcePath); } + catch (cause) { 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)}`, cause }); } +} +function loadScopeConfig(scope: ModelGroupScope, access: ModelGroupsAccess): ModelGroupsConfig { + const loaded = loadScope(scope, access); + if (loaded.issue?.backupFailed) throw persistenceError({ operation: "save", scope, sourcePath: loaded.issue.sourcePath, targetPath: loaded.issue.backupPath, phase: "load-recovery", message: `Refusing to overwrite ${scope} model-groups config after ${loaded.issue.kind} recovery because backup failed: ${loaded.issue.message}`, cause: loaded.issue }); return loaded.config; } - -function ensureGroupName(name: string): void { - if (!name.trim()) throw new Error("Model group name is required"); -} - -export function createGroup(scope: ModelGroupScope, cwd: string, name: string, def: ModelGroupDef): void { - ensureGroupName(name); - const config = loadScopeConfig(scope, cwd); - if (config.groups[name]) throw new Error(`Model group '${name}' already exists in ${scope} scope`); - config.groups[name] = cloneDef(def); - saveModelGroups(scope, cwd, config); -} - -export function updateGroup(scope: ModelGroupScope, cwd: string, name: string, def: ModelGroupDef): void { - const config = loadScopeConfig(scope, cwd); - if (!config.groups[name]) throw new Error(`Model group '${name}' does not exist in ${scope} scope`); - config.groups[name] = cloneDef(def); - saveModelGroups(scope, cwd, config); -} - -export function renameGroup(scope: ModelGroupScope, cwd: string, oldName: string, newName: string): void { - ensureGroupName(newName); - if (oldName === newName) return; - const config = loadScopeConfig(scope, cwd); - const existing = config.groups[oldName]; - if (!existing) throw new Error(`Model group '${oldName}' does not exist in ${scope} scope`); - if (config.groups[newName]) throw new Error(`Model group '${newName}' already exists in ${scope} scope`); - delete config.groups[oldName]; - config.groups[newName] = cloneDef(existing); - saveModelGroups(scope, cwd, config); -} - -export function deleteGroup(scope: ModelGroupScope, cwd: string, name: string): { otherScopeHasOverride: boolean } { - const config = loadScopeConfig(scope, cwd); - if (!config.groups[name]) throw new Error(`Model group '${name}' does not exist in ${scope} scope`); +function canonicalName(raw: string): string { const name = canonicalizeModelGroupName(raw); if (!name) throw new Error("Model group name is required"); return name; } +export function createGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawName: string, def: ModelGroupDef): void { + assertScopeAllowed(scope, access); const name = canonicalName(rawName); const config = loadScopeConfig(scope, access); + if (hasOwnGroup(config.groups, name)) throw new Error(`Model group '${name}' already exists in ${scope} scope`); + defineGroup(config.groups, name, def); saveModelGroups(scope, access, config); +} +export function updateGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawName: string, def: ModelGroupDef): void { + assertScopeAllowed(scope, access); const name = canonicalName(rawName); const config = loadScopeConfig(scope, access); + if (!hasOwnGroup(config.groups, name)) throw new Error(`Model group '${name}' does not exist in ${scope} scope`); + defineGroup(config.groups, name, def); saveModelGroups(scope, access, config); +} +export function renameGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawOldName: string, rawNewName: string): void { + assertScopeAllowed(scope, access); const oldName = canonicalName(rawOldName); const newName = canonicalName(rawNewName); if (oldName === newName) return; + const config = loadScopeConfig(scope, access); + if (!hasOwnGroup(config.groups, oldName)) throw new Error(`Model group '${oldName}' does not exist in ${scope} scope`); + if (hasOwnGroup(config.groups, newName)) throw new Error(`Model group '${newName}' already exists in ${scope} scope`); + const existing = config.groups[oldName]; delete config.groups[oldName]; defineGroup(config.groups, newName, existing); saveModelGroups(scope, access, config); +} +export function deleteGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawName: string): { otherScopeHasOverride: boolean } { + assertScopeAllowed(scope, access); const name = canonicalName(rawName); const config = loadScopeConfig(scope, access); + if (!hasOwnGroup(config.groups, name)) throw new Error(`Model group '${name}' does not exist in ${scope} scope`); delete config.groups[name]; - - // Load the opposite scope before saving — if this throws, nothing was persisted yet. - const other = loadScopeConfig(scope === "global" ? "project" : "global", cwd); - - try { - saveModelGroups(scope, cwd, 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: Boolean(other.groups[name]) }; + const other = access.policy === "global-only" ? emptyConfig() : loadScopeConfig(scope === "global" ? "project" : "global", access); + try { saveModelGroups(scope, access, config); } + catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw new ModelGroupsPersistenceError({ operation: "delete", scope: cause.scope, sourcePath: cause.sourcePath, targetPath: cause.targetPath, phase: cause.phase, message: cause.message, cause }); throw cause; } + return { otherScopeHasOverride: hasOwnGroup(other.groups, name) }; +} +export function moveGroup(access: ModelGroupsAccess, rawName: string, newScope: ModelGroupScope): void { + const name = canonicalName(rawName); const oldScope: ModelGroupScope = newScope === "project" ? "global" : "project"; + assertScopeAllowed(oldScope, access); assertScopeAllowed(newScope, access); + const source = loadScopeConfig(oldScope, access); const target = loadScopeConfig(newScope, access); + if (!hasOwnGroup(source.groups, name)) throw new Error(`Model group '${name}' does not exist in ${oldScope} scope`); + if (hasOwnGroup(target.groups, name)) throw new Error(`Model group '${name}' already exists in ${newScope} scope`); + defineGroup(target.groups, name, source.groups[name]); + try { saveModelGroups(newScope, access, target); } + catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw new ModelGroupsPersistenceError({ operation: "move", scope: newScope, sourcePath: modelGroupsPath(oldScope, access.cwd), targetPath: modelGroupsPath(newScope, access.cwd), phase: cause.phase, message: `Model group '${name}' was not written to ${newScope}: ${cause.message}`, cause }); throw cause; } + delete source.groups[name]; + try { saveModelGroups(oldScope, access, source); } + catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw new ModelGroupsPersistenceError({ operation: "move", scope: oldScope, sourcePath: modelGroupsPath(oldScope, access.cwd), targetPath: modelGroupsPath(newScope, access.cwd), phase: "source-remove", partialMove: "target-written-source-retained", message: `Model group '${name}' was written to ${newScope} but retained in ${oldScope}: ${cause.message}`, cause }); throw cause; } } - -export function moveGroup(cwd: string, name: string, newScope: ModelGroupScope): void { - const oldScope: ModelGroupScope = newScope === "project" ? "global" : "project"; - const source = loadScopeConfig(oldScope, cwd); - const target = loadScopeConfig(newScope, cwd); - const def = source.groups[name]; - if (!def) throw new Error(`Model group '${name}' does not exist in ${oldScope} scope`); - if (target.groups[name]) throw new Error(`Model group '${name}' already exists in ${newScope} scope`); - const nextTarget: ModelGroupsConfig = { version: CURRENT_VERSION, groups: { ...target.groups, [name]: cloneDef(def) } }; - try { - saveModelGroups(newScope, cwd, nextTarget); - } catch (cause) { - if (cause instanceof ModelGroupsPersistenceError) { - throw new ModelGroupsPersistenceError({ - operation: "move", - scope: newScope, - sourcePath: modelGroupsPath(oldScope, cwd), - targetPath: modelGroupsPath(newScope, cwd), - phase: cause.phase, - message: `Model group '${name}' was not written to ${newScope}: ${cause.message}`, - cause, - }); - } - throw cause; - } - const nextSource: ModelGroupsConfig = { version: CURRENT_VERSION, groups: { ...source.groups } }; - delete nextSource.groups[name]; - try { - saveModelGroups(oldScope, cwd, nextSource); - } catch (cause) { - if (cause instanceof ModelGroupsPersistenceError) { - throw new ModelGroupsPersistenceError({ - operation: "move", - scope: oldScope, - sourcePath: modelGroupsPath(oldScope, cwd), - targetPath: modelGroupsPath(newScope, cwd), - phase: "source-remove", - partialMove: "target-written-source-retained", - message: `Model group '${name}' was written to ${newScope} but retained in ${oldScope}: ${cause.message}`, - cause, - }); - } - throw cause; - } -} - export function validateModelGroups(loadResult: ModelGroupsLoadResult, modelRegistry: ModelRegistry): ResolvedModelGroup[] { const projectNames = new Set(Object.keys(loadResult.configs.project.groups)); return loadResult.merged.map((group) => { const unavailableRefs: Array<{ provider: string; modelId: string }> = []; - for (const modelRef of group.models) { - const model = modelRegistry.find(modelRef.provider, modelRef.modelId); - if (!model || !modelRegistry.hasConfiguredAuth(model)) unavailableRefs.push({ provider: modelRef.provider, modelId: modelRef.modelId }); - } - const unavailableCount = unavailableRefs.length; - const availableCount = group.models.length - unavailableCount; - return { - ...group, - validation: { - unavailableRefs, - shadowedByProject: group.scope === "global" && projectNames.has(group.name), - degraded: unavailableCount > 0 && availableCount > 0, - }, - }; + for (const ref of group.models) { const model = modelRegistry.find(ref.provider, ref.modelId); if (!model || !modelRegistry.hasConfiguredAuth(model)) unavailableRefs.push({ provider: ref.provider, modelId: ref.modelId }); } + return { ...group, validation: { unavailableRefs, shadowedByProject: group.scope === "global" && projectNames.has(group.name), degraded: unavailableRefs.length > 0 && unavailableRefs.length < group.models.length } }; }); } - -export function listResolvedModelGroups(cwd: string, modelRegistry: ModelRegistry): ModelGroupsBootValidation { - const loaded = loadModelGroups(cwd); - return { groups: validateModelGroups(loaded, modelRegistry), loadIssues: loaded.issues }; +export function listResolvedModelGroups(access: ModelGroupsAccess, modelRegistry: ModelRegistry): ModelGroupsBootValidation { + const loaded = loadModelGroups(access); return { groups: validateModelGroups(loaded, modelRegistry), loadIssues: loaded.issues }; } - export function summarizeBootValidation(groups: ResolvedModelGroup[]): { unavailableCount: number; overrideCount: number } { - return { - unavailableCount: groups.reduce((sum, group) => sum + group.validation.unavailableRefs.length, 0), - overrideCount: groups.filter((group) => group.validation.shadowedByProject).length, - }; + return { unavailableCount: groups.reduce((sum, group) => sum + group.validation.unavailableRefs.length, 0), overrideCount: groups.filter((group) => group.validation.shadowedByProject).length }; } - -export { CURRENT_VERSION as MODEL_GROUPS_CONFIG_VERSION, EMPTY_CONFIG as EMPTY_MODEL_GROUPS_CONFIG }; +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 6b0cbdf..7799dd8 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -1,7 +1,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent"; import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import { getSupportedThinkingLevels, type Model, type ModelThinkingLevel, type Api } from "@earendil-works/pi-ai"; -import { Key, matchesKey, type Component, type TUI } from "@earendil-works/pi-tui"; +import { Container, Input, Key, matchesKey, SelectList, truncateToWidth, type Component, type Focusable, type SelectItem, type TUI } from "@earendil-works/pi-tui"; import { createGroup, deleteGroup, @@ -11,7 +11,9 @@ import { summarizeBootValidation, updateGroup, } from "./store.js"; -import { ModelGroupsPersistenceError, type ModelGroupDef, type ModelGroupScope, type ModelGroupsBootValidation, type ResolvedModelGroup } from "./types.js"; +import { ModelGroupsPersistenceError, type ModelGroupDef, type ModelGroupScope, type ModelGroupsAccess, type ModelGroupsBootValidation, type ResolvedModelGroup } from "./types.js"; +import { canonicalizeModelGroupName } from "./names.js"; +import { decodeDisplayLabel, escapeDisplayLabel } from "./display.js"; export type ModelGroupsScreen = "LIST" | "EDITOR" | "MODEL_EDIT" | "WIZARD_PROVIDER" | "WIZARD_MODEL" | "WIZARD_THINKING" | "DELETE_CONFIRM"; @@ -38,9 +40,7 @@ function isEsc(data: string): boolean { return matchesKey(data, Key.escape); } 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); } -function isBackspace(data: string): boolean { return matchesKey(data, Key.backspace); } function isDeleteChord(data: string): boolean { return data === "D" || matchesKey(data, Key.delete); } -function isPrintable(data: string): boolean { return data.length === 1 && data >= " " && data !== "\u007f"; } function cloneDef(def: ModelGroupDef): ModelGroupDef { return { models: def.models.map((model) => ({ ...model })) }; @@ -60,27 +60,29 @@ function modelAvailable(registry: ModelRegistry, provider: string, modelId: stri } function modelDisplay(model: Model): string { - return `${model.provider}/${model.id}`; + return `${escapeDisplayLabel(model.provider)}/${escapeDisplayLabel(model.id)}`; } function toPersistenceMessage(error: unknown): string { if (error instanceof ModelGroupsPersistenceError) { const scope = error.scope ? ` for ${error.scope} scope` : ""; - const paths = [error.sourcePath ? `source: ${error.sourcePath}` : "", error.targetPath ? `target: ${error.targetPath}` : ""].filter(Boolean).join("; "); + const paths = [error.sourcePath ? `source: ${escapeDisplayLabel(error.sourcePath)}` : "", error.targetPath ? `target: ${escapeDisplayLabel(error.targetPath)}` : ""].filter(Boolean).join("; "); const pathDetails = paths ? ` (${paths})` : ""; - return `${error.operation} failed at ${error.phase}${scope}${pathDetails}: ${error.message}`; + const cause = error.cause === undefined ? "" : `; cause: ${escapeDisplayLabel(error.cause instanceof Error ? error.cause.message : String(error.cause))}`; + const partial = error.partialMove ? `; ${error.partialMove}` : ""; + return `${error.operation} failed at ${error.phase}${scope}${pathDetails}: ${escapeDisplayLabel(error.message)}${cause}${partial}`; } - return error instanceof Error ? error.message : String(error); + return escapeDisplayLabel(error instanceof Error ? error.message : String(error)); } export function createModelGroupsComponent( tui: TUI, theme: Theme, modelRegistry: ModelRegistry, - cwd: string, + access: ModelGroupsAccess, done: (result: void) => void, options: ModelGroupsComponentOptions = {}, -): Component { +): Component & Focusable { const store = { ...defaultStore, ...options.store }; const notify = options.notify ?? (() => {}); const state = { @@ -90,9 +92,9 @@ export function createModelGroupsComponent( loadIssues: [] as ModelGroupsBootValidation["loadIssues"], editKey: null as string | null, editName: "", - editScope: "project" as ModelGroupScope, + editScope: (access.policy === "global-project" ? "project" : "global") as ModelGroupScope, editDraft: null as ModelGroupDef | null, - activeTextInput: null as null | "group-name" | "wizard-filter", + activeTextInput: null as null | "group-name", modelEditIndex: 0, wizardProvider: "", wizardModelId: "", @@ -100,21 +102,36 @@ export function createModelGroupsComponent( deleteKey: null as string | null, finished: false, }; + const groupNameInput = new Input(); + let rootFocused = false; + let activeSelect: SelectList | null = null; + const nameRow = () => access.policy === "global-project" ? 2 : 1; + const modelStartRow = () => nameRow() + 1; + function syncInputFocus(): void { + groupNameInput.focused = rootFocused && state.screen === "EDITOR" && state.row === nameRow() && state.activeTextInput === "group-name"; + } + function setGroupNameInputValue(value: string): void { + groupNameInput.setValue(value); + groupNameInput.handleInput("\u001b[F"); + } function refresh(): void { - const boot = store.listResolvedModelGroups(cwd, modelRegistry); + const boot = store.listResolvedModelGroups(access, modelRegistry); state.groups = boot.groups; state.loadIssues = boot.loadIssues; options.onRefresh?.(boot); for (const issue of boot.loadIssues) { - const backup = issue.backupFailed ? "; backup failed, original file left untouched" : ""; - notify(`Model Groups config ${issue.kind} in ${issue.scope} scope (${issue.sourcePath}); using empty config for that scope${backup}`, "warning"); + const backupPath = issue.backupPath ? escapeDisplayLabel(issue.backupPath) : ""; + const backup = issue.backupFailed ? `; backup failed${backupPath ? ` (${backupPath})` : ""}, original file left untouched` : ""; + notify(`Model Groups config ${issue.kind} in ${issue.scope} scope (${escapeDisplayLabel(issue.sourcePath)}); using empty config for that scope${backup}; ${escapeDisplayLabel(issue.message)}`, "warning"); } } if (options.initialValidation) { - state.groups = options.initialValidation.groups; - state.loadIssues = options.initialValidation.loadIssues; + state.groups = options.initialValidation.groups + .filter((group) => access.policy === "global-project" || group.scope === "global") + .map((group) => access.policy === "global-project" ? group : { ...group, validation: { ...group.validation, shadowedByProject: false } }); + state.loadIssues = options.initialValidation.loadIssues.filter((issue) => access.policy === "global-project" || issue.scope === "global"); } else { refresh(); } @@ -127,10 +144,12 @@ export function createModelGroupsComponent( state.screen = "EDITOR"; state.row = 0; state.editKey = groupKey(group); - state.editName = group.name; + state.editName = escapeDisplayLabel(group.name); + setGroupNameInputValue(state.editName); state.editScope = group.scope; state.editDraft = cloneDef(group); state.activeTextInput = null; + syncInputFocus(); } function currentEditGroup(): ResolvedModelGroup | undefined { @@ -152,21 +171,38 @@ export function createModelGroupsComponent( function commitName(): boolean { const group = currentEditGroup(); if (!group) return false; - const nextName = state.editName.trim(); - if (!nextName || nextName === group.name) { - state.editName = group.name; + state.editName = groupNameInput.getValue(); + const decoded = decodeDisplayLabel(state.editName); + if (!decoded.ok) { + notify(escapeDisplayLabel(decoded.message), "error"); + state.editName = escapeDisplayLabel(group.name); + setGroupNameInputValue(state.editName); + return false; + } + const nextName = canonicalizeModelGroupName(decoded.value); + if (!nextName) { + notify("Model group name is required", "error"); + state.editName = escapeDisplayLabel(group.name); + setGroupNameInputValue(state.editName); + return false; + } + if (nextName === group.name) { + state.editName = escapeDisplayLabel(group.name); + setGroupNameInputValue(state.editName); return true; } try { - store.renameGroup(group.scope, cwd, group.name, nextName); + store.renameGroup(group.scope, access, group.name, nextName); refresh(); const renamed = state.groups.find((candidate) => candidate.name === nextName && candidate.scope === group.scope); if (renamed) openEditor(renamed); return true; } catch (error) { notifyError(error); - state.editName = group.name; + state.editName = escapeDisplayLabel(group.name); + setGroupNameInputValue(state.editName); state.activeTextInput = null; + syncInputFocus(); return false; } } @@ -178,7 +214,7 @@ export function createModelGroupsComponent( const confirmed = currentEditGroup(); if (!confirmed) return; try { - store.moveGroup(cwd, confirmed.name, newScope); + store.moveGroup(access, confirmed.name, newScope); refresh(); const moved = state.groups.find((candidate) => candidate.name === confirmed.name && candidate.scope === newScope); if (moved) openEditor(moved); @@ -191,7 +227,7 @@ export function createModelGroupsComponent( const group = currentEditGroup(); if (!group) return; try { - store.updateGroup(group.scope, cwd, group.name, def); + store.updateGroup(group.scope, access, group.name, def); refresh(); const updated = state.groups.find((candidate) => candidate.name === group.name && candidate.scope === group.scope); if (updated) openEditor(updated); @@ -229,7 +265,7 @@ export function createModelGroupsComponent( function maxRow(): number { switch (state.screen) { case "LIST": return state.groups.length; - case "EDITOR": return 3 + (state.editDraft?.models.length ?? 0); + 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 "WIZARD_PROVIDER": return Math.max(0, allProviders().length - 1); case "WIZARD_MODEL": return Math.max(0, modelsForProvider(state.wizardProvider).length - 1); @@ -248,9 +284,10 @@ export function createModelGroupsComponent( if (state.row === state.groups.length) { const name = uniqueNewGroupName(); try { - store.createGroup("project", cwd, name, { models: [] }); + const scope = access.policy === "global-project" ? "project" : "global"; + store.createGroup(scope, access, name, { models: [] }); refresh(); - const created = state.groups.find((group) => group.name === name && group.scope === "project"); + const created = state.groups.find((group) => group.name === name && group.scope === scope); if (created) openEditor(created); } catch (error) { notifyError(error); } return; @@ -260,11 +297,11 @@ export function createModelGroupsComponent( return; } case "EDITOR": { - if (state.row === 0) { switchScope("project"); return; } - if (state.row === 1) { switchScope("global"); return; } - if (state.row === 2) { state.activeTextInput = "group-name"; return; } + 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 (!commitName()) return; - const modelIndex = state.row - 3; + const modelIndex = state.row - modelStartRow(); if (state.editDraft && modelIndex < state.editDraft.models.length) { state.modelEditIndex = modelIndex; state.screen = "MODEL_EDIT"; @@ -324,7 +361,7 @@ export function createModelGroupsComponent( const group = state.groups.find((candidate) => groupKey(candidate) === state.deleteKey); if (!group) { state.screen = "LIST"; return; } try { - store.deleteGroup(group.scope, cwd, group.name); + store.deleteGroup(group.scope, access, group.name); refresh(); state.screen = "LIST"; state.row = 0; @@ -365,67 +402,128 @@ export function createModelGroupsComponent( return `${theme.fg("accent", "→")} ${theme.fg("accent", primary)}${suffix}`; } - function renderList(): string[] { + function textLine(value: string): Component { + return { render: () => [value], invalidate: () => {} }; + } + + const selectTheme = { + selectedPrefix: (text: string) => theme.fg("accent", text), + selectedText: (text: string) => theme.fg("accent", text), + description: (text: string) => theme.fg("dim", text), + scrollInfo: (text: string) => theme.fg("dim", text), + noMatch: (text: string) => theme.fg("dim", text), + }; + + function buildSelect(items: SelectItem[]): SelectList { + const select = new SelectList(items, Math.max(1, items.length), selectTheme); + select.setSelectedIndex(Math.min(state.row, Math.max(0, items.length - 1))); + select.onSelectionChange = (item) => { state.row = Number(item.value); syncInputFocus(); }; + select.onSelect = (item) => { state.row = Number(item.value); activate(); syncInputFocus(); }; + select.onCancel = () => { goBack(); syncInputFocus(); }; + activeSelect = select; + return select; + } + + function renderListComponent(): Component { const summary = summarizeBootValidation(state.groups); - const lines = [theme.fg("accent", "Model Groups"), theme.fg("dim", `Boot validation: ${summary.unavailableCount} unavailable model references · ${summary.overrideCount} project overrides`)]; - state.groups.forEach((group, index) => { + 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 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"; - lines.push(selectableLine(index === state.row, group.name, ` [${group.scope}] ${group.models.length} models ${models}${tags.length ? ` — ${tags.join(" · ")}` : ""}`)); + return { value: String(index), label: escapeDisplayLabel(group.name), description: `[${group.scope}] ${group.models.length} models ${models}${tags.length ? ` — ${tags.join(" · ")}` : ""}` }; }); - lines.push(selectableLine(state.row === state.groups.length, "+ Add group")); - lines.push(theme.fg("dim", "↑↓ navigate • Enter open/add • D delete • Esc close")); - return lines; + items.push({ value: String(state.groups.length), label: "+ Add group" }); + container.addChild(buildSelect(items)); + container.addChild(textLine(theme.fg("dim", "↑↓ navigate • Enter open/add • D delete • Esc close"))); + return container; } - function renderEditor(): string[] { - const lines = [theme.fg("accent", `Model Group: ${state.editName}`)]; - lines.push(selectableLine(state.row === 0, "Location: project", state.editScope === "project" ? " ✓" : "")); - lines.push(selectableLine(state.row === 1, "Location: global", state.editScope === "global" ? " ✓" : "")); - lines.push(selectableLine(state.row === 2, `Name: ${state.editName}${state.activeTextInput === "group-name" ? "_" : ""}`)); + function renderEditorComponent(): Component { + 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" ? " ✓" : ""))); + container.addChild(textLine(selectableLine(state.row === nameRow(), "Name:"))); + container.addChild(groupNameInput); state.editDraft?.models.forEach((model, index) => { const available = modelAvailable(modelRegistry, model.provider, model.modelId) ? "available" : "unavailable"; - lines.push(selectableLine(state.row === index + 3, `${model.provider}/${model.modelId}`, ` (${available}, thinking ${thinkingLabel(model.thinkingLevel)})`)); + container.addChild(textLine(selectableLine(state.row === index + modelStartRow(), `${escapeDisplayLabel(model.provider)}/${escapeDisplayLabel(model.modelId)}`, ` (${available}, thinking ${thinkingLabel(model.thinkingLevel)})`))); }); - const addRow = 3 + (state.editDraft?.models.length ?? 0); - lines.push(selectableLine(state.row === addRow, "+ Add model…")); - return lines; + const addRow = modelStartRow() + (state.editDraft?.models.length ?? 0); + container.addChild(textLine(selectableLine(state.row === addRow, "+ Add model…"))); + return container; } - function renderModelEdit(): string[] { + function renderModelEditComponent(): Component { + activeSelect = null; + const container = new Container(); const model = state.editDraft?.models[state.modelEditIndex]; - if (!model) return ["Model not found"]; + if (!model) { container.addChild(textLine("Model not found")); return container; } const found = modelRegistry.find(model.provider, model.modelId) as Model | undefined; - const lines = [theme.fg("accent", "Edit model"), `Provider: ${model.provider}`, `Model ID: ${model.modelId}`, `Status: ${found && modelRegistry.hasConfiguredAuth(found) ? "available" : "unavailable"}`]; - thinkingOptionsFor(found).forEach((level, index) => lines.push(selectableLine(state.row === index, `Thinking: ${thinkingLabel(level)}`))); - lines.push(selectableLine(state.row === thinkingOptionsFor(found).length, "Remove model")); - return lines; + container.addChild(textLine(theme.fg("accent", "Edit model"))); + container.addChild(textLine(`Provider: ${escapeDisplayLabel(model.provider)}`)); + container.addChild(textLine(`Model ID: ${escapeDisplayLabel(model.modelId)}`)); + container.addChild(textLine(`Status: ${found && modelRegistry.hasConfiguredAuth(found) ? "available" : "unavailable"}`)); + thinkingOptionsFor(found).forEach((level, index) => container.addChild(textLine(selectableLine(state.row === index, `Thinking: ${thinkingLabel(level)}`)))); + container.addChild(textLine(selectableLine(state.row === thinkingOptionsFor(found).length, "Remove model"))); + return container; } - function renderWizard(): string[] { - if (state.screen === "WIZARD_PROVIDER") return [theme.fg("accent", "Add model — Step 1/3 Provider"), ...allProviders().map((provider, index) => selectableLine(state.row === index, provider))]; - if (state.screen === "WIZARD_MODEL") return [theme.fg("accent", "Add model — Step 2/3 Model"), ...modelsForProvider(state.wizardProvider).map((model, index) => selectableLine(state.row === index, modelDisplay(model)))]; - return [theme.fg("accent", "Add model — Step 3/3 Thinking"), ...thinkingOptionsFor(currentWizardModel()).map((level, index) => selectableLine(state.row === index, thinkingLabel(level)))]; + function renderWizardComponent(): Component { + const container = new Container(); + let title: string; + let items: SelectItem[]; + if (state.screen === "WIZARD_PROVIDER") { + title = "Add model — Step 1/3 Provider"; + items = allProviders().map((provider, index) => ({ value: String(index), label: escapeDisplayLabel(provider) })); + } else if (state.screen === "WIZARD_MODEL") { + title = "Add model — Step 2/3 Model"; + items = modelsForProvider(state.wizardProvider).map((model, index) => ({ value: String(index), label: modelDisplay(model) })); + } else { + title = "Add model — Step 3/3 Thinking"; + items = thinkingOptionsFor(currentWizardModel()).map((level, index) => ({ value: String(index), label: thinkingLabel(level) })); + } + container.addChild(textLine(theme.fg("accent", title))); + container.addChild(buildSelect(items)); + return container; } - function renderDelete(): string[] { + function renderDeleteComponent(): Component { + activeSelect = null; + const container = new Container(); const group = state.groups.find((candidate) => groupKey(candidate) === state.deleteKey); const otherScope = group ? state.groups.some((candidate) => candidate.name === group.name && candidate.scope !== group.scope) : false; - return [theme.fg("warning", "Delete Model Group?"), group ? `${group.name} [${group.scope}] with ${group.models.length} models` : "Missing group", otherScope ? "Same-name group in the other scope remains unaffected." : "", selectableLine(state.row === 0, "Keep group"), selectableLine(state.row === 1, "Delete group")].filter(Boolean); + container.addChild(textLine(theme.fg("warning", "Delete Model Group?"))); + container.addChild(textLine(group ? `${escapeDisplayLabel(group.name)} [${group.scope}] with ${group.models.length} models` : "Missing group")); + if (otherScope) container.addChild(textLine("Same-name group in the other scope remains unaffected.")); + container.addChild(textLine(selectableLine(state.row === 0, "Keep group"))); + container.addChild(textLine(selectableLine(state.row === 1, "Delete group"))); + return container; + } + + function activeComponent(): Component { + if (state.screen === "LIST") return renderListComponent(); + if (state.screen === "EDITOR") return renderEditorComponent(); + if (state.screen === "MODEL_EDIT") return renderModelEditComponent(); + if (state.screen === "DELETE_CONFIRM") return renderDeleteComponent(); + return renderWizardComponent(); } return { - render: (_width: number) => { - if (state.screen === "LIST") return renderList(); - if (state.screen === "EDITOR") return renderEditor(); - if (state.screen === "MODEL_EDIT") return renderModelEdit(); - if (state.screen === "DELETE_CONFIRM") return renderDelete(); - return renderWizard(); + get focused() { return rootFocused; }, + set focused(value: boolean) { rootFocused = value; syncInputFocus(); }, + render: (width: number) => { + syncInputFocus(); + return activeComponent().render(width).map((line) => truncateToWidth(line, width)); }, - invalidate: () => {}, + invalidate: () => groupNameInput.invalidate(), handleInput: (data: string) => { if (state.finished) return; if (state.activeTextInput === "group-name") { @@ -436,17 +534,31 @@ export function createModelGroupsComponent( state.row = previousRow + (isDown(data) ? 1 : -1); clampRow(); } - } else if (isEnter(data) || isEsc(data)) { commitName(); state.activeTextInput = null; } - else if (isBackspace(data)) state.editName = state.editName.slice(0, -1); - else if (isPrintable(data)) state.editName += data; + } else if (isEnter(data) || isEsc(data)) { + commitName(); + state.activeTextInput = null; + } else { + groupNameInput.handleInput(data); + state.editName = groupNameInput.getValue(); + } + syncInputFocus(); tui.requestRender(); return; } if (isDeleteChord(data) && (state.screen === "LIST" || state.screen === "MODEL_EDIT")) deleteAction(); + else if (activeSelect && (state.screen === "LIST" || state.screen.startsWith("WIZARD_")) && (isUp(data) || isDown(data))) { + const previousRow = state.row; + activeSelect.handleInput(data); + state.row = previousRow + (isDown(data) ? 1 : -1); + clampRow(); + activeSelect.setSelectedIndex(state.row); + } + 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 (isLeft(data) || isEsc(data)) goBack(); else if (isEnter(data)) activate(); + syncInputFocus(); tui.requestRender(); }, }; diff --git a/model-groups/types.ts b/model-groups/types.ts index 6eebe0d..9bb9463 100644 --- a/model-groups/types.ts +++ b/model-groups/types.ts @@ -1,40 +1,28 @@ import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; export type ModelGroupScope = "project" | "global"; +export type ModelGroupsAccessPolicy = "global-project" | "global-only"; +export interface ModelGroupsAccess { cwd: string; policy: ModelGroupsAccessPolicy } export interface ModelGroupModel { provider: string; modelId: string; thinkingLevel?: ModelThinkingLevel; } - -export interface ModelGroupDef { - models: ModelGroupModel[]; -} - -export interface ModelGroupsConfig { - version: 1; - groups: Record; -} - +export interface ModelGroupDef { models: ModelGroupModel[] } +export interface ModelGroupsConfig { version: 1; groups: Record } export interface ModelGroupValidation { unavailableRefs: Array<{ provider: string; modelId: string }>; shadowedByProject: boolean; degraded: boolean; } - export interface ModelGroupsLoadedGroup extends ModelGroupDef { name: string; scope: ModelGroupScope; sourcePath: string; } - -export interface ResolvedModelGroup extends ModelGroupsLoadedGroup { - validation: ModelGroupValidation; -} - +export interface ResolvedModelGroup extends ModelGroupsLoadedGroup { validation: ModelGroupValidation } export type ModelGroupsLoadIssueKind = "corrupt-json" | "schema-invalid" | "unsupported-version"; - export interface ModelGroupsLoadIssue { scope: ModelGroupScope; sourcePath: string; @@ -44,19 +32,16 @@ export interface ModelGroupsLoadIssue { backupFailed?: boolean; version?: number; } - export type ModelGroupsPersistenceOperation = "save" | "delete" | "move"; -export type ModelGroupsPersistencePhase = "temp-write" | "rename" | "source-remove" | "load-recovery"; - +export type ModelGroupsPersistencePhase = "config-validation" | "temp-write" | "rename" | "source-remove" | "load-recovery"; export class ModelGroupsPersistenceError extends Error { - readonly operation: ModelGroupsPersistenceOperation; + readonly operation!: ModelGroupsPersistenceOperation; readonly scope?: ModelGroupScope; readonly sourcePath?: string; readonly targetPath?: string; - readonly phase: ModelGroupsPersistencePhase; + readonly phase!: ModelGroupsPersistencePhase; readonly partialMove?: "target-written-source-retained"; readonly cause?: unknown; - constructor(details: { operation: ModelGroupsPersistenceOperation; scope?: ModelGroupScope; @@ -69,23 +54,12 @@ export class ModelGroupsPersistenceError extends Error { }) { super(details.message); this.name = "ModelGroupsPersistenceError"; - this.operation = details.operation; - this.scope = details.scope; - this.sourcePath = details.sourcePath; - this.targetPath = details.targetPath; - this.phase = details.phase; - this.partialMove = details.partialMove; - this.cause = details.cause; + Object.assign(this, details); } } - export interface ModelGroupsLoadResult { configs: Record; merged: ModelGroupsLoadedGroup[]; issues: ModelGroupsLoadIssue[]; } - -export interface ModelGroupsBootValidation { - groups: ResolvedModelGroup[]; - loadIssues: ModelGroupsLoadIssue[]; -} +export interface ModelGroupsBootValidation { groups: ResolvedModelGroup[]; loadIssues: ModelGroupsLoadIssue[] } diff --git a/package-lock.json b/package-lock.json index 944ec5f..ec67f2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "version": "0.3.0", "license": "MIT", "devDependencies": { - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-coding-agent": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-ai": "^0.80.7", + "@earendil-works/pi-coding-agent": "^0.80.7", + "@earendil-works/pi-tui": "^0.80.7", "fast-check": "^4.8.0", "typebox": "^1.2.2", "typescript": "^6.0.3" @@ -20,10 +20,10 @@ "node": ">=22" }, "peerDependencies": { - "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-tui": "*", - "typebox": "*" + "@earendil-works/pi-ai": "^0.80.7", + "@earendil-works/pi-coding-agent": "^0.80.7", + "@earendil-works/pi-tui": "^0.80.7", + "typebox": "^1.2.2" } }, "node_modules/@anthropic-ai/sdk": { @@ -535,16 +535,17 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.1.tgz", - "integrity": "sha512-CM2pkTs1iupG/maw381lC9Q/Y/aQaMGK7GILc28ttImD0ci3LDwKroDsGkWbly5JIy3iqxdRxB9JlG7vvzCzTg==", + "version": "0.80.7", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.7.tgz", + "integrity": "sha512-8RLKLwe5TFM9kKFMNu/lTzveduq4GxZbnlG6ba8FAhLeb5wJP4zbj1eBumKBRvggpFQnW5R/Vo2a8zTlHsV9SQ==", "dev": true, "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", @@ -567,16 +568,16 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.78.1.tgz", - "integrity": "sha512-Syjf6Ib8UoY5t9ZdKjp0BRrQZuFkFBc8j2KEU9zG/ZnmYPcAxYeioofdv2Q3MEXnHEX2U8sKQptkSnJIdMsd0g==", + "version": "0.80.7", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.7.tgz", + "integrity": "sha512-mxq3IClhdgmCrYiKuzKehs4QKCVJgKmlA70nUEzsgeNsGdxriT7o5sKQTCcxzVJkzDRMcHD/8tAP4/eGrV4gKQ==", "dev": true, "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.78.1", - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-agent-core": "^0.80.7", + "@earendil-works/pi-ai": "^0.80.7", + "@earendil-works/pi-tui": "^0.80.7", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -588,8 +589,9 @@ "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", + "semver": "7.8.0", "typebox": "1.1.38", - "undici": "8.3.0", + "undici": "8.5.0", "yaml": "2.9.0" }, "bin": { @@ -1065,12 +1067,12 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.78.1.tgz", + "version": "0.80.7", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.7.tgz", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-ai": "^0.80.7", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -1080,15 +1082,16 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.1.tgz", + "version": "0.80.7", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.7.tgz", "dev": true, "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", @@ -1104,13 +1107,13 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.78.1.tgz", + "version": "0.80.7", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.7.tgz", "dev": true, "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", - "marked": "15.0.12" + "marked": "18.0.5" }, "engines": { "node": ">=22.19.0" @@ -1347,15 +1350,24 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", "dev": true, "license": "Apache-2.0", "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { @@ -1371,6 +1383,26 @@ ], "license": "MIT" }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1393,9 +1425,9 @@ "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "dev": true, "license": "BSD-3-Clause" }, @@ -1416,13 +1448,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -2066,16 +2091,16 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "dev": true, "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { @@ -2267,9 +2292,9 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", - "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", @@ -2277,15 +2302,14 @@ "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -2322,6 +2346,19 @@ ], "license": "MIT" }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2387,9 +2424,9 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "dev": true, "license": "MIT", "engines": { @@ -2430,9 +2467,9 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -2504,14 +2541,14 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.78.1.tgz", - "integrity": "sha512-07GVQo/38a0yvIPlWDr3RJn1B8gk3ZuIX9h2oIQ+Biyu3JN0KppWmgWHfaWRydQgse5JtC++KDw5MWaIRnV0mw==", + "version": "0.80.7", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.7.tgz", + "integrity": "sha512-1B2++fLZfgI3XMzW2BTpuDuam2uyHnUUEmsOvi5R0Ne9RAt59WjFV0G8ozX6l1Xafa9P5Y3eT4aDtRr/v/CUTA==", "dev": true, "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", - "marked": "15.0.12" + "marked": "18.0.5" }, "engines": { "node": ">=22.19.0" @@ -2543,15 +2580,24 @@ } }, "node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", "dev": true, "license": "Apache-2.0", "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@nodable/entities": { @@ -2567,6 +2613,26 @@ ], "license": "MIT" }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -3139,16 +3205,16 @@ "license": "Apache-2.0" }, "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "dev": true, "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/ms": { diff --git a/package.json b/package.json index 86336d7..98db041 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,9 @@ "node": ">=22" }, "peerDependencies": { - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-coding-agent": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-ai": "^0.80.7", + "@earendil-works/pi-coding-agent": "^0.80.7", + "@earendil-works/pi-tui": "^0.80.7", "typebox": "^1.2.2" }, "scripts": { @@ -28,9 +28,9 @@ "test:snapshots:update": "node ./scripts/run-node-test.mjs --update-snapshots tests/unit/render-snapshots.test.ts" }, "devDependencies": { - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-coding-agent": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-ai": "^0.80.7", + "@earendil-works/pi-coding-agent": "^0.80.7", + "@earendil-works/pi-tui": "^0.80.7", "fast-check": "^4.8.0", "typebox": "^1.2.2", "typescript": "^6.0.3" diff --git a/tests/typecheck/model-groups-producer.ts b/tests/typecheck/model-groups-producer.ts new file mode 100644 index 0000000..0c6219b --- /dev/null +++ b/tests/typecheck/model-groups-producer.ts @@ -0,0 +1,16 @@ +import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; +import { escapeDisplayLabel } from "../../model-groups/display.js"; +import { canonicalizeModelGroupName } from "../../model-groups/names.js"; +import type { ModelGroupModel, ModelGroupsAccess, ModelGroupsBootValidation } from "../../model-groups/types.js"; +import { createState } from "../../state.js"; + +const nativeMax: ModelThinkingLevel = "max"; +const model: ModelGroupModel = { provider: "provider", modelId: "model", thinkingLevel: nativeMax }; +const access: ModelGroupsAccess = { cwd: "/project", policy: "global-only" }; +const canonical = canonicalizeModelGroupName(" canonical "); +const displayed = escapeDisplayLabel("controlled\nlabel"); +const validation: ModelGroupsBootValidation = { groups: [], loadIssues: [] }; +const state = createState(); +state.modelGroups = { groups: validation.groups, validation }; + +void [model, access, canonical, displayed, state]; diff --git a/tests/unit/model-groups-crud.test.ts b/tests/unit/model-groups-crud.test.ts index 62c7b8c..d318bc9 100644 --- a/tests/unit/model-groups-crud.test.ts +++ b/tests/unit/model-groups-crud.test.ts @@ -15,9 +15,11 @@ import { updateGroup, validateModelGroups, } from "../../model-groups/store.js"; -import { ModelGroupsPersistenceError, type ModelGroupScope } from "../../model-groups/types.js"; +import { ModelGroupsPersistenceError, type ModelGroupScope, type ModelGroupsAccess } from "../../model-groups/types.js"; import { withTemp } from "./model-groups-helpers.js"; +function access(cwd: string, policy: ModelGroupsAccess["policy"] = "global-project"): ModelGroupsAccess { return { cwd, policy }; } + function read(scope: ModelGroupScope, cwd: string): any { return JSON.parse(fs.readFileSync(modelGroupsPath(scope, cwd), "utf8")); } @@ -36,43 +38,43 @@ 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.deepEqual(loadModelGroups(cwd).configs.project.groups, {}); - createGroup("project", cwd, "review", { models: [] }); + assert.equal(Object.keys(loadModelGroups(access(cwd)).configs.project.groups).length, 0); + createGroup("project", access(cwd), "review", { models: [] }); assert.deepEqual(read("project", cwd).groups.review.models, []); - assert.throws(() => createGroup("project", cwd, "review", { models: [] }), /already exists/); + assert.throws(() => createGroup("project", access(cwd), "review", { models: [] }), /already exists/); - createGroup("project", cwd, "inherit-roundtrip", { models: [{ provider: "anthropic", modelId: "claude" }] }); - const inheritLoaded = loadModelGroups(cwd).configs.project.groups["inherit-roundtrip"].models[0]; + createGroup("project", access(cwd), "inherit-roundtrip", { models: [{ provider: "anthropic", modelId: "claude" }] }); + 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); const inheritPersisted = read("project", cwd).groups["inherit-roundtrip"].models[0]; assert.equal(inheritPersisted.thinkingLevel, undefined); assert.equal(Object.prototype.hasOwnProperty.call(inheritPersisted, "thinkingLevel"), false); - updateGroup("project", cwd, "review", { models: [{ provider: "openai", modelId: "gpt-5", thinkingLevel: "high" }] }); - renameGroup("project", cwd, "review", "reviewers"); + updateGroup("project", access(cwd), "review", { models: [{ provider: "openai", modelId: "gpt-5", thinkingLevel: "high" }] }); + 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", cwd, "collision", { models: [] }); - assert.throws(() => renameGroup("project", cwd, "reviewers", "collision"), /already exists/); - createGroup("global", cwd, "reviewers", { models: [{ provider: "openai", modelId: "gpt-5" }, { provider: "missing", modelId: "nope" }] }); - const loaded = loadModelGroups(cwd); + createGroup("project", access(cwd), "collision", { models: [] }); + 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" }] }); + const loaded = loadModelGroups(access(cwd)); const resolved = validateModelGroups(loaded, registry()); const globalReviewers = resolved.find((g) => g.name === "reviewers" && g.scope === "global"); assert.equal(globalReviewers?.validation.shadowedByProject, true); assert.deepEqual(globalReviewers?.validation.unavailableRefs, [{ provider: "missing", modelId: "nope" }]); assert.equal(globalReviewers?.validation.degraded, true); - createGroup("global", cwd, "move-collision", { models: [] }); - createGroup("project", cwd, "move-collision", { models: [] }); - assert.throws(() => moveGroup(cwd, "move-collision", "project"), /already exists in project scope/); + createGroup("global", access(cwd), "move-collision", { models: [] }); + createGroup("project", access(cwd), "move-collision", { models: [] }); + 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"]); - const deleted = deleteGroup("global", cwd, "reviewers"); + const deleted = deleteGroup("global", access(cwd), "reviewers"); assert.equal(deleted.otherScopeHasOverride, true); - moveGroup(cwd, "reviewers", "global"); + moveGroup(access(cwd), "reviewers", "global"); assert.ok(read("global", cwd).groups.reviewers); assert.equal(read("project", cwd).groups.reviewers, undefined); })); @@ -80,34 +82,34 @@ test("model groups store creates, round-trips, validates, renames, updates, dele test("model groups load recovery handles malformed, schema-invalid, unsupported version, and backup failure", () => withTemp(({ cwd }) => { fs.mkdirSync(path.dirname(modelGroupsPath("global", cwd)), { recursive: true }); fs.writeFileSync(modelGroupsPath("global", cwd), "{not json", "utf8"); - let loaded = loadModelGroups(cwd); + let loaded = loadModelGroups(access(cwd)); assert.equal(loaded.issues[0].kind, "corrupt-json"); 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"); - loaded = loadModelGroups(cwd); + loaded = loadModelGroups(access(cwd)); const schemaIssue = loaded.issues.find((i) => i.scope === "project")!; assert.equal(schemaIssue.kind, "schema-invalid"); assert.equal(schemaIssue.scope, "project"); assert.equal(schemaIssue.sourcePath, modelGroupsPath("project", cwd)); assert.equal(schemaIssue.backupPath, `${modelGroupsPath("project", cwd)}.bak`); - assert.match(schemaIssue.message, /invalid model entry/); + assert.match(schemaIssue.message, /provider/); assert.ok(fs.existsSync(schemaIssue.backupPath!)); - assert.deepEqual(loaded.configs.project.groups, {}); + assert.equal(Object.keys(loaded.configs.project.groups).length, 0); fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 99, groups: {} }), "utf8"); - loaded = loadModelGroups(cwd); + loaded = loadModelGroups(access(cwd)); assert.equal(loaded.issues.find((i) => i.scope === "project")?.kind, "unsupported-version"); assert.equal(loaded.issues.find((i) => i.scope === "project")?.version, 99); fs.writeFileSync(modelGroupsPath("project", cwd), "{bad", "utf8"); __setModelGroupsFsForTests({ copyFileSync: () => { throw new Error("denied"); } }); - loaded = loadModelGroups(cwd); + loaded = loadModelGroups(access(cwd)); 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", cwd, "must-not-overwrite", { models: [] }), (error) => { + assert.throws(() => createGroup("project", access(cwd), "must-not-overwrite", { models: [] }), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.operation, "save"); assert.equal(error.phase, "load-recovery"); @@ -117,9 +119,9 @@ test("model groups load recovery handles malformed, schema-invalid, unsupported })); test("model groups persistence failures throw typed errors and preserve committed state", () => withTemp(({ cwd }) => { - saveModelGroups("project", cwd, { version: 1, groups: { keep: { models: [] } } }); + saveModelGroups("project", access(cwd), { version: 1, groups: { keep: { models: [] } } }); __setModelGroupsFsForTests({ writeFileSync: () => { throw new Error("temp denied"); } }); - assert.throws(() => updateGroup("project", cwd, "keep", { models: [{ provider: "openai", modelId: "gpt-5" }] }), (error) => { + assert.throws(() => updateGroup("project", access(cwd), "keep", { models: [{ provider: "openai", modelId: "gpt-5" }] }), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.operation, "save"); assert.equal(error.phase, "temp-write"); @@ -133,7 +135,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", cwd, { version: 1, groups: { drop: { models: [] } } }), (error) => { + assert.throws(() => saveModelGroups("project", access(cwd), { version: 1, groups: { drop: { models: [] } } }), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.phase, "rename"); return true; @@ -143,16 +145,16 @@ test("model groups persistence failures throw typed errors and preserve committe assert.equal(read("project", cwd).groups.drop, undefined); __setModelGroupsFsForTests({ renameSync: () => { throw new Error("delete denied"); } }); - assert.throws(() => deleteGroup("project", cwd, "keep"), (error) => { + assert.throws(() => deleteGroup("project", access(cwd), "keep"), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.operation, "delete"); return true; }); __setModelGroupsFsForTests(null); - createGroup("global", cwd, "move-target-fails", { models: [] }); + createGroup("global", access(cwd), "move-target-fails", { models: [] }); __setModelGroupsFsForTests({ renameSync: () => { throw new Error("target denied"); } }); - assert.throws(() => moveGroup(cwd, "move-target-fails", "project"), (error) => { + assert.throws(() => moveGroup(access(cwd), "move-target-fails", "project"), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.operation, "move"); assert.equal(error.partialMove, undefined); @@ -160,13 +162,106 @@ test("model groups persistence failures throw typed errors and preserve committe }); __setModelGroupsFsForTests(null); - createGroup("global", cwd, "move-me", { models: [] }); + createGroup("global", access(cwd), "move-me", { models: [] }); let writes = 0; __setModelGroupsFsForTests({ renameSync: (from, to) => { writes++; if (writes === 2) throw new Error("source denied"); fs.renameSync(from, to); } }); - assert.throws(() => moveGroup(cwd, "move-me", "project"), (error) => { + assert.throws(() => moveGroup(access(cwd), "move-me", "project"), (error) => { assert.ok(error instanceof ModelGroupsPersistenceError); assert.equal(error.partialMove, "target-written-source-retained"); assert.equal(error.phase, "source-remove"); return true; }); })); + +test("model groups strictly partitions schema and legacy version domains", () => withTemp(({ cwd }) => { + const projectPath = modelGroupsPath("project", cwd); + fs.mkdirSync(path.dirname(projectPath), { recursive: true }); + 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/], + ]; + for (const [label, raw, message] of invalid) { + 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); + assert.equal(Object.keys(loaded.configs.project.groups).length, 0, label); + } + for (const raw of [{ groups: { legacy: { models: [] } } }, { version: 0, groups: { legacy: { models: [] } } }]) { + 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); + } +})); + +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":[]}}}'; + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), raw, "utf8"); + let loaded = loadModelGroups(access(cwd)); + for (const name of ["__proto__", "constructor", "toString"]) assert.ok(Object.hasOwn(loaded.configs.project.groups, name)); + assert.equal(loaded.configs.project.groups.__proto__.models[0].thinkingLevel, "max"); + saveModelGroups("project", access(cwd), loaded.configs.project); + const persisted = read("project", cwd); + assert.deepEqual(Object.keys(persisted.groups).sort(), ["__proto__", "constructor", "toString"].sort()); + assert.equal(Object.getPrototypeOf(loaded.configs.project.groups), null); + + 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 }] }); + moveGroup(access(cwd), name, "project"); + assert.ok(Object.hasOwn(read("project", cwd).groups, name)); + deleteGroup("project", access(cwd), name); + } + + let projectProbe = false; + __setModelGroupsFsForTests({ existsSync: (candidate) => { + if (String(candidate).startsWith(cwd)) { projectProbe = true; throw new Error("project probed"); } + return fs.existsSync(candidate); + } }); + 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.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: [] } } }); + const unique: Record = Object.create(null); + Object.defineProperty(unique, " unique ", { value: { models: [] }, enumerable: true }); + saveModelGroups("project", a, { version: 1, groups: unique }); + assert.deepEqual(Object.keys(read("project", cwd).groups), ["unique"]); + for (const keys of [[" "], ["same", " same "]]) { + const groups: Record = Object.create(null); + for (const key of keys) Object.defineProperty(groups, key, { value: { models: [] }, enumerable: true }); + 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.ok(error instanceof ModelGroupsPersistenceError); + assert.equal(error.operation, "save"); + assert.equal(error.phase, "config-validation"); + return true; + }); + assert.equal(writes, 0); + assert.equal(fs.readFileSync(modelGroupsPath("project", cwd), "utf8"), before); + __setModelGroupsFsForTests(null); + } +})); diff --git a/tests/unit/model-groups-display.test.ts b/tests/unit/model-groups-display.test.ts new file mode 100644 index 0000000..d4056f5 --- /dev/null +++ b/tests/unit/model-groups-display.test.ts @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { decodeDisplayLabel, escapeDisplayLabel } from "../../model-groups/display.js"; + +const dangerous = [ + ...Array.from({ length: 0x20 }, (_, i) => String.fromCharCode(i)), + String.fromCharCode(0x7f), + ...Array.from({ length: 0x20 }, (_, i) => String.fromCharCode(0x80 + i)), + "\u2028", + "\u2029", +]; + +test("model-groups display codec reversibly escapes every controlled code point", () => { + for (const raw of ["ordinary λ", "\\", ...dangerous, "\u001b]8;;https://example.test\u0007link\u001b]8;;\u0007"]) { + const encoded = escapeDisplayLabel(raw); + assert.deepEqual(decodeDisplayLabel(encoded), { ok: true, value: raw }); + for (const codePoint of encoded) { + const code = codePoint.codePointAt(0)!; + assert.equal(code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0x2028 || code === 0x2029, false, JSON.stringify({ raw, encoded })); + } + } + assert.equal(escapeDisplayLabel("\n\r\t\u0007\u001b\u007f\u0080\u009f\u2028\u2029\\"), "\\n\\r\\t\\x07\\x1B\\x7F\\x80\\x9F\\u2028\\u2029\\\\"); +}); + +test("model-groups display decoder rejects non-canonical, unknown, malformed, and incomplete escapes", () => { + for (const encoded of ["\\q", "\\x", "\\x0", "\\x0g", "\\x1b", "\\x20", "\\u2027", "\\u202A", "trailing\\"]) { + assert.equal(decodeDisplayLabel(encoded).ok, false, encoded); + } +}); diff --git a/tests/unit/model-groups-integration.test.ts b/tests/unit/model-groups-integration.test.ts index bbcf5d1..8ed15f2 100644 --- a/tests/unit/model-groups-integration.test.ts +++ b/tests/unit/model-groups-integration.test.ts @@ -3,9 +3,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; import registerAgenticoding from "../../index.js"; -import { registerModelGroupsCommand } from "../../model-groups/command.js"; import { __setModelGroupsFsForTests, modelGroupsPath } from "../../model-groups/store.js"; -import { createState } from "../../state.js"; import { createTestPI, theme } from "./helpers.js"; import { withTemp } from "./model-groups-helpers.js"; @@ -23,7 +21,6 @@ test("/model-groups command registers and opens ctx.ui.custom with live registry fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 1, groups: { "cwd-sentinel-group": { models: [{ provider: "openai", modelId: "gpt-5" }] } } }), "utf8"); const pi = createTestPI(); - const state = createState(); const findCalls: string[] = []; const registrySentinel = { ...registry(), @@ -33,12 +30,14 @@ test("/model-groups command registers and opens ctx.ui.custom with live registry }, hasConfiguredAuth: () => true, }; - registerModelGroupsCommand(pi as any, state); + registerAgenticoding(pi as any); assert.ok(pi.commands.has("model-groups")); let customCalled = 0; let rendered = ""; await pi.commands.get("model-groups")!.handler("", { hasUI: true, + mode: "tui", + isProjectTrusted: () => true, cwd, modelRegistry: registrySentinel, ui: { @@ -66,6 +65,8 @@ test("index session_start stores model group validation and notifies load and va const notifications: string[] = []; const ctx = { hasUI: true, + mode: "tui", + isProjectTrusted: () => true, cwd, modelRegistry: registry(), getContextUsage: () => ({ percent: 10 }), @@ -91,6 +92,8 @@ test("index session_start notifies corrupt/schema/unsupported load issues", asyn const notifications: string[] = []; const ctx = { hasUI: true, + mode: "tui", + isProjectTrusted: () => true, cwd, modelRegistry: registry(), getContextUsage: () => ({ percent: 10 }), @@ -110,6 +113,8 @@ test("index session_start notifies schema-invalid load issues", async () => with const notifications: string[] = []; const ctx = { hasUI: true, + mode: "tui", + isProjectTrusted: () => true, cwd, modelRegistry: registry(), getContextUsage: () => ({ percent: 10 }), @@ -131,6 +136,8 @@ test("index session_start includes backup-failure detail in load issue notificat const notifications: string[] = []; const ctx = { hasUI: true, + mode: "tui", + isProjectTrusted: () => true, cwd, modelRegistry: registry(), getContextUsage: () => ({ percent: 10 }), @@ -138,7 +145,7 @@ test("index session_start includes backup-failure detail in load issue notificat }; const handler = pi.handlers.get("session_start")!.at(-1)!; await handler({ reason: "load" }, ctx); - assert.ok(notifications.some((m) => /corrupt-json/.test(m) && /backup failed, original file left untouched/.test(m) && m.includes(modelGroupsPath("project", cwd)))); + assert.ok(notifications.some((m) => /corrupt-json/.test(m) && /backup failed.*original file left untouched/.test(m) && m.includes(modelGroupsPath("project", cwd)))); })); test("before_agent_start injects fresh names-only Model Groups guidance", async () => withTemp(async ({ cwd }) => { @@ -147,7 +154,7 @@ test("before_agent_start injects fresh names-only Model Groups guidance", async 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, cwd, modelRegistry: registry(), getContextUsage: () => null }); + 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, /exact group name/); @@ -164,6 +171,8 @@ test("session_start registers Model Groups autocomplete provider when UI support const handler = pi.handlers.get("session_start")!.at(-1)!; await handler({ reason: "load" }, { hasUI: true, + mode: "tui", + isProjectTrusted: () => true, cwd, modelRegistry: registry(), getContextUsage: () => ({ percent: 10 }), @@ -178,6 +187,8 @@ test("index session_start does not notify when load and validation issues are ab const notifications: string[] = []; const ctx = { hasUI: true, + mode: "tui", + isProjectTrusted: () => true, cwd, modelRegistry: registry(), getContextUsage: () => ({ percent: 10 }), @@ -187,3 +198,68 @@ test("index session_start does not notify when load and validation issues are ab await handler({ reason: "load" }, ctx); assert.deepEqual(notifications, []); })); + +test("root-recorded /model-groups rejects RPC and skips JSON/print before custom UI", async () => { + for (const mode of ["rpc", "json", "print"] as const) { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notifications: string[] = []; + let customCalls = 0; + await pi.commands.get("model-groups")!.handler("", { + mode, + hasUI: mode === "rpc", + cwd: "/must-not-load", + modelRegistry: registry(), + isProjectTrusted: () => true, + ui: { notify: (message: string) => notifications.push(message), custom: async () => { customCalls++; } }, + }); + assert.equal(customCalls, 0, mode); + assert.deepEqual(notifications, mode === "rpc" ? ["/model-groups requires TUI mode"] : [], mode); + } +}); + +test("model groups untrusted root flow never probes project data and publishes global-only state to TUI", async () => withTemp(async ({ cwd }) => { + fs.mkdirSync(path.dirname(modelGroupsPath("global", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("global", cwd), JSON.stringify({ version: 1, groups: { globalOnly: { models: [] } } }), "utf8"); + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 1, groups: { projectSecret: { models: [] } } }), "utf8"); + let projectProbe = false; + __setModelGroupsFsForTests({ existsSync: (candidate) => { + if (String(candidate).startsWith(cwd)) { projectProbe = true; throw new Error("project probe"); } + return fs.existsSync(candidate); + } }); + const pi = createTestPI(); + registerAgenticoding(pi as any); + const baseUi = { theme, notify: () => {}, setStatus: () => {}, setWidget: () => {}, addAutocompleteProvider: () => {} }; + await pi.handlers.get("session_start")!.at(-1)!({ reason: "load" }, { + mode: "tui", hasUI: true, isProjectTrusted: () => false, cwd, modelRegistry: registry(), getContextUsage: () => ({ percent: 10 }), ui: baseUi, + }); + assert.equal(projectProbe, false); + let rendered = ""; + await pi.commands.get("model-groups")!.handler("", { + mode: "tui", hasUI: true, isProjectTrusted: () => false, cwd, modelRegistry: registry(), + ui: { ...baseUi, custom: async (factory: any) => { rendered = factory({ requestRender: () => {} }, theme, {}, () => {}).render(80).join("\n"); } }, + }); + assert.match(rendered, /globalOnly/); + assert.doesNotMatch(rendered, /projectSecret|\[project\]/); + assert.equal(projectProbe, false); +})); + +test("model groups boot load-issue notifications escape hostile source, backup, and error detail fields", async () => withTemp(async ({ cwd }) => { + const hostileCwd = `${cwd}\n\u001b]8;;https://example.test\u0007path`; + const projectPath = modelGroupsPath("project", hostileCwd); + fs.mkdirSync(path.dirname(projectPath), { recursive: true }); + fs.writeFileSync(projectPath, "{bad", "utf8"); + __setModelGroupsFsForTests({ copyFileSync: () => { throw new Error("backup\n\u001b[31mfailed\u0007"); } }); + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notifications: string[] = []; + await pi.handlers.get("session_start")!.at(-1)!({ reason: "load" }, { + mode: "tui", hasUI: true, isProjectTrusted: () => true, cwd: hostileCwd, modelRegistry: registry(), getContextUsage: () => ({ percent: 10 }), + ui: { theme, notify: (message: string) => notifications.push(message), setStatus: () => {}, setWidget: () => {}, addAutocompleteProvider: () => {} }, + }); + const notification = notifications.find((message) => message.includes("corrupt-json"))!; + assert.match(notification, /\\n\\x1B\]8;;https:\/example\.test\\x07path/); + assert.match(notification, /backup\\n\\x1B\[31mfailed\\x07/); + assert.doesNotMatch(notification, /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/); +})); diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts index 28fc89f..69f1574 100644 --- a/tests/unit/model-groups-tui.test.ts +++ b/tests/unit/model-groups-tui.test.ts @@ -1,5 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { CURSOR_MARKER, visibleWidth } from "@earendil-works/pi-tui"; import { createModelGroupsComponent } from "../../model-groups/tui.js"; import { ModelGroupsPersistenceError, type ModelGroupsBootValidation, type ResolvedModelGroup } from "../../model-groups/types.js"; import { theme } from "./helpers.js"; @@ -9,7 +10,7 @@ function registry(): any { const models = [ { provider: "anthropic", id: "claude", reasoning: false }, { provider: "google", id: "gemini-no-auth", reasoning: true, configuredAuth: false }, - { provider: "openai", id: "gpt-5", reasoning: true, thinkingLevelMap: { xhigh: "x" } }, + { provider: "openai", id: "gpt-5", reasoning: true, thinkingLevelMap: { xhigh: "x", max: "m" } }, { provider: "openai", id: "gpt-no-auth", reasoning: true, configuredAuth: false }, ]; return { @@ -22,13 +23,13 @@ function registry(): any { function boot(groups: ResolvedModelGroup[]): ModelGroupsBootValidation { return { groups, loadIssues: [] }; } -function component(args: { groups?: ResolvedModelGroup[]; store?: any; notify?: (m: string, t?: any) => void; renderTheme?: any } = {}) { +function component(args: { groups?: ResolvedModelGroup[]; store?: any; notify?: (m: string, t?: any) => void; renderTheme?: any; policy?: "global-project" | "global-only" } = {}) { let renders = 0; const c = createModelGroupsComponent( { requestRender: () => { renders++; } } as any, args.renderTheme ?? theme, registry(), - "/tmp/project", + { cwd: "/tmp/project", policy: args.policy ?? "global-project" }, () => {}, { initialValidation: boot(args.groups ?? []), store: args.store, notify: args.notify }, ); @@ -202,9 +203,9 @@ test("model groups TUI selected markers and primary labels use accent token", () const list = component({ groups: [group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] })], renderTheme: accentTheme }).c; let text = rendered(list); - assert.match(text, /→<\/accent> review<\/accent> \[project\]/); + assert.match(text, /→ review\s+\[project\] 1 models inherit<\/accent>/); press(list, DOWN); - assert.match(rendered(list), /→<\/accent> \+ Add group<\/accent>/); + assert.match(rendered(list), /→ \+ Add group<\/accent>/); const editor = component({ groups: [group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] })], renderTheme: accentTheme }).c; press(editor, ENTER); @@ -216,11 +217,11 @@ test("model groups TUI selected markers and primary labels use accent token", () assert.match(rendered(editor), /→<\/accent> \+ Add model…<\/accent>/); press(editor, ENTER); - assert.match(rendered(editor), /→<\/accent> anthropic<\/accent>/); + assert.match(rendered(editor), /→ anthropic<\/accent>/); press(editor, DOWN, ENTER); - assert.match(rendered(editor), /→<\/accent> openai\/gpt-5<\/accent>/); + assert.match(rendered(editor), /→ openai\/gpt-5<\/accent>/); press(editor, ENTER); - assert.match(rendered(editor), /→<\/accent> inherit<\/accent>/); + 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); @@ -334,7 +335,7 @@ test("model groups TUI name edit commits through renameGroup on row-change and D c.handleInput?.("\u001b[B"); // name row c.handleInput?.("\r"); // focus name c.handleInput?.("d"); - assert.match(c.render(100).join("\n"), /Name: abcd_/); + assert.match(c.render(100).join("\n"), /> abcd/); c.handleInput?.("\u001b[B"); // row-change flushes the pending rename before moving to + Add model assert.deepEqual(calls, ["abc->abcd"]); const rendered = c.render(100).join("\n"); @@ -416,3 +417,141 @@ test("model groups TUI notifies and keeps visible state on persistence errors", assert.match(messages[0], /collision/); assert.match(c.render(100).join("\n"), /Model Group: review/); }); + +test("model groups TUI uses root Focusable propagation and MODEL_EDIT parent navigation", () => { + const c = component({ groups: [group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] })] }).c; + c.focused = true; + press(c, ENTER, DOWN, DOWN, ENTER); + 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); + assert.match(rendered(c), /Edit model/); + press(c, ESC); + assert.match(rendered(c), /Location: project/); + assert.doesNotMatch(rendered(c), /Edit model/); +}); + +test("model groups TUI clamps final add row and enforces global-only access", () => { + let groups = [group("global-name", { scope: "global" }), group("project-secret", { scope: "project" })]; + const calls: any[] = []; + const store = { + createGroup: (scope: string, access: any, name: string, def: any) => { + calls.push({ scope, access, name, def }); + groups = [group("global-name", { scope: "global" }), group(name, { scope: "global" })]; + }, + listResolvedModelGroups: () => boot(groups.filter((candidate) => candidate.scope === "global")), + }; + const c = component({ groups, store, policy: "global-only" }).c; + assert.doesNotMatch(rendered(c), /project-secret/); + press(c, DOWN, DOWN); + assert.match(rendered(c), /→ \+ Add group/); + press(c, ENTER); + assert.equal(calls.length, 1); + assert.equal(calls[0].scope, "global"); + assert.deepEqual(calls[0].access, { cwd: "/tmp/project", policy: "global-only" }); + assert.deepEqual(calls[0].def, { models: [] }); + const text = rendered(c); + assert.match(text, /Location: global/); + assert.doesNotMatch(text, /Location: project/); +}); + +test("model groups TUI escapes controlled labels, bounds width, and offers native max", () => { + const hostile = "group\n\u001b]8;;https://example.test\u0007link\u001b]8;;\u0007"; + const c = component({ groups: [group(hostile, { scope: "project", models: [{ provider: "openai\u001b[31m", modelId: "model\nlong" }] })] }).c; + const lines = c.render(20); + assert.equal(lines.every((line) => visibleWidth(line) <= 20), true); + const text = lines.join("\n"); + assert.match(text, /group\\n\\x1B/); + assert.doesNotMatch(text, /\u001b\]8;;/); + + let maxGroups = [group("review", { scope: "project" })]; + let selectedThinking: string | undefined; + const max = component({ + groups: maxGroups, + store: { + updateGroup: (_scope: string, _access: any, name: string, def: any) => { selectedThinking = def.models.at(-1)?.thinkingLevel; maxGroups = [group(name, { scope: "project", models: def.models })]; }, + listResolvedModelGroups: () => boot(maxGroups), + }, + }).c; + press(max, ENTER, DOWN, DOWN, DOWN, ENTER); + press(max, DOWN, ENTER, ENTER); + assert.match(rendered(max), /Add model — Step 3\/3 Thinking/); + assert.match(rendered(max), /max/); + press(max, ...Array(20).fill(DOWN), ENTER); + assert.equal(selectedThinking, "max"); +}); + +test("model groups TUI decodes then canonicalizes prototype-sensitive names and rejects malformed escapes", () => { + for (const nextName of ["__proto__", "constructor", "toString"]) { + let groups = [group("abc", { scope: "project" })]; + const calls: string[] = []; + const notifications: string[] = []; + const store = { + renameGroup: (_scope: string, _access: any, oldName: string, renamed: string) => { calls.push(`${oldName}->${renamed}`); groups = [group(renamed, { scope: "project" })]; }, + listResolvedModelGroups: () => boot(groups), + }; + const c = component({ groups, store, notify: (message) => notifications.push(message) }).c; + press(c, ENTER, DOWN, DOWN, ENTER, "\u007f", "\u007f", "\u007f", ...[...` ${nextName} `], DOWN); + assert.deepEqual(calls, [`abc->${nextName}`]); + assert.match(rendered(c), new RegExp(nextName.replaceAll("_", "\\_"))); + assert.deepEqual(notifications, []); + } + + const malformedCalls: string[] = []; + const notifications: string[] = []; + const malformed = component({ + groups: [group("abc", { scope: "project" })], + store: { renameGroup: () => malformedCalls.push("called"), listResolvedModelGroups: () => boot([group("abc", { scope: "project" })]) }, + notify: (message) => notifications.push(message), + }).c; + press(malformed, ENTER, DOWN, DOWN, ENTER, "\\", ENTER); + assert.deepEqual(malformedCalls, []); + assert.equal(notifications.length, 1); + assert.match(rendered(malformed), /abc/); +}); + +test("model groups TUI keeps every screen width-bounded without wrapping logical rows", () => { + const assertScreen = (c: ReturnType["c"]) => { + const wideCount = c.render(200).length; + const narrow = c.render(12); + assert.equal(narrow.length, wideCount); + assert.equal(narrow.every((line) => visibleWidth(line) <= 12), true); + }; + const longModel = { provider: "openai", modelId: "gpt-5", thinkingLevel: "max" as const }; + const c = component({ groups: [group("a-very-long-group-name", { scope: "project", models: [longModel] })] }).c; + assertScreen(c); // LIST + press(c, ENTER); + assertScreen(c); // EDITOR + press(c, DOWN, DOWN, DOWN, ENTER); + assertScreen(c); // MODEL_EDIT + press(c, ESC, DOWN, DOWN, DOWN, DOWN, ENTER); + assertScreen(c); // WIZARD_PROVIDER + press(c, DOWN, DOWN, ENTER); + assertScreen(c); // WIZARD_MODEL + press(c, ENTER); + assertScreen(c); // WIZARD_THINKING + const deletion = component({ groups: [group("a-very-long-group-name", { scope: "project", models: [longModel] })] }).c; + press(deletion, "D"); + assertScreen(deletion); // DELETE_CONFIRM +}); + +test("model groups TUI persistence notifications escape each hostile dynamic field", () => { + const notifications: string[] = []; + const raw = "\n\u001b]8;;https://example.test\u0007field"; + const c = component({ + groups: [group("review", { scope: "project" })], + store: { + renameGroup: () => { throw new ModelGroupsPersistenceError({ operation: "save", scope: "project", sourcePath: `source${raw}`, targetPath: `target${raw}`, phase: "rename", message: `message${raw}`, cause: new Error(`cause${raw}`) }); }, + listResolvedModelGroups: () => boot([group("review", { scope: "project" })]), + }, + notify: (message) => notifications.push(message), + }).c; + press(c, ENTER, DOWN, DOWN, ENTER, "x", ENTER); + assert.equal(notifications.length, 1); + assert.match(notifications[0], /source\\n\\x1B/); + assert.match(notifications[0], /target\\n\\x1B/); + assert.match(notifications[0], /message\\n\\x1B/); + assert.match(notifications[0], /cause\\n\\x1B/); + assert.doesNotMatch(notifications[0], /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/); +}); diff --git a/tsconfig.model-groups-producer.json b/tsconfig.model-groups-producer.json new file mode 100644 index 0000000..cd2e5a1 --- /dev/null +++ b/tsconfig.model-groups-producer.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "include": [], + "files": [ + "model-groups/types.ts", + "model-groups/names.ts", + "model-groups/store.ts", + "model-groups/display.ts", + "model-groups/tui.ts", + "model-groups/command.ts", + "state.ts", + "tests/typecheck/model-groups-producer.ts" + ] +} From 41d6d28cd33af646938a223ad062acc546082dec Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Thu, 16 Jul 2026 14:36:28 +0000 Subject: [PATCH 6/7] refactor(model-groups): remove unused wizard thinking state --- model-groups/tui.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/model-groups/tui.ts b/model-groups/tui.ts index 7799dd8..7585550 100644 --- a/model-groups/tui.ts +++ b/model-groups/tui.ts @@ -98,7 +98,6 @@ export function createModelGroupsComponent( modelEditIndex: 0, wizardProvider: "", wizardModelId: "", - wizardThinking: undefined as ModelThinkingLevel | undefined, deleteKey: null as string | null, finished: false, }; From e988f72033a56725982f712d5fc76b7de412e836 Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Thu, 16 Jul 2026 15:55:26 +0000 Subject: [PATCH 7/7] test: make model group notification checks cross-platform --- tests/unit/model-groups-integration.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/unit/model-groups-integration.test.ts b/tests/unit/model-groups-integration.test.ts index 3430a58..c5680e3 100644 --- a/tests/unit/model-groups-integration.test.ts +++ b/tests/unit/model-groups-integration.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; import registerAgenticoding from "../../index.js"; +import { escapeDisplayLabel } from "../../model-groups/display.js"; import { __setModelGroupsFsForTests, modelGroupsPath } from "../../model-groups/store.js"; import { createTestPI, theme } from "./helpers.js"; import { withTemp } from "./model-groups-helpers.js"; @@ -124,7 +125,7 @@ test("index session_start notifies schema-invalid load issues", async () => with await handler({ reason: "load" }, ctx); assert.ok(notifications.some((m) => /schema-invalid/.test(m))); assert.ok(notifications.some((m) => /project scope/.test(m))); - assert.ok(notifications.some((m) => m.includes(modelGroupsPath("project", cwd)))); + assert.ok(notifications.some((m) => m.includes(escapeDisplayLabel(modelGroupsPath("project", cwd))))); })); test("index session_start includes backup-failure detail in load issue notifications", async () => withTemp(async ({ cwd }) => { @@ -145,7 +146,7 @@ test("index session_start includes backup-failure detail in load issue notificat }; const handler = pi.handlers.get("session_start")!.at(-1)!; await handler({ reason: "load" }, ctx); - assert.ok(notifications.some((m) => /corrupt-json/.test(m) && /backup failed.*original file left untouched/.test(m) && m.includes(modelGroupsPath("project", cwd)))); + 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 }) => { @@ -261,9 +262,11 @@ test("model groups untrusted root flow never probes project data and publishes g test("model groups boot load-issue notifications escape hostile source, backup, and error detail fields", async () => withTemp(async ({ cwd }) => { const hostileCwd = `${cwd}\n\u001b]8;;https://example.test\u0007path`; const projectPath = modelGroupsPath("project", hostileCwd); - fs.mkdirSync(path.dirname(projectPath), { recursive: true }); - fs.writeFileSync(projectPath, "{bad", "utf8"); - __setModelGroupsFsForTests({ copyFileSync: () => { throw new Error("backup\n\u001b[31mfailed\u0007"); } }); + __setModelGroupsFsForTests({ + existsSync: (candidate) => String(candidate) === projectPath, + readFileSync: () => { throw new Error("invalid JSON"); }, + copyFileSync: () => { throw new Error("backup\n\u001b[31mfailed\u0007"); }, + }); const pi = createTestPI(); registerAgenticoding(pi as any); const notifications: string[] = []; @@ -272,7 +275,8 @@ test("model groups boot load-issue notifications escape hostile source, backup, ui: { theme, notify: (message: string) => notifications.push(message), setStatus: () => {}, setWidget: () => {}, addAutocompleteProvider: () => {} }, }); const notification = notifications.find((message) => message.includes("corrupt-json"))!; - assert.match(notification, /\\n\\x1B\]8;;https:\/example\.test\\x07path/); + assert.ok(notification.includes(`project scope (${escapeDisplayLabel(projectPath)})`)); + assert.ok(notification.includes(`backup failed (${escapeDisplayLabel(`${projectPath}.bak`)}), original file left untouched`)); assert.match(notification, /backup\\n\\x1B\[31mfailed\\x07/); assert.doesNotMatch(notification, /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/); }));