diff --git a/packages/mcp/src/tool/metadata.ts b/packages/mcp/src/tool/metadata.ts index 8988e20bc..e8a19d9d8 100644 --- a/packages/mcp/src/tool/metadata.ts +++ b/packages/mcp/src/tool/metadata.ts @@ -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 { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -const TOOL_EFFECTS: ReadonlySet = new Set(['read', 'write']) -const TOOL_AVAILABILITIES: ReadonlySet = new Set([ - 'default', - 'eval', - 'filesystem' -]) -const TOOL_CAPABILITIES: ReadonlySet = new Set([ +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 = new Set([ '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 + +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 } diff --git a/packages/mcp/tests/tool/metadata.test.ts b/packages/mcp/tests/tool/metadata.test.ts new file mode 100644 index 000000000..536a65b4a --- /dev/null +++ b/packages/mcp/tests/tool/metadata.test.ts @@ -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() + } + }) +}) diff --git a/src/app/ai/models/store.ts b/src/app/ai/models/store.ts index ea115f976..72529b2a8 100644 --- a/src/app/ai/models/store.ts +++ b/src/app/ai/models/store.ts @@ -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 { - 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(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): 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((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): 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'] = { diff --git a/src/app/editor/clipboard/figma-images.ts b/src/app/editor/clipboard/figma-images.ts index 4f5416494..a12cc0c6a 100644 --- a/src/app/editor/clipboard/figma-images.ts +++ b/src/app/editor/clipboard/figma-images.ts @@ -1,3 +1,5 @@ +import * as v from 'valibot' + import { tauriFetch } from '@/app/tauri/http' type ClipboardImageFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise @@ -7,27 +9,24 @@ type FigmaImageURLs = Record const IMAGE_FETCH_CONCURRENCY = 6 const IMAGE_FETCH_TIMEOUT_MS = 15_000 -function isRecord(value: unknown): value is Record { - 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 { diff --git a/src/app/integrations/mcp/store.ts b/src/app/integrations/mcp/store.ts index 08f98a217..ccb403fd5 100644 --- a/src/app/integrations/mcp/store.ts +++ b/src/app/integrations/mcp/store.ts @@ -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 { - 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() const names = new Set() - 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 diff --git a/src/app/settings/preferences/store.ts b/src/app/settings/preferences/store.ts index 470e871cb..a64cd45dd 100644 --- a/src/app/settings/preferences/store.ts +++ b/src/app/settings/preferences/store.ts @@ -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 = { 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(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( STORAGE_KEY, structuredClone(DEFAULT_APP_PREFERENCES), - { mergeDefaults: (storageValue) => normalizePreferences(storageValue) } + { mergeDefaults: (storageValue) => parseAppPreferences(storageValue) } ) export function updateAnimationPreference(animations: AnimationPreference): void { diff --git a/tests/app/ai/models/store.test.ts b/tests/app/ai/models/store.test.ts new file mode 100644 index 000000000..30de7dc13 --- /dev/null +++ b/tests/app/ai/models/store.test.ts @@ -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 = {}) { + 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 + }) + }) +}) diff --git a/tests/app/settings/preferences/store.test.ts b/tests/app/settings/preferences/store.test.ts new file mode 100644 index 000000000..64178efd8 --- /dev/null +++ b/tests/app/settings/preferences/store.test.ts @@ -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' + ) + }) +}) diff --git a/tests/engine/app/integrations/mcp-connections.test.ts b/tests/engine/app/integrations/mcp-connections.test.ts index eef2e5b67..b5d4d85ac 100644 --- a/tests/engine/app/integrations/mcp-connections.test.ts +++ b/tests/engine/app/integrations/mcp-connections.test.ts @@ -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()