diff --git a/src/app/ai/models/settings/assignments.ts b/src/app/ai/models/settings/assignments.ts index c9ae5e820..2cea6db9c 100644 --- a/src/app/ai/models/settings/assignments.ts +++ b/src/app/ai/models/settings/assignments.ts @@ -8,16 +8,19 @@ import { type AIModelRole, type OptionalAIModelRole } from '@/app/ai/models' + export function useModelRoleAssignments( ai: Readonly> ) { const SAME_AS_DESIGN = '__design__' const NO_MODEL = '__none__' + function assignmentValue(role: AIModelRole): string { const assignment = aiModelSettings.value.assignments[role] if (assignment === null) return NO_MODEL return assignment === 'design' ? SAME_AS_DESIGN : assignment } + function optionsForRole(role: AIModelRole) { const profiles = aiModelSettings.value.models .filter((profile) => { @@ -28,6 +31,7 @@ export function useModelRoleAssignments( }) .map((profile) => ({ value: profile.id, label: profile.name })) if (role === 'design') return profiles + const design = modelProfile(aiModelSettings.value.assignments.design) const canInherit = !isAgentModelProfile(design) && (role !== 'vision' || design?.capabilities.includes('vision')) @@ -37,6 +41,7 @@ export function useModelRoleAssignments( ...profiles ] } + function updateAssignment(role: AIModelRole, value: string): void { if (role === 'design') { const profile = modelProfile(value) diff --git a/src/app/ai/models/settings/profile-editor/connection.ts b/src/app/ai/models/settings/profile-editor/connection.ts index 99d6bb0ca..787e305f3 100644 --- a/src/app/ai/models/settings/profile-editor/connection.ts +++ b/src/app/ai/models/settings/profile-editor/connection.ts @@ -15,6 +15,7 @@ import { type AIModelProfileDraft } from '@/app/ai/models' import type { CredentialStatus } from '@/app/settings/credentials/types' + interface ConnectionOptions { draft: AIModelProfileDraft keyInput: Ref @@ -23,6 +24,7 @@ interface ConnectionOptions { isHarness: Readonly> customModelSelected: Ref } + export function useProfileConnection({ draft, keyInput, @@ -34,6 +36,7 @@ export function useProfileConnection({ const keyStatus = ref('missing') const connectionTestStatus = ref<'idle' | 'testing' | 'success' | 'error'>('idle') const connectionTestReason = ref(null) + const hasExistingKey = computed(() => keyStatus.value === 'configured') const canTest = computed(() => { if (isACP.value || isHarness.value) return false @@ -43,22 +46,27 @@ export function useProfileConnection({ ? Boolean(draft.customModelID.trim()) : Boolean(draft.modelID.trim()) }) + function resetConnectionTest(): void { connectionTestStatus.value = 'idle' connectionTestReason.value = null } + async function refreshKeyStatus(): Promise { const connection = findModelConnectionForDraft(draft) keyStatus.value = connection ? await modelConnectionCredentialStatus(connection.id) : 'missing' } + async function clearKey(): Promise { const connection = findModelConnectionForDraft(draft) if (!connection) return + await setModelConnectionAPIKey(connection.id, '') await refreshAIProviderStatus() keyInput.value = '' await refreshKeyStatus() } + async function testConnection(): Promise { connectionTestStatus.value = 'testing' connectionTestReason.value = null @@ -79,6 +87,7 @@ export function useProfileConnection({ connectionTestStatus.value = 'error' connectionTestReason.value = result.reason } + return { keyStatus, connectionTestStatus, diff --git a/src/app/ai/models/settings/profile-editor/selection.ts b/src/app/ai/models/settings/profile-editor/selection.ts index c896f882d..b95ee9501 100644 --- a/src/app/ai/models/settings/profile-editor/selection.ts +++ b/src/app/ai/models/settings/profile-editor/selection.ts @@ -11,6 +11,7 @@ import type { AIModelProfileDraft, AIModelCapability } from '@/app/ai/models' import { resolveModelsDevModel } from '@/app/ai/models/catalog' import { modelPickerOptions, type ModelPickerLabels } from '@/app/ai/models/picker/options' import { useProviderModelCatalog } from '@/app/ai/models/catalog/use' + export function useProfileModelSelection( draft: AIModelProfileDraft, ai: Readonly> @@ -21,6 +22,7 @@ export function useProfileModelSelection( Boolean(draft.customModelID.trim()) || draft.providerID === 'harness:pi' ) const catalogModel = ref(null) + const providerDef = computed( () => AI_PROVIDERS.find((provider) => provider.id === draft.providerID) ?? AI_PROVIDERS[0] ) @@ -34,6 +36,7 @@ export function useProfileModelSelection( ) const providerDisplayName = computed(() => { if (!isACP.value) return providerDef.value.name + const agentID = draft.providerID.slice('acp:'.length) return ACP_AGENTS.find((agent) => agent.id === agentID)?.name ?? draft.providerID }) @@ -74,6 +77,7 @@ export function useProfileModelSelection( }) const toolsEnabled = capabilityModel('tools') const visionEnabled = capabilityModel('vision') + function capabilityModel(capability: AIModelCapability) { return computed({ get: () => draft.capabilities.includes(capability), @@ -85,9 +89,11 @@ export function useProfileModelSelection( } }) } + function effectiveModelID(): string { return customModelSelected.value ? draft.customModelID.trim() : draft.modelID.trim() } + async function refreshCatalogModel(): Promise { if (isACP.value) { catalogModel.value = null @@ -97,14 +103,18 @@ export function useProfileModelSelection( const modelID = effectiveModelID() const resolved = await resolveModelsDevModel(providerID, modelID) if (providerID !== draft.providerID || modelID !== effectiveModelID()) return + catalogModel.value = resolved applyKnownModelMetadata() } + function applyKnownModelMetadata(): void { if (!knownModel.value) return + draft.capabilities = [...knownCapabilities.value] draft.maxOutputTokens = outputTokenRecommendation.value } + function updateProvider(providerID: AIProviderID): void { catalogModel.value = null draft.providerID = providerID @@ -125,6 +135,7 @@ export function useProfileModelSelection( void refreshCatalogModel() } + function updateModel(modelID: string): void { if (modelID === CUSTOM_MODEL_VALUE) { customModelSelected.value = true @@ -141,13 +152,16 @@ export function useProfileModelSelection( void refreshCatalogModel() if (!draft.name.trim()) draft.name = modelDisplayName.value } + watch( () => draft.customModelID, () => { if (customModelSelected.value) void refreshCatalogModel() } ) + void refreshCatalogModel() + return { providerDef, isACP, diff --git a/src/app/ai/models/settings/profile-editor/use.ts b/src/app/ai/models/settings/profile-editor/use.ts index 832b2ba66..4558d48ab 100644 --- a/src/app/ai/models/settings/profile-editor/use.ts +++ b/src/app/ai/models/settings/profile-editor/use.ts @@ -26,6 +26,7 @@ interface ProfileEditorOptions { export function useModelProfileEditor({ profileId, keyInput, labels: ai }: ProfileEditorOptions) { const draft = reactive(createModelProfileDraft(profileId)) + const selection = useProfileModelSelection(draft, ai) const { providerDef, @@ -44,7 +45,9 @@ export function useModelProfileEditor({ profileId, keyInput, labels: ai }: Profi visionEnabled, applyKnownModelMetadata } = selection + const saveError = ref(null) + const { connectionTestStatus, connectionTestReason, @@ -55,6 +58,7 @@ export function useModelProfileEditor({ profileId, keyInput, labels: ai }: Profi clearKey, testConnection } = useProfileConnection({ draft, keyInput, providerDef, isACP, isHarness, customModelSelected }) + const canDelete = computed(() => Boolean(profileId) && aiModelSettings.value.models.length > 1) const canSave = computed( () => @@ -64,18 +68,22 @@ export function useModelProfileEditor({ profileId, keyInput, labels: ai }: Profi ? Boolean(draft.customModelID.trim()) : Boolean(draft.modelID.trim()))) ) + function updateProvider(id: AIProviderID) { selection.updateProvider(id) keyInput.value = '' resetConnectionTest() void refreshKeyStatus() } + function updateModel(id: string) { selection.updateModel(id) resetConnectionTest() } + async function save(): Promise { saveError.value = null + try { applyKnownModelMetadata() if (!draft.name.trim()) draft.name = modelDisplayName.value || providerDisplayName.value @@ -91,8 +99,10 @@ export function useModelProfileEditor({ profileId, keyInput, labels: ai }: Profi return false } } + async function remove(): Promise { if (!profileId) return false + const profile = modelProfile(profileId) if (profile && modelConnectionUsageCount(profile.connectionId) === 1) { await setModelConnectionAPIKey(profile.connectionId, '') @@ -102,11 +112,14 @@ export function useModelProfileEditor({ profileId, keyInput, labels: ai }: Profi return true } + watch( () => [draft.customBaseURL, draft.customModelID, draft.customAPIType, draft.modelID], resetConnectionTest ) + void refreshKeyStatus() + return { draft, providerDef, diff --git a/src/app/ai/models/settings/use.ts b/src/app/ai/models/settings/use.ts index 64ff3e3f8..97f009a5d 100644 --- a/src/app/ai/models/settings/use.ts +++ b/src/app/ai/models/settings/use.ts @@ -5,13 +5,17 @@ import { ACP_AGENTS, AI_PROVIDERS } from '@open-pencil/core/constants' import { aiModelSettings, modelConnection, modelConnectionCredentialStatus } from '@/app/ai/models' import type { CredentialStatus } from '@/app/settings/credentials/types' + export function useModelSettings() { let version = 0 let disposed = false + tryOnScopeDispose(() => { disposed = true }) + const statusByConnection = ref>({}) + function providerName(providerID: string): string { if (providerID === 'harness:pi') return 'Pi' if (providerID.startsWith('acp:')) { @@ -20,12 +24,14 @@ export function useModelSettings() { } return AI_PROVIDERS.find((provider) => provider.id === providerID)?.name ?? providerID } + const profiles = computed(() => aiModelSettings.value.models.map((profile) => { const connection = modelConnection(profile.connectionId) const provider = AI_PROVIDERS.find((definition) => definition.id === connection?.providerID) const modelId = profile.customModelID || profile.modelID const modelName = provider?.models.find((model) => model.id === modelId)?.name || modelId + return { ...profile, providerID: connection?.providerID ?? '', @@ -34,6 +40,7 @@ export function useModelSettings() { } }) ) + async function refreshStatuses(): Promise { const request = ++version const entries = await Promise.all( @@ -44,6 +51,7 @@ export function useModelSettings() { ) if (!disposed && request === version) statusByConnection.value = Object.fromEntries(entries) } + watch( () => aiModelSettings.value.connections.map((connection) => connection.id), () => void refreshStatuses(), diff --git a/src/app/automation/mcp/settings/use.ts b/src/app/automation/mcp/settings/use.ts index cdb8b5b85..7327de6f1 100644 --- a/src/app/automation/mcp/settings/use.ts +++ b/src/app/automation/mcp/settings/use.ts @@ -9,17 +9,22 @@ import { } from '@/app/automation/mcp/preferences' import { refreshMCPRuntime, restartMCPRuntime } from '@/app/automation/mcp/runtime' import { isTauri } from '@/app/tauri/env' + export function useMCPSettings() { const toolSearch = ref('') + const disabledToolNames = computed(() => new Set(disabledMCPTools.value)) + function categoryStatus(effect: ToolEffect) { const tools = configurableMCPTools.value.filter((tool) => tool.effect === effect) const enabled = tools.filter((tool) => !disabledToolNames.value.has(tool.name)).length + return { enabled: enabled > 0, state: enabled > 0 && enabled < tools.length ? ('mixed' as const) : ('idle' as const) } } + const inspectionToolsStatus = computed(() => categoryStatus('read')) const modificationToolsStatus = computed(() => categoryStatus('write')) const enabledToolCount = computed( @@ -34,21 +39,27 @@ export function useMCPSettings() { tool.name.toLowerCase().includes(query) || tool.description.toLowerCase().includes(query) ) }) + onMounted(() => { void refreshMCPRuntime() }) + function restart(): void { void restartMCPRuntime() } + async function chooseRootDirectory(): Promise { if (!isTauri()) return + const { open } = await import('@tauri-apps/plugin-dialog') const directory = await open({ directory: true, multiple: false }) if (typeof directory === 'string') mcpRootDirectory.value = directory } + function isToolEnabled(name: string): boolean { return !disabledToolNames.value.has(name) } + function enableAllTools(): void { disabledMCPTools.value = [] } diff --git a/src/app/diagnostics/settings/recent.ts b/src/app/diagnostics/settings/recent.ts index e06e889c1..02961e4bf 100644 --- a/src/app/diagnostics/settings/recent.ts +++ b/src/app/diagnostics/settings/recent.ts @@ -8,21 +8,27 @@ export function useRecentDiagnostics( refreshStats: () => Promise ) { const recentEvents = ref([]) + let version = 0 let disposed = false + async function refresh() { const request = ++version const events = await diagnostics.list() if (!disposed && request === version) recentEvents.value = summarize(events.slice(0, 20)) } + const unsubscribe = diagnostics.subscribe(() => { void refresh() void refreshStats() }) + tryOnScopeDispose(() => { disposed = true unsubscribe() }) + void refresh() + return { recentEvents } } diff --git a/src/app/editor/vectorize/settings/use.ts b/src/app/editor/vectorize/settings/use.ts index b80b48e99..bed57515c 100644 --- a/src/app/editor/vectorize/settings/use.ts +++ b/src/app/editor/vectorize/settings/use.ts @@ -14,12 +14,15 @@ const vectorizeServices = { status: vectorizeCredentialStatus, set: setVectorize export function useVectorizeSettings(keyDraft: Ref, services = vectorizeServices) { const keyStatus = ref('missing') const error = ref('') + let version = 0 let disposed = false + tryOnScopeDispose(() => { disposed = true version++ }) + const provider = computed(() => VECTORIZE_PROVIDER_DEFINITIONS.find((definition) => definition.id === vectorizeProviderID.value) ) @@ -27,9 +30,11 @@ export function useVectorizeSettings(keyDraft: Ref, services = vectorize value: definition.id, label: definition.name })) + async function refreshStatus(): Promise { const request = ++version const id = vectorizeProviderID.value + try { const status = await services.status(id) if (!disposed && version === request) keyStatus.value = status @@ -38,12 +43,15 @@ export function useVectorizeSettings(keyDraft: Ref, services = vectorize error.value = cause instanceof Error ? cause.message : String(cause) } } + async function updateCredential(clear: boolean): Promise { const value = keyDraft.value if (!clear && !value.trim()) return + const request = ++version const id = vectorizeProviderID.value error.value = '' + try { await services.set(id, clear ? '' : value) if (disposed || version !== request) return @@ -54,6 +62,7 @@ export function useVectorizeSettings(keyDraft: Ref, services = vectorize error.value = cause instanceof Error ? cause.message : String(cause) } } + watch( vectorizeProviderID, () => { diff --git a/src/app/integrations/mcp/mutations.ts b/src/app/integrations/mcp/mutations.ts new file mode 100644 index 000000000..f461f85c6 --- /dev/null +++ b/src/app/integrations/mcp/mutations.ts @@ -0,0 +1,22 @@ +import type { MCPConnectionID } from './types' + +// Domain-owned: pending operations survive disposal of a Settings editor. +const pendingByConnection = new Map>() + +export function enqueueMCPConnectionMutation( + id: MCPConnectionID, + operation: () => Promise +): Promise { + const previous = pendingByConnection.get(id) ?? Promise.resolve() + const pending = previous.then(operation, operation) + const settled = pending.then( + () => undefined, + () => undefined + ) + pendingByConnection.set(id, settled) + void settled.then(() => { + if (pendingByConnection.get(id) === settled) pendingByConnection.delete(id) + return undefined + }) + return pending +} diff --git a/src/app/integrations/mcp/settings/use.ts b/src/app/integrations/mcp/settings/use.ts index b8e0c4872..1edc47def 100644 --- a/src/app/integrations/mcp/settings/use.ts +++ b/src/app/integrations/mcp/settings/use.ts @@ -12,6 +12,9 @@ import { } from '@/app/integrations/mcp' import type { CredentialStatus } from '@/app/settings/credentials/types' +import { enqueueMCPConnectionMutation } from '../mutations' +import type { MCPConnectionID } from '../types' + const connectionServices = { status: mcpConnectionCredentialStatus, save: saveMCPConnectionDraft, @@ -27,18 +30,23 @@ export function useMCPConnectionSettings( const draft = ref(createMCPConnectionDraft()) const tokenStatus = ref('missing') const error = ref('') + let version = 0 let disposed = false + tryOnScopeDispose(() => { disposed = true version++ }) + const savedConnection = computed(() => mcpConnectionSettings.value.connections.find((connection) => connection.id === draft.value.id) ) + function current(request: number): boolean { return !disposed && request === version } + function startAdd(): void { version++ draft.value = createMCPConnectionDraft() @@ -46,29 +54,39 @@ export function useMCPConnectionSettings( tokenStatus.value = 'missing' error.value = '' } + async function startEdit(id: string): Promise { const connection = mcpConnectionSettings.value.connections.find((item) => item.id === id) if (!connection) return false + const request = ++version draft.value = createMCPConnectionDraft(connection) tokenDraft.value = '' tokenStatus.value = 'missing' error.value = '' + try { const status = await services.status(connection.id) if (!current(request)) return false + tokenStatus.value = status return true } catch (cause) { - if (current(request)) error.value = cause instanceof Error ? cause.message : String(cause) - return false + if (!current(request)) return false + + error.value = cause instanceof Error ? cause.message : String(cause) + return true } } + async function save(): Promise { const request = ++version - const target = { ...draft.value } + const id: MCPConnectionID = draft.value.id ?? `mcp-${crypto.randomUUID()}` + draft.value.id = id + const target = { ...draft.value, id } const token = tokenDraft.value error.value = '' + try { if ( target.enabled && @@ -78,10 +96,19 @@ export function useMCPConnectionSettings( ) { throw new Error(automation.value.bearerTokenRequired) } - const connection = services.save(target) - if (target.authenticationType === 'none') await services.setCredential(connection.id, '') - else if (token.trim()) await services.setCredential(connection.id, token) + + await enqueueMCPConnectionMutation(id, async () => { + const connection = services.save({ ...target, enabled: false }) + if (target.authenticationType === 'none') await services.setCredential(connection.id, '') + else if (token.trim()) await services.setCredential(connection.id, token) + else if (target.enabled && (await services.status(connection.id)) !== 'configured') { + throw new Error(automation.value.bearerTokenRequired) + } + + services.save({ ...target, id: connection.id }) + }) if (!current(request)) return false + if (tokenDraft.value === token) tokenDraft.value = '' return true } catch (cause) { @@ -89,17 +116,25 @@ export function useMCPConnectionSettings( return false } } + async function clearCredential(): Promise { - const target = { ...draft.value } - if (!target.id) return + const id = draft.value.id + if (!id) return + const request = ++version const token = tokenDraft.value error.value = '' + try { - await services.setCredential(target.id, '') - // Disable the connection whose credential was cleared, even if the editor moved on. - services.save({ ...target, enabled: false }) + await enqueueMCPConnectionMutation(id, async () => { + const connection = mcpConnectionSettings.value.connections.find((item) => item.id === id) + if (!connection) throw new Error('Connection no longer exists') + + services.save({ ...createMCPConnectionDraft(connection), enabled: false }) + await services.setCredential(id, '') + }) if (!current(request)) return + draft.value.enabled = false if (tokenDraft.value === token) tokenDraft.value = '' tokenStatus.value = 'missing' @@ -107,25 +142,30 @@ export function useMCPConnectionSettings( if (current(request)) error.value = cause instanceof Error ? cause.message : String(cause) } } + async function remove(): Promise { const id = draft.value.id if (!id) return false + const request = ++version error.value = '' + try { - await services.remove(id) + await enqueueMCPConnectionMutation(id, () => services.remove(id)) return current(request) } catch (cause) { if (current(request)) error.value = cause instanceof Error ? cause.message : String(cause) return false } } + watch( () => draft.value.authenticationType, (type) => { if (type === 'none') tokenDraft.value = '' } ) + return { draft, tokenStatus, diff --git a/src/app/integrations/storage/settings/use.ts b/src/app/integrations/storage/settings/use.ts index ef1930af6..787b26019 100644 --- a/src/app/integrations/storage/settings/use.ts +++ b/src/app/integrations/storage/settings/use.ts @@ -1,3 +1,4 @@ +import { tryOnScopeDispose } from '@vueuse/core' import { computed, onMounted, ref, watch, type Ref } from 'vue' import { @@ -13,13 +14,16 @@ import { appCredentialServices } from '@/app/settings/credentials/app' import { credentialRef } from '@/app/settings/credentials/reference' import type { CredentialStatus } from '@/app/settings/credentials/types' import { resumeStorageSync } from '@/app/storage/sync' + export function useStorageSettings(credentialDrafts: Ref>) { const provider = computed(() => storageProviderRegistry.get(activeStorageProviderID.value)) + const preferenceDrafts = ref>({ ...readStoragePreferences(provider.value.id) }) const credentialStatuses = ref>({}) const busy = ref(false) + const configured = computed( () => storagePreferencesComplete(provider.value.id) && @@ -27,55 +31,84 @@ export function useStorageSettings(credentialDrafts: Ref> (field) => !field.required || credentialStatuses.value[field.id] === 'configured' ) ) + + let statusRequest = 0 + let disposed = false + + tryOnScopeDispose(() => { + disposed = true + statusRequest++ + }) + async function refreshStatuses(): Promise { - const id = provider.value.id - const statuses = await storageCredentialStatuses(id) - if (provider.value.id === id) credentialStatuses.value = statuses + const request = ++statusRequest + const target = provider.value + try { + const statuses = await storageCredentialStatuses(target.id) + if (!disposed && statusRequest === request && provider.value.id === target.id) + credentialStatuses.value = statuses + } catch { + if (!disposed && statusRequest === request && provider.value.id === target.id) { + credentialStatuses.value = Object.fromEntries( + target.credentialFields.map((field) => [field.id, 'unavailable' as const]) + ) + } + } } + function savePreferences(): void { for (const field of provider.value.preferenceFields) { writeStoragePreference(provider.value.id, field.id, preferenceDrafts.value[field.id] ?? '') } void resumeStorageSync() } + async function saveCredential(field: string): Promise { const target = credentialDrafts.value const value = target[field]?.trim() if (!value) return + await appCredentialServices.manager.set(credentialRef(provider.value.id, field), value) if (credentialDrafts.value === target && target[field]?.trim() === value) target[field] = '' await refreshStatuses() await resumeStorageSync() } + async function clearCredential(field: string): Promise { const target = credentialDrafts.value await appCredentialServices.manager.clear(credentialRef(provider.value.id, field)) if (credentialDrafts.value === target) target[field] = '' await refreshStatuses() } + watch(activeStorageProviderID, (providerID) => { credentialStatuses.value = {} preferenceDrafts.value = { ...readStoragePreferences(providerID) } credentialDrafts.value = {} void refreshStatuses() }) + onMounted(() => void refreshStatuses()) async function testConnection() { busy.value = true const target = provider.value + try { savePreferences() for (const field of target.credentialFields) { if (provider.value.id !== target.id) return null + await saveCredential(field.id) } await resumeStorageSync() if (provider.value.id !== target.id) return null + const result = await createActiveStorageAdapter(target.id).testConnection() return provider.value.id === target.id ? result : null } catch (error) { if (provider.value.id !== target.id) return null + return { ok: false as const, message: error instanceof Error ? error.message : String(error) } } finally { busy.value = false diff --git a/src/app/usage/settings/use.ts b/src/app/usage/settings/use.ts index 17ff56d09..60132dce9 100644 --- a/src/app/usage/settings/use.ts +++ b/src/app/usage/settings/use.ts @@ -4,19 +4,25 @@ import { onMounted, ref } from 'vue' import { diagnostics } from '@/app/diagnostics' import { isUsageEnabled } from '@/app/diagnostics/settings' import { summarizeUsage, type UsageSummary } from '@/app/usage' + export function useUsageSettings() { let version = 0 let disposed = false + const summary = ref(summarizeUsage([])) + async function refresh() { const request = ++version const events = await diagnostics.list() if (!disposed && request === version && isUsageEnabled()) summary.value = summarizeUsage(events) } + onMounted(() => { if (!isUsageEnabled()) return + void refresh() }) + const unsubscribe = diagnostics.subscribe(() => { if (isUsageEnabled()) void refresh() else { @@ -24,6 +30,7 @@ export function useUsageSettings() { summary.value = summarizeUsage([]) } }) + tryOnScopeDispose(() => { disposed = true unsubscribe() diff --git a/tests/engine/app/settings/mcp-workflow.test.ts b/tests/engine/app/settings/mcp-workflow.test.ts index bef1565de..612d164be 100644 --- a/tests/engine/app/settings/mcp-workflow.test.ts +++ b/tests/engine/app/settings/mcp-workflow.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'bun:test' +import { beforeEach, afterEach, expect, test } from 'bun:test' import { effectScope, ref } from 'vue' @@ -13,6 +13,15 @@ const connection: MCPConnection = { authentication: { type: 'none' } } +let previousConnections = mcpConnectionSettings.value.connections +beforeEach(() => { + previousConnections = mcpConnectionSettings.value.connections + mcpConnectionSettings.value.connections = [structuredClone(connection)] +}) +afterEach(() => { + mcpConnectionSettings.value.connections = previousConnections +}) + test('MCP credential clear disables its captured target without changing a new draft', async () => { const scope = effectScope() let finish: () => void = () => undefined @@ -106,3 +115,206 @@ test('MCP save and clear failures preserve token input', async () => { scope.stop() } }) + +test('failed bearer write never enables the saved connection', async () => { + const scope = effectScope() + const saved: boolean[] = [] + try { + const state = scope.run(() => + useMCPConnectionSettings(ref('token'), ref({ bearerTokenRequired: 'Required' }), { + status: async () => 'configured', + setCredential: async () => { + throw new Error('Write failed') + }, + save: (draft) => { + saved.push(draft.enabled) + return connection + }, + remove: async () => undefined + }) + ) + if (!state) throw new Error('Missing scope') + state.draft.value.authenticationType = 'bearer' + state.draft.value.enabled = true + expect(await state.save()).toBe(false) + expect(saved).toEqual([false]) + } finally { + scope.stop() + } +}) + +test('clear uses persisted fields rather than invalid unsaved input', async () => { + const scope = effectScope() + const names: string[] = [] + try { + const state = scope.run(() => + useMCPConnectionSettings(ref(''), ref({ bearerTokenRequired: 'Required' }), { + status: async () => 'configured', + setCredential: async () => undefined, + save: (draft) => { + names.push(draft.name) + expect(draft.url).toBe(connection.transport.url) + return connection + }, + remove: async () => undefined + }) + ) + if (!state) throw new Error('Missing scope') + state.draft.value.id = connection.id + state.draft.value.name = '' + state.draft.value.url = 'invalid' + await state.clearCredential() + expect(names).toEqual(['Test']) + } finally { + scope.stop() + } +}) + +test('status lookup failure opens the editor with an error', async () => { + const scope = effectScope() + try { + const state = scope.run(() => + useMCPConnectionSettings(ref(''), ref({ bearerTokenRequired: 'Required' }), { + status: async () => { + throw new Error('Locked') + }, + setCredential: async () => undefined, + save: () => connection, + remove: async () => undefined + }) + ) + if (!state) throw new Error('Missing scope') + expect(await state.startEdit(connection.id)).toBe(true) + expect(state.error.value).toBe('Locked') + } finally { + scope.stop() + } +}) + +test('serializes overlapping credential replacements', async () => { + const scope = effectScope() + const writes: string[] = [] + let finish: () => void = () => undefined + const blocked = new Promise((resolve) => { + finish = resolve + }) + const token = ref('first') + try { + const state = scope.run(() => + useMCPConnectionSettings(token, ref({ bearerTokenRequired: 'Required' }), { + status: async () => 'configured', + setCredential: async (_id, value) => { + writes.push(value) + if (value === 'first') await blocked + }, + save: () => connection, + remove: async () => undefined + }) + ) + if (!state) throw new Error('Missing scope') + state.draft.value.id = connection.id + state.draft.value.authenticationType = 'bearer' + const first = state.save() + await Promise.resolve() + token.value = 'second' + const second = state.save() + await Promise.resolve() + expect(writes).toEqual(['first']) + finish() + await Promise.all([first, second]) + expect(writes).toEqual(['first', 'second']) + } finally { + finish() + scope.stop() + } +}) + +test('reopened editors serialize writes to the same connection while other connections proceed', async () => { + const firstScope = effectScope() + const secondScope = effectScope() + let finish: () => void = () => undefined + const blocked = new Promise((resolve) => { + finish = resolve + }) + const writes: string[] = [] + const services = { + status: async () => 'configured' as const, + setCredential: async (_id: string, value: string) => { + writes.push(value) + if (value === 'first') await blocked + }, + save: (draft: { id: `mcp-${string}` | null }) => ({ + ...connection, + id: draft.id ?? connection.id + }), + remove: async () => undefined + } + try { + const first = firstScope.run(() => + useMCPConnectionSettings(ref('first'), ref({ bearerTokenRequired: 'Required' }), services) + ) + const second = secondScope.run(() => + useMCPConnectionSettings(ref('second'), ref({ bearerTokenRequired: 'Required' }), services) + ) + const other = secondScope.run(() => + useMCPConnectionSettings(ref('other'), ref({ bearerTokenRequired: 'Required' }), services) + ) + if (!first || !second || !other) throw new Error('Missing scope') + for (const editor of [first, second, other]) { + editor.draft.value.id = connection.id + editor.draft.value.authenticationType = 'bearer' + } + other.draft.value.id = 'mcp-other' + const savingFirst = first.save() + await Promise.resolve() + firstScope.stop() + const savingSecond = second.save() + await other.save() + expect(writes).toEqual(['first', 'other']) + finish() + await Promise.all([savingFirst, savingSecond]) + expect(writes).toEqual(['first', 'other', 'second']) + } finally { + finish() + firstScope.stop() + secondScope.stop() + } +}) + +test('blank-token save rechecks status after a different editor clears the credential', async () => { + const scope = effectScope() + let configured = true + const enabled: boolean[] = [] + const services = { + status: async () => (configured ? ('configured' as const) : ('missing' as const)), + setCredential: async (_id: string, value: string) => { + configured = Boolean(value) + }, + save: (draft: { enabled: boolean }) => { + enabled.push(draft.enabled) + return connection + }, + remove: async () => undefined + } + try { + const first = scope.run(() => + useMCPConnectionSettings(ref(''), ref({ bearerTokenRequired: 'Required' }), services) + ) + const second = scope.run(() => + useMCPConnectionSettings(ref(''), ref({ bearerTokenRequired: 'Required' }), services) + ) + if (!first || !second) throw new Error('Missing scope') + await first.startEdit(connection.id) + await second.startEdit(connection.id) + second.draft.value.authenticationType = 'bearer' + second.draft.value.enabled = true + const clearing = first.clearCredential() + const saving = second.save() + await clearing + expect(await saving).toBe(false) + expect(second.error.value).toBe('Required') + expect(enabled).toEqual([false, false]) + } finally { + scope.stop() + } +})