Merge branch 'refactor/settings-workflows' into refactor-ui-families

# Conflicts:
#	src/app/ai/models/settings/profile-editor/selection.ts
This commit is contained in:
Danila Poyarkov 2026-09-06 16:46:02 +03:00
commit 3fdac60afa
13 changed files with 405 additions and 16 deletions

View file

@ -8,16 +8,19 @@ import {
type AIModelRole,
type OptionalAIModelRole
} from '@/app/ai/models'
export function useModelRoleAssignments(
ai: Readonly<Ref<{ modelRoleUseDesign: string; noModel: string }>>
) {
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)

View file

@ -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<string>
@ -23,6 +24,7 @@ interface ConnectionOptions {
isHarness: Readonly<Ref<boolean>>
customModelSelected: Ref<boolean>
}
export function useProfileConnection({
draft,
keyInput,
@ -34,6 +36,7 @@ export function useProfileConnection({
const keyStatus = ref<CredentialStatus>('missing')
const connectionTestStatus = ref<'idle' | 'testing' | 'success' | 'error'>('idle')
const connectionTestReason = ref<ProviderConnectionTestFailureReason | null>(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<void> {
const connection = findModelConnectionForDraft(draft)
keyStatus.value = connection ? await modelConnectionCredentialStatus(connection.id) : 'missing'
}
async function clearKey(): Promise<void> {
const connection = findModelConnectionForDraft(draft)
if (!connection) return
await setModelConnectionAPIKey(connection.id, '')
await refreshAIProviderStatus()
keyInput.value = ''
await refreshKeyStatus()
}
async function testConnection(): Promise<void> {
connectionTestStatus.value = 'testing'
connectionTestReason.value = null
@ -79,6 +87,7 @@ export function useProfileConnection({
connectionTestStatus.value = 'error'
connectionTestReason.value = result.reason
}
return {
keyStatus,
connectionTestStatus,

View file

@ -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<Ref<ModelPickerLabels & { customModel: string }>>
@ -21,6 +22,7 @@ export function useProfileModelSelection(
Boolean(draft.customModelID.trim()) || draft.providerID === 'harness:pi'
)
const catalogModel = ref<ModelOption | null>(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<void> {
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,

View file

@ -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<string | null>(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<boolean> {
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<boolean> {
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,

View file

@ -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<Record<string, CredentialStatus>>({})
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<void> {
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(),

View file

@ -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<void> {
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 = []
}

View file

@ -8,21 +8,27 @@ export function useRecentDiagnostics(
refreshStats: () => Promise<void>
) {
const recentEvents = ref<DiagnosticEventSummary[]>([])
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 }
}

View file

@ -14,12 +14,15 @@ const vectorizeServices = { status: vectorizeCredentialStatus, set: setVectorize
export function useVectorizeSettings(keyDraft: Ref<string>, services = vectorizeServices) {
const keyStatus = ref<CredentialStatus>('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<string>, services = vectorize
value: definition.id,
label: definition.name
}))
async function refreshStatus(): Promise<void> {
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<string>, services = vectorize
error.value = cause instanceof Error ? cause.message : String(cause)
}
}
async function updateCredential(clear: boolean): Promise<void> {
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<string>, services = vectorize
error.value = cause instanceof Error ? cause.message : String(cause)
}
}
watch(
vectorizeProviderID,
() => {

View file

@ -0,0 +1,22 @@
import type { MCPConnectionID } from './types'
// Domain-owned: pending operations survive disposal of a Settings editor.
const pendingByConnection = new Map<MCPConnectionID, Promise<unknown>>()
export function enqueueMCPConnectionMutation<T>(
id: MCPConnectionID,
operation: () => Promise<T>
): Promise<T> {
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
}

View file

@ -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<MCPConnectionDraft>(createMCPConnectionDraft())
const tokenStatus = ref<CredentialStatus>('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<boolean> {
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<boolean> {
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<void> {
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<boolean> {
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,

View file

@ -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<Record<string, string>>) {
const provider = computed(() => storageProviderRegistry.get(activeStorageProviderID.value))
const preferenceDrafts = ref<Record<string, string>>({
...readStoragePreferences(provider.value.id)
})
const credentialStatuses = ref<Record<string, CredentialStatus>>({})
const busy = ref(false)
const configured = computed(
() =>
storagePreferencesComplete(provider.value.id) &&
@ -27,55 +31,84 @@ export function useStorageSettings(credentialDrafts: Ref<Record<string, string>>
(field) => !field.required || credentialStatuses.value[field.id] === 'configured'
)
)
let statusRequest = 0
let disposed = false
tryOnScopeDispose(() => {
disposed = true
statusRequest++
})
async function refreshStatuses(): Promise<void> {
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<void> {
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<void> {
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

View file

@ -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<UsageSummary>(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()

View file

@ -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<void>((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<void>((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()
}
})