diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 17861a9..cb3a44d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -52,7 +52,9 @@ jobs: - run: npm ci - # Uniform pre-flight checks — type errors and security issues on every platform + # Uniform pre-flight checks — type errors and security issues on every platform. + # audit-ci fails on moderate-or-higher advisories; reviewed exceptions, if ever + # needed, must be explicit and expiry-tracked in audit-ci.jsonc. - name: Type check run: npx tsc --noEmit 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/audit-ci.jsonc b/audit-ci.jsonc index fdb63dd..4df8971 100644 --- a/audit-ci.jsonc +++ b/audit-ci.jsonc @@ -1,19 +1,7 @@ { "$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json", "moderate": true, - "allowlist": [ - // Upstream: pi-coding-agent ships a shrinkwrap that pins vulnerable transitive - // deps (ws, protobufjs, undici). The fix must happen in pi-coding-agent itself - // — nothing to do from this extension. - { "GHSA-96hv-2xvq-fx4p": { "active": true, "expiry": "2026-09-01", "notes": "ws <8.21.0 via pi-coding-agent shrinkwrap" } }, - { "GHSA-f38q-mgvj-vph7": { "active": true, "expiry": "2026-09-01", "notes": "protobufjs <=7.6.2 via pi-coding-agent shrinkwrap" } }, - { "GHSA-wcpc-wj8m-hjx6": { "active": true, "expiry": "2026-09-01", "notes": "protobufjs <=7.6.0 via pi-coding-agent shrinkwrap" } }, - { "GHSA-vmh5-mc38-953g|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }, - { "GHSA-pr7r-676h-xcf6|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }, - { "GHSA-38rv-x7px-6hhq|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }, - { "GHSA-p88m-4jfj-68fv|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }, - { "GHSA-vxpw-j846-p89q|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } } - // Low-severity undici advisories (GHSA-35p6-xmwp-9g52, GHSA-g8m3-5g58-fq7m) are - // not allowlisted — the config gates moderate+ only, so they don't block CI. - ] + // Pi 0.80.7's resolved graph is currently clean at moderate severity and above. + // Keep this explicit so future advisories fail CI until reviewed. + "allowlist": [] } diff --git a/index.ts b/index.ts index 0b579c8..eb477cc 100644 --- a/index.ts +++ b/index.ts @@ -56,6 +56,12 @@ import { buildReadonlyTopicBoundaryNotification, } from "./readonly-copy.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 { escapeDisplayLabel } from "./model-groups/display.js"; +import type { ModelGroupsAccess } from "./model-groups/types.js"; import { cacheLookupCommand, cacheLookupCommandIssue, @@ -186,6 +192,30 @@ function consumePendingReadonlyToggle( } } +function modelGroupsAccess(ctx: ExtensionContext): ModelGroupsAccess { + return { cwd: ctx.cwd, policy: ctx.isProjectTrusted() ? "global-project" : "global-only" }; +} + +function refreshModelGroupsState(state: AgenticodingState, ctx: ExtensionContext) { + state.modelGroups.groups = []; + state.modelGroups.validation = null; + if (!ctx.cwd || !(ctx as any).modelRegistry) return null; + const loadedModelGroups = loadModelGroups(modelGroupsAccess(ctx)); + const resolvedModelGroups = validateModelGroups(loadedModelGroups, (ctx as any).modelRegistry); + state.modelGroups.groups = resolvedModelGroups; + state.modelGroups.validation = { groups: resolvedModelGroups, loadIssues: loadedModelGroups.issues }; + 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(); @@ -202,6 +232,7 @@ export default function (pi: ExtensionAPI): void { // ── Register commands ─────────────────────────────────────────── registerHandoffCommand(pi, state); + registerModelGroupsCommand(pi, state); // ── Readonly mode ─────────────────────────────────────────────── @@ -459,6 +490,7 @@ export default function (pi: ExtensionAPI): void { // Update TUI indicators before each user-prompt agent run updateIndicators(ctx, state); + refreshModelGroupsState(state, ctx); const parts: string[] = [event.systemPrompt]; @@ -478,6 +510,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) { @@ -626,6 +663,23 @@ 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 sourcePath = escapeDisplayLabel(issue.sourcePath); + const backupPath = issue.backupPath ? escapeDisplayLabel(issue.backupPath) : undefined; + const detail = escapeDisplayLabel(issue.message); + const backupNote = issue.backupFailed ? `; backup failed${backupPath ? ` (${backupPath})` : ""}, original file left untouched` : ""; + ctx.ui.notify(`Model Groups config ${issue.kind} in ${issue.scope} scope (${sourcePath}); using empty config for that scope${backupNote}; ${detail}`, "warning"); + } + const { unavailableCount, overrideCount } = summarizeBootValidation(validation.groups); + if (unavailableCount > 0 || overrideCount > 0) { + ctx.ui.notify(`Model Groups boot validation: ${unavailableCount} unavailable model references · ${overrideCount} project overrides`, "warning"); + } + } + rehydrateReadonlyState(ctx); 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..24e5b6b --- /dev/null +++ b/model-groups/command.ts @@ -0,0 +1,31 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { AgenticodingState } from "../state.js"; +import type { ModelGroupsAccess } from "./types.js"; +import { createModelGroupsComponent } from "./tui.js"; + +export function registerModelGroupsCommand(pi: ExtensionAPI, state: AgenticodingState): void { + pi.registerCommand("model-groups", { + description: "Manage Model Groups", + handler: async (_args, ctx) => { + if (ctx.mode !== "tui") { + if (ctx.mode === "rpc") ctx.ui.notify("/model-groups requires TUI mode"); + return; + } + if (!ctx.cwd || !ctx.modelRegistry) { + ctx.ui.notify("Cannot manage model groups: missing working directory or model registry", "error"); + return; + } + const access: ModelGroupsAccess = { cwd: ctx.cwd, policy: ctx.isProjectTrusted() ? "global-project" : "global-only" }; + await ctx.ui.custom((tui, theme, _keybindings, done) => + createModelGroupsComponent(tui, theme, ctx.modelRegistry, access, 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/display.ts b/model-groups/display.ts new file mode 100644 index 0000000..cf0ad11 --- /dev/null +++ b/model-groups/display.ts @@ -0,0 +1,53 @@ +export type DisplayDecodeResult = { ok: true; value: string } | { ok: false; message: string }; + +function hex(value: number): string { + return value.toString(16).toUpperCase().padStart(2, "0"); +} + +export function escapeDisplayLabel(raw: string): string { + let encoded = ""; + for (const char of raw) { + const code = char.codePointAt(0)!; + if (char === "\\") encoded += "\\\\"; + else if (char === "\n") encoded += "\\n"; + else if (char === "\r") encoded += "\\r"; + else if (char === "\t") encoded += "\\t"; + else if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) encoded += `\\x${hex(code)}`; + else if (code === 0x2028 || code === 0x2029) encoded += `\\u${code.toString(16).toUpperCase()}`; + else encoded += char; + } + return encoded; +} + +export function decodeDisplayLabel(encoded: string): DisplayDecodeResult { + let value = ""; + for (let index = 0; index < encoded.length; index++) { + const char = encoded[index]; + if (char !== "\\") { + value += char; + continue; + } + const escape = encoded[++index]; + if (escape === undefined) return { ok: false, message: "Incomplete display escape" }; + if (escape === "\\") value += "\\"; + else if (escape === "n") value += "\n"; + else if (escape === "r") value += "\r"; + else if (escape === "t") value += "\t"; + else if (escape === "x") { + const digits = encoded.slice(index + 1, index + 3); + if (!/^[0-9A-F]{2}$/.test(digits)) return { ok: false, message: "Invalid or non-canonical \\xHH display escape" }; + const code = Number.parseInt(digits, 16); + if (!(code < 0x20 || (code >= 0x7f && code <= 0x9f)) || code === 0x09 || code === 0x0a || code === 0x0d) { + return { ok: false, message: "Display escape does not encode a canonical control" }; + } + value += String.fromCharCode(code); + index += 2; + } else if (escape === "u") { + const digits = encoded.slice(index + 1, index + 5); + if (digits !== "2028" && digits !== "2029") return { ok: false, message: "Invalid canonical Unicode display escape" }; + value += String.fromCharCode(Number.parseInt(digits, 16)); + index += 4; + } else return { ok: false, message: `Unknown display escape \\${escape}` }; + } + return { ok: true, value }; +} diff --git a/model-groups/names.ts b/model-groups/names.ts new file mode 100644 index 0000000..a2bc891 --- /dev/null +++ b/model-groups/names.ts @@ -0,0 +1,3 @@ +export function canonicalizeModelGroupName(raw: string): string { + return raw.trim(); +} diff --git a/model-groups/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..56548c8 --- /dev/null +++ b/model-groups/store.ts @@ -0,0 +1,206 @@ +import { homedir } from "node:os"; +import path from "node:path"; +import * as fs from "node:fs"; +import { CONFIG_DIR_NAME, type ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; +import { canonicalizeModelGroupName } from "./names.js"; +import { + ModelGroupsPersistenceError, + type ModelGroupDef, + type ModelGroupModel, + type ModelGroupScope, + type ModelGroupsAccess, + type ModelGroupsBootValidation, + type ModelGroupsConfig, + type ModelGroupsLoadedGroup, + type ModelGroupsLoadIssue, + type ModelGroupsLoadResult, + type ResolvedModelGroup, +} from "./types.js"; + +const CURRENT_VERSION = 1; +const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); +type FsOps = Pick; +let fsOps: FsOps = fs; + +export function __setModelGroupsFsForTests(next: Partial | null): void { fsOps = next ? { ...fs, ...next } : fs; } +export function modelGroupsPath(scope: ModelGroupScope, cwd: string, projectConfigDirName = CONFIG_DIR_NAME): string { + return scope === "global" + ? path.join(homedir(), ".pi", "agent", "pi-agenticoding", "model-groups.json") + : path.join(cwd, projectConfigDirName, "pi-agenticoding", "model-groups.json"); +} + +function ownGroups(): Record { return Object.create(null) as Record; } +function defineGroup(groups: Record, name: string, def: ModelGroupDef): void { + Object.defineProperty(groups, name, { value: cloneDef(def), enumerable: true, writable: true, configurable: true }); +} +function hasOwnGroup(groups: Record, name: string): boolean { return Object.hasOwn(groups, name); } +function emptyConfig(): ModelGroupsConfig { return { version: CURRENT_VERSION, groups: ownGroups() }; } +function cloneDef(def: ModelGroupDef): ModelGroupDef { return { models: def.models.map((model) => ({ ...model })) }; } +function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function assertScopeAllowed(scope: ModelGroupScope, access: ModelGroupsAccess): void { + if (scope === "project" && access.policy === "global-only") throw new Error("Project Model Groups are unavailable in global-only mode"); +} +function persistenceError(details: ConstructorParameters[0]): ModelGroupsPersistenceError { + return new ModelGroupsPersistenceError(details); +} +function validateModelEntry(value: unknown, at: string): { ok: true; model: ModelGroupModel } | { ok: false; message: string } { + if (!isPlainRecord(value)) return { ok: false, message: `${at} must be an object` }; + if (typeof value.provider !== "string" || value.provider.length === 0) return { ok: false, message: `${at}.provider must be a non-empty string` }; + if (typeof value.modelId !== "string" || value.modelId.length === 0) return { ok: false, message: `${at}.modelId must be a non-empty string` }; + if (value.thinkingLevel !== undefined && !VALID_THINKING.has(value.thinkingLevel as ModelThinkingLevel)) return { ok: false, message: `${at}.thinkingLevel is invalid` }; + const model: ModelGroupModel = { provider: value.provider, modelId: value.modelId }; + if (value.thinkingLevel !== undefined) model.thinkingLevel = value.thinkingLevel as ModelThinkingLevel; + return { ok: true, model }; +} +function normalizeGroups(rawGroups: Record): { ok: true; groups: Record } | { ok: false; message: string } { + const groups = ownGroups(); + for (const rawName of Object.keys(rawGroups)) { + const name = canonicalizeModelGroupName(rawName); + if (!name) return { ok: false, message: "group name must not be empty after trimming" }; + if (hasOwnGroup(groups, name)) return { ok: false, message: `group keys collide after trimming at '${name}'` }; + const rawDef = rawGroups[rawName]; + if (!isPlainRecord(rawDef)) return { ok: false, message: `group ${rawName} must be an object` }; + if (!Array.isArray(rawDef.models)) return { ok: false, message: `group ${rawName}.models must be an array` }; + const models: ModelGroupModel[] = []; + for (let index = 0; index < rawDef.models.length; index++) { + const result = validateModelEntry(rawDef.models[index], `group ${rawName}.models[${index}]`); + if (!result.ok) return result; + models.push(result.model); + } + defineGroup(groups, name, { models }); + } + return { ok: true, groups }; +} +function validateConfig(raw: unknown): { ok: true; config: ModelGroupsConfig } | { ok: false; message: string } { + if (!isPlainRecord(raw)) return { ok: false, message: "config root must be an object" }; + const version = raw.version === undefined || raw.version === 0 ? CURRENT_VERSION : raw.version; + if (typeof version !== "number" || !Number.isInteger(version) || version < 1) return { ok: false, message: "version must be a non-negative supported integer (only missing/0 normalize)" }; + if (version > CURRENT_VERSION) return { ok: false, message: `unsupported version ${version}` }; + if (!isPlainRecord(raw.groups)) return { ok: false, message: "groups must be an object" }; + const normalized = normalizeGroups(raw.groups); + return normalized.ok ? { ok: true, config: { version: CURRENT_VERSION, groups: normalized.groups } } : normalized; +} +function backupAndIssue(scope: ModelGroupScope, sourcePath: string, kind: ModelGroupsLoadIssue["kind"], message: string, version?: number): ModelGroupsLoadIssue { + const backupPath = `${sourcePath}.bak`; + const issue: ModelGroupsLoadIssue = { scope, sourcePath, kind, message, backupPath, version }; + if (kind === "unsupported-version") return issue; + try { fsOps.copyFileSync(sourcePath, backupPath); } + catch (cause) { + issue.backupFailed = true; + issue.message = `${message}; backup failed: ${cause instanceof Error ? cause.message : String(cause)}`; + } + return issue; +} +function loadScope(scope: ModelGroupScope, access: ModelGroupsAccess): { config: ModelGroupsConfig; issue?: ModelGroupsLoadIssue } { + assertScopeAllowed(scope, access); + const sourcePath = modelGroupsPath(scope, access.cwd); + if (!fsOps.existsSync(sourcePath)) return { config: emptyConfig() }; + let parsed: unknown; + try { parsed = JSON.parse(String(fsOps.readFileSync(sourcePath, "utf8"))); } + catch (cause) { return { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "corrupt-json", cause instanceof Error ? cause.message : String(cause)) }; } + if (isPlainRecord(parsed) && typeof parsed.version === "number" && Number.isInteger(parsed.version) && parsed.version > CURRENT_VERSION) { + return { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "unsupported-version", `unsupported version ${parsed.version}`, parsed.version) }; + } + const validated = validateConfig(parsed); + if (!validated.ok) return { config: emptyConfig(), issue: backupAndIssue(scope, sourcePath, "schema-invalid", validated.message) }; + return { config: validated.config }; +} +function mergeLoaded(configs: Record, access: ModelGroupsAccess): ModelGroupsLoadedGroup[] { + const names = new Set([...Object.keys(configs.global.groups), ...Object.keys(configs.project.groups)]); + const merged: ModelGroupsLoadedGroup[] = []; + for (const name of [...names].sort()) { + if (hasOwnGroup(configs.global.groups, name)) merged.push({ name, scope: "global", sourcePath: modelGroupsPath("global", access.cwd), ...cloneDef(configs.global.groups[name]) }); + if (access.policy === "global-project" && hasOwnGroup(configs.project.groups, name)) merged.push({ name, scope: "project", sourcePath: modelGroupsPath("project", access.cwd), ...cloneDef(configs.project.groups[name]) }); + } + return merged; +} +export function loadModelGroups(access: ModelGroupsAccess): ModelGroupsLoadResult { + const global = loadScope("global", access); + const project = access.policy === "global-project" ? loadScope("project", access) : { config: emptyConfig() }; + const configs = { global: global.config, project: project.config }; + return { configs, merged: mergeLoaded(configs, access), issues: [global.issue, project.issue].filter((issue): issue is ModelGroupsLoadIssue => Boolean(issue)) }; +} +function normalizeSaveConfig(scope: ModelGroupScope, sourcePath: string, config: ModelGroupsConfig): ModelGroupsConfig { + const normalized = normalizeGroups(config.groups as unknown as Record); + if (!normalized.ok) throw persistenceError({ operation: "save", scope, sourcePath, phase: "config-validation", message: normalized.message }); + return { version: CURRENT_VERSION, groups: normalized.groups }; +} +export function saveModelGroups(scope: ModelGroupScope, access: ModelGroupsAccess, config: ModelGroupsConfig): void { + assertScopeAllowed(scope, access); + const sourcePath = modelGroupsPath(scope, access.cwd); + const normalized = normalizeSaveConfig(scope, sourcePath, config); + const dir = path.dirname(sourcePath); + const tempPath = `${sourcePath}.${process.pid}.${Date.now()}.tmp`; + let raw: Record = {}; + if (fsOps.existsSync(sourcePath)) { + try { const parsed = JSON.parse(String(fsOps.readFileSync(sourcePath, "utf8"))); if (isPlainRecord(parsed)) raw = parsed; } + catch { /* load recovery owns malformed content */ } + } + const body = JSON.stringify({ ...raw, version: CURRENT_VERSION, groups: normalized.groups }, null, 2) + "\n"; + try { fsOps.mkdirSync(dir, { recursive: true }); fsOps.writeFileSync(tempPath, body, "utf8"); } + catch (cause) { throw persistenceError({ operation: "save", scope, sourcePath, targetPath: tempPath, phase: "temp-write", message: `Failed to write temp model-groups file for ${scope}: ${cause instanceof Error ? cause.message : String(cause)}`, cause }); } + try { fsOps.renameSync(tempPath, sourcePath); } + catch (cause) { throw persistenceError({ operation: "save", scope, sourcePath, targetPath: tempPath, phase: "rename", message: `Failed to commit model-groups file for ${scope}: ${cause instanceof Error ? cause.message : String(cause)}`, cause }); } +} +function loadScopeConfig(scope: ModelGroupScope, access: ModelGroupsAccess): ModelGroupsConfig { + const loaded = loadScope(scope, access); + if (loaded.issue?.backupFailed) throw persistenceError({ operation: "save", scope, sourcePath: loaded.issue.sourcePath, targetPath: loaded.issue.backupPath, phase: "load-recovery", message: `Refusing to overwrite ${scope} model-groups config after ${loaded.issue.kind} recovery because backup failed: ${loaded.issue.message}`, cause: loaded.issue }); + return loaded.config; +} +function canonicalName(raw: string): string { const name = canonicalizeModelGroupName(raw); if (!name) throw new Error("Model group name is required"); return name; } +export function createGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawName: string, def: ModelGroupDef): void { + assertScopeAllowed(scope, access); const name = canonicalName(rawName); const config = loadScopeConfig(scope, access); + if (hasOwnGroup(config.groups, name)) throw new Error(`Model group '${name}' already exists in ${scope} scope`); + defineGroup(config.groups, name, def); saveModelGroups(scope, access, config); +} +export function updateGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawName: string, def: ModelGroupDef): void { + assertScopeAllowed(scope, access); const name = canonicalName(rawName); const config = loadScopeConfig(scope, access); + if (!hasOwnGroup(config.groups, name)) throw new Error(`Model group '${name}' does not exist in ${scope} scope`); + defineGroup(config.groups, name, def); saveModelGroups(scope, access, config); +} +export function renameGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawOldName: string, rawNewName: string): void { + assertScopeAllowed(scope, access); const oldName = canonicalName(rawOldName); const newName = canonicalName(rawNewName); if (oldName === newName) return; + const config = loadScopeConfig(scope, access); + if (!hasOwnGroup(config.groups, oldName)) throw new Error(`Model group '${oldName}' does not exist in ${scope} scope`); + if (hasOwnGroup(config.groups, newName)) throw new Error(`Model group '${newName}' already exists in ${scope} scope`); + const existing = config.groups[oldName]; delete config.groups[oldName]; defineGroup(config.groups, newName, existing); saveModelGroups(scope, access, config); +} +export function deleteGroup(scope: ModelGroupScope, access: ModelGroupsAccess, rawName: string): { otherScopeHasOverride: boolean } { + assertScopeAllowed(scope, access); const name = canonicalName(rawName); const config = loadScopeConfig(scope, access); + if (!hasOwnGroup(config.groups, name)) throw new Error(`Model group '${name}' does not exist in ${scope} scope`); + delete config.groups[name]; + const other = access.policy === "global-only" ? emptyConfig() : loadScopeConfig(scope === "global" ? "project" : "global", access); + try { saveModelGroups(scope, access, config); } + catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw new ModelGroupsPersistenceError({ operation: "delete", scope: cause.scope, sourcePath: cause.sourcePath, targetPath: cause.targetPath, phase: cause.phase, message: cause.message, cause }); throw cause; } + return { otherScopeHasOverride: hasOwnGroup(other.groups, name) }; +} +export function moveGroup(access: ModelGroupsAccess, rawName: string, newScope: ModelGroupScope): void { + const name = canonicalName(rawName); const oldScope: ModelGroupScope = newScope === "project" ? "global" : "project"; + assertScopeAllowed(oldScope, access); assertScopeAllowed(newScope, access); + const source = loadScopeConfig(oldScope, access); const target = loadScopeConfig(newScope, access); + if (!hasOwnGroup(source.groups, name)) throw new Error(`Model group '${name}' does not exist in ${oldScope} scope`); + if (hasOwnGroup(target.groups, name)) throw new Error(`Model group '${name}' already exists in ${newScope} scope`); + defineGroup(target.groups, name, source.groups[name]); + try { saveModelGroups(newScope, access, target); } + catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw new ModelGroupsPersistenceError({ operation: "move", scope: newScope, sourcePath: modelGroupsPath(oldScope, access.cwd), targetPath: modelGroupsPath(newScope, access.cwd), phase: cause.phase, message: `Model group '${name}' was not written to ${newScope}: ${cause.message}`, cause }); throw cause; } + delete source.groups[name]; + try { saveModelGroups(oldScope, access, source); } + catch (cause) { if (cause instanceof ModelGroupsPersistenceError) throw new ModelGroupsPersistenceError({ operation: "move", scope: oldScope, sourcePath: modelGroupsPath(oldScope, access.cwd), targetPath: modelGroupsPath(newScope, access.cwd), phase: "source-remove", partialMove: "target-written-source-retained", message: `Model group '${name}' was written to ${newScope} but retained in ${oldScope}: ${cause.message}`, cause }); throw cause; } +} +export function validateModelGroups(loadResult: ModelGroupsLoadResult, modelRegistry: ModelRegistry): ResolvedModelGroup[] { + const projectNames = new Set(Object.keys(loadResult.configs.project.groups)); + return loadResult.merged.map((group) => { + const unavailableRefs: Array<{ provider: string; modelId: string }> = []; + for (const ref of group.models) { const model = modelRegistry.find(ref.provider, ref.modelId); if (!model || !modelRegistry.hasConfiguredAuth(model)) unavailableRefs.push({ provider: ref.provider, modelId: ref.modelId }); } + return { ...group, validation: { unavailableRefs, shadowedByProject: group.scope === "global" && projectNames.has(group.name), degraded: unavailableRefs.length > 0 && unavailableRefs.length < group.models.length } }; + }); +} +export function listResolvedModelGroups(access: ModelGroupsAccess, modelRegistry: ModelRegistry): ModelGroupsBootValidation { + const loaded = loadModelGroups(access); return { groups: validateModelGroups(loaded, modelRegistry), loadIssues: loaded.issues }; +} +export function summarizeBootValidation(groups: ResolvedModelGroup[]): { unavailableCount: number; overrideCount: number } { + return { unavailableCount: groups.reduce((sum, group) => sum + group.validation.unavailableRefs.length, 0), overrideCount: groups.filter((group) => group.validation.shadowedByProject).length }; +} +export const EMPTY_MODEL_GROUPS_CONFIG: ModelGroupsConfig = emptyConfig(); +export { CURRENT_VERSION as MODEL_GROUPS_CONFIG_VERSION, hasOwnGroup }; diff --git a/model-groups/tui.ts b/model-groups/tui.ts new file mode 100644 index 0000000..7585550 --- /dev/null +++ b/model-groups/tui.ts @@ -0,0 +1,564 @@ +import type { Theme } from "@earendil-works/pi-coding-agent"; +import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { getSupportedThinkingLevels, type Model, type ModelThinkingLevel, type Api } from "@earendil-works/pi-ai"; +import { Container, Input, Key, matchesKey, SelectList, truncateToWidth, type Component, type Focusable, type SelectItem, 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 ModelGroupsAccess, type ModelGroupsBootValidation, type ResolvedModelGroup } from "./types.js"; +import { canonicalizeModelGroupName } from "./names.js"; +import { decodeDisplayLabel, escapeDisplayLabel } from "./display.js"; + +export type ModelGroupsScreen = "LIST" | "EDITOR" | "MODEL_EDIT" | "WIZARD_PROVIDER" | "WIZARD_MODEL" | "WIZARD_THINKING" | "DELETE_CONFIRM"; + +export 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 isDeleteChord(data: string): boolean { return data === "D" || matchesKey(data, Key.delete); } + +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 `${escapeDisplayLabel(model.provider)}/${escapeDisplayLabel(model.id)}`; +} + +function toPersistenceMessage(error: unknown): string { + if (error instanceof ModelGroupsPersistenceError) { + const scope = error.scope ? ` for ${error.scope} scope` : ""; + const paths = [error.sourcePath ? `source: ${escapeDisplayLabel(error.sourcePath)}` : "", error.targetPath ? `target: ${escapeDisplayLabel(error.targetPath)}` : ""].filter(Boolean).join("; "); + const pathDetails = paths ? ` (${paths})` : ""; + const cause = error.cause === undefined ? "" : `; cause: ${escapeDisplayLabel(error.cause instanceof Error ? error.cause.message : String(error.cause))}`; + const partial = error.partialMove ? `; ${error.partialMove}` : ""; + return `${error.operation} failed at ${error.phase}${scope}${pathDetails}: ${escapeDisplayLabel(error.message)}${cause}${partial}`; + } + return escapeDisplayLabel(error instanceof Error ? error.message : String(error)); +} + +export function createModelGroupsComponent( + tui: TUI, + theme: Theme, + modelRegistry: ModelRegistry, + access: ModelGroupsAccess, + done: (result: void) => void, + options: ModelGroupsComponentOptions = {}, +): Component & Focusable { + 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: (access.policy === "global-project" ? "project" : "global") as ModelGroupScope, + editDraft: null as ModelGroupDef | null, + activeTextInput: null as null | "group-name", + modelEditIndex: 0, + wizardProvider: "", + wizardModelId: "", + deleteKey: null as string | null, + finished: false, + }; + const groupNameInput = new Input(); + let rootFocused = false; + let activeSelect: SelectList | null = null; + const nameRow = () => access.policy === "global-project" ? 2 : 1; + const modelStartRow = () => nameRow() + 1; + function syncInputFocus(): void { + groupNameInput.focused = rootFocused && state.screen === "EDITOR" && state.row === nameRow() && state.activeTextInput === "group-name"; + } + function setGroupNameInputValue(value: string): void { + groupNameInput.setValue(value); + groupNameInput.handleInput("\u001b[F"); + } + + function refresh(): void { + const boot = store.listResolvedModelGroups(access, modelRegistry); + state.groups = boot.groups; + state.loadIssues = boot.loadIssues; + options.onRefresh?.(boot); + for (const issue of boot.loadIssues) { + const backupPath = issue.backupPath ? escapeDisplayLabel(issue.backupPath) : ""; + const backup = issue.backupFailed ? `; backup failed${backupPath ? ` (${backupPath})` : ""}, original file left untouched` : ""; + notify(`Model Groups config ${issue.kind} in ${issue.scope} scope (${escapeDisplayLabel(issue.sourcePath)}); using empty config for that scope${backup}; ${escapeDisplayLabel(issue.message)}`, "warning"); + } + } + + if (options.initialValidation) { + state.groups = options.initialValidation.groups + .filter((group) => access.policy === "global-project" || group.scope === "global") + .map((group) => access.policy === "global-project" ? group : { ...group, validation: { ...group.validation, shadowedByProject: false } }); + state.loadIssues = options.initialValidation.loadIssues.filter((issue) => access.policy === "global-project" || issue.scope === "global"); + } else { + refresh(); + } + + 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 = escapeDisplayLabel(group.name); + setGroupNameInputValue(state.editName); + state.editScope = group.scope; + state.editDraft = cloneDef(group); + state.activeTextInput = null; + syncInputFocus(); + } + + 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; + state.editName = groupNameInput.getValue(); + const decoded = decodeDisplayLabel(state.editName); + if (!decoded.ok) { + notify(escapeDisplayLabel(decoded.message), "error"); + state.editName = escapeDisplayLabel(group.name); + setGroupNameInputValue(state.editName); + return false; + } + const nextName = canonicalizeModelGroupName(decoded.value); + if (!nextName) { + notify("Model group name is required", "error"); + state.editName = escapeDisplayLabel(group.name); + setGroupNameInputValue(state.editName); + return false; + } + if (nextName === group.name) { + state.editName = escapeDisplayLabel(group.name); + setGroupNameInputValue(state.editName); + return true; + } + try { + store.renameGroup(group.scope, access, group.name, nextName); + refresh(); + const renamed = state.groups.find((candidate) => candidate.name === nextName && candidate.scope === group.scope); + if (renamed) openEditor(renamed); + return true; + } catch (error) { + notifyError(error); + state.editName = escapeDisplayLabel(group.name); + setGroupNameInputValue(state.editName); + state.activeTextInput = null; + syncInputFocus(); + 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(access, 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, access, 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 modelStartRow() + (state.editDraft?.models.length ?? 0); + case "MODEL_EDIT": return thinkingOptionsFor(modelRegistry.find(state.editDraft?.models[state.modelEditIndex]?.provider ?? "", state.editDraft?.models[state.modelEditIndex]?.modelId ?? "") as Model | undefined).length; + case "WIZARD_PROVIDER": return Math.max(0, allProviders().length - 1); + case "WIZARD_MODEL": return Math.max(0, modelsForProvider(state.wizardProvider).length - 1); + 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 { + const scope = access.policy === "global-project" ? "project" : "global"; + store.createGroup(scope, access, name, { models: [] }); + refresh(); + const created = state.groups.find((group) => group.name === name && group.scope === scope); + if (created) openEditor(created); + } catch (error) { notifyError(error); } + return; + } + const group = selectedGroup(); + if (group) openEditor(group); + return; + } + case "EDITOR": { + if (access.policy === "global-project" && state.row === 0) { switchScope("project"); return; } + if ((access.policy === "global-project" && state.row === 1) || (access.policy === "global-only" && state.row === 0)) { switchScope("global"); return; } + if (state.row === nameRow()) { state.activeTextInput = "group-name"; syncInputFocus(); return; } + if (!commitName()) return; + const modelIndex = state.row - modelStartRow(); + 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, access, 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 textLine(value: string): Component { + return { render: () => [value], invalidate: () => {} }; + } + + const selectTheme = { + selectedPrefix: (text: string) => theme.fg("accent", text), + selectedText: (text: string) => theme.fg("accent", text), + description: (text: string) => theme.fg("dim", text), + scrollInfo: (text: string) => theme.fg("dim", text), + noMatch: (text: string) => theme.fg("dim", text), + }; + + function buildSelect(items: SelectItem[]): SelectList { + const select = new SelectList(items, Math.max(1, items.length), selectTheme); + select.setSelectedIndex(Math.min(state.row, Math.max(0, items.length - 1))); + select.onSelectionChange = (item) => { state.row = Number(item.value); syncInputFocus(); }; + select.onSelect = (item) => { state.row = Number(item.value); activate(); syncInputFocus(); }; + select.onCancel = () => { goBack(); syncInputFocus(); }; + activeSelect = select; + return select; + } + + function renderListComponent(): Component { + const summary = summarizeBootValidation(state.groups); + const container = new Container(); + container.addChild(textLine(theme.fg("accent", "Model Groups"))); + container.addChild(textLine(theme.fg("dim", `Boot validation: ${summary.unavailableCount} unavailable model references · ${summary.overrideCount} project overrides`))); + const items: SelectItem[] = state.groups.map((group, index) => { + const tags: string[] = []; + if (group.validation.degraded) tags.push("⚠ degraded"); + if (group.validation.unavailableRefs.length > 0) tags.push("✗ unavailable"); + if (group.validation.shadowedByProject) tags.push("project override"); + const models = group.models.map((model) => thinkingLabel(model.thinkingLevel)).join(", ") || "empty"; + return { value: String(index), label: escapeDisplayLabel(group.name), description: `[${group.scope}] ${group.models.length} models ${models}${tags.length ? ` — ${tags.join(" · ")}` : ""}` }; + }); + items.push({ value: String(state.groups.length), label: "+ Add group" }); + container.addChild(buildSelect(items)); + container.addChild(textLine(theme.fg("dim", "↑↓ navigate • Enter open/add • D delete • Esc close"))); + return container; + } + + function renderEditorComponent(): Component { + activeSelect = null; + const container = new Container(); + const current = currentEditGroup(); + container.addChild(textLine(theme.fg("accent", `Model Group: ${escapeDisplayLabel(current?.name ?? "")}`))); + if (access.policy === "global-project") container.addChild(textLine(selectableLine(state.row === 0, "Location: project", state.editScope === "project" ? " ✓" : ""))); + container.addChild(textLine(selectableLine(state.row === (access.policy === "global-project" ? 1 : 0), "Location: global", state.editScope === "global" ? " ✓" : ""))); + container.addChild(textLine(selectableLine(state.row === nameRow(), "Name:"))); + container.addChild(groupNameInput); + state.editDraft?.models.forEach((model, index) => { + const available = modelAvailable(modelRegistry, model.provider, model.modelId) ? "available" : "unavailable"; + container.addChild(textLine(selectableLine(state.row === index + modelStartRow(), `${escapeDisplayLabel(model.provider)}/${escapeDisplayLabel(model.modelId)}`, ` (${available}, thinking ${thinkingLabel(model.thinkingLevel)})`))); + }); + const addRow = modelStartRow() + (state.editDraft?.models.length ?? 0); + container.addChild(textLine(selectableLine(state.row === addRow, "+ Add model…"))); + return container; + } + + function renderModelEditComponent(): Component { + activeSelect = null; + const container = new Container(); + const model = state.editDraft?.models[state.modelEditIndex]; + if (!model) { container.addChild(textLine("Model not found")); return container; } + const found = modelRegistry.find(model.provider, model.modelId) as Model | undefined; + container.addChild(textLine(theme.fg("accent", "Edit model"))); + container.addChild(textLine(`Provider: ${escapeDisplayLabel(model.provider)}`)); + container.addChild(textLine(`Model ID: ${escapeDisplayLabel(model.modelId)}`)); + container.addChild(textLine(`Status: ${found && modelRegistry.hasConfiguredAuth(found) ? "available" : "unavailable"}`)); + thinkingOptionsFor(found).forEach((level, index) => container.addChild(textLine(selectableLine(state.row === index, `Thinking: ${thinkingLabel(level)}`)))); + container.addChild(textLine(selectableLine(state.row === thinkingOptionsFor(found).length, "Remove model"))); + return container; + } + + function renderWizardComponent(): Component { + const container = new Container(); + let title: string; + let items: SelectItem[]; + if (state.screen === "WIZARD_PROVIDER") { + title = "Add model — Step 1/3 Provider"; + items = allProviders().map((provider, index) => ({ value: String(index), label: escapeDisplayLabel(provider) })); + } else if (state.screen === "WIZARD_MODEL") { + title = "Add model — Step 2/3 Model"; + items = modelsForProvider(state.wizardProvider).map((model, index) => ({ value: String(index), label: modelDisplay(model) })); + } else { + title = "Add model — Step 3/3 Thinking"; + items = thinkingOptionsFor(currentWizardModel()).map((level, index) => ({ value: String(index), label: thinkingLabel(level) })); + } + container.addChild(textLine(theme.fg("accent", title))); + container.addChild(buildSelect(items)); + return container; + } + + function renderDeleteComponent(): Component { + activeSelect = null; + const container = new Container(); + const group = state.groups.find((candidate) => groupKey(candidate) === state.deleteKey); + const otherScope = group ? state.groups.some((candidate) => candidate.name === group.name && candidate.scope !== group.scope) : false; + container.addChild(textLine(theme.fg("warning", "Delete Model Group?"))); + container.addChild(textLine(group ? `${escapeDisplayLabel(group.name)} [${group.scope}] with ${group.models.length} models` : "Missing group")); + if (otherScope) container.addChild(textLine("Same-name group in the other scope remains unaffected.")); + container.addChild(textLine(selectableLine(state.row === 0, "Keep group"))); + container.addChild(textLine(selectableLine(state.row === 1, "Delete group"))); + return container; + } + + function activeComponent(): Component { + if (state.screen === "LIST") return renderListComponent(); + if (state.screen === "EDITOR") return renderEditorComponent(); + if (state.screen === "MODEL_EDIT") return renderModelEditComponent(); + if (state.screen === "DELETE_CONFIRM") return renderDeleteComponent(); + return renderWizardComponent(); + } + + return { + get focused() { return rootFocused; }, + set focused(value: boolean) { rootFocused = value; syncInputFocus(); }, + render: (width: number) => { + syncInputFocus(); + return activeComponent().render(width).map((line) => truncateToWidth(line, width)); + }, + invalidate: () => groupNameInput.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 { + groupNameInput.handleInput(data); + state.editName = groupNameInput.getValue(); + } + syncInputFocus(); + tui.requestRender(); + return; + } + if (isDeleteChord(data) && (state.screen === "LIST" || state.screen === "MODEL_EDIT")) deleteAction(); + else if (activeSelect && (state.screen === "LIST" || state.screen.startsWith("WIZARD_")) && (isUp(data) || isDown(data))) { + const previousRow = state.row; + activeSelect.handleInput(data); + state.row = previousRow + (isDown(data) ? 1 : -1); + clampRow(); + activeSelect.setSelectedIndex(state.row); + } + else if (activeSelect && (state.screen === "LIST" || state.screen.startsWith("WIZARD_")) && isEnter(data)) activate(); + else if (isUp(data)) { state.row--; clampRow(); } + else if (isDown(data)) { state.row++; clampRow(); } + else if (isLeft(data) || isEsc(data)) goBack(); + else if (isEnter(data)) activate(); + syncInputFocus(); + tui.requestRender(); + }, + }; +} diff --git a/model-groups/types.ts b/model-groups/types.ts new file mode 100644 index 0000000..9bb9463 --- /dev/null +++ b/model-groups/types.ts @@ -0,0 +1,65 @@ +import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; + +export type ModelGroupScope = "project" | "global"; +export type ModelGroupsAccessPolicy = "global-project" | "global-only"; +export interface ModelGroupsAccess { cwd: string; policy: ModelGroupsAccessPolicy } + +export interface ModelGroupModel { + provider: string; + modelId: string; + thinkingLevel?: ModelThinkingLevel; +} +export interface ModelGroupDef { models: ModelGroupModel[] } +export interface ModelGroupsConfig { version: 1; groups: Record } +export interface 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 = "config-validation" | "temp-write" | "rename" | "source-remove" | "load-recovery"; +export class ModelGroupsPersistenceError extends Error { + readonly operation!: ModelGroupsPersistenceOperation; + readonly scope?: ModelGroupScope; + readonly sourcePath?: string; + readonly targetPath?: string; + readonly phase!: ModelGroupsPersistencePhase; + readonly partialMove?: "target-written-source-retained"; + readonly cause?: unknown; + constructor(details: { + operation: ModelGroupsPersistenceOperation; + scope?: ModelGroupScope; + sourcePath?: string; + targetPath?: string; + phase: ModelGroupsPersistencePhase; + partialMove?: "target-written-source-retained"; + message: string; + cause?: unknown; + }) { + super(details.message); + this.name = "ModelGroupsPersistenceError"; + Object.assign(this, details); + } +} +export interface ModelGroupsLoadResult { + configs: Record; + merged: ModelGroupsLoadedGroup[]; + issues: ModelGroupsLoadIssue[]; +} +export interface ModelGroupsBootValidation { groups: ResolvedModelGroup[]; loadIssues: ModelGroupsLoadIssue[] } diff --git a/package-lock.json b/package-lock.json index 62348ad..e9325c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "version": "0.3.0", "license": "MIT", "devDependencies": { - "@earendil-works/pi-ai": "^0.79.6", - "@earendil-works/pi-coding-agent": "^0.79.6", - "@earendil-works/pi-tui": "^0.79.6", + "@earendil-works/pi-ai": "^0.80.7", + "@earendil-works/pi-coding-agent": "^0.80.7", + "@earendil-works/pi-tui": "^0.80.7", "@types/node": "^25.9.3", "audit-ci": "^7.1.0", "fast-check": "^4.8.0", @@ -23,9 +23,9 @@ "node": ">=22" }, "peerDependencies": { - "@earendil-works/pi-ai": "^0.79.6", - "@earendil-works/pi-coding-agent": "^0.79.6", - "@earendil-works/pi-tui": "^0.79.6", + "@earendil-works/pi-ai": "^0.80.7", + "@earendil-works/pi-coding-agent": "^0.80.7", + "@earendil-works/pi-tui": "^0.80.7", "typebox": "^1.2.2" } }, @@ -538,16 +538,17 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.6.tgz", - "integrity": "sha512-KGepEdgEeWDs7Imwlp96tBsO8TjSIpcDBvazsCDtHRa81+uwJI/YGetTegI52pMlKhVpJFLIGajRi4PCGC5MUg==", + "version": "0.80.7", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.7.tgz", + "integrity": "sha512-8RLKLwe5TFM9kKFMNu/lTzveduq4GxZbnlG6ba8FAhLeb5wJP4zbj1eBumKBRvggpFQnW5R/Vo2a8zTlHsV9SQ==", "dev": true, "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", @@ -570,16 +571,16 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.79.6.tgz", - "integrity": "sha512-xPQDoA3+4Q8UsInST8OGFHPHyi7JQIbx51ku5kf2VuhXrrTv/npjVTXYD3A3D5xs6QRebV0B2mQqHfXaxQAplw==", + "version": "0.80.7", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.7.tgz", + "integrity": "sha512-mxq3IClhdgmCrYiKuzKehs4QKCVJgKmlA70nUEzsgeNsGdxriT7o5sKQTCcxzVJkzDRMcHD/8tAP4/eGrV4gKQ==", "dev": true, "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.6", - "@earendil-works/pi-ai": "^0.79.6", - "@earendil-works/pi-tui": "^0.79.6", + "@earendil-works/pi-agent-core": "^0.80.7", + "@earendil-works/pi-ai": "^0.80.7", + "@earendil-works/pi-tui": "^0.80.7", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -593,7 +594,7 @@ "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", - "undici": "8.3.0", + "undici": "8.5.0", "yaml": "2.9.0" }, "bin": { @@ -1069,12 +1070,12 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.6.tgz", + "version": "0.80.7", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.7.tgz", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-ai": "^0.80.7", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -1084,15 +1085,16 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.6.tgz", + "version": "0.80.7", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.7.tgz", "dev": true, "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", @@ -1101,15 +1103,15 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "dist/cli.js" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.6.tgz", + "version": "0.80.7", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.7.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -1351,15 +1353,24 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", "dev": true, "license": "Apache-2.0", "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { @@ -1375,6 +1386,26 @@ ], "license": "MIT" }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1397,9 +1428,9 @@ "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "dev": true, "license": "BSD-3-Clause" }, @@ -1420,13 +1451,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -2271,9 +2295,9 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", - "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", @@ -2281,15 +2305,14 @@ "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -2404,9 +2427,9 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "dev": true, "license": "MIT", "engines": { @@ -2447,9 +2470,9 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -2521,9 +2544,9 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.6.tgz", - "integrity": "sha512-6JCq780X0UuqvJsUDSJmi4V54ObB8qSwFQiBOI1jhPpC+Ydusd8SEXn2HtyIqve/utMgwcZT9aOyZM72m26A0w==", + "version": "0.80.7", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.7.tgz", + "integrity": "sha512-1B2++fLZfgI3XMzW2BTpuDuam2uyHnUUEmsOvi5R0Ne9RAt59WjFV0G8ozX6l1Xafa9P5Y3eT4aDtRr/v/CUTA==", "dev": true, "license": "MIT", "dependencies": { @@ -2560,15 +2583,24 @@ } }, "node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", "dev": true, "license": "Apache-2.0", "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@nodable/entities": { @@ -2584,6 +2616,26 @@ ], "license": "MIT" }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -2629,13 +2681,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -2787,9 +2832,9 @@ } }, "node_modules/@types/node": { - "version": "25.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", - "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", "dev": true, "license": "MIT", "dependencies": { @@ -3549,9 +3594,9 @@ } }, "node_modules/protobufjs": { - "version": "7.6.1", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", - "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", @@ -3562,7 +3607,6 @@ "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", diff --git a/package.json b/package.json index 298dc53..06d1c38 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,9 @@ "node": ">=22" }, "peerDependencies": { - "@earendil-works/pi-ai": "^0.79.6", - "@earendil-works/pi-coding-agent": "^0.79.6", - "@earendil-works/pi-tui": "^0.79.6", + "@earendil-works/pi-ai": "^0.80.7", + "@earendil-works/pi-coding-agent": "^0.80.7", + "@earendil-works/pi-tui": "^0.80.7", "typebox": "^1.2.2" }, "scripts": { @@ -30,9 +30,9 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { - "@earendil-works/pi-ai": "^0.79.6", - "@earendil-works/pi-coding-agent": "^0.79.6", - "@earendil-works/pi-tui": "^0.79.6", + "@earendil-works/pi-ai": "^0.80.7", + "@earendil-works/pi-coding-agent": "^0.80.7", + "@earendil-works/pi-tui": "^0.80.7", "@types/node": "^25.9.3", "audit-ci": "^7.1.0", "fast-check": "^4.8.0", diff --git a/spawn/index.ts b/spawn/index.ts index 8d30ec9..3cf3b22 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -29,11 +29,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 { applyReadonlyBashGuard } from "../readonly-bash.js"; import { renderSpawnCall, @@ -199,13 +199,14 @@ function createReadonlyChildBashTool( 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({ @@ -214,13 +215,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.", + })), }); @@ -262,7 +259,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: { @@ -274,12 +271,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 @@ -303,8 +315,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 }); @@ -388,6 +398,7 @@ export async function executeSpawn( thinking: childThinking, truncated: false, outcome: "running", + route: routeDetails, } satisfies SpawnResultDetails, }); @@ -450,6 +461,7 @@ export async function executeSpawn( thinking: childThinking, truncated, outcome, + route: routeDetails, }; if (stats) { details.stats = stats; @@ -490,7 +502,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 0577893..467baaf 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; @@ -848,7 +863,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", @@ -902,7 +917,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, @@ -1155,8 +1170,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) { @@ -1216,7 +1231,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) : "", @@ -1226,7 +1241,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..4de6382 100644 --- a/spawn/shared.ts +++ b/spawn/shared.ts @@ -1,11 +1,17 @@ -export type ThinkingValue = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; +export type ThinkingValue = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; 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 e99a8aa..4332199 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"; import type { ReadonlyCacheEntry, ReadonlyCacheIssue } from "./readonly-cache.js"; import type { NotebookTopicBoundaryHint } from "./notebook/topic.js"; @@ -54,6 +55,12 @@ export interface AgenticodingState { enforcementAttempts: number; } | 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. @@ -136,6 +143,7 @@ export function createState(): AgenticodingState { handoffGeneration: 0, handoffCompactionGeneration: null, pendingRequestedHandoff: null, + modelGroups: { groups: [], validation: null }, childSessions, liveChildSessions, childSessionEpoch: 0, @@ -178,6 +186,8 @@ export function resetState(state: AgenticodingState): void { // /new abandons the previous session completely; do not carry its in-flight // reservation into the fresh session. state.handoffCompactionGeneration = null; + state.modelGroups.groups = []; + state.modelGroups.validation = null; state.readonlyEnabled = false; state.readonlyNudgePending = false; state.readonlySkillCache.clear(); 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/typecheck/model-groups-producer.ts b/tests/typecheck/model-groups-producer.ts new file mode 100644 index 0000000..0c6219b --- /dev/null +++ b/tests/typecheck/model-groups-producer.ts @@ -0,0 +1,16 @@ +import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; +import { escapeDisplayLabel } from "../../model-groups/display.js"; +import { canonicalizeModelGroupName } from "../../model-groups/names.js"; +import type { ModelGroupModel, ModelGroupsAccess, ModelGroupsBootValidation } from "../../model-groups/types.js"; +import { createState } from "../../state.js"; + +const nativeMax: ModelThinkingLevel = "max"; +const model: ModelGroupModel = { provider: "provider", modelId: "model", thinkingLevel: nativeMax }; +const access: ModelGroupsAccess = { cwd: "/project", policy: "global-only" }; +const canonical = canonicalizeModelGroupName(" canonical "); +const displayed = escapeDisplayLabel("controlled\nlabel"); +const validation: ModelGroupsBootValidation = { groups: [], loadIssues: [] }; +const state = createState(); +state.modelGroups = { groups: validation.groups, validation }; + +void [model, access, canonical, displayed, state]; diff --git a/tests/unit/config-invariants.test.ts b/tests/unit/config-invariants.test.ts index 1eeac4d..f460af1 100644 --- a/tests/unit/config-invariants.test.ts +++ b/tests/unit/config-invariants.test.ts @@ -45,16 +45,7 @@ const EXPECTED_MATRIX = new Set([ "macos-latest@24", "windows-latest@24", ]); -const EXPECTED_ALLOWLIST_KEYS = new Set([ - "GHSA-96hv-2xvq-fx4p", - "GHSA-f38q-mgvj-vph7", - "GHSA-wcpc-wj8m-hjx6", - "GHSA-vmh5-mc38-953g|@earendil-works/pi-coding-agent>undici", - "GHSA-pr7r-676h-xcf6|@earendil-works/pi-coding-agent>undici", - "GHSA-38rv-x7px-6hhq|@earendil-works/pi-coding-agent>undici", - "GHSA-p88m-4jfj-68fv|@earendil-works/pi-coding-agent>undici", - "GHSA-vxpw-j846-p89q|@earendil-works/pi-coding-agent>undici", -]); +const EXPECTED_ALLOWLIST_KEYS = new Set(); function readText(url: URL): string { return readFileSync(url, "utf8"); diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index a831cbe..659c28a 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -20,6 +20,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, @@ -163,6 +180,7 @@ export function createTestPI() { }, getFlag: (name: string) => _flags.get(name), registerMessageRenderer: () => {}, + registerEntryRenderer: () => {}, setLabel: () => {}, unregisterProvider: () => {}, events: { on: () => () => {}, emit: () => {} } as import("@earendil-works/pi-coding-agent").EventBus, diff --git a/tests/unit/model-groups-autocomplete.test.ts b/tests/unit/model-groups-autocomplete.test.ts new file mode 100644 index 0000000..9368b58 --- /dev/null +++ b/tests/unit/model-groups-autocomplete.test.ts @@ -0,0 +1,53 @@ +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"; +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", { models: [ + { provider: "openai", modelId: "gpt-5", thinkingLevel: "high" }, + { provider: "anthropic", modelId: "claude-sonnet-4" }, + ]}), + group("research", { models: [{ 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", { 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)"); + + 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..d318bc9 --- /dev/null +++ b/tests/unit/model-groups-crud.test.ts @@ -0,0 +1,267 @@ +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, type ModelGroupsAccess } from "../../model-groups/types.js"; +import { withTemp } from "./model-groups-helpers.js"; + +function access(cwd: string, policy: ModelGroupsAccess["policy"] = "global-project"): ModelGroupsAccess { return { cwd, policy }; } + +function read(scope: ModelGroupScope, cwd: string): any { + return JSON.parse(fs.readFileSync(modelGroupsPath(scope, cwd), "utf8")); +} + +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.equal(Object.keys(loadModelGroups(access(cwd)).configs.project.groups).length, 0); + createGroup("project", access(cwd), "review", { models: [] }); + assert.deepEqual(read("project", cwd).groups.review.models, []); + assert.throws(() => createGroup("project", access(cwd), "review", { models: [] }), /already exists/); + + createGroup("project", access(cwd), "inherit-roundtrip", { models: [{ provider: "anthropic", modelId: "claude" }] }); + const inheritLoaded = loadModelGroups(access(cwd)).configs.project.groups["inherit-roundtrip"].models[0]; + assert.equal(inheritLoaded.thinkingLevel, undefined); + assert.equal(Object.prototype.hasOwnProperty.call(inheritLoaded, "thinkingLevel"), false); + const inheritPersisted = read("project", cwd).groups["inherit-roundtrip"].models[0]; + assert.equal(inheritPersisted.thinkingLevel, undefined); + assert.equal(Object.prototype.hasOwnProperty.call(inheritPersisted, "thinkingLevel"), false); + + updateGroup("project", access(cwd), "review", { models: [{ provider: "openai", modelId: "gpt-5", thinkingLevel: "high" }] }); + renameGroup("project", access(cwd), "review", "reviewers"); + assert.equal(read("project", cwd).groups.review, undefined); + assert.equal(read("project", cwd).groups.reviewers.models[0].thinkingLevel, "high"); + + createGroup("project", access(cwd), "collision", { models: [] }); + assert.throws(() => renameGroup("project", access(cwd), "reviewers", "collision"), /already exists/); + createGroup("global", access(cwd), "reviewers", { models: [{ provider: "openai", modelId: "gpt-5" }, { provider: "missing", modelId: "nope" }] }); + const loaded = loadModelGroups(access(cwd)); + const resolved = validateModelGroups(loaded, registry()); + const globalReviewers = resolved.find((g) => g.name === "reviewers" && g.scope === "global"); + assert.equal(globalReviewers?.validation.shadowedByProject, true); + assert.deepEqual(globalReviewers?.validation.unavailableRefs, [{ provider: "missing", modelId: "nope" }]); + assert.equal(globalReviewers?.validation.degraded, true); + + createGroup("global", access(cwd), "move-collision", { models: [] }); + createGroup("project", access(cwd), "move-collision", { models: [] }); + assert.throws(() => moveGroup(access(cwd), "move-collision", "project"), /already exists in project scope/); + assert.ok(read("global", cwd).groups["move-collision"]); + assert.ok(read("project", cwd).groups["move-collision"]); + + const deleted = deleteGroup("global", access(cwd), "reviewers"); + assert.equal(deleted.otherScopeHasOverride, true); + moveGroup(access(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(access(cwd)); + assert.equal(loaded.issues[0].kind, "corrupt-json"); + assert.ok(fs.existsSync(`${modelGroupsPath("global", cwd)}.bak`)); + + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 1, groups: { bad: { models: [{ provider: 1 }] } } }), "utf8"); + loaded = loadModelGroups(access(cwd)); + const schemaIssue = loaded.issues.find((i) => i.scope === "project")!; + assert.equal(schemaIssue.kind, "schema-invalid"); + assert.equal(schemaIssue.scope, "project"); + assert.equal(schemaIssue.sourcePath, modelGroupsPath("project", cwd)); + assert.equal(schemaIssue.backupPath, `${modelGroupsPath("project", cwd)}.bak`); + assert.match(schemaIssue.message, /provider/); + assert.ok(fs.existsSync(schemaIssue.backupPath!)); + assert.equal(Object.keys(loaded.configs.project.groups).length, 0); + + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 99, groups: {} }), "utf8"); + loaded = loadModelGroups(access(cwd)); + assert.equal(loaded.issues.find((i) => i.scope === "project")?.kind, "unsupported-version"); + assert.equal(loaded.issues.find((i) => i.scope === "project")?.version, 99); + + fs.writeFileSync(modelGroupsPath("project", cwd), "{bad", "utf8"); + __setModelGroupsFsForTests({ copyFileSync: () => { throw new Error("denied"); } }); + loaded = loadModelGroups(access(cwd)); + const issue = loaded.issues.find((i) => i.scope === "project")!; + assert.equal(issue.backupFailed, true); + assert.equal(fs.readFileSync(modelGroupsPath("project", cwd), "utf8"), "{bad"); + assert.throws(() => createGroup("project", access(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", access(cwd), { version: 1, groups: { keep: { models: [] } } }); + __setModelGroupsFsForTests({ writeFileSync: () => { throw new Error("temp denied"); } }); + assert.throws(() => updateGroup("project", access(cwd), "keep", { models: [{ provider: "openai", modelId: "gpt-5" }] }), (error) => { + assert.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", access(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", access(cwd), "keep"), (error) => { + assert.ok(error instanceof ModelGroupsPersistenceError); + assert.equal(error.operation, "delete"); + return true; + }); + __setModelGroupsFsForTests(null); + + createGroup("global", access(cwd), "move-target-fails", { models: [] }); + __setModelGroupsFsForTests({ renameSync: () => { throw new Error("target denied"); } }); + assert.throws(() => moveGroup(access(cwd), "move-target-fails", "project"), (error) => { + assert.ok(error instanceof ModelGroupsPersistenceError); + assert.equal(error.operation, "move"); + assert.equal(error.partialMove, undefined); + return true; + }); + __setModelGroupsFsForTests(null); + + createGroup("global", access(cwd), "move-me", { models: [] }); + let writes = 0; + __setModelGroupsFsForTests({ renameSync: (from, to) => { writes++; if (writes === 2) throw new Error("source denied"); fs.renameSync(from, to); } }); + assert.throws(() => moveGroup(access(cwd), "move-me", "project"), (error) => { + assert.ok(error instanceof ModelGroupsPersistenceError); + assert.equal(error.partialMove, "target-written-source-retained"); + assert.equal(error.phase, "source-remove"); + return true; + }); +})); + +test("model groups strictly partitions schema and legacy version domains", () => withTemp(({ cwd }) => { + const projectPath = modelGroupsPath("project", cwd); + fs.mkdirSync(path.dirname(projectPath), { recursive: true }); + const invalid: Array<[string, unknown, RegExp]> = [ + ["root", [], /root/], ["version type", { version: "1", groups: {} }, /version/], + ["negative", { version: -1, groups: {} }, /version/], ["fraction low", { version: 0.5, groups: {} }, /version/], + ["fraction high", { version: 1.5, groups: {} }, /version/], ["groups", { version: 1, groups: [] }, /groups/], + ["group", { version: 1, groups: { bad: 1 } }, /group/], + ["provider missing", { version: 1, groups: { bad: { models: [{ modelId: "m" }] } } }, /provider/], + ["provider type", { version: 1, groups: { bad: { models: [{ provider: 1, modelId: "m" }] } } }, /provider/], + ["model missing", { version: 1, groups: { bad: { models: [{ provider: "p" }] } } }, /modelId/], + ["model type", { version: 1, groups: { bad: { models: [{ provider: "p", modelId: 1 }] } } }, /modelId/], + ["models", { version: 1, groups: { bad: { models: 1 } } }, /models/], + ["thinking", { version: 1, groups: { bad: { models: [{ provider: "p", modelId: "m", thinkingLevel: "turbo" }] } } }, /thinkingLevel/], + ]; + for (const [label, raw, message] of invalid) { + fs.writeFileSync(projectPath, JSON.stringify(raw), "utf8"); + const loaded = loadModelGroups(access(cwd)); + const issue = loaded.issues.find((candidate) => candidate.scope === "project")!; + assert.equal(issue.kind, "schema-invalid", label); + assert.match(issue.message, message, label); + assert.ok(fs.existsSync(`${projectPath}.bak`), label); + assert.equal(Object.keys(loaded.configs.project.groups).length, 0, label); + } + for (const raw of [{ groups: { legacy: { models: [] } } }, { version: 0, groups: { legacy: { models: [] } } }]) { + fs.writeFileSync(projectPath, JSON.stringify(raw), "utf8"); + const loaded = loadModelGroups(access(cwd)); + assert.equal(loaded.issues.length, 0); + assert.equal(loaded.configs.project.version, 1); + updateGroup("project", access(cwd), "legacy", { models: [] }); + assert.equal(read("project", cwd).version, 1); + } +})); + +test("model groups use branded paths, global-only access, canonical own keys, and native max", () => withTemp(({ cwd }) => { + assert.equal(modelGroupsPath("project", cwd, "branded-pi"), path.join(cwd, "branded-pi", "pi-agenticoding", "model-groups.json")); + const raw = '{"version":1,"groups":{" __proto__ ":{"models":[{"provider":"p","modelId":"proto","thinkingLevel":"max"}]},"constructor":{"models":[]},"toString":{"models":[]}}}'; + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), raw, "utf8"); + let loaded = loadModelGroups(access(cwd)); + for (const name of ["__proto__", "constructor", "toString"]) assert.ok(Object.hasOwn(loaded.configs.project.groups, name)); + assert.equal(loaded.configs.project.groups.__proto__.models[0].thinkingLevel, "max"); + saveModelGroups("project", access(cwd), loaded.configs.project); + const persisted = read("project", cwd); + assert.deepEqual(Object.keys(persisted.groups).sort(), ["__proto__", "constructor", "toString"].sort()); + assert.equal(Object.getPrototypeOf(loaded.configs.project.groups), null); + + for (const name of ["__proto__", "constructor", "toString"]) { + deleteGroup("project", access(cwd), name); + createGroup("global", access(cwd), ` ${name} `, { models: [] }); + updateGroup("global", access(cwd), name, { models: [{ provider: "p", modelId: name }] }); + moveGroup(access(cwd), name, "project"); + assert.ok(Object.hasOwn(read("project", cwd).groups, name)); + deleteGroup("project", access(cwd), name); + } + + let projectProbe = false; + __setModelGroupsFsForTests({ existsSync: (candidate) => { + if (String(candidate).startsWith(cwd)) { projectProbe = true; throw new Error("project probed"); } + return fs.existsSync(candidate); + } }); + const untrusted = loadModelGroups(access(cwd, "global-only")); + assert.equal(projectProbe, false); + assert.equal(untrusted.merged.every((group) => group.scope === "global"), true); + assert.throws(() => createGroup("project", access(cwd, "global-only"), "forbidden", { models: [] }), /global-only/); + assert.equal(projectProbe, false); +})); + +test("model groups direct save canonicalizes unique keys and rejects empty/colliding keys before write", () => withTemp(({ cwd }) => { + const a = access(cwd); + saveModelGroups("project", a, { version: 1, groups: { committed: { models: [] } } }); + const unique: Record = Object.create(null); + Object.defineProperty(unique, " unique ", { value: { models: [] }, enumerable: true }); + saveModelGroups("project", a, { version: 1, groups: unique }); + assert.deepEqual(Object.keys(read("project", cwd).groups), ["unique"]); + for (const keys of [[" "], ["same", " same "]]) { + const groups: Record = Object.create(null); + for (const key of keys) Object.defineProperty(groups, key, { value: { models: [] }, enumerable: true }); + const before = fs.readFileSync(modelGroupsPath("project", cwd), "utf8"); + let writes = 0; + __setModelGroupsFsForTests({ writeFileSync: (..._args: any[]) => { writes++; throw new Error("must not write"); } }); + assert.throws(() => saveModelGroups("project", a, { version: 1, groups }), (error) => { + assert.ok(error instanceof ModelGroupsPersistenceError); + assert.equal(error.operation, "save"); + assert.equal(error.phase, "config-validation"); + return true; + }); + assert.equal(writes, 0); + assert.equal(fs.readFileSync(modelGroupsPath("project", cwd), "utf8"), before); + __setModelGroupsFsForTests(null); + } +})); diff --git a/tests/unit/model-groups-display.test.ts b/tests/unit/model-groups-display.test.ts new file mode 100644 index 0000000..d4056f5 --- /dev/null +++ b/tests/unit/model-groups-display.test.ts @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { decodeDisplayLabel, escapeDisplayLabel } from "../../model-groups/display.js"; + +const dangerous = [ + ...Array.from({ length: 0x20 }, (_, i) => String.fromCharCode(i)), + String.fromCharCode(0x7f), + ...Array.from({ length: 0x20 }, (_, i) => String.fromCharCode(0x80 + i)), + "\u2028", + "\u2029", +]; + +test("model-groups display codec reversibly escapes every controlled code point", () => { + for (const raw of ["ordinary λ", "\\", ...dangerous, "\u001b]8;;https://example.test\u0007link\u001b]8;;\u0007"]) { + const encoded = escapeDisplayLabel(raw); + assert.deepEqual(decodeDisplayLabel(encoded), { ok: true, value: raw }); + for (const codePoint of encoded) { + const code = codePoint.codePointAt(0)!; + assert.equal(code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0x2028 || code === 0x2029, false, JSON.stringify({ raw, encoded })); + } + } + assert.equal(escapeDisplayLabel("\n\r\t\u0007\u001b\u007f\u0080\u009f\u2028\u2029\\"), "\\n\\r\\t\\x07\\x1B\\x7F\\x80\\x9F\\u2028\\u2029\\\\"); +}); + +test("model-groups display decoder rejects non-canonical, unknown, malformed, and incomplete escapes", () => { + for (const encoded of ["\\q", "\\x", "\\x0", "\\x0g", "\\x1b", "\\x20", "\\u2027", "\\u202A", "trailing\\"]) { + assert.equal(decodeDisplayLabel(encoded).ok, false, encoded); + } +}); diff --git a/tests/unit/model-groups-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 new file mode 100644 index 0000000..c5680e3 --- /dev/null +++ b/tests/unit/model-groups-integration.test.ts @@ -0,0 +1,282 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import registerAgenticoding from "../../index.js"; +import { escapeDisplayLabel } from "../../model-groups/display.js"; +import { __setModelGroupsFsForTests, modelGroupsPath } from "../../model-groups/store.js"; +import { createTestPI, theme } from "./helpers.js"; +import { withTemp } from "./model-groups-helpers.js"; + +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}`), + }; +} + +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 findCalls: string[] = []; + const registrySentinel = { + ...registry(), + find: (provider: string, id: string) => { + findCalls.push(`${provider}:${id}`); + return { provider, id, reasoning: true, thinkingLevelMap: { xhigh: "x" } }; + }, + hasConfiguredAuth: () => true, + }; + registerAgenticoding(pi as any); + assert.ok(pi.commands.has("model-groups")); + let customCalled = 0; + let rendered = ""; + await pi.commands.get("model-groups")!.handler("", { + hasUI: true, + mode: "tui", + isProjectTrusted: () => true, + cwd, + modelRegistry: registrySentinel, + ui: { + 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, + mode: "tui", + isProjectTrusted: () => true, + cwd, + modelRegistry: registry(), + getContextUsage: () => ({ percent: 10 }), + ui: { + theme, + notify: (message: string) => notifications.push(message), + setStatus: () => {}, + setWidget: () => {}, + }, + }; + const handler = pi.handlers.get("session_start")!.at(-1)!; + await handler({ reason: "load" }, ctx); + assert.ok(notifications.some((m) => /1 unavailable model references · 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, + mode: "tui", + isProjectTrusted: () => true, + cwd, + modelRegistry: registry(), + getContextUsage: () => ({ percent: 10 }), + ui: { theme, notify: (message: string) => notifications.push(message), setStatus: () => {}, setWidget: () => {} }, + }; + const handler = pi.handlers.get("session_start")!.at(-1)!; + await handler({ reason: "load" }, ctx); + assert.ok(notifications.some((m) => /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, + mode: "tui", + isProjectTrusted: () => true, + cwd, + modelRegistry: registry(), + getContextUsage: () => ({ percent: 10 }), + ui: { theme, notify: (message: string) => notifications.push(message), setStatus: () => {}, setWidget: () => {} }, + }; + const handler = pi.handlers.get("session_start")!.at(-1)!; + await handler({ reason: "load" }, ctx); + assert.ok(notifications.some((m) => /schema-invalid/.test(m))); + assert.ok(notifications.some((m) => /project scope/.test(m))); + assert.ok(notifications.some((m) => m.includes(escapeDisplayLabel(modelGroupsPath("project", cwd))))); +})); + +test("index session_start includes backup-failure detail in load issue notifications", async () => withTemp(async ({ cwd }) => { + 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, + mode: "tui", + isProjectTrusted: () => true, + cwd, + modelRegistry: registry(), + getContextUsage: () => ({ percent: 10 }), + ui: { theme, notify: (message: string) => notifications.push(message), setStatus: () => {}, setWidget: () => {} }, + }; + const handler = pi.handlers.get("session_start")!.at(-1)!; + await handler({ reason: "load" }, ctx); + assert.ok(notifications.some((m) => /corrupt-json/.test(m) && /backup failed.*original file left untouched/.test(m) && m.includes(escapeDisplayLabel(modelGroupsPath("project", cwd))))); +})); + +test("before_agent_start injects fresh names-only Model Groups guidance", async () => withTemp(async ({ cwd }) => { + 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, isProjectTrusted: () => true, cwd, modelRegistry: registry(), getContextUsage: () => null }); + assert.match(result.systemPrompt, /## Model Groups for spawn/); + assert.match(result.systemPrompt, /Available Model Groups: review/); + assert.match(result.systemPrompt, /exact group name/); + 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("before_agent_start clears stale Model Groups guidance when registry becomes unavailable", async () => withTemp(async ({ cwd }) => { + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 1, groups: { review: { models: [{ provider: "openai", modelId: "gpt-5" }] } } }), "utf8"); + const pi = createTestPI(); + registerAgenticoding(pi as any); + const handler = pi.handlers.get("before_agent_start")!.at(-1)!; + const loaded = await handler({ systemPrompt: "Base." }, { hasUI: false, isProjectTrusted: () => true, cwd, modelRegistry: registry(), getContextUsage: () => null }); + assert.match(loaded.systemPrompt, /Available Model Groups: review/); + + const unavailable = await handler({ systemPrompt: "Base." }, { hasUI: false, isProjectTrusted: () => true, cwd, modelRegistry: undefined, getContextUsage: () => null }); + assert.doesNotMatch(unavailable.systemPrompt, /## Model Groups for spawn|Available Model Groups: review/); +})); + +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, + mode: "tui", + isProjectTrusted: () => 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, + mode: "tui", + isProjectTrusted: () => true, + cwd, + modelRegistry: registry(), + getContextUsage: () => ({ percent: 10 }), + ui: { theme, notify: (message: string) => notifications.push(message), setStatus: () => {}, setWidget: () => {} }, + }; + const handler = pi.handlers.get("session_start")!.at(-1)!; + await handler({ reason: "load" }, ctx); + assert.deepEqual(notifications, []); +})); + +test("root-recorded /model-groups rejects RPC and skips JSON/print before custom UI", async () => { + for (const mode of ["rpc", "json", "print"] as const) { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notifications: string[] = []; + let customCalls = 0; + await pi.commands.get("model-groups")!.handler("", { + mode, + hasUI: mode === "rpc", + cwd: "/must-not-load", + modelRegistry: registry(), + isProjectTrusted: () => true, + ui: { notify: (message: string) => notifications.push(message), custom: async () => { customCalls++; } }, + }); + assert.equal(customCalls, 0, mode); + assert.deepEqual(notifications, mode === "rpc" ? ["/model-groups requires TUI mode"] : [], mode); + } +}); + +test("model groups untrusted root flow never probes project data and publishes global-only state to TUI", async () => withTemp(async ({ cwd }) => { + fs.mkdirSync(path.dirname(modelGroupsPath("global", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("global", cwd), JSON.stringify({ version: 1, groups: { globalOnly: { models: [] } } }), "utf8"); + fs.mkdirSync(path.dirname(modelGroupsPath("project", cwd)), { recursive: true }); + fs.writeFileSync(modelGroupsPath("project", cwd), JSON.stringify({ version: 1, groups: { projectSecret: { models: [] } } }), "utf8"); + let projectProbe = false; + __setModelGroupsFsForTests({ existsSync: (candidate) => { + if (String(candidate).startsWith(cwd)) { projectProbe = true; throw new Error("project probe"); } + return fs.existsSync(candidate); + } }); + const pi = createTestPI(); + registerAgenticoding(pi as any); + const baseUi = { theme, notify: () => {}, setStatus: () => {}, setWidget: () => {}, addAutocompleteProvider: () => {} }; + await pi.handlers.get("session_start")!.at(-1)!({ reason: "load" }, { + mode: "tui", hasUI: true, isProjectTrusted: () => false, cwd, modelRegistry: registry(), getContextUsage: () => ({ percent: 10 }), ui: baseUi, + }); + assert.equal(projectProbe, false); + let rendered = ""; + await pi.commands.get("model-groups")!.handler("", { + mode: "tui", hasUI: true, isProjectTrusted: () => false, cwd, modelRegistry: registry(), + ui: { ...baseUi, custom: async (factory: any) => { rendered = factory({ requestRender: () => {} }, theme, {}, () => {}).render(80).join("\n"); } }, + }); + assert.match(rendered, /globalOnly/); + assert.doesNotMatch(rendered, /projectSecret|\[project\]/); + assert.equal(projectProbe, false); +})); + +test("model groups boot load-issue notifications escape hostile source, backup, and error detail fields", async () => withTemp(async ({ cwd }) => { + const hostileCwd = `${cwd}\n\u001b]8;;https://example.test\u0007path`; + const projectPath = modelGroupsPath("project", hostileCwd); + __setModelGroupsFsForTests({ + existsSync: (candidate) => String(candidate) === projectPath, + readFileSync: () => { throw new Error("invalid JSON"); }, + copyFileSync: () => { throw new Error("backup\n\u001b[31mfailed\u0007"); }, + }); + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notifications: string[] = []; + await pi.handlers.get("session_start")!.at(-1)!({ reason: "load" }, { + mode: "tui", hasUI: true, isProjectTrusted: () => true, cwd: hostileCwd, modelRegistry: registry(), getContextUsage: () => ({ percent: 10 }), + ui: { theme, notify: (message: string) => notifications.push(message), setStatus: () => {}, setWidget: () => {}, addAutocompleteProvider: () => {} }, + }); + const notification = notifications.find((message) => message.includes("corrupt-json"))!; + assert.ok(notification.includes(`project scope (${escapeDisplayLabel(projectPath)})`)); + assert.ok(notification.includes(`backup failed (${escapeDisplayLabel(`${projectPath}.bak`)}), original file left untouched`)); + assert.match(notification, /backup\\n\\x1B\[31mfailed\\x07/); + assert.doesNotMatch(notification, /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/); +})); diff --git a/tests/unit/model-groups-router.test.ts b/tests/unit/model-groups-router.test.ts new file mode 100644 index 0000000..00f8f8d --- /dev/null +++ b/tests/unit/model-groups-router.test.ts @@ -0,0 +1,69 @@ +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 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", { scope: "global", shadowedByProject: true }), + group("review", { scope: "project" }), + group("research", { scope: "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", { scope: "project" })], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), + (error: unknown) => error instanceof SpawnRouteError && error.group === "empty" && error.reason === "empty" && /empty/.test(error.message), + ); + assert.throws( + () => resolveSpawnModelRoute({ requestedGroup: "bad", groups: [group("bad", { scope: "project", models: [{ provider: "openai", modelId: "missing" }] })], parentModel: parent, parentThinking: "low", modelRegistry: registry([parent]) }), + (error: unknown) => error instanceof SpawnRouteError && error.group === "bad" && error.reason === "no-usable-models" && /configured\/authenticated/.test(error.message), + ); +}); + +test("known usable groups filter registry/auth, draw with rng seam, and clamp thinking", () => { + const parent = model("openai", "parent"); + const usableA = model("openai", "a", { thinkingLevelMap: { xhigh: "x" } }); + const usableB = model("anthropic", "b", { thinkingLevelMap: { xhigh: null } }); + const unauth = model("openai", "unauth"); + const groups = [group("review", { scope: "project", models: [ + { provider: "openai", modelId: "missing" }, + { provider: "openai", modelId: "unauth" }, + { provider: "openai", modelId: "a" }, + { provider: "anthropic", modelId: "b", thinkingLevel: "xhigh" }, + ] })]; + const reg = registry([parent, usableA, usableB, unauth], new Set(["openai:parent", "openai:a", "anthropic:b"])); + const first = resolveSpawnModelRoute({ requestedGroup: "review", groups, parentModel: parent, parentThinking: "low", modelRegistry: reg, rng: () => 0 }); + assert.equal(first.status, "routed"); + assert.equal(first.model, usableA); + assert.equal(first.thinking, "low", "entry without thinking inherits parent"); + const second = resolveSpawnModelRoute({ requestedGroup: "review", groups, parentModel: parent, parentThinking: "low", modelRegistry: reg, rng: () => 0.99 }); + assert.equal(second.model, usableB); + assert.equal(second.thinking, "high", "xhigh clamps when selected model does not support it"); +}); diff --git a/tests/unit/model-groups-tui.test.ts b/tests/unit/model-groups-tui.test.ts new file mode 100644 index 0000000..69f1574 --- /dev/null +++ b/tests/unit/model-groups-tui.test.ts @@ -0,0 +1,557 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { CURSOR_MARKER, visibleWidth } from "@earendil-works/pi-tui"; +import { createModelGroupsComponent } from "../../model-groups/tui.js"; +import { ModelGroupsPersistenceError, type ModelGroupsBootValidation, type ResolvedModelGroup } from "../../model-groups/types.js"; +import { theme } from "./helpers.js"; +import { group } from "./model-groups-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", max: "m" } }, + { 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 boot(groups: ResolvedModelGroup[]): ModelGroupsBootValidation { return { groups, loadIssues: [] }; } + +function component(args: { groups?: ResolvedModelGroup[]; store?: any; notify?: (m: string, t?: any) => void; renderTheme?: any; policy?: "global-project" | "global-only" } = {}) { + let renders = 0; + const c = createModelGroupsComponent( + { requestRender: () => { renders++; } } as any, + args.renderTheme ?? theme, + registry(), + { cwd: "/tmp/project", policy: args.policy ?? "global-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", { scope: "global" }); + override.validation.shadowedByProject = true; + 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", { 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 }; }, + 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", { 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, { scope: "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", { scope: "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", { scope: "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", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] })], renderTheme: accentTheme }).c; + + let text = rendered(list); + assert.match(text, /→ review\s+\[project\] 1 models inherit<\/accent>/); + press(list, DOWN); + assert.match(rendered(list), /→ \+ Add group<\/accent>/); + + const editor = component({ groups: [group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] })], renderTheme: accentTheme }).c; + press(editor, ENTER); + 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), /→ anthropic<\/accent>/); + press(editor, DOWN, ENTER); + assert.match(rendered(editor), /→ openai\/gpt-5<\/accent>/); + press(editor, ENTER); + assert.match(rendered(editor), /→ inherit<\/accent>/); + + const modelEdit = component({ groups: [group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] })], renderTheme: accentTheme }).c; + press(modelEdit, ENTER, DOWN, DOWN, DOWN, ENTER); + assert.match(rendered(modelEdit), /→<\/accent> Thinking: inherit<\/accent>/); + + 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); + 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", { 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); + 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", { scope: "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", { 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"}`)); + 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", { scope: "project" })]; + const calls: string[] = []; + const store = { + 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 }); + 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"), /> 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", { 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, { 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 }); + 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", { scope: "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", { scope: "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/); +}); + +test("model groups TUI uses root Focusable propagation and MODEL_EDIT parent navigation", () => { + const c = component({ groups: [group("review", { scope: "project", models: [{ provider: "openai", modelId: "gpt-5" }] })] }).c; + c.focused = true; + press(c, ENTER, DOWN, DOWN, ENTER); + assert.ok(c.render(80).join("\n").includes(CURSOR_MARKER)); + press(c, ENTER); + assert.equal(c.render(80).join("\n").includes(CURSOR_MARKER), false); + press(c, DOWN, ENTER); + assert.match(rendered(c), /Edit model/); + press(c, ESC); + assert.match(rendered(c), /Location: project/); + assert.doesNotMatch(rendered(c), /Edit model/); +}); + +test("model groups TUI clamps final add row and enforces global-only access", () => { + let groups = [group("global-name", { scope: "global" }), group("project-secret", { scope: "project" })]; + const calls: any[] = []; + const store = { + createGroup: (scope: string, access: any, name: string, def: any) => { + calls.push({ scope, access, name, def }); + groups = [group("global-name", { scope: "global" }), group(name, { scope: "global" })]; + }, + listResolvedModelGroups: () => boot(groups.filter((candidate) => candidate.scope === "global")), + }; + const c = component({ groups, store, policy: "global-only" }).c; + assert.doesNotMatch(rendered(c), /project-secret/); + press(c, DOWN, DOWN); + assert.match(rendered(c), /→ \+ Add group/); + press(c, ENTER); + assert.equal(calls.length, 1); + assert.equal(calls[0].scope, "global"); + assert.deepEqual(calls[0].access, { cwd: "/tmp/project", policy: "global-only" }); + assert.deepEqual(calls[0].def, { models: [] }); + const text = rendered(c); + assert.match(text, /Location: global/); + assert.doesNotMatch(text, /Location: project/); +}); + +test("model groups TUI escapes controlled labels, bounds width, and offers native max", () => { + const hostile = "group\n\u001b]8;;https://example.test\u0007link\u001b]8;;\u0007"; + const c = component({ groups: [group(hostile, { scope: "project", models: [{ provider: "openai\u001b[31m", modelId: "model\nlong" }] })] }).c; + const lines = c.render(20); + assert.equal(lines.every((line) => visibleWidth(line) <= 20), true); + const text = lines.join("\n"); + assert.match(text, /group\\n\\x1B/); + assert.doesNotMatch(text, /\u001b\]8;;/); + + let maxGroups = [group("review", { scope: "project" })]; + let selectedThinking: string | undefined; + const max = component({ + groups: maxGroups, + store: { + updateGroup: (_scope: string, _access: any, name: string, def: any) => { selectedThinking = def.models.at(-1)?.thinkingLevel; maxGroups = [group(name, { scope: "project", models: def.models })]; }, + listResolvedModelGroups: () => boot(maxGroups), + }, + }).c; + press(max, ENTER, DOWN, DOWN, DOWN, ENTER); + press(max, DOWN, ENTER, ENTER); + assert.match(rendered(max), /Add model — Step 3\/3 Thinking/); + assert.match(rendered(max), /max/); + press(max, ...Array(20).fill(DOWN), ENTER); + assert.equal(selectedThinking, "max"); +}); + +test("model groups TUI decodes then canonicalizes prototype-sensitive names and rejects malformed escapes", () => { + for (const nextName of ["__proto__", "constructor", "toString"]) { + let groups = [group("abc", { scope: "project" })]; + const calls: string[] = []; + const notifications: string[] = []; + const store = { + renameGroup: (_scope: string, _access: any, oldName: string, renamed: string) => { calls.push(`${oldName}->${renamed}`); groups = [group(renamed, { scope: "project" })]; }, + listResolvedModelGroups: () => boot(groups), + }; + const c = component({ groups, store, notify: (message) => notifications.push(message) }).c; + press(c, ENTER, DOWN, DOWN, ENTER, "\u007f", "\u007f", "\u007f", ...[...` ${nextName} `], DOWN); + assert.deepEqual(calls, [`abc->${nextName}`]); + assert.match(rendered(c), new RegExp(nextName.replaceAll("_", "\\_"))); + assert.deepEqual(notifications, []); + } + + const malformedCalls: string[] = []; + const notifications: string[] = []; + const malformed = component({ + groups: [group("abc", { scope: "project" })], + store: { renameGroup: () => malformedCalls.push("called"), listResolvedModelGroups: () => boot([group("abc", { scope: "project" })]) }, + notify: (message) => notifications.push(message), + }).c; + press(malformed, ENTER, DOWN, DOWN, ENTER, "\\", ENTER); + assert.deepEqual(malformedCalls, []); + assert.equal(notifications.length, 1); + assert.match(rendered(malformed), /abc/); +}); + +test("model groups TUI keeps every screen width-bounded without wrapping logical rows", () => { + const assertScreen = (c: ReturnType["c"]) => { + const wideCount = c.render(200).length; + const narrow = c.render(12); + assert.equal(narrow.length, wideCount); + assert.equal(narrow.every((line) => visibleWidth(line) <= 12), true); + }; + const longModel = { provider: "openai", modelId: "gpt-5", thinkingLevel: "max" as const }; + const c = component({ groups: [group("a-very-long-group-name", { scope: "project", models: [longModel] })] }).c; + assertScreen(c); // LIST + press(c, ENTER); + assertScreen(c); // EDITOR + press(c, DOWN, DOWN, DOWN, ENTER); + assertScreen(c); // MODEL_EDIT + press(c, ESC, DOWN, DOWN, DOWN, DOWN, ENTER); + assertScreen(c); // WIZARD_PROVIDER + press(c, DOWN, DOWN, ENTER); + assertScreen(c); // WIZARD_MODEL + press(c, ENTER); + assertScreen(c); // WIZARD_THINKING + const deletion = component({ groups: [group("a-very-long-group-name", { scope: "project", models: [longModel] })] }).c; + press(deletion, "D"); + assertScreen(deletion); // DELETE_CONFIRM +}); + +test("model groups TUI persistence notifications escape each hostile dynamic field", () => { + const notifications: string[] = []; + const raw = "\n\u001b]8;;https://example.test\u0007field"; + const c = component({ + groups: [group("review", { scope: "project" })], + store: { + renameGroup: () => { throw new ModelGroupsPersistenceError({ operation: "save", scope: "project", sourcePath: `source${raw}`, targetPath: `target${raw}`, phase: "rename", message: `message${raw}`, cause: new Error(`cause${raw}`) }); }, + listResolvedModelGroups: () => boot([group("review", { scope: "project" })]), + }, + notify: (message) => notifications.push(message), + }).c; + press(c, ENTER, DOWN, DOWN, ENTER, "x", ENTER); + assert.equal(notifications.length, 1); + assert.match(notifications[0], /source\\n\\x1B/); + assert.match(notifications[0], /target\\n\\x1B/); + assert.match(notifications[0], /message\\n\\x1B/); + assert.match(notifications[0], /cause\\n\\x1B/); + assert.doesNotMatch(notifications[0], /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/); +}); diff --git a/tests/unit/os-sandbox.test.ts b/tests/unit/os-sandbox.test.ts index ad0701b..268b07f 100644 --- a/tests/unit/os-sandbox.test.ts +++ b/tests/unit/os-sandbox.test.ts @@ -123,4 +123,3 @@ test("sandbox allows writes through a symlinked temp path", () => { try { fs.rmSync(realDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } } }); - diff --git a/tests/unit/readonly-mode.test.ts b/tests/unit/readonly-mode.test.ts index cf1687f..1ba5125 100644 --- a/tests/unit/readonly-mode.test.ts +++ b/tests/unit/readonly-mode.test.ts @@ -104,5 +104,3 @@ test("readonly toggle off delivers a deactivation nudge via context hook", async assert.match(readonlyNudge.content, /readonly/i); assert.match(readonlyNudge.content, /off|disabled|turned off/i); }); - - 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 8ad0475..e73879a 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -179,7 +179,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), @@ -188,6 +188,69 @@ 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 composes Model Group routing with readonly child guards", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "write", "edit", "spawn", "handoff"]); + pi.setAllTools(["read", "bash", "write", "edit", "spawn", "handoff"]); + const state = createState(); + state.readonlyEnabled = true; + 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; + let seenPrompt = ""; + const mockFactory = async (config: any) => { + seenConfig = config; + const session = { + messages: [] as any[], + prompt: async (prompt: string) => { + seenPrompt = prompt; + 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( + new Set(seenConfig.tools), + new Set(["read", "bash", "notebook_write", "notebook_read", "notebook_index"]), + ); + assert.ok(seenConfig.customTools.some((tool: any) => tool.name === "bash")); + assert.ok(!seenConfig.tools.includes("write")); + assert.ok(!seenConfig.tools.includes("edit")); + assert.ok(!seenConfig.tools.includes("spawn")); + assert.ok(!seenConfig.tools.includes("handoff")); + assert.match(seenPrompt, /inherit readonly authority/i); + assert.match(seenPrompt, /\[readonly\] write\/edit blocked/i); + 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"]); @@ -316,7 +379,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"); @@ -994,7 +1057,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); @@ -1013,10 +1076,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 }); @@ -1248,6 +1314,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"); diff --git a/tests/unit/state-invariants.test.ts b/tests/unit/state-invariants.test.ts index 4ea08a8..7c3cb96 100644 --- a/tests/unit/state-invariants.test.ts +++ b/tests/unit/state-invariants.test.ts @@ -109,6 +109,8 @@ function assertResetClears(state: AgenticodingState): void { assert.equal(state.pendingHandoff, null, "pendingHandoff must be null after reset"); assert.equal(state.pendingRequestedHandoff, null, "pendingRequestedHandoff must be null after reset"); assert.equal(state.pendingTopicBoundaryHint, null, "pendingTopicBoundaryHint must be null after reset"); + assert.equal(state.modelGroups.groups.length, 0, "modelGroups.groups must be empty after reset"); + assert.equal(state.modelGroups.validation, null, "modelGroups.validation must be null after reset"); assert.equal(state.readonlyEnabled, false, "readonlyEnabled must be false after reset"); assert.equal(state.readonlyNudgePending, false, "readonlyNudgePending must be false after reset"); assert.equal(state.readonlySkillCache.size, 0, "readonlySkillCache must be empty after reset"); @@ -272,6 +274,14 @@ test("Property 4: Reset clears all state fields", async () => { s2.readonlySkillIssues.set("skill-b", { kind: "invalid-readonly-value", filePath: "/tmp/skill-b.md" }); s2.readonlyPromptIssues.set("prompt-b", { kind: "unreadable-file", filePath: "/tmp/prompt-b.md" }); s2.pendingReadonlyCommands.push({ type: "skill", name: "skill-a" }); + s2.modelGroups.groups = [{ + name: "stale", + scope: "project", + sourcePath: "", + models: [], + validation: { unavailableRefs: [], shadowedByProject: false, degraded: false }, + }]; + s2.modelGroups.validation = { groups: s2.modelGroups.groups, loadIssues: [] }; resetState(s2); assertResetClears(s2); }, diff --git a/tsconfig.model-groups-producer.json b/tsconfig.model-groups-producer.json new file mode 100644 index 0000000..cd2e5a1 --- /dev/null +++ b/tsconfig.model-groups-producer.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "include": [], + "files": [ + "model-groups/types.ts", + "model-groups/names.ts", + "model-groups/store.ts", + "model-groups/display.ts", + "model-groups/tui.ts", + "model-groups/command.ts", + "state.ts", + "tests/typecheck/model-groups-producer.ts" + ] +}