From 907b4e7e6e8b2e955334d2e4ea2b46a75970c15d Mon Sep 17 00:00:00 2001 From: Grzegorz Nowak Date: Tue, 16 Jun 2026 04:53:37 +0000 Subject: [PATCH 1/4] 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/4] 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/4] =?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/4] 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");