mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
The remaining P1 from the #939 review, plus the P2 it shares a mechanism with. Nothing carried a version, so two tabs editing one namespace silently overwrote each other — reproduced as tab B's `reasoning` lost to tab A's older draft. The seam's per-namespace write queue orders writes; it cannot tell a fresh writer from one replaying a snapshot a predecessor superseded. Each namespace now carries a monotonic `revision` over its RAW section. A write may send `expectedRevision`, checked at the FRONT of the queue (not at call time, which would race the very predecessor it guards against); a mismatch rejects with `SettingsConflictError` → `settings-conflict` on the wire, carrying both revisions. The editor captures the revision it opened at and, on conflict, asks the user to reopen rather than replaying its snapshot. The same counter fixes the missing broadcast. `settings/updated` is gated on the resolved value — correct for consumers, wrong for configuration surfaces: storing an override equal to the composition base leaves the resolved value alone while changing what the document says (the field is now overridden, not inherited) and moving every open editor's revision. `settings/document-updated (ns, revision)` fires on any raw-section change, in-process or external, and `host/settings-changed` now rides it. That event also closes the stale model picker: editing a provider's `models` changes no route, so `llm/adapters-updated` never fired and an open picker kept serving the old catalog. A change to an exposed provider namespace now emits `host/models-changed` too — that namespace holds the catalog. Docs: both sides of the five touched README pairs, a type-equiv block for `SettingsPathOp`, and an Agent Note recording what the plane exposes and who may overwrite what. The deferred wire-redaction gaps (secrets behind union/intersection/transform, `.default(...)` in the served envelope, schema text in rejection messages, `new Function` rehydration, pi-ai's `headers`) are recorded as TODO(settings-wire-redaction) and in Known Limitations rather than half-fixed.
230 lines
8.9 KiB
TypeScript
230 lines
8.9 KiB
TypeScript
/** Page-store join: directory × namespaces × credentials, with last-good rows on failure. */
|
||
import { describe, expect, it } from 'vitest'
|
||
import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
|
||
import { ModelsSettingsStore } from '../src/client/store.ts'
|
||
|
||
let nextRpc = 0
|
||
function ok<T>(value: T): RpcResponse<T> {
|
||
return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
|
||
}
|
||
function fail<T>(message: string): RpcResponse<T> {
|
||
return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code: 'internal', message, details: {} } } }
|
||
}
|
||
|
||
const DIRECTORY = [
|
||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
|
||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
|
||
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
|
||
{ provider: 'ghost', displayName: 'Ghost', settingsNs: '', settingsPath: [], active: true },
|
||
]
|
||
|
||
const NAMESPACES = [
|
||
{
|
||
ns: 'llm-deepseek',
|
||
schema: {},
|
||
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' },
|
||
base: { baseURL: 'https://base' },
|
||
applies: 'live' as const,
|
||
secrets: [{ path: ['apiKey'], set: false }],
|
||
revision: 0,
|
||
},
|
||
{
|
||
ns: 'llm-pi-ai',
|
||
schema: {},
|
||
value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } },
|
||
user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } },
|
||
applies: 'live' as const,
|
||
secrets: [],
|
||
revision: 0,
|
||
},
|
||
]
|
||
|
||
function api(overrides: {
|
||
providers?: () => Promise<RpcResponse<{ providers: typeof DIRECTORY }>>
|
||
describeSettings?: () => Promise<RpcResponse<{ writable: boolean; namespaces: typeof NAMESPACES }>>
|
||
describeCredentials?: (refs: string[]) => Promise<RpcResponse<{ credentials: Record<string, unknown> }>>
|
||
} = {}) {
|
||
const seenRefs: string[][] = []
|
||
const face = {
|
||
llm: {
|
||
providers: overrides.providers ?? (() => Promise.resolve(ok({ providers: DIRECTORY }))),
|
||
models: () => Promise.resolve(ok({ groups: [], failures: [] })),
|
||
},
|
||
settings: {
|
||
describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, namespaces: NAMESPACES }))),
|
||
update: () => Promise.resolve(fail('unused')),
|
||
replace: () => Promise.resolve(fail('unused')),
|
||
},
|
||
credentials: {
|
||
describe: (payload: { refs: string[] }) => {
|
||
seenRefs.push(payload.refs)
|
||
return (overrides.describeCredentials ?? (refs => Promise.resolve(ok({
|
||
credentials: Object.fromEntries(refs.map(ref => [ref, { configured: ref === 'OPENAI_API_KEY', writable: true }])),
|
||
}))))(payload.refs)
|
||
},
|
||
set: () => Promise.resolve(ok({})),
|
||
unset: () => Promise.resolve(ok({})),
|
||
},
|
||
}
|
||
return { face: face as never, seenRefs }
|
||
}
|
||
|
||
describe('ModelsSettingsStore', () => {
|
||
it('joins rows with configured, removable, and credential state', async () => {
|
||
const { face, seenRefs } = api()
|
||
const store = new ModelsSettingsStore(face)
|
||
await store.load()
|
||
const state = store.store.getSnapshot()
|
||
expect(state.status).toBe('ready')
|
||
expect(state.writable).toBe(true)
|
||
expect(seenRefs).toEqual([['DEEPSEEK_API_KEY', 'OPENAI_API_KEY']])
|
||
const byProvider = new Map(state.rows.map(row => [row.entry.provider, row]))
|
||
expect(byProvider.get('deepseek-official')).toMatchObject({
|
||
configured: true,
|
||
removable: false,
|
||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||
credential: { configured: false, writable: true },
|
||
})
|
||
expect(byProvider.get('openai')).toMatchObject({
|
||
configured: true,
|
||
removable: true,
|
||
apiKeyEnv: 'OPENAI_API_KEY',
|
||
credential: { configured: true },
|
||
})
|
||
expect(byProvider.get('anthropic')).toMatchObject({ configured: false, removable: false })
|
||
expect(byProvider.get('anthropic')?.apiKeyEnv).toBeUndefined()
|
||
expect(byProvider.get('ghost')).toMatchObject({ configured: false, removable: false })
|
||
expect(state.namespaces.get('llm-pi-ai')?.ns).toBe('llm-pi-ai')
|
||
})
|
||
|
||
it('degrades the credential badge, not the page, when the credential domain fails', async () => {
|
||
const { face } = api({ describeCredentials: () => Promise.resolve(fail('no provider')) })
|
||
const store = new ModelsSettingsStore(face)
|
||
await store.load()
|
||
const state = store.store.getSnapshot()
|
||
expect(state.status).toBe('ready')
|
||
expect(state.rows.every(row => row.credential === undefined)).toBe(true)
|
||
})
|
||
|
||
it('surfaces a directory failure and keeps the last good rows', async () => {
|
||
const { face } = api()
|
||
const store = new ModelsSettingsStore(face)
|
||
await store.load()
|
||
expect(store.store.getSnapshot().rows).toHaveLength(4)
|
||
const broken = api({ providers: () => Promise.resolve(fail('directory down')) })
|
||
const failing = new ModelsSettingsStore(broken.face)
|
||
await failing.load()
|
||
expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'directory down' })
|
||
// The first store's snapshot is untouched by the second's failure.
|
||
expect(store.store.getSnapshot().status).toBe('ready')
|
||
})
|
||
|
||
it('lets the newest load win over a stale slow response', async () => {
|
||
let release: (() => void) | undefined
|
||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||
let call = 0
|
||
const { face } = api({
|
||
providers: async () => {
|
||
call += 1
|
||
if (call === 1) {
|
||
await gate
|
||
return fail('stale slow failure')
|
||
}
|
||
return ok({ providers: DIRECTORY })
|
||
},
|
||
})
|
||
const store = new ModelsSettingsStore(face)
|
||
const first = store.load()
|
||
const second = store.load()
|
||
release?.()
|
||
await Promise.all([first, second])
|
||
expect(store.store.getSnapshot().status).toBe('ready')
|
||
})
|
||
})
|
||
|
||
describe('edge joins', () => {
|
||
it('treats a non-object profile as having no credential reference', async () => {
|
||
const { face } = api({
|
||
describeSettings: () => Promise.resolve(ok({
|
||
writable: true,
|
||
namespaces: [{
|
||
ns: 'llm-pi-ai',
|
||
schema: {},
|
||
value: { providers: { weird: 'oops' } },
|
||
applies: 'live' as const,
|
||
secrets: [],
|
||
revision: 0,
|
||
}] as never,
|
||
})),
|
||
providers: () => Promise.resolve(ok({
|
||
providers: [
|
||
{ provider: 'weird', displayName: 'weird', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'weird'], active: false },
|
||
] as never,
|
||
})),
|
||
})
|
||
const store = new ModelsSettingsStore(face)
|
||
await store.load()
|
||
const state = store.store.getSnapshot()
|
||
expect(state.rows[0]).toMatchObject({ configured: true, removable: false })
|
||
expect(state.rows[0]?.apiKeyEnv).toBeUndefined()
|
||
})
|
||
|
||
it('skips the credential describe entirely when no row names a reference', async () => {
|
||
const { face, seenRefs } = api({
|
||
describeSettings: () => Promise.resolve(ok({
|
||
writable: true,
|
||
namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never,
|
||
})),
|
||
providers: () => Promise.resolve(ok({
|
||
providers: [
|
||
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
|
||
] as never,
|
||
})),
|
||
})
|
||
const store = new ModelsSettingsStore(face)
|
||
await store.load()
|
||
expect(seenRefs).toEqual([])
|
||
expect(store.store.getSnapshot().status).toBe('ready')
|
||
})
|
||
|
||
it('surfaces a settings describe failure', async () => {
|
||
const { face } = api({ describeSettings: () => Promise.resolve(fail('settings down')) })
|
||
const store = new ModelsSettingsStore(face)
|
||
await store.load()
|
||
expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' })
|
||
})
|
||
|
||
it('stringifies a non-Error load failure', async () => {
|
||
// The wire can surface non-Error throwables; the store must stringify them.
|
||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||
const { face } = api({ providers: () => Promise.reject('plain refusal') })
|
||
const store = new ModelsSettingsStore(face)
|
||
await store.load()
|
||
expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' })
|
||
})
|
||
|
||
it('drops a stale successful response after a newer load finished', async () => {
|
||
let release: (() => void) | undefined
|
||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||
let call = 0
|
||
const { face } = api({
|
||
providers: async () => {
|
||
call += 1
|
||
if (call === 1) {
|
||
await gate
|
||
return ok({ providers: [] as never })
|
||
}
|
||
return ok({ providers: DIRECTORY })
|
||
},
|
||
})
|
||
const store = new ModelsSettingsStore(face)
|
||
const first = store.load()
|
||
const second = store.load()
|
||
await second
|
||
release?.()
|
||
await first
|
||
// The stale empty directory never overwrote the newer join.
|
||
expect(store.store.getSnapshot().rows).toHaveLength(4)
|
||
})
|
||
})
|