refactor: read stored settings and external responses with Valibot (#937)

* refactor(settings): read stored preferences with a Valibot schema

Each field falls back to its default on its own, so one bad value keeps the rest, as before. Tests pin the behavior; they pass against the previous implementation too.

* refactor(mcp): read stored MCP connections with a Valibot schema

A connection schema checks the ID, trimmed name, transport, and URL rule; a bearer token is kept only for the connection's own credential. A new test covers the credential check and normalization, and passes against the previous implementation.

* refactor(clipboard): read Figma's image batch response with a Valibot schema

* refactor(mcp): read tool descriptors with a Valibot schema

The descriptor type now follows the schema, and tests cover what a descriptor must contain.

* refactor(ai): read stored model settings with Valibot schemas

Connections and model profiles are schemas with their defaults and bounds; the thinking-level migration and the checks between connections, models, and role assignments stay as code. Characterization tests written against the previous implementation pin the behavior.
This commit is contained in:
Danila Poyarkov 2026-10-07 09:28:42 +00:00 committed by GitHub
parent 9b4100b1d5
commit 2f79278df1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 453 additions and 278 deletions

View file

@ -1,33 +1,11 @@
import * as v from 'valibot'
import type { ToolCapability } from '@open-pencil/core/tools'
export type { ToolCapability } from '@open-pencil/core/tools'
export type ToolEffect = 'read' | 'write'
export type ToolAvailability = 'default' | 'eval' | 'filesystem'
export interface ToolDescriptor {
name: string
description: string
effect: ToolEffect
availability: ToolAvailability
capabilities: ToolCapability[]
enabled: boolean
}
export interface ToolPolicy {
allowEval: boolean
disabledTools: string[]
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
const TOOL_EFFECTS: ReadonlySet<string> = new Set<ToolEffect>(['read', 'write'])
const TOOL_AVAILABILITIES: ReadonlySet<string> = new Set<ToolAvailability>([
'default',
'eval',
'filesystem'
])
const TOOL_CAPABILITIES: ReadonlySet<string> = new Set<ToolCapability>([
const TOOL_EFFECTS = ['read', 'write'] as const
const TOOL_AVAILABILITIES = ['default', 'eval', 'filesystem'] as const
const TOOL_CAPABILITIES = [
'document:read',
'document:write',
'filesystem:read',
@ -36,30 +14,28 @@ const TOOL_CAPABILITIES: ReadonlySet<string> = new Set<ToolCapability>([
'code:execute',
'settings:read',
'settings:write'
])
] as const satisfies readonly ToolCapability[]
export type ToolEffect = (typeof TOOL_EFFECTS)[number]
export type ToolAvailability = (typeof TOOL_AVAILABILITIES)[number]
const toolDescriptorSchema = v.object({
name: v.pipe(v.string(), v.minLength(1)),
description: v.string(),
effect: v.picklist(TOOL_EFFECTS),
availability: v.picklist(TOOL_AVAILABILITIES),
capabilities: v.array(v.picklist(TOOL_CAPABILITIES)),
enabled: v.boolean()
})
export type ToolDescriptor = v.InferOutput<typeof toolDescriptorSchema>
export interface ToolPolicy {
allowEval: boolean
disabledTools: string[]
}
export function parseToolDescriptor(value: unknown): ToolDescriptor | null {
if (!isRecord(value)) return null
const { name, description, effect, availability, capabilities, enabled } = value
if (typeof name !== 'string' || !name) return null
if (typeof description !== 'string') return null
if (typeof effect !== 'string' || !TOOL_EFFECTS.has(effect)) return null
if (typeof availability !== 'string' || !TOOL_AVAILABILITIES.has(availability)) return null
if (typeof enabled !== 'boolean') return null
if (
!Array.isArray(capabilities) ||
capabilities.some(
(capability) => typeof capability !== 'string' || !TOOL_CAPABILITIES.has(capability)
)
) {
return null
}
return {
name,
description,
effect: effect as ToolEffect,
availability: availability as ToolAvailability,
capabilities: capabilities as ToolCapability[],
enabled
}
const parsed = v.safeParse(toolDescriptorSchema, value)
return parsed.success ? parsed.output : null
}

View file

@ -0,0 +1,30 @@
import { describe, expect, test } from 'bun:test'
import { parseToolDescriptor, type ToolDescriptor } from '@open-pencil/mcp/tools'
const descriptor = {
name: 'export_image',
description: 'Exports a node as an image',
effect: 'read',
availability: 'filesystem',
capabilities: ['document:read', 'filesystem:write'],
enabled: true
} satisfies ToolDescriptor
describe('parseToolDescriptor', () => {
test('reads a descriptor the server reports', () => {
expect(parseToolDescriptor({ ...descriptor, extra: 1 })).toEqual(descriptor)
})
test('rejects a descriptor with a field the app cannot act on', () => {
for (const change of [
{ name: '' },
{ effect: 'delete' },
{ availability: 'always' },
{ capabilities: ['document:read', 'shell:run'] },
{ enabled: 'true' }
]) {
expect(parseToolDescriptor({ ...descriptor, ...change })).toBeNull()
}
})
})

View file

@ -1,3 +1,5 @@
import { uniq } from 'es-toolkit'
import * as v from 'valibot'
import { computed, ref, watch } from 'vue'
import {
@ -25,7 +27,6 @@ import {
type AIModelSettings,
type OptionalAIModelRole,
type ResolvedAIModelRole,
type HarnessPermissionMode,
type ThinkingLevel
} from '@/app/ai/models/types'
@ -33,10 +34,6 @@ const LEGACY_CONNECTION_ID = 'connection-default'
const LEGACY_MODEL_ID: AIModelProfileId = 'model-default'
const DEFAULT_MAX_OUTPUT_TOKENS = 16_384
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isProviderID(value: unknown): value is AIProviderID {
return (
typeof value === 'string' &&
@ -44,78 +41,81 @@ function isProviderID(value: unknown): value is AIProviderID {
)
}
function isAPIType(value: unknown): value is 'completions' | 'responses' {
return value === 'completions' || value === 'responses'
}
const connectionSchema = v.object({
id: v.pipe(v.string(), v.minLength(1)),
providerID: v.custom<AIProviderID>(isProviderID),
customBaseURL: v.fallback(v.string(), ''),
customAPIType: v.fallback(v.picklist(['completions', 'responses']), 'completions'),
credentialProfileId: v.pipe(v.string(), v.minLength(1))
})
function isThinkingLevel(value: unknown): value is ThinkingLevel {
return typeof value === 'string' && THINKING_LEVELS.includes(value as ThinkingLevel)
}
const thinkingLevelSchema = v.picklist(THINKING_LEVELS)
/** Profiles saved before thinking levels kept a Pi level or a free-text provider effort. */
function parseThinkingLevel(value: Record<string, unknown>): ThinkingLevel {
if (isThinkingLevel(value.thinkingLevel)) return value.thinkingLevel
if (isThinkingLevel(value.harnessThinkingLevel)) return value.harnessThinkingLevel
const effort = stringValue(value.reasoningEffort).trim().toLowerCase()
if (effort === 'none') return 'off'
if (effort === 'max') return 'xhigh'
return isThinkingLevel(effort) ? effort : 'default'
function migrateThinkingLevel(stored: {
thinkingLevel?: unknown
harnessThinkingLevel?: unknown
reasoningEffort?: unknown
}): ThinkingLevel {
for (const value of [stored.thinkingLevel, stored.harnessThinkingLevel]) {
if (v.is(thinkingLevelSchema, value)) return value
}
const effort = typeof stored.reasoningEffort === 'string' ? stored.reasoningEffort : ''
const normalized = effort.trim().toLowerCase()
if (normalized === 'none') return 'off'
if (normalized === 'max') return 'xhigh'
return v.is(thinkingLevelSchema, normalized) ? normalized : 'default'
}
function isHarnessPermissionMode(value: unknown): value is HarnessPermissionMode {
return (
typeof value === 'string' && HARNESS_PERMISSION_MODES.includes(value as HarnessPermissionMode)
)
}
function isCapability(value: unknown): value is AIModelCapability {
return value === 'tools' || value === 'vision'
}
function stringValue(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback
}
const maxOutputTokensSchema = v.fallback(
v.pipe(
v.number(),
v.finite(),
v.transform((tokens) => Math.min(128_000, Math.max(1024, Math.round(tokens))))
),
DEFAULT_MAX_OUTPUT_TOKENS
)
function normalizedMaxOutputTokens(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value)
? Math.min(128_000, Math.max(1024, Math.round(value)))
: DEFAULT_MAX_OUTPUT_TOKENS
return v.parse(maxOutputTokensSchema, value)
}
const capabilitySchema = v.picklist(['tools', 'vision'])
const profileSchema = v.object({
id: v.custom<AIModelProfileId>((id) => typeof id === 'string' && id.startsWith('model-')),
name: v.fallback(v.string(), 'Model'),
connectionId: v.string(),
modelID: v.fallback(v.string(), ''),
customModelID: v.fallback(v.string(), ''),
maxOutputTokens: maxOutputTokensSchema,
thinkingLevel: v.optional(v.unknown()),
harnessThinkingLevel: v.optional(v.unknown()),
reasoningEffort: v.optional(v.unknown()),
harnessPermissionMode: v.fallback(v.optional(v.picklist(HARNESS_PERMISSION_MODES)), undefined),
capabilities: v.fallback(
v.pipe(
v.array(v.unknown()),
v.transform((capabilities) =>
uniq(capabilities.filter((capability) => v.is(capabilitySchema, capability)))
)
),
(): AIModelCapability[] => ['tools']
)
})
function parseConnection(value: unknown): AIModelConnection | null {
if (!isRecord(value)) return null
const id = stringValue(value.id)
const credentialProfileId = stringValue(value.credentialProfileId)
if (!id || !credentialProfileId || !isProviderID(value.providerID)) return null
return {
id,
providerID: value.providerID,
customBaseURL: stringValue(value.customBaseURL),
customAPIType: isAPIType(value.customAPIType) ? value.customAPIType : 'completions',
credentialProfileId
}
const parsed = v.safeParse(connectionSchema, value)
return parsed.success ? parsed.output : null
}
function parseProfile(value: unknown, connectionIds: Set<string>): AIModelProfile | null {
if (!isRecord(value)) return null
const id = stringValue(value.id)
const connectionId = stringValue(value.connectionId)
if (!id.startsWith('model-') || !connectionIds.has(connectionId)) return null
const capabilities = Array.isArray(value.capabilities)
? value.capabilities.filter(isCapability)
: ['tools' as const]
const parsed = v.safeParse(profileSchema, value)
if (!parsed.success || !connectionIds.has(parsed.output.connectionId)) return null
const { harnessThinkingLevel, reasoningEffort, ...profile } = parsed.output
return {
id: id as AIModelProfileId,
name: stringValue(value.name, 'Model'),
connectionId,
modelID: stringValue(value.modelID),
customModelID: stringValue(value.customModelID),
maxOutputTokens: normalizedMaxOutputTokens(value.maxOutputTokens),
thinkingLevel: parseThinkingLevel(value),
harnessPermissionMode: isHarnessPermissionMode(value.harnessPermissionMode)
? value.harnessPermissionMode
: undefined,
capabilities: [...new Set(capabilities)]
...profile,
thinkingLevel: migrateThinkingLevel({ ...profile, harnessThinkingLevel, reasoningEffort })
}
}
@ -147,23 +147,29 @@ function hydrateCuratedCapabilities(
}
}
const settingsSchema = v.object({
version: v.literal(1),
connections: v.fallback(v.array(v.unknown()), () => []),
models: v.fallback(v.array(v.unknown()), () => []),
assignments: v.fallback(v.record(v.string(), v.unknown()), () => ({}))
})
/** Normalizes persisted settings, migrating fields from earlier versions. */
export function parseAIModelSettings(value: unknown): AIModelSettings | null {
if (!isRecord(value) || value.version !== 1) return null
const connections = Array.isArray(value.connections)
? value.connections.map(parseConnection).filter((connection) => connection !== null)
: []
const stored = v.safeParse(settingsSchema, value)
if (!stored.success) return null
const connections = stored.output.connections
.map(parseConnection)
.filter((connection) => connection !== null)
const connectionIds = new Set(connections.map((connection) => connection.id))
const models = Array.isArray(value.models)
? value.models
.map((profile) => parseProfile(profile, connectionIds))
.filter((profile) => profile !== null)
: []
const models = stored.output.models
.map((profile) => parseProfile(profile, connectionIds))
.filter((profile) => profile !== null)
if (!models.length) return null
hydrateCuratedCapabilities(models, connections)
const modelIds = new Set(models.map((profile) => profile.id))
const rawAssignments = isRecord(value.assignments) ? value.assignments : {}
const rawDesign = stringValue(rawAssignments.design)
const rawAssignments = stored.output.assignments
const rawDesign = typeof rawAssignments.design === 'string' ? rawAssignments.design : ''
const design = rawDesign.startsWith('model-') ? (rawDesign as AIModelProfileId) : models[0].id
const resolvedDesign = modelIds.has(design) ? design : models[0].id
const assignments: AIModelSettings['assignments'] = {

View file

@ -1,3 +1,5 @@
import * as v from 'valibot'
import { tauriFetch } from '@/app/tauri/http'
type ClipboardImageFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
@ -7,27 +9,24 @@ type FigmaImageURLs = Record<string, string>
const IMAGE_FETCH_CONCURRENCY = 6
const IMAGE_FETCH_TIMEOUT_MS = 15_000
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Figma's image batch response: a URL per image hash, unless it reports a failure. */
const batchResponseSchema = v.pipe(
v.object({
error: v.optional(v.unknown()),
status: v.optional(v.unknown()),
meta: v.object({ s3_urls: v.record(v.string(), v.unknown()) })
}),
v.check(({ error, status }) => error !== true && (typeof status !== 'number' || status === 200))
)
function imageURLsFromBatchResponse(value: unknown): FigmaImageURLs {
if (
!isRecord(value) ||
value.error === true ||
(typeof value.status === 'number' && value.status !== 200) ||
!isRecord(value.meta)
) {
throw new Error('Figma returned an invalid image response')
}
const urls = value.meta.s3_urls
if (!isRecord(urls)) throw new Error('Figma returned an invalid image URL map')
const result: FigmaImageURLs = {}
for (const [hash, url] of Object.entries(urls)) {
if (typeof url === 'string') result[hash] = url
}
return result
const parsed = v.safeParse(batchResponseSchema, value)
if (!parsed.success) throw new Error('Figma returned an invalid image response')
return Object.fromEntries(
Object.entries(parsed.output.meta.s3_urls).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
)
}
async function sha1Hex(bytes: Uint8Array): Promise<string> {

View file

@ -1,3 +1,5 @@
import { isEqual } from 'es-toolkit'
import * as v from 'valibot'
import { computed, ref, toRaw, watch } from 'vue'
import { appCredentialServices } from '@/app/settings/credentials/app'
@ -17,61 +19,57 @@ export const MCP_CONNECTION_NAME_MAX_LENGTH = 80
const MAX_URL_LENGTH = 2048
const MCP_CONNECTION_ID_PATTERN = /^mcp-[a-z0-9._-]{1,60}$/
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isCredentialRef(value: unknown): value is CredentialRef {
return (
isRecord(value) &&
typeof value.integrationId === 'string' &&
typeof value.profileId === 'string' &&
typeof value.field === 'string'
)
}
const bearerSchema = v.object({
type: v.literal('bearer'),
credentialRef: v.object({ integrationId: v.string(), profileId: v.string(), field: v.string() })
})
/** A stored bearer token counts only when it points at this connection's own credential. */
function parseAuthentication(value: unknown, id: MCPConnectionID): MCPAuthentication {
if (!isRecord(value) || value.type !== 'bearer') return { type: 'none' }
const expectedReference = mcpConnectionCredentialRef(id)
if (!isCredentialRef(value.credentialRef)) return { type: 'none' }
const matchesExpected =
value.credentialRef.integrationId === expectedReference.integrationId &&
value.credentialRef.profileId === expectedReference.profileId &&
value.credentialRef.field === expectedReference.field
return matchesExpected ? { type: 'bearer', credentialRef: expectedReference } : { type: 'none' }
const bearer = v.safeParse(bearerSchema, value)
const expected = mcpConnectionCredentialRef(id)
return bearer.success && isEqual(bearer.output.credentialRef, expected)
? { type: 'bearer', credentialRef: expected }
: { type: 'none' }
}
const connectionSchema = v.object({
id: v.pipe(v.string(), v.regex(MCP_CONNECTION_ID_PATTERN)),
name: v.pipe(
v.string(),
v.trim(),
v.minLength(1),
v.transform((name) => name.slice(0, MCP_CONNECTION_NAME_MAX_LENGTH))
),
enabled: v.fallback(v.boolean(), false),
transport: v.object({
type: v.literal('streamable-http'),
url: v.pipe(
v.string(),
v.trim(),
v.check((url) => isValidMCPConnectionURL(url))
)
}),
authentication: v.optional(v.unknown())
})
function parseConnection(value: unknown): MCPConnection | null {
if (!isRecord(value) || !isRecord(value.transport)) return null
const id = typeof value.id === 'string' ? value.id : ''
const name = typeof value.name === 'string' ? value.name.trim() : ''
const url = typeof value.transport.url === 'string' ? value.transport.url.trim() : ''
if (!MCP_CONNECTION_ID_PATTERN.test(id) || !name || value.transport.type !== 'streamable-http') {
return null
}
try {
validateMCPConnectionURL(url)
} catch {
return null
}
const connectionId = id as MCPConnectionID
return {
id: connectionId,
name: name.slice(0, MCP_CONNECTION_NAME_MAX_LENGTH),
enabled: value.enabled === true,
transport: { type: 'streamable-http', url },
authentication: parseAuthentication(value.authentication, connectionId)
}
const parsed = v.safeParse(connectionSchema, value)
if (!parsed.success) return null
const { authentication, ...connection } = parsed.output
const id = connection.id as MCPConnectionID
return { ...connection, id, authentication: parseAuthentication(authentication, id) }
}
const settingsSchema = v.object({ version: v.literal(1), connections: v.array(v.unknown()) })
export function parseMCPConnectionSettings(value: unknown): MCPConnectionSettings {
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.connections)) {
return { version: 1, connections: [] }
}
const settings = v.safeParse(settingsSchema, value)
if (!settings.success) return { version: 1, connections: [] }
const connections: MCPConnection[] = []
const ids = new Set<string>()
const names = new Set<string>()
for (const candidate of value.connections) {
for (const candidate of settings.output.connections) {
const connection = parseConnection(candidate)
if (!connection || ids.has(connection.id)) continue
const normalizedName = connection.name.toLowerCase()
@ -83,6 +81,15 @@ export function parseMCPConnectionSettings(value: unknown): MCPConnectionSetting
return { version: 1, connections }
}
function isValidMCPConnectionURL(url: string): boolean {
try {
validateMCPConnectionURL(url)
return true
} catch {
return false
}
}
export function validateMCPConnectionURL(value: string): URL {
if (!value || value.length > MAX_URL_LENGTH) throw new Error('Enter a valid MCP server URL')
let url: URL

View file

@ -1,4 +1,6 @@
import { useLocalStorage } from '@vueuse/core'
import { uniq } from 'es-toolkit'
import * as v from 'valibot'
import { DEFAULT_SNAPPING_PREFERENCES, type SnappingPreferences } from '@open-pencil/core/editor'
@ -29,7 +31,8 @@ export interface DesignCheckPreferences {
}
/** Whether guided AI setup was offered and finished or skipped. */
export type AISetupState = 'pending' | 'done'
export const AI_SETUP_STATES = ['pending', 'done'] as const
export type AISetupState = (typeof AI_SETUP_STATES)[number]
export interface AppPreferences {
appearance: { animations: AnimationPreference }
@ -73,105 +76,65 @@ export const DEFAULT_APP_PREFERENCES: Readonly<AppPreferences> = {
const STORAGE_KEY = 'open-pencil:preferences:v1'
function booleanOrDefault(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback
/** An object whose fields each fall back to their default, and that is all defaults when absent. */
function section<TEntries extends v.ObjectEntries>(entries: TEntries) {
const schema = v.object(entries)
return v.fallback(schema, () => v.parse(schema, {}))
}
interface StoredSnappingPreferences {
geometry?: unknown
objects?: unknown
pixelGrid?: unknown
}
const defaults = DEFAULT_APP_PREFERENCES
const snapping = defaults.editing.snapping
interface StoredAppPreferences {
appearance?: { animations?: unknown }
chat?: { reasoningDisplay?: unknown; maxAgentSteps?: unknown; changePreviewSize?: unknown }
recovery?: { enabled?: unknown }
editing?: { snapping?: StoredSnappingPreferences }
rendering?: { canvasMode?: unknown }
designCheck?: { showOnCanvas?: unknown; preset?: unknown; disabledRules?: unknown }
onboarding?: { aiSetup?: unknown }
}
function isStoredAppPreferences(value: unknown): value is StoredAppPreferences {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function normalizeAnimationPreference(value: unknown): AnimationPreference {
return value === 'off' ? 'off' : 'system'
}
function normalizeChatPreferences(chat: StoredAppPreferences['chat']): AppPreferences['chat'] {
return {
maxAgentSteps: resolveAgentStepLimit(chat?.maxAgentSteps),
reasoningDisplay:
chat?.reasoningDisplay === 'expanded' || chat?.reasoningDisplay === 'while-thinking'
? chat.reasoningDisplay
: 'collapsed',
changePreviewSize: CHANGE_PREVIEW_SIZES.includes(chat?.changePreviewSize as ChangePreviewSize)
? (chat?.changePreviewSize as ChangePreviewSize)
: DEFAULT_APP_PREFERENCES.chat.changePreviewSize
}
}
function normalizeDesignCheckPreferences(
designCheck: StoredAppPreferences['designCheck']
): DesignCheckPreferences {
const preset = DESIGN_CHECK_PRESETS.find((candidate) => candidate === designCheck?.preset)
const disabledRules = Array.isArray(designCheck?.disabledRules)
? designCheck.disabledRules.filter((rule): rule is string => typeof rule === 'string')
: []
return {
showOnCanvas: booleanOrDefault(
designCheck?.showOnCanvas,
DEFAULT_APP_PREFERENCES.designCheck.showOnCanvas
/** Stored preferences, read field by field so one bad value keeps the rest. */
const appPreferencesSchema = section({
appearance: section({
animations: v.fallback(v.picklist(ANIMATION_PREFERENCES), defaults.appearance.animations)
}),
chat: section({
reasoningDisplay: v.fallback(v.picklist(REASONING_DISPLAYS), defaults.chat.reasoningDisplay),
maxAgentSteps: v.fallback(
v.pipe(v.unknown(), v.transform(resolveAgentStepLimit)),
defaults.chat.maxAgentSteps
),
preset: preset ?? DEFAULT_APP_PREFERENCES.designCheck.preset,
disabledRules: [...new Set(disabledRules)]
}
}
changePreviewSize: v.fallback(v.picklist(CHANGE_PREVIEW_SIZES), defaults.chat.changePreviewSize)
}),
version: v.fallback(v.literal(1), 1),
recovery: section({ enabled: v.fallback(v.boolean(), defaults.recovery.enabled) }),
editing: section({
snapping: section({
geometry: v.fallback(v.boolean(), snapping.geometry),
objects: v.fallback(v.boolean(), snapping.objects),
pixelGrid: v.fallback(v.boolean(), snapping.pixelGrid)
})
}),
rendering: section({
canvasMode: v.fallback(v.picklist(CANVAS_RENDERING_MODES), defaults.rendering.canvasMode)
}),
designCheck: section({
showOnCanvas: v.fallback(v.boolean(), defaults.designCheck.showOnCanvas),
preset: v.fallback(v.picklist(DESIGN_CHECK_PRESETS), defaults.designCheck.preset),
disabledRules: v.fallback(
v.pipe(
v.array(v.unknown()),
v.transform((rules) => uniq(rules.filter((rule) => typeof rule === 'string')))
),
() => []
)
}),
onboarding: section({
aiSetup: v.fallback(v.picklist(AI_SETUP_STATES), defaults.onboarding.aiSetup)
})
})
function normalizePreferences(value: unknown): AppPreferences {
const stored = isStoredAppPreferences(value) ? value : undefined
const snapping = stored?.editing?.snapping
return {
appearance: { animations: normalizeAnimationPreference(stored?.appearance?.animations) },
chat: normalizeChatPreferences(stored?.chat),
version: 1,
recovery: {
enabled: booleanOrDefault(stored?.recovery?.enabled, DEFAULT_APP_PREFERENCES.recovery.enabled)
},
editing: {
snapping: {
geometry: booleanOrDefault(
snapping?.geometry,
DEFAULT_APP_PREFERENCES.editing.snapping.geometry
),
objects: booleanOrDefault(
snapping?.objects,
DEFAULT_APP_PREFERENCES.editing.snapping.objects
),
pixelGrid: booleanOrDefault(
snapping?.pixelGrid,
DEFAULT_APP_PREFERENCES.editing.snapping.pixelGrid
)
}
},
rendering: {
canvasMode: stored?.rendering?.canvasMode === 'tiled' ? 'tiled' : 'retained'
},
designCheck: normalizeDesignCheckPreferences(stored?.designCheck),
onboarding: {
aiSetup: stored?.onboarding?.aiSetup === 'done' ? 'done' : 'pending'
}
}
/** Reads stored preferences, keeping every valid field and defaulting the rest. */
export function parseAppPreferences(value: unknown): AppPreferences {
return v.parse(appPreferencesSchema, value)
}
export const appPreferences = useLocalStorage<AppPreferences>(
STORAGE_KEY,
structuredClone(DEFAULT_APP_PREFERENCES),
{ mergeDefaults: (storageValue) => normalizePreferences(storageValue) }
{ mergeDefaults: (storageValue) => parseAppPreferences(storageValue) }
)
export function updateAnimationPreference(animations: AnimationPreference): void {

View file

@ -0,0 +1,116 @@
import { describe, expect, test } from 'bun:test'
import { parseAIModelSettings } from '@/app/ai/models/store'
import type { AIModelConnection } from '@/app/ai/models/types'
const connection = {
id: 'connection-a',
providerID: 'openrouter',
customBaseURL: '',
customAPIType: 'completions',
credentialProfileId: 'default'
} satisfies AIModelConnection
const profile = {
id: 'model-a',
name: 'Design',
connectionId: 'connection-a',
modelID: 'custom/model',
customModelID: '',
maxOutputTokens: 16_384,
thinkingLevel: 'default',
capabilities: ['tools']
}
function settings(overrides: Record<string, unknown> = {}) {
return {
version: 1,
connections: [connection],
models: [profile],
assignments: { design: 'model-a', review: 'design', fast: null, vision: null },
...overrides
}
}
describe('parseAIModelSettings', () => {
test('rejects settings it cannot use', () => {
for (const stored of [null, 'settings', { ...settings(), version: 2 }]) {
expect(parseAIModelSettings(stored)).toBeNull()
}
expect(parseAIModelSettings(settings({ models: [] }))).toBeNull()
})
test('drops connections and models that are malformed or point nowhere', () => {
const parsed = parseAIModelSettings(
settings({
connections: [
connection,
{ ...connection, id: 'connection-b', providerID: 'unknown-provider' },
{ ...connection, id: '' },
'connection'
],
models: [
profile,
{ ...profile, id: 'model-b', connectionId: 'connection-b' },
{ ...profile, id: 'profile-c' },
null
]
})
)
expect(parsed?.connections.map((candidate) => candidate.id)).toEqual(['connection-a'])
expect(parsed?.models.map((candidate) => candidate.id)).toEqual(['model-a'])
})
test('fills and bounds the fields of a stored model', () => {
const parsed = parseAIModelSettings(
settings({
connections: [{ ...connection, customAPIType: 'soap', customBaseURL: 7 }],
models: [
{
id: 'model-a',
connectionId: 'connection-a',
maxOutputTokens: 1_000_000,
harnessPermissionMode: 'everything',
capabilities: ['vision', 'tools', 'vision', 'audio']
}
]
})
)
expect(parsed?.connections[0]).toEqual({
...connection,
customBaseURL: '',
customAPIType: 'completions'
})
expect(parsed?.models[0]).toEqual({
id: 'model-a',
name: 'Model',
connectionId: 'connection-a',
modelID: '',
customModelID: '',
maxOutputTokens: 128_000,
thinkingLevel: 'default',
harnessPermissionMode: undefined,
capabilities: ['vision', 'tools']
})
})
test('assigns roles only to models that can take them', () => {
const parsed = parseAIModelSettings(
settings({
models: [profile, { ...profile, id: 'model-b', capabilities: ['tools', 'vision'] }],
assignments: {
design: 'model-missing',
review: 'model-b',
fast: 'model-x',
vision: 'design'
}
})
)
expect(parsed?.assignments).toEqual({
design: 'model-a',
review: 'model-b',
fast: null,
vision: null
})
})
})

View file

@ -0,0 +1,48 @@
import { describe, expect, test } from 'bun:test'
import { DEFAULT_APP_PREFERENCES, parseAppPreferences } from '@/app/settings/preferences/store'
describe('parseAppPreferences', () => {
test('starts from the defaults when nothing usable is stored', () => {
for (const stored of [undefined, null, 'preferences', [], {}]) {
expect(parseAppPreferences(stored)).toEqual(DEFAULT_APP_PREFERENCES)
}
})
test('keeps valid choices and defaults only the fields that are not', () => {
const preferences = parseAppPreferences({
appearance: { animations: 'off' },
chat: { reasoningDisplay: 'sometimes', changePreviewSize: 'large', maxAgentSteps: 'many' },
editing: { snapping: { geometry: false, objects: 'yes' } },
rendering: 'tiled'
})
expect(preferences.appearance.animations).toBe('off')
expect(preferences.chat).toEqual({
...DEFAULT_APP_PREFERENCES.chat,
changePreviewSize: 'large'
})
expect(preferences.editing.snapping).toEqual({
...DEFAULT_APP_PREFERENCES.editing.snapping,
geometry: false
})
expect(preferences.rendering).toEqual(DEFAULT_APP_PREFERENCES.rendering)
})
test('keeps each disabled design check rule once and drops what is not a rule name', () => {
const { designCheck } = parseAppPreferences({
designCheck: { preset: 'strict', disabledRules: ['contrast', 7, 'contrast', 'spacing'] }
})
expect(designCheck).toEqual({
showOnCanvas: true,
preset: 'strict',
disabledRules: ['contrast', 'spacing']
})
})
test('remembers that guided AI setup was finished or skipped', () => {
expect(parseAppPreferences({ onboarding: { aiSetup: 'done' } }).onboarding.aiSetup).toBe('done')
expect(parseAppPreferences({ onboarding: { aiSetup: 'later' } }).onboarding.aiSetup).toBe(
'pending'
)
})
})

View file

@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'bun:test'
import {
buildACPMCPServers,
createMCPConnectionDraft,
mcpConnectionCredentialRef,
mcpConnectionSettingsSnapshot,
parseMCPConnectionSettings,
removeMCPConnection,
@ -83,6 +84,35 @@ describe('MCP connections', () => {
).toEqual(['mcp-valid'])
})
test("keeps a stored token only when it is the connection's own credential", () => {
const stored = (credentialRef: unknown) => ({
version: 1,
connections: [
{
id: 'mcp-tools',
name: ' Tools ',
enabled: 'yes',
transport: { type: 'streamable-http', url: ' https://example.com/mcp ' },
authentication: { type: 'bearer', credentialRef }
}
]
})
const own = mcpConnectionCredentialRef('mcp-tools')
expect(parseMCPConnectionSettings(stored(own)).connections).toEqual([
{
id: 'mcp-tools',
name: 'Tools',
enabled: false,
transport: { type: 'streamable-http', url: 'https://example.com/mcp' },
authentication: { type: 'bearer', credentialRef: own }
}
])
const other = mcpConnectionCredentialRef('mcp-other')
expect(parseMCPConnectionSettings(stored(other)).connections[0]?.authentication).toEqual({
type: 'none'
})
})
test('defaults new connections to disabled and reserves unique names', async () => {
const draft = createMCPConnectionDraft()
expect(draft.enabled).toBeFalse()