refactor(client): use settings Remote namespaces

This commit is contained in:
imccyu
2026-08-27 03:01:02 +08:00
parent dd70c0c88d
commit 5918dd205e
65 changed files with 1103 additions and 819 deletions
@@ -141,7 +141,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
// Regression pin for the reload flash: both steps are satisfied, yet each
// must load private facts before deciding not to show. Dialog chrome lives
// inside each visible branch, so the deciding window paints and blocks
// nothing. Holding settings.describe widens that window from loopback
// nothing. Holding settings/describe widens that window from loopback
// RTT scale to a deterministic hundreds of milliseconds, removing all
// timing dependence from the sampler assertions below.
//
@@ -162,7 +162,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
if (document.getElementById('root')?.inert === true) sightings.push('inert')
}, 8)
})
// EVERY settings.describe issued before the release is held — not just
// EVERY settings/describe issued before the release is held — not just
// the first — so the pin cannot silently collapse back to loopback
// timing if a second boot-time consumer of the join ever appears.
let released = false
@@ -171,7 +171,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
released = true
for (const resolve of heldRoutes.splice(0)) resolve()
}
await page.route('**/api/settings.describe', async (route) => {
await page.route('**/api/settings/describe', async (route) => {
if (!released) await new Promise<void>((resolve) => { heldRoutes.push(resolve) })
await route.continue()
})
@@ -182,7 +182,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
await page.waitForTimeout(600)
releaseDescribe()
await page.waitForTimeout(400)
await page.unroute('**/api/settings.describe')
await page.unroute('**/api/settings/describe')
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
expect(await page.evaluate(() =>
(window as unknown as { __takeoverSightings: string[] }).__takeoverSightings)).toEqual([])
+35 -24
View File
@@ -317,17 +317,6 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
type Result<T> = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } }
interface PreviewApi {
skills: { list(payload: { sessionId: string }): Promise<Result<{ skills: unknown[] }>> }
settings: {
describe(payload: object): Promise<Result<{ namespaces: Array<{ ns: string; revision: number }> }>>
update(payload: { ns: string; patch: object; expectedRevision: number }): Promise<Result<unknown>>
}
credentials: {
set(payload: { ref: string; value: string }): Promise<Result<unknown>>
unset(payload: { ref: string }): Promise<Result<unknown>>
describe(payload: { refs: string[] }): Promise<Result<{
credentials: Record<string, { configured: boolean }>
}>>
}
}
interface PreviewTransport {
fetch(input: string, init: RequestInit): Promise<Response>
@@ -373,22 +362,44 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
if (!refreshed.result.ok) throw new Error(`skill.list refresh failed: ${refreshed.result.error.message}`)
}
await createDirectory('/dsh/workspace/.agents/skills', 'runtime-created')
const settings = await api.settings.describe({})
if (!settings.result.ok) throw new Error(`settings.describe failed: ${settings.result.error.message}`)
const shell = settings.result.value.namespaces.find(namespace => namespace.ns === 'shell')
if (shell === undefined) throw new Error('settings.describe omitted the shell namespace')
const updated = await api.settings.update({ ns: 'shell', patch: { timeoutMs: 61_000 }, expectedRevision: shell.revision })
if (!updated.result.ok) throw new Error(`settings.update failed: ${updated.result.error.message}`)
const stored = await api.credentials.set({ ref: 'PREVIEW_TEST_SECRET', value: 'worker-only' })
if (!stored.result.ok) throw new Error(`credentials.set failed: ${stored.result.error.message}`)
const credentials = await api.credentials.describe({ refs: ['PREVIEW_TEST_SECRET'] })
if (!credentials.result.ok) throw new Error(`credentials.describe failed: ${credentials.result.error.message}`)
const removed = await api.credentials.unset({ ref: 'PREVIEW_TEST_SECRET' })
if (!removed.result.ok) throw new Error(`credentials.unset failed: ${removed.result.error.message}`)
// Settings and credentials both answer over the Remote carrier, so this
// half of the sweep posts the generated endpoints directly like the
// session read above.
const remote = async <T>(endpoint: string, args: object): Promise<T> => {
const answered = await transport.fetch(`/api/${endpoint}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request',
rpcId: `preview-${endpoint.replace('/', '-')}`,
method: endpoint,
payload: { args },
}),
})
const body = await answered.json() as Result<T>
if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.message}`)
return body.result.value
}
const settings = await remote<{ namespaces: { ns: string; revision: number }[] }>(
'settings/describe', {},
)
const shell = settings.namespaces.find(namespace => namespace.ns === 'shell')
if (shell === undefined) throw new Error('settings/describe omitted the shell namespace')
await remote('settings/update', {
ns: 'shell',
patch: { timeoutMs: 61_000 },
expectedRevision: shell.revision,
})
await remote('credentials/set', { ref: 'PREVIEW_TEST_SECRET', value: 'worker-only' })
const credentials = await remote<Record<string, { configured: boolean }>>(
'credentials/describe',
{ refs: ['PREVIEW_TEST_SECRET'] },
)
await remote('credentials/unset', { ref: 'PREVIEW_TEST_SECRET' })
await new Promise((resolve) => { setTimeout(resolve, 250) })
return {
skillCount: skills.result.value.skills.length,
credentialConfigured: credentials.result.value.credentials.PREVIEW_TEST_SECRET?.configured,
credentialConfigured: credentials.PREVIEW_TEST_SECRET?.configured,
}
})
expect(exercised.skillCount).toBeGreaterThan(0)
+2 -2
View File
@@ -1,4 +1,4 @@
// Cold boot may issue at most two settings.describe calls regardless of client
// Cold boot may issue at most two settings/describe calls regardless of client
// plugin count. No model call or replay fixture is involved.
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
@@ -38,7 +38,7 @@ describe('startup RPC budget', () => {
// absorbs the first-connection reset wave the budget must include.
await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 })
await page.waitForTimeout(3000)
const describeCount = calls.filter(method => method === 'settings.describe').length
const describeCount = calls.filter(method => method === 'settings/describe').length
expect(describeCount, `startup /api calls:\n${calls.join('\n')}`).toBe(DESCRIBE_BUDGET)
})
})
+2
View File
@@ -63,6 +63,7 @@
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-api-settings-controller": "workspace:^",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
@@ -85,6 +86,7 @@
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-api-settings-controller": "workspace:^",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
+15 -3
View File
@@ -3,6 +3,7 @@
import type { Context } from '@deepseek-ai/cordis'
import agentPresetsRemote from '@deepseek-ai/dsh-agent-presets/remote'
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/remote'
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote'
import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote'
@@ -18,6 +19,7 @@ export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types'
export type {} from '@deepseek-ai/dsh-agent-presets/remote'
export type {} from '@deepseek-ai/dsh-commands/remote'
export type {} from '@deepseek-ai/dsh-api-settings-controller/remote'
export type {} from '@deepseek-ai/dsh-file-reference/remote'
export type {} from '@deepseek-ai/dsh-goal/remote'
export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
@@ -53,10 +55,10 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types'
*/
export type {
ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock,
CredentialView, DiscoveredModelView, IApiClient,
DiscoveredModelView, IApiClient,
MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId,
SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
SkillEntry, StreamChunk,
} from '@deepseek-ai/dsh-client-connection/client'
export type {} from '@deepseek-ai/dsh-api-gateway/client'
export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote'
@@ -102,6 +104,13 @@ export type {
// reason: a Client contribution names what it sends without importing a Host
// package, and this assembly is where both planes legitimately meet.
export type { JsonValue } from '@deepseek-ai/dsh-session/types'
// Credential state vocabulary for the credentials namespace (values never ride it).
export type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
// Redacted namespace vocabulary for the settings namespace (secrets never ride
// it). It travels with its seam, whose `./types` the Client face already reads.
export type {
SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
} from '@deepseek-ai/dsh-settings/types'
// Reference-discovery result vocabulary for the fileReferences and
// sessionReferenceResolver namespaces.
export type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types'
@@ -112,6 +121,8 @@ export type ClientFailure =
| import('@deepseek-ai/dsh-client-connection/client').RpcError
| import('@deepseek-ai/dsh-agent-presets/types').AgentPresetError
| import('@deepseek-ai/dsh-api-session-controller/types').SessionError
| import('@deepseek-ai/dsh-api-settings-controller/types').CredentialError
| import('@deepseek-ai/dsh-api-settings-controller/types').SettingsError
| import('@deepseek-ai/dsh-subagent/client').SubagentControlError
| import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError
@@ -139,7 +150,8 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
const disposers: Array<() => Promise<void>> = []
try {
for (const contribution of [
agentPresetsRemote, commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote,
agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, dynamicRemote,
fileReferencesRemote,
pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote,
subagentsRemote, sessionRemote, workspaceRemote,
]) {
@@ -66,6 +66,9 @@
{
"path": "../session-controller/tsconfig.client.json"
},
{
"path": "../settings-controller"
},
{
"path": "../workspace-controller/tsconfig.client.json"
},
@@ -234,17 +234,7 @@ export class FakeApiClient implements IApiClient {
}
readonly settings: IApiClient['settings'] = {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
}
readonly credentials: IApiClient['credentials'] = {
describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))),
set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))),
unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))),
}
readonly llm: IApiClient['llm'] = {
+8 -6
View File
@@ -47,29 +47,31 @@
"lib/types/**/*.d.ts"
],
"peerDependencies": {
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^"
}
}
+2 -2
View File
@@ -11,8 +11,8 @@ export type {
SkillsApi, SkillEntry,
ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
SettingsApi,
ConfigurableProviderView, DiscoveredModelView, LlmApi,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type {
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
@@ -29,7 +29,9 @@ import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
// wire-fabrication boundary (the schema layer's one-cast-point posture).
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types'
import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
import type { DirectoryListing as FixtureDirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
import type { SettingsDescribeValue, SettingsNamespaceView } from '@deepseek-ai/dsh-settings/types'
import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface'
import type {
ApiProxy, ClientRequest,
@@ -1778,6 +1780,83 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
// DeepSeek route so unrelated GUI journeys do not enter first-run setup.
['DEEPSEEK_API_KEY', true],
])
/** Canonical fixture implementation of the generated Settings Remote contract. */
const settingsRemotes = {
// Only the resolved DeepSeek address needed by first-run readiness is
// represented here. Fixture-backed journeys do not open its Models editor;
// real schema-driven forms ride the HTTP transport.
describe(): RpcResult<SettingsDescribeValue> {
return {
ok: true,
value: {
writable: true,
hasDocument: true,
namespaces: [{
ns: 'llm-deepseek',
schema: {},
value: { apiKeyEnv: 'DEEPSEEK_API_KEY' },
applies: 'live',
secrets: [{ path: ['apiKey'], set: false }],
revision: 0,
}],
},
}
},
update(ns: string): ConnectionRpcResult<SettingsNamespaceView> {
return {
ok: false,
error: {
code: 'settings-rejected',
message: 'fixture: the minimal readiness settings descriptor is read-only',
details: { ns },
},
}
},
replace(ns: string): ConnectionRpcResult<SettingsNamespaceView> {
return {
ok: false,
error: {
code: 'settings-rejected',
message: 'fixture: the minimal readiness settings descriptor is read-only',
details: { ns },
},
}
},
mutate(ns: string): ConnectionRpcResult<SettingsNamespaceView> {
// A Remote failure code is free-form, unlike the unary error vocabulary.
return {
ok: false,
error: {
code: 'settings-rejected',
message: 'fixture: no settings namespaces are registered',
details: { ns },
},
}
},
}
const credentialRemotes = {
describe(refs: readonly string[]): RpcResult<Record<string, CredentialInfo>> {
return {
ok: true,
value: Object.fromEntries(refs.map(ref => [ref, {
configured: fixtureCredentials.has(ref),
...fixtureCredentials.has(ref) ? { source: 'file' } : {},
writable: true,
}])),
}
},
set(ref: string): RpcResult<void> {
fixtureCredentials.set(ref, true)
return { ok: true, value: undefined }
},
unset(ref: string): RpcResult<void> {
fixtureCredentials.delete(ref)
return { ok: true, value: undefined }
},
}
/**
* Preset compositions the fixture serves. Held as state rather than
* constants so the settings editor's save and delete are exercisable: the
@@ -3347,55 +3426,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
},
},
settings: {
// Only the resolved DeepSeek address needed by first-run readiness is
// represented here. Fixture-backed journeys do not open its Models
// editor; real schema-driven forms ride the HTTP transport.
describe: request => ok(request, {
writable: true,
hasDocument: true,
namespaces: [{
ns: 'llm-deepseek',
schema: {},
value: { apiKeyEnv: 'DEEPSEEK_API_KEY' },
applies: 'live',
secrets: [{ path: ['apiKey'], set: false }],
revision: 0,
}],
}),
// Native opens are deterministic no-op successes in this fixture, as is host.openPath.
openDocument: request => ok(request, { opened: true as const }),
update: request => err(request, {
code: 'settings-rejected',
message: 'fixture: the minimal readiness settings descriptor is read-only',
details: { ns: request.payload.ns },
}),
replace: request => err(request, {
code: 'settings-rejected',
message: 'fixture: the minimal readiness settings descriptor is read-only',
details: { ns: request.payload.ns },
}),
mutate: request => err(request, {
code: 'settings-rejected',
message: 'fixture: no settings namespaces are registered',
details: { ns: request.payload.ns },
}),
},
credentials: {
describe: request => ok(request, {
credentials: Object.fromEntries(request.payload.refs.map(ref => [ref, {
configured: fixtureCredentials.has(ref),
...fixtureCredentials.has(ref) ? { source: 'file' } : {},
writable: true,
}])),
}),
set: (request) => {
fixtureCredentials.set(request.payload.ref, true)
return ok(request, {})
},
unset: (request) => {
fixtureCredentials.delete(request.payload.ref)
return ok(request, {})
},
},
llm: {
providers: request => ok(request, {
@@ -3442,7 +3474,11 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
path?: string
name?: string
images?: readonly unknown[]
ref?: { id: string; revision: number }
// A goal ref and a credential reference name share this wire field name.
ref?: string | { id: string; revision: number }
refs?: readonly string[]
value?: string
ns?: string
agentPreset?: string
from?: string
id?: string
@@ -3493,6 +3529,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
},
})
case 'subagents/interruptByParent': return Promise.resolve({ ok: true, value: { accepted: true } })
case 'credentials/describe': return Promise.resolve(credentialRemotes.describe(args.refs ?? []))
case 'credentials/set': return Promise.resolve(credentialRemotes.set(args.ref as string))
case 'credentials/unset': return Promise.resolve(credentialRemotes.unset(args.ref as string))
case 'settings/describe': return Promise.resolve(settingsRemotes.describe())
case 'settings/update': return Promise.resolve(settingsRemotes.update(args.ns as string))
case 'settings/replace': return Promise.resolve(settingsRemotes.replace(args.ns as string))
case 'settings/mutate': return Promise.resolve(settingsRemotes.mutate(args.ns as string))
case 'session/list': return sessionApi.list(
args._request as Parameters<FixtureSessionApi['list']>[0],
)
@@ -3619,14 +3662,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal)
case 'skill.list': return this.api.skills.list(request)
case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal)
case 'settings.describe': return this.api.settings.describe(request)
case 'settings.openDocument': return this.api.settings.openDocument(request, signal)
case 'settings.update': return this.api.settings.update(request)
case 'settings.replace': return this.api.settings.replace(request)
case 'settings.mutate': return this.api.settings.mutate(request)
case 'credentials.describe': return this.api.credentials.describe(request)
case 'credentials.set': return this.api.credentials.set(request)
case 'credentials.unset': return this.api.credentials.unset(request)
case 'llm.providers': return this.api.llm.providers(request)
case 'llm.models': return this.api.llm.models(request)
case 'llm.discoverModels': return this.api.llm.discoverModels(request, signal)
@@ -37,8 +37,8 @@ export type {
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, RpcMessage,
HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
SettingsApi,
ConfigurableProviderView, DiscoveredModelView, LlmApi,
} from './api.ts'
export {
RpcId,
@@ -76,17 +76,7 @@ export class FakeApiClient implements IApiClient {
}
readonly settings: IApiClient['settings'] = {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
}
readonly credentials: IApiClient['credentials'] = {
describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))),
set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))),
unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))),
}
readonly llm: IApiClient['llm'] = {
@@ -297,6 +297,8 @@ type FixtureTestApi = ReturnType<typeof createFixtureFaces>['api'] & {
readonly sessionRemote: FixtureSessionRemote
readonly workspace: FixtureWorkspaceApi
readonly workspaceRemote: FixtureWorkspaceRemote
readonly credentialRemote: FixtureCredentialRemote
readonly settingsRemote: FixtureSettingsRemote
readonly remoteEvents: (signal: AbortSignal) => FixtureRemoteEventStream
readonly answerRemoteEvent: (result: FixtureRemoteEventResult) => Promise<unknown>
}
@@ -318,12 +320,48 @@ function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi {
sessionRemote: createSessionRemote(rpc),
workspace: createWorkspaceApi(rpc),
workspaceRemote: createWorkspaceRemote(rpc),
credentialRemote: createCredentialRemote(rpc),
settingsRemote: createSettingsRemote(rpc),
remoteEvents: (signal: AbortSignal) => openFixtureRemoteEvents(rpc, signal),
answerRemoteEvent: (result: FixtureRemoteEventResult) =>
rpc.call('/api', '$events/result', { args: result }),
})
}
/** The fixture's Credentials Remote endpoints over the shared RPC carrier. */
interface FixtureCredentialRemote {
describe(refs: readonly string[]): Promise<ConnectionRpcResult<unknown>>
set(ref: string, value: string): Promise<ConnectionRpcResult<unknown>>
unset(ref: string): Promise<ConnectionRpcResult<unknown>>
}
/** The settings Remote reads the fixture serves, addressed like the credential half. */
interface FixtureSettingsRemote {
describe(): Promise<ConnectionRpcResult<unknown>>
update(ns: string, patch: unknown, expectedRevision?: number): Promise<ConnectionRpcResult<unknown>>
replace(ns: string, section: unknown, expectedRevision?: number): Promise<ConnectionRpcResult<unknown>>
}
function createSettingsRemote(rpc: ClientConnectionRpc): FixtureSettingsRemote {
return {
describe: () => rpc.call('/api', 'settings/describe', { args: {} }),
update: (ns, patch, expectedRevision) => rpc.call('/api', 'settings/update', {
args: { ns, patch, expectedRevision },
}),
replace: (ns, section, expectedRevision) => rpc.call('/api', 'settings/replace', {
args: { ns, section, expectedRevision },
}),
}
}
function createCredentialRemote(rpc: ClientConnectionRpc): FixtureCredentialRemote {
return {
describe: refs => rpc.call('/api', 'credentials/describe', { args: { refs } }),
set: (ref, value) => rpc.call('/api', 'credentials/set', { args: { ref, value } }),
unset: ref => rpc.call('/api', 'credentials/unset', { args: { ref } }),
}
}
function openFixtureRemoteEvents(
rpc: ClientConnectionRpc,
signal: AbortSignal,
@@ -724,32 +762,40 @@ describe('createFixtureApi', () => {
it('serves configured DeepSeek readiness and keeps credential values write-only', async () => {
const api = createFixtureApi()
const settings = await api.settings.describe(req({}))
if (!settings.result.ok) throw new Error('settings describe failed')
expect(settings.result.value.namespaces).toMatchObject([{
const settings = await api.settingsRemote.describe()
if (!settings.ok) throw new Error('settings describe failed')
expect((settings.value as { namespaces: unknown[] }).namespaces).toMatchObject([{
ns: 'llm-deepseek',
value: { apiKeyEnv: 'DEEPSEEK_API_KEY' },
secrets: [{ path: ['apiKey'], set: false }],
}])
for (const result of [
await api.settingsRemote.update('llm-deepseek', {}, undefined),
await api.settingsRemote.replace('llm-deepseek', {}, undefined),
]) {
expect(result).toMatchObject({
ok: false,
error: { code: 'settings-rejected', message: 'fixture: the minimal readiness settings descriptor is read-only' },
})
}
const initial = await api.credentials.describe(req({ refs: ['DEEPSEEK_API_KEY', 'TEST_API_KEY'] }))
if (!initial.result.ok) throw new Error('credential describe failed')
expect(initial.result.value.credentials).toEqual({
const describe = async (refs: readonly string[]): Promise<Record<string, unknown>> => {
const result = await api.credentialRemote.describe(refs)
if (!result.ok) throw new Error('credential describe failed')
return result.value as Record<string, unknown>
}
expect(await describe(['DEEPSEEK_API_KEY', 'TEST_API_KEY'])).toEqual({
DEEPSEEK_API_KEY: { configured: true, source: 'file', writable: true },
TEST_API_KEY: { configured: false, writable: true },
})
await api.credentials.set(req({ ref: 'TEST_API_KEY', value: 'write-only-fixture-secret' }))
const configured = await api.credentials.describe(req({ refs: ['TEST_API_KEY'] }))
if (!configured.result.ok) throw new Error('credential describe failed')
expect(configured.result.value.credentials.TEST_API_KEY).toEqual({
await api.credentialRemote.set('TEST_API_KEY', 'write-only-fixture-secret')
expect((await describe(['TEST_API_KEY'])).TEST_API_KEY).toEqual({
configured: true,
source: 'file',
writable: true,
})
await api.credentials.unset(req({ ref: 'TEST_API_KEY' }))
const cleared = await api.credentials.describe(req({ refs: ['TEST_API_KEY'] }))
if (!cleared.result.ok) throw new Error('credential describe failed')
expect(cleared.result.value.credentials.TEST_API_KEY).toEqual({ configured: false, writable: true })
await api.credentialRemote.unset('TEST_API_KEY')
expect((await describe(['TEST_API_KEY'])).TEST_API_KEY).toEqual({ configured: false, writable: true })
})
it('emits the todo/write snapshot at the real tool boundary: between tool/call and tool/result, timestamps monotonic', async () => {
@@ -175,7 +175,6 @@ describe('connection node half', () => {
const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] })
const methods = [
'host.openPath',
'settings.describe', 'settings.update', 'credentials.describe', 'credentials.set',
'llm.discoverModels', 'llm.models', 'agentPreset.openDocument',
]
for (const method of methods) {
@@ -501,8 +500,7 @@ describe('connection node half over a real HTTP server', () => {
const { port, close } = await serve(routes)
try {
const methods = [
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'settings.openDocument',
'host.openPath',
'llm.discoverModels',
'agentPreset.openDocument',
@@ -512,7 +510,7 @@ describe('connection node half over a real HTTP server', () => {
expect([method, await call(port, method, 'localhost')]).toEqual([method, 401])
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 401])
}
expect(await call(port, 'settings.describe', 'other.example')).toBe(403)
expect(await call(port, 'settings.openDocument', 'other.example')).toBe(403)
const declaredCookie = browserCookie(connection, 'harness.example')
for (const method of methods) {
@@ -521,7 +519,7 @@ describe('connection node half over a real HTTP server', () => {
const loopbackAuthority = `127.0.0.1:${String(port)}`
expect(await call(
port,
'settings.describe',
'settings.openDocument',
loopbackAuthority,
browserCookie(connection, loopbackAuthority),
)).toBe(404)
@@ -33,6 +33,12 @@
{
"path": "../../core/tools"
},
{
"path": "../../credentials/credentials"
},
{
"path": "../../settings/settings"
},
{
"path": "../../host/apiproxy"
},
@@ -30,22 +30,16 @@ async function bench() {
revision,
})
const describe = vi.fn(async () => ({
rpcId: 'locale-describe' as never,
result: {
ok: true as const,
value: { writable: true, hasDocument: true, namespaces: [namespace()] },
},
ok: true as const,
value: { writable: true, hasDocument: true, namespaces: [namespace()] },
}))
const mutate = vi.fn(async (request: { ops: { value: string }[] }) => {
preference = request.ops[0]!.value
const mutate = vi.fn(async (_ns: string, ops: { value: string }[]) => {
preference = ops[0]!.value
revision += 1
return {
rpcId: 'locale-mutate' as never,
result: { ok: true as const, value: namespace() },
}
return { ok: true as const, value: namespace() }
})
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never)
const events = new TestRemote(ctx)
ctx.provide('connection', { api: {}, isLoopback: true } as never)
const events = new TestRemote(ctx, { settings: { describe, mutate } })
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
return {
ctx, slots: ctx.get('slots') as SlotRegistry, describe, mutate, events,
@@ -32,17 +32,17 @@ async function bench(preference?: string) {
revision,
})
const describeRpc = vi.fn(async () => ({
rpcId: 'locale-describe' as never,
result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [namespace()] } },
ok: true as const,
value: { writable: true, hasDocument: true, namespaces: [namespace()] },
}))
const mutate = vi.fn(async (request: { ops: { value: string }[] }) => {
stored = request.ops[0]!.value
const mutate = vi.fn(async (_ns: string, ops: { value: string }[]) => {
stored = ops[0]!.value
revision += 1
return { rpcId: 'locale-mutate' as never, result: { ok: true as const, value: namespace() } }
return { ok: true as const, value: namespace() }
})
ctx.provide('connection', { api: { settings: { describe: describeRpc, mutate } }, isLoopback: true } as never)
ctx.provide('connection', { api: {}, isLoopback: true } as never)
// The settings transport and the forwarded-event port the plugin injects.
new TestRemote(ctx)
new TestRemote(ctx, { settings: { describe: describeRpc, mutate } })
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
await ctx.plugin({ inject: [...inject], apply }).await()
return { ctx, locale: ctx.get('locale') as LocaleRuntime }
@@ -50,7 +50,9 @@ export type { AgentPresetOption, AgentPresetSettingsState } from './settings-sto
export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts'
/** Required services (cordis fiber inject). */
export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'settingsScope']
export const inject = [
'slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope',
]
/**
* Mount the General-settings row.
@@ -58,11 +60,12 @@ export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.agentP
*/
export function apply(ctx: ClientContext): void {
const { api } = ctx.get('connection') as ConnectionHandle
const controller = new AgentPresetSettingsController(api, ctx.remote, ctx.settingsScope.describe())
const settingsWire = { settings: ctx.remote.settings }
const controller = new AgentPresetSettingsController(settingsWire, ctx.remote, ctx.settingsScope.describe())
// One roster, four surfaces. The chip is registered in a later scope, so it
// subscribes here rather than being reached from this one.
const rosterReaders = new Set<() => void>()
const section = new AgentPresetSectionController(api, ctx.remote, () => {
const section = new AgentPresetSectionController({ ...api, ...settingsWire }, ctx.remote, () => {
void controller.load()
for (const read of rosterReaders) read()
})
@@ -15,6 +15,7 @@
*/
import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts'
@@ -133,7 +134,7 @@ export class AgentPresetSectionController {
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
constructor(
private readonly api: Pick<IApiClient, 'agentPresets' | 'settings' | 'host'>,
private readonly api: SettingsWireFace & Pick<IApiClient, 'agentPresets' | 'host'>,
private readonly remote: Pick<ClientRemote, 'agentPresets'>,
/**
* Called after this page changes the roster DIRECTORY, so the other
@@ -7,10 +7,10 @@
* namespace's `default` field, which is what the host resolves at creation.
*/
import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { AgentPresetRoster } from '@deepseek-ai/dsh-agent-presets/types'
import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { SettingsDescribeFace, SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client'
/** The agent-preset settings namespace on the host wire. */
export const AGENT_PRESET_SETTINGS_NS = 'agent-presets'
@@ -37,18 +37,22 @@ export function messageOf(error: unknown): string {
* @returns the failure message, or undefined once the write landed.
*/
export async function writeDefaultPreset(
api: Pick<IApiClient, 'settings'>,
api: SettingsWireFace,
id: string,
): Promise<string | undefined> {
let response
try {
response = await api.settings.update({ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: id } })
response = await api.settings.update(
AGENT_PRESET_SETTINGS_NS,
{ default: id },
undefined,
)
} catch (error) {
// The transport rejected rather than answering; the caller must be able to
// say so instead of the row silently snapping back.
return messageOf(error)
}
return response.result.ok ? undefined : response.result.error.message
return response.ok ? undefined : response.error.message
}
/** One selectable preset. */
@@ -181,7 +185,7 @@ export class AgentPresetSettingsController {
* @param describeFace - the shared mirror's describe face (writability source).
*/
constructor(
private readonly api: Pick<IApiClient, 'settings'>,
private readonly api: SettingsWireFace,
private readonly remote: Pick<ClientRemote, 'agentPresets'>,
private readonly describeFace: SettingsDescribeFace,
) {}
@@ -70,8 +70,20 @@ async function bench() {
const locale = new LocaleRuntime(ctx)
locale.setLocale('zh')
ctx.provide('locale', locale)
const remote = new TestRemote(ctx)
const calls: string[] = []
// The row reads `describe` to learn whether this browser may write at all,
// and its default write is the one op this spec records.
const settings = {
describe: () => Promise.resolve({
ok: true as const,
value: { writable: true, hasDocument: true, namespaces: [] },
}),
update: (_ns: string, patch: unknown) => {
calls.push(`settings:${JSON.stringify(patch)}`)
return Promise.resolve({ ok: true as const, value: {} })
},
}
const remote = new TestRemote(ctx, { settings })
// The roster and the switch are the AgentPresets Remote namespace; the
// shared double carries no generated namespaces, so this spec stages its
// own. Registered twice on purpose: the nested key satisfies the plugin's
@@ -112,14 +124,6 @@ async function bench() {
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { opened: true as const } } })
},
},
settings: {
// The row reads this to learn whether this browser may write at all.
describe: () => Promise.resolve({
rpcId: 'r',
result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
}),
update: (payload: { patch: unknown }) => { calls.push(`settings:${JSON.stringify(payload.patch)}`); return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }) },
},
},
} as never)
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
@@ -182,7 +186,9 @@ function sessionsDouble(state: {
describe('ui-agent-preset apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'settingsScope'])
expect(inject).toEqual([
'slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope',
])
})
it('registers the General row and the settings section', async () => {
@@ -8,6 +8,7 @@
import { describe, expect, it } from 'vitest'
import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client'
import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts'
import type { CopyDraft, PresetRow } from '../src/client/section-store.ts'
@@ -65,7 +66,7 @@ const remoteFail = (message: string) =>
function fakeApi(
defaultId: { id: string },
options: FakeOptions = {},
): Pick<IApiClient, 'agentPresets' | 'settings' | 'host'> {
): SettingsWireFace & Pick<IApiClient, 'agentPresets' | 'host'> {
const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) }
return {
host: {
@@ -84,15 +85,15 @@ function fakeApi(
},
},
settings: {
update: (payload: { ns: string; patch: { default?: string } }) => {
record('settings.update', payload)
if (options.failSettings !== undefined) return fail(options.failSettings)
/* v8 ignore next -- the controller only ever patches `default` */
defaultId.id = payload.patch.default ?? defaultId.id
return ok({})
update: (ns: string, patch: { default?: string }) => {
record('settings.update', { ns, patch })
if (options.failSettings !== undefined) return remoteFail(options.failSettings)
/* v8 ignore next -- the controller only ever sets `default` */
defaultId.id = patch.default ?? defaultId.id
return remoteOk({})
},
},
} as unknown as Pick<IApiClient, 'agentPresets' | 'settings' | 'host'>
} as unknown as SettingsWireFace & Pick<IApiClient, 'agentPresets' | 'host'>
}
/**
@@ -579,7 +580,7 @@ describe('deleting', () => {
await controller.load()
presets.clear()
const broken = new AgentPresetSectionController(
{ agentPresets: {}, settings: {}, host: {} } as unknown as Pick<IApiClient, 'agentPresets' | 'settings' | 'host'>,
{ agentPresets: {}, settings: {}, host: {} } as unknown as SettingsWireFace & Pick<IApiClient, 'agentPresets' | 'host'>,
{
agentPresets: {
list: () => Promise.reject(new Error('gone')),
@@ -6,7 +6,8 @@
*/
import { describe, expect, it } from 'vitest'
import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -16,7 +17,7 @@ import {
/** The two faces the row reads: the roster Remote and the settings wire. */
interface FakeWire {
api: IApiClient
api: SettingsWireFace
remote: Pick<ClientRemote, 'agentPresets'>
}
@@ -28,7 +29,7 @@ import { AgentPresetSeatController } from '../src/client/seat-store.ts'
type SeatSession = Pick<SessionSummary, 'id' | 'blank' | 'projectionValues'>
interface Recorded { ns: string; patch: unknown }
interface Recorded { ns: string; ops: unknown }
/** A roster Remote answering a fixed set of rows, or refusing. */
function fakeRoster(
@@ -66,26 +67,23 @@ function fakeApi(
// Host persistence is enabled in production only on the selected client path; a read-only provider answers writable:false
// and the row disables its control instead of offering a refused write.
describe: () => Promise.resolve({
rpcId: 'r',
result: {
ok: true as const,
value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] },
},
ok: true as const,
value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] },
}),
update: (payload: { ns: string; patch: unknown }) => {
options.writes?.push({ ns: payload.ns, patch: payload.patch })
update: (ns: string, patch: { default?: unknown }) => {
options.writes?.push({ ns, ops: patch })
if (options.failWriteWith !== undefined) return Promise.reject(options.failWriteWith)
if (options.failWrite !== undefined) {
return Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } } })
return Promise.resolve({ ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } })
}
// A committed write moves the roster's default.
for (const preset of presets) {
preset.isDefault = preset.id === (payload.patch as { default?: string }).default
preset.isDefault = preset.id === patch.default
}
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } })
return Promise.resolve({ ok: true as const, value: {} })
},
},
} as unknown as IApiClient
} as unknown as SettingsWireFace
return {
api,
remote: fakeRoster(presets, options.failList === undefined ? {} : { failList: options.failList }),
@@ -165,7 +163,7 @@ describe('the agent-preset settings controller', () => {
it('treats an unavailable optional namespace as an empty roster', async () => {
const controller = derivedController({
api: {} as IApiClient,
api: {} as SettingsWireFace,
remote: fakeRoster([], {
failList: 'no active Remote method exports this endpoint',
failListCode: 'invocation-unavailable',
@@ -187,7 +185,10 @@ describe('the agent-preset settings controller', () => {
await controller.select('minimal')
expect(writes).toEqual([{ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: 'minimal' } }])
expect(writes).toEqual([{
ns: AGENT_PRESET_SETTINGS_NS,
ops: { default: 'minimal' },
}])
expect(controller.store.getSnapshot().currentValue).toBe('minimal')
})
@@ -260,7 +261,7 @@ describe('the agent-preset settings controller', () => {
it('reports a transport that rejects rather than answering', async () => {
const controller = derivedController({
api: {} as IApiClient,
api: {} as SettingsWireFace,
remote: fakeRoster([], { throwOnList: true }),
})
@@ -310,7 +311,7 @@ describe('the new-session chip controller', () => {
},
select: (agentId: SessionId, agentPreset: string) => {
if (options.throwOn === 'select') return Promise.reject(new Error('socket closed'))
options.writes?.push({ ns: 'select', patch: agentPreset })
options.writes?.push({ ns: 'select', ops: agentPreset })
return Promise.resolve(options.failSelect === undefined
? { ok: true as const, value: agentPreset }
: {
@@ -434,7 +435,7 @@ describe('the new-session chip controller', () => {
await controller.load()
await controller.select('minimal')
expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }])
expect(writes).toEqual([{ ns: 'select', ops: 'minimal' }])
expect(controller.store.getSnapshot().current).toBe('minimal')
})
@@ -453,7 +454,7 @@ describe('the new-session chip controller', () => {
// Every later list movement calls apply(); an unspent stage would keep
// switching sessions the user never picked for.
expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }])
expect(writes).toEqual([{ ns: 'select', ops: 'minimal' }])
})
it('drops the stage against a session that already started', async () => {
@@ -535,7 +536,7 @@ describe('the new-session chip controller', () => {
await controller.select('standard')
await first
expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }])
expect(writes).toEqual([{ ns: 'select', ops: 'minimal' }])
})
it('keeps a staged pick across a roster refresh', async () => {
@@ -570,7 +571,7 @@ describe('the new-session chip controller', () => {
const controller = derivedController({
// The roster answered; the mirror's read is what failed, so the row
// shows the current default without offering a write it never confirmed.
api: { settings: { describe: () => Promise.reject(new Error('socket closed')) } } as unknown as IApiClient,
api: { settings: { describe: () => Promise.reject(new Error('socket closed')) } } as unknown as SettingsWireFace,
remote: fakeRoster([{ id: 'standard', trust: 'system', isDefault: true }]),
})
@@ -33,7 +33,6 @@
"client": {
"inject": [
"@deepseek-ai/dsh-api-session-controller",
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-commands",
"@deepseek-ai/dsh-api-remotes",
@@ -51,7 +50,6 @@
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
@@ -65,7 +63,6 @@
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
@@ -14,7 +14,6 @@
* created later through the host Settings API.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -44,7 +43,10 @@ export type {
} from './settings-store.ts'
/** Required services (cordis fiber inject). */
export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote', 'settingsScope', 'settingsSchema']
export const inject = [
'commandUi', 'sessions', 'slots', 'locale', 'remote', 'remote.settings',
'settingsScope', 'settingsSchema',
]
const ACCESS_NS = 'permission.access'
@@ -113,10 +115,9 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries')
const connection = ctx.get('connection') as ConnectionHandle
// The shared SettingsScope mirror updates after document commits and reconnects.
const controller = new PermissionPresetSettingsController(
ctx.settingsScope.describe(), connection.api, ctx.settingsSchema)
ctx.settingsScope.describe(), { settings: ctx.remote.settings }, ctx.settingsSchema)
const load = (): Promise<void> => controller.load()
const select = (preset: string): Promise<void> => controller.select(preset)
const injected = (): PermissionRowInjected => ({
@@ -6,14 +6,12 @@
* back into the mirror.
*/
import type {
IApiClient, SettingsNamespaceView,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import {
createSnapshotStore, type SnapshotStore,
} from '@deepseek-ai/dsh-client-store'
import type {
SchemaNode, SettingsDescribeFace, SettingsSchemaService,
SchemaNode, SettingsDescribeFace, SettingsSchemaService, SettingsWireFace,
} from '@deepseek-ai/dsh-client-ui-settings/client'
import { displayPermissionPreset } from './presentation.ts'
@@ -101,7 +99,7 @@ export class PermissionPresetSettingsController {
*/
constructor(
private readonly describeFace: SettingsDescribeFace,
private readonly api: Pick<IApiClient, 'settings'>,
private readonly api: SettingsWireFace,
private readonly schema: SettingsSchemaService,
) {}
@@ -139,17 +137,17 @@ export class PermissionPresetSettingsController {
draft.error = null
})
try {
const response = await this.api.settings.mutate({
ns: PERMISSION_SETTINGS_NS,
ops: [{ op: 'set', path: ['defaultPreset'], value: preset }],
expectedRevision: view.revision,
})
if (!response.result.ok) throw new Error(response.result.error.message)
const response = await this.api.settings.mutate(
PERMISSION_SETTINGS_NS,
[{ op: 'set', path: ['defaultPreset'], value: preset }],
view.revision,
)
if (!response.ok) throw new Error(response.error.message)
this.saving = false
if (this.disposed) return
// The mirror publish reaches this row's own subscription, so the fold
// is also what republishes the accepted value here.
this.describeFace.acceptView(response.result.value)
this.describeFace.acceptView(response.value)
} catch (error) {
this.saving = false
if (this.disposed) return
@@ -13,7 +13,7 @@ import { describe, expect, it } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-commands/client'
import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client'
@@ -40,7 +40,8 @@ async function bench() {
const locale = new LocaleRuntime(ctx)
locale.setLocale('en')
ctx.provide('locale', locale)
const remote = new TestRemote(ctx)
const settingsRemote = scriptedSettingsRemote()
const remote = new TestRemote(ctx, { settings: settingsRemote.settings })
ctx.slots.register({
name: 'root',
children: {
@@ -43,8 +43,9 @@ function view(defaultPreset: string, revision = 0): SettingsNamespaceView {
}
}
/** The settings namespace answers over the Remote carrier, which has no envelope. */
function ok<T>(value: T) {
return { rpcId: 'test', result: { ok: true as const, value } }
return { ok: true as const, value }
}
const dictionary: Record<string, string> = en
@@ -153,11 +154,8 @@ describe('PermissionRow', () => {
settings: {
describe: () => describe.promise,
mutate: () => Promise.resolve({
rpcId: 'test',
result: {
ok: false as const,
error: { code: 'settings-conflict', message: 'changed elsewhere', details: {} },
},
ok: false as const,
error: { code: 'settings-conflict', message: 'changed elsewhere', details: {} },
}),
},
})
@@ -35,8 +35,9 @@ function view(defaultPreset: string, revision = 0, schema: SettingsNamespaceView
}
}
/** The settings namespace answers over the Remote carrier, which has no envelope. */
function ok<T>(value: T) {
return { rpcId: 'test', result: { ok: true as const, value } }
return { ok: true as const, value }
}
/** The permission controller over a real mirror and one fake wire. */
@@ -117,11 +118,11 @@ describe('permission settings store', () => {
revision: 4,
})
await controller.select('workspace-write')
expect(mutate).toHaveBeenCalledWith({
ns: 'permission',
ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }],
expectedRevision: 4,
})
expect(mutate).toHaveBeenCalledWith(
'permission',
[{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }],
4,
)
expect(controller.store.getSnapshot()).toMatchObject({
status: 'ready',
currentValue: 'workspace-write',
@@ -140,11 +141,8 @@ describe('permission settings store', () => {
const failing = permissionController({
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate: () => Promise.resolve({
rpcId: 'test',
result: {
ok: false as const,
error: { code: 'settings-conflict', message: 'stale', details: {} },
},
ok: false as const,
error: { code: 'settings-conflict', message: 'stale', details: {} },
}),
}).controller
await failing.load()
@@ -171,8 +169,8 @@ describe('permission settings store', () => {
const rejected = permissionController({
describe: () => Promise.resolve({
rpcId: 'test',
result: { ok: false as const, error: { code: 'internal', message: 'offline', details: {} } },
ok: false as const,
error: { code: 'internal', message: 'offline', details: {} },
}),
mutate,
}).controller
@@ -32,14 +32,11 @@ async function bench(isLoopback = true) {
locale.setLocale('zh')
ctx.provide('locale', locale)
const settingsDescribe = vi.fn(() => Promise.resolve({
rpcId: 'settings-general' as never,
result: {
ok: true as const,
value: {
writable: true,
hasDocument: true,
namespaces: [],
},
ok: true as const,
value: {
writable: true,
hasDocument: true,
namespaces: [],
},
}))
const settingsOpenDocument = vi.fn(() => Promise.resolve({
@@ -47,10 +44,10 @@ async function bench(isLoopback = true) {
result: { ok: true as const, value: { opened: true as const } },
}))
ctx.provide('connection', {
api: { settings: { describe: settingsDescribe, openDocument: settingsOpenDocument } },
api: { settings: { openDocument: settingsOpenDocument } },
isLoopback,
} as never)
new TestRemote(ctx)
new TestRemote(ctx, { settings: { describe: settingsDescribe } })
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument }
}
@@ -77,11 +77,8 @@ describe('SettingsDocumentAction', () => {
const controller = derivedDocumentStore({
settings: {
describe: vi.fn(() => Promise.resolve({
rpcId: 'document-action' as never,
result: {
ok: true as const,
value: { writable: true, hasDocument: true, namespaces: [] },
},
ok: true as const,
value: { writable: true, hasDocument: true, namespaces: [] },
})),
openDocument,
},
@@ -99,14 +96,8 @@ describe('SettingsDocumentAction', () => {
it('stays absent without a document and follows a mirror refresh to available', async () => {
const describe = vi.fn()
.mockResolvedValueOnce({
rpcId: 'document-action-absent' as never,
result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } },
})
.mockResolvedValueOnce({
rpcId: 'document-action-ready' as never,
result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
})
.mockResolvedValueOnce({ ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } })
.mockResolvedValueOnce({ ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } })
const wire = { settings: { describe, openDocument: vi.fn() } } as never
const mirror = new SettingsDescribeMirror(wire)
const controller = new SettingsDocumentStore(wire, mirror)
@@ -138,11 +129,8 @@ describe('SettingsDocumentAction', () => {
const controller = derivedDocumentStore({
settings: {
describe: vi.fn(() => Promise.resolve({
rpcId: 'document-action' as never,
result: {
ok: true as const,
value: { writable: true, hasDocument: true, namespaces: [] },
},
ok: true as const,
value: { writable: true, hasDocument: true, namespaces: [] },
})),
openDocument: vi.fn(() => Promise.resolve({
rpcId: 'document-open-failed' as never,
@@ -9,18 +9,8 @@ function derivedDocumentStore(api: object) {
return new SettingsDocumentStore(wire, new SettingsDescribeMirror(wire))
}
function response(hasDocument = false): RpcResponse<{
writable: boolean
hasDocument: boolean
namespaces: []
}> {
return {
rpcId: 'settings-document' as never,
result: {
ok: true,
value: { writable: true, hasDocument, namespaces: [] },
},
}
function response(hasDocument = false) {
return { ok: true, value: { writable: true, hasDocument, namespaces: [] } }
}
function opened(): RpcResponse<{ opened: true }> {
@@ -30,11 +20,8 @@ function opened(): RpcResponse<{ opened: true }> {
}
}
function describeFailed(message: string): RpcResponse<never> {
return {
rpcId: 'settings-document-failed' as never,
result: { ok: false, error: { code: 'internal', message, details: {} } },
}
function describeFailed(message: string) {
return { ok: false as const, error: { code: 'internal', message, details: {} } }
}
describe('SettingsDocumentStore', () => {
@@ -18,11 +18,14 @@ async function bench() {
getSnapshot: () => ({ active: 'zh', locales: [], revision: 0 }),
subscribe: () => () => {},
} as never)
ctx.provide('connection', {
api: { settings: { describe: async () => ({ result: { ok: false } }) } },
isLoopback: false,
} as never)
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('connection', { api: {}, isLoopback: false } as never)
// The shell mounts ui-settings, which injects `remote.settings`; without the
// namespace provided its fiber parks and no slot is ever declared.
const settings = {
describe: async () => ({ ok: false, error: { code: 'internal', message: 'no settings', details: {} } }),
}
ctx.provide('remote', { $on: () => () => {}, settings } as never)
ctx.provide('remote.settings', settings as never)
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
return { ctx, slots: ctx.get('slots') as SlotRegistry }
}
@@ -7,7 +7,7 @@
* the provider editor with extra fields: the route id is being *chosen* here,
* and the settings address does not exist until it is. One `settings.mutate`
* sets the whole profile at `providers.<route>`; the key travels separately
* through `credentials.set` under the reference the profile records, exactly as
* through `credentials/set` under the reference the profile records, exactly as
* an existing provider's key does.
*
* The three fields a hand-declared route cannot default endpoint, protocol,
@@ -23,13 +23,14 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import type { JsonValue } from '@deepseek-ai/dsh-api-remotes/client'
import { apiKeyFailure } from './apiKey.ts'
import { EditorFooter } from './EditorFooter.tsx'
import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
import { ModelListEditor } from './ModelListEditor.tsx'
import type { ModelDraft } from './ModelListEditor.tsx'
import { deriveKeyRef, messageOf } from './store.ts'
import type { ModelsWire } from './store.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -59,7 +60,7 @@ export interface CustomProviderCardProps {
*/
revision: number
/** Wire faces for the write and for interrogating the endpoint. */
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
api: ModelsWire
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable writes (read-only settings provider). */
@@ -143,15 +144,15 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
baseURL,
models: models.map(model => ({ ...model })),
}
const response = await api.settings.mutate({
ns: NS,
ops: [{ op: 'set', path: ['providers', route], value: profile }],
// `taken` is a snapshot too, so the id check alone cannot see a route
// declared after this card opened; the revision makes that race a
// `settings-conflict` instead of a write over the other profile.
expectedRevision: openedAt,
})
if (!response.result.ok) return response.result.error.message
// `taken` is a snapshot too, so the id check alone cannot see a route
// declared after this card opened; the revision makes that race a
// `settings-conflict` instead of a write over the other profile.
const response = await api.settings.mutate(
NS,
[{ op: 'set', path: ['providers', route], value: profile as JsonValue }],
openedAt,
)
if (!response.ok) return response.error.message
// The provider now exists. A retry after the key write below fails must
// not re-run this mutate: the revision it holds is the one this write
// just superseded, so the Host would answer `settings-conflict` and the
@@ -159,10 +160,10 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
setCommitted(true)
}
if (storesKey) {
const stored = await api.credentials.set({ ref: keyRef, value: keyValue })
const stored = await api.credentials.set(keyRef, keyValue)
// The profile landed; saying the key did not is the only honest report,
// and the retry above now goes straight back to this write.
if (!stored.result.ok) return stored.result.error.message
if (!stored.ok) return stored.error.message
}
return undefined
}
@@ -8,10 +8,9 @@
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
import type { ModelsSettingsState, ModelsSettingsStore, ModelsWire } from './store.ts'
import { onboardingReadiness } from './store.ts'
import type { SettingsSchemaOperations } from './schema-operations.ts'
import { ProviderEditor } from './ProviderEditor.tsx'
@@ -28,7 +27,7 @@ export interface DeepSeekOnboardingInjected {
/** Shared Models-page join controller. */
controller: ModelsSettingsStore
/** Existing wire face reused by the Models credential editor. */
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
api: ModelsWire
/** Settings schema and immutable path callbacks. */
schema: SettingsSchemaOperations
/** Feature copy. */
@@ -14,14 +14,13 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls this package's SlotMap merge (the two Models child slots).
import type {} from './slot-contract.ts'
import { CustomProviderCard } from './CustomProviderCard.tsx'
import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts'
import type { ModelsSettingsStore, ProviderRow } from './store.ts'
import type { ModelsSettingsStore, ModelsWire, ProviderRow } from './store.ts'
import type { SettingsSchemaOperations } from './schema-operations.ts'
import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx'
import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx'
@@ -37,7 +36,7 @@ export interface ModelsSectionInjected {
snapshot: ModelsSettingsStore['store']
}
/** Wire faces the editor writes through. */
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
api: ModelsWire
/** Settings schema and immutable path callbacks. */
schema: SettingsSchemaOperations
/** Section copy. */
@@ -112,20 +111,21 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps):
* @returns the failure message, or undefined once the write and reload landed.
*/
export async function removeProviderProfile(
api: Pick<IApiClient, 'settings' | 'credentials'>,
api: Pick<ModelsWire, 'settings' | 'credentials'>,
controller: ModelsSettingsStore,
target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string },
): Promise<string | undefined> {
try {
if (target.credentialRef !== undefined) {
const credential = await api.credentials.unset({ ref: target.credentialRef })
if (!credential.result.ok) return credential.result.error.message
const credential = await api.credentials.unset(target.credentialRef)
if (!credential.ok) return credential.error.message
}
const response = await api.settings.mutate({
ns: target.settingsNs,
ops: [{ op: 'unset', path: [...target.settingsPath] }],
})
if (!response.result.ok) return response.result.error.message
const response = await api.settings.mutate(
target.settingsNs,
[{ op: 'unset', path: [...target.settingsPath] }],
undefined,
)
if (!response.ok) return response.error.message
} catch (error) {
// The transport rejected rather than answering; the caller must be able
// to retry the idempotent operation instead of the row silently staying.
@@ -1,7 +1,7 @@
/**
* One provider's editor card, hand-written per adapter family: the primary
* field is a single write-only **API key** input (the page never asks for an
* environment-variable name a typed key stores through `credentials.set`
* environment-variable name a typed key stores through `credentials/set`
* under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile
* has none. The pi-ai profile records that derivation as `apiKeyEnv` only when
* a key is entered; a blank key materializes a reference-free profile for
@@ -23,7 +23,9 @@
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client'
import type {
CredentialInfo, JsonValue, SettingsNamespaceView, SettingsPathOpView,
} from '@deepseek-ai/dsh-api-remotes/client'
import {
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
} from './DeepSeekModelsEditor.tsx'
@@ -31,6 +33,7 @@ import { apiKeyFailure } from './apiKey.ts'
import { EditorFooter } from './EditorFooter.tsx'
import { ModelListEditor } from './ModelListEditor.tsx'
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
import type { ModelsWire } from './store.ts'
import type { SettingsSchemaOperations } from './schema-operations.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -64,7 +67,7 @@ export interface ProviderEditorProps {
/** Path from the section root to this provider's profile. */
settingsPath: readonly string[]
/** Wire faces for writes and for interrogating a provider endpoint. */
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
api: ModelsWire
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable writes (read-only settings provider). */
@@ -117,7 +120,7 @@ export function pathOps(
const ops: SettingsPathOpView[] = []
for (const [key, value] of Object.entries(after)) {
if (JSON.stringify(previous[key]) === JSON.stringify(value)) continue
ops.push({ op: 'set', path: [...base, key], value })
ops.push({ op: 'set', path: [...base, key], value: value as JsonValue })
}
for (const key of Object.keys(previous)) {
if (!(key in after)) ops.push({ op: 'unset', path: [...base, key] })
@@ -155,7 +158,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const { namespace, schema, settingsPath, api, t } = props
const [draft, setDraft] = useState<Record<string, unknown>>(() => draftAt(schema, namespace, settingsPath))
const [keyDraft, setKeyDraft] = useState('')
const [keyState, setKeyState] = useState<CredentialView | undefined>(undefined)
const [keyState, setKeyState] = useState<CredentialInfo | undefined>(undefined)
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | undefined>(undefined)
// A settings success advances both retry baselines immediately. Keeping the
@@ -187,10 +190,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
// neither a business rejection nor a transport failure may reach the
// browser as an unhandled rejection, so the card simply renders without
// the "already configured" hint.
void api.credentials.describe({ refs: [keyRef] }).then(
void api.credentials.describe([keyRef]).then(
(response) => {
if (stale || !response.result.ok) return
setKeyState(response.result.value.credentials[keyRef])
if (stale || !response.ok) return
setKeyState(response.value[keyRef])
},
() => undefined,
)
@@ -279,19 +282,19 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
? [{ op: 'set', path: [...settingsPath], value: {} }]
: pathOps(settingsPath, committedOriginal, next)
if (ops.length > 0) {
const response = await api.settings.mutate({ ns, ops, expectedRevision })
if (!response.result.ok) {
return response.result.error.code === 'settings-conflict'
const response = await api.settings.mutate(ns, ops, expectedRevision)
if (!response.ok) {
return response.error.code === 'settings-conflict'
? t('conflict')
: response.result.error.message
: response.error.message
}
setCommittedOriginal(schema.getPath(response.result.value.user, settingsPath))
setExpectedRevision(response.result.value.revision)
setCommittedOriginal(schema.getPath(response.value.user, settingsPath))
setExpectedRevision(response.value.revision)
setDraft(next)
}
if (keyValue.length > 0) {
const stored = await api.credentials.set({ ref: keyRef, value: keyValue })
if (!stored.result.ok) return stored.result.error.message
const stored = await api.credentials.set(keyRef, keyValue)
if (!stored.ok) return stored.error.message
}
setKeyDraft('')
return undefined
@@ -2,7 +2,8 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { ModelsSettingsStore } from './store.ts'
import type { en } from './locales.ts'
import { messageOf } from './store.ts'
@@ -15,7 +16,7 @@ export interface SubagentModelSelectionCardProps {
/** Whether the settings provider accepts writes. */
writable: boolean
/** Settings wire face. */
api: Pick<IApiClient, 'settings'>
api: SettingsWireFace
/** Models page controller to refresh after a commit. */
controller: ModelsSettingsStore
/** Localized Models copy. */
@@ -45,13 +46,13 @@ export function SubagentModelSelectionCard({
setSaving(true)
setSaved(false)
setError(undefined)
void api.settings.update({
ns: namespace.ns,
patch: { enabled: !enabled },
expectedRevision: namespace.revision,
}).then(async (response) => {
if (!response.result.ok) throw new Error(response.result.error.message)
controller.acceptNamespace(response.result.value)
void api.settings.update(
namespace.ns,
{ enabled: !enabled },
namespace.revision,
).then(async (response) => {
if (!response.ok) throw new Error(response.error.message)
controller.acceptNamespace(response.value)
await controller.load()
setSaved(true)
}).catch((reason: unknown) => {
@@ -24,6 +24,7 @@ import { WelcomeNotice } from './WelcomeNotice.tsx'
import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
import { decodeWelcomeSection, WelcomeNoticeStore } from './welcome-store.ts'
import { ModelsSettingsStore } from './store.ts'
import type { ModelsWire } from './store.ts'
import { createSettingsSchemaOperations } from './schema-operations.ts'
import { en, zh, type ModelsKey } from './locales.ts'
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
@@ -41,7 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Dictionary namespace owned by this plugin. */
const NS = 'settings.models'
export type { ModelsSettingsState, ProviderRow } from './store.ts'
export type { ModelsCredentials, ModelsSettingsState, ModelsWire, ProviderRow } from './store.ts'
/**
* Refetch the page snapshot only after its first load: an unopened Models
@@ -58,7 +59,10 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void {
* ui-settings' apply, whose activation order relative to this one is NOT
* constrained; registration depends on each slot through `slots.inject()`.
*/
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope', 'settingsSchema']
export const inject = [
'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.settings',
'settingsScope', 'settingsSchema',
]
/**
* Register the Models section once the `settings.section` declaration is on
@@ -71,21 +75,29 @@ export function apply(ctx: ClientContext): void {
const connection = ctx.get('connection') as ConnectionHandle
const schema = createSettingsSchemaOperations(ctx.settingsSchema)
const controller = new ModelsSettingsStore(connection.api, schema, ctx.settingsScope.describe())
// The page's two carriers under one face: model discovery and the catalog
// still ride the unary API, while settings and credentials are Remote
// namespaces.
const wire: ModelsWire = {
...connection.api,
credentials: ctx.remote.credentials,
settings: ctx.remote.settings,
}
const controller = new ModelsSettingsStore(wire, schema, ctx.settingsScope.describe())
// Registration-time text (the nav label thunk) and the inject faces share
// one bound translate; copy freshness rides the locale revision.
const t = ctx.locale.bind(NS) as ModelsSectionInjected['t']
const injected = (): ModelsSectionInjected => ({
controller,
hooks: { snapshot: controller.store },
api: connection.api,
api: wire,
schema,
t,
})
const deepSeekOnboardingInjected = (): DeepSeekOnboardingInjected => ({
controller,
hooks: { models: controller.store },
api: connection.api,
api: wire,
schema,
t,
})
@@ -1,17 +1,17 @@
/**
* Models settings page store: one snapshot joining the configurable-provider
* directory (`llm.providers`), the settings namespaces (shared settings mirror),
* and the referenced credentials (`credentials.describe`). The host stays the
* and the referenced credentials (`credentials/describe`). The host stays the
* single fact source every mutation writes through the wire and the page
* re-renders from the next describe, pushed or refetched.
*/
import type {
ConfigurableProviderView, CredentialView, IApiClient, SettingsNamespaceView,
ClientRemote, ConfigurableProviderView, CredentialInfo, IApiClient, SettingsNamespaceView,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { SettingsDescribeFace, SettingsRemote } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { SettingsSchemaOperations } from './schema-operations.ts'
/**
@@ -20,6 +20,21 @@ import type { SettingsSchemaOperations } from './schema-operations.ts'
*/
const PROBE_ROUTE = '\u0000probe'
/** The credentials Remote methods the Models page reads and writes through. */
export type ModelsCredentials = Pick<ClientRemote['credentials'], 'describe' | 'set' | 'unset'>
/**
* Every wire face the Models page reaches: the settings and llm unary domains,
* plus the credentials Remote namespace, which is addressed by reference name
* and never answers with a value.
*/
export interface ModelsWire extends Pick<IApiClient, 'llm'> {
/** The settings Remote namespace: the redacted read and the profile writes. */
settings: SettingsRemote
/** Credential state and writes for the references provider profiles name. */
credentials: ModelsCredentials
}
/** One provider row the page renders. */
export interface ProviderRow {
/** The directory entry (route id, display name, settings address, live state). */
@@ -31,14 +46,14 @@ export interface ProviderRow {
/** The credential reference the resolved profile names, when one does. */
apiKeyEnv: string | undefined
/** Credential state for {@link apiKeyEnv}, once described. */
credential: CredentialView | undefined
credential: CredentialInfo | undefined
/**
* Credential state for the page's derived `<ROUTE>_API_KEY`, described only
* while the profile names no reference the provider-card seat's
* `keyConfigured` fact for dormant and keyless rows, matching the editor's
* own derivation rule.
*/
derivedCredential?: CredentialView
derivedCredential?: CredentialInfo
}
/** Page snapshot. */
@@ -122,11 +137,11 @@ export class ModelsSettingsStore {
private generation = 0
/**
* @param api - the wire face (credentials/llm domains, and settings writes).
* @param api - the page's wire faces (credentials Remote, llm reads, settings writes).
* @param describeFace - the shared mirror's describe face (namespace views and writability).
*/
constructor(
private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>,
private readonly api: ModelsWire,
private readonly schema: SettingsSchemaOperations,
private readonly describeFace: SettingsDescribeFace,
) {}
@@ -193,16 +208,16 @@ export class ModelsSettingsStore {
}
})
const refs = [...new Set(rows.map(row => row.apiKeyEnv ?? deriveKeyRef(row.entry.provider)))]
let credentials: Record<string, CredentialView> = {}
let credentials: Record<string, CredentialInfo> = {}
let credentialError: string | null = null
if (refs.length > 0) {
try {
const response = await this.api.credentials.describe({ refs })
const response = await this.api.credentials.describe(refs)
// Credential state is an enrichment for the Models page: neither a
// business rejection nor a transport failure fails the load. The
// onboarding projection below retains the failure distinction.
if (response.result.ok) credentials = response.result.value.credentials
else credentialError = response.result.error.message
if (response.ok) credentials = response.value
else credentialError = response.error.message
} catch (error) {
credentialError = messageOf(error)
}
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client'
import {
@@ -24,14 +24,18 @@ async function bench(isLoopback = true, settings?: object, services: object = {}
const locale = new LocaleRuntime(ctx)
locale.setLocale('zh')
ctx.provide('locale', locale)
const remote = new TestRemote(ctx)
// Without a settings face the mirror's reads fail and stay contained; the
// Models join itself never fetches until a section actually loads. The real
// ui-settings apply also provides the settingsSchema service.
ctx.provide('connection', {
api: settings === undefined ? services : { ...services, settings },
isLoopback,
} as never)
const remote = new TestRemote(ctx, {
credentials: {
describe: vi.fn(() => Promise.resolve({ ok: true, value: {} })),
set: vi.fn(),
unset: vi.fn(),
},
// Without a settings face the mirror's reads fail and stay contained; the
// Models join itself never fetches until a section actually loads. The real
// ui-settings apply also provides the settingsSchema service.
settings: settings ?? scriptedSettingsRemote().settings,
})
ctx.provide('connection', { api: services, isLoopback } as never)
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, remote }
}
@@ -51,7 +55,10 @@ function declare(slots: SlotRegistry): () => void {
describe('ui-settings-models apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope', 'settingsSchema'])
expect(inject).toEqual([
'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.settings',
'settingsScope', 'settingsSchema',
])
})
it('registers the models nav entry for declarations before or after apply', async () => {
@@ -243,21 +250,18 @@ describe('pushed invalidations', () => {
const acknowledgement = { current: undefined as string | undefined }
const settings = {
describe: vi.fn(() => Promise.resolve({
rpcId: 'apply-welcome' as never,
result: {
ok: true as const,
value: {
writable: true,
hasDocument: false,
namespaces: [{
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
schema: {},
value: acknowledgement.current === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: acknowledgement.current },
applies: 'live' as const,
secrets: [],
revision: 0,
}],
},
ok: true as const,
value: {
writable: true,
hasDocument: false,
namespaces: [{
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
schema: {},
value: acknowledgement.current === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: acknowledgement.current },
applies: 'live' as const,
secrets: [],
revision: 0,
}],
},
})),
}
@@ -284,21 +288,18 @@ describe('pushed invalidations', () => {
it('joins the refreshed mirror view on a settings invalidation', async () => {
let revision = 1
const describe = vi.fn(() => Promise.resolve({
rpcId: `apply-models-${revision}` as never,
result: {
ok: true as const,
value: {
writable: true,
hasDocument: false,
namespaces: [{
ns: 'llm-test',
schema: {},
value: {},
applies: 'live' as const,
secrets: [],
revision,
}],
},
ok: true as const,
value: {
writable: true,
hasDocument: false,
namespaces: [{
ns: 'llm-test',
schema: {},
value: {},
applies: 'live' as const,
secrets: [],
revision,
}],
},
}))
const providers = vi.fn(() => Promise.resolve({
@@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi
import { afterEach, describe, expect, it, vi } from 'vitest'
import Schema from '@deepseek-ai/schemastery'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import {
ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile,
} from '../src/client/ModelsSection.tsx'
@@ -90,7 +90,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
return [
{
ns: 'llm-deepseek',
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue,
value: {
apiKeyEnv: 'DEEPSEEK_API_KEY',
baseURL: 'https://base',
@@ -108,7 +108,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
ns: 'llm-plain',
schema: JSON.parse(JSON.stringify(Schema.object({
profiles: Schema.dict(Schema.object({ note: Schema.string() })),
}).toJSON())) as unknown,
}).toJSON())) as JsonValue,
value: {},
applies: 'live',
secrets: [],
@@ -116,7 +116,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
},
{
ns: 'llm-pi-ai',
schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as unknown,
schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as JsonValue,
value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
applies: 'live',
@@ -125,7 +125,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
},
{
ns: 'subagent-model-selection',
schema: JSON.parse(JSON.stringify(Schema.object({ enabled: Schema.boolean().default(false) }).toJSON())) as unknown,
schema: JSON.parse(JSON.stringify(Schema.object({ enabled: Schema.boolean().default(false) }).toJSON())) as JsonValue,
value: { enabled: false },
applies: 'live',
secrets: [],
@@ -144,20 +144,25 @@ function fail<T>(message: string, code = 'settings-rejected'): RpcResponse<T> {
result: { ok: false, error: { code, message, details: { ns: 'x' } } as never },
}
}
/** Credentials answers over the Remote carrier, which has no envelope. */
function remoteOk<T>(value: T) {
return { ok: true as const, value }
}
function remoteFail(message: string, code = 'credential-rejected') {
return { ok: false as const, error: { code, message, details: {} } }
}
function scriptedFace(overrides: {
update?: ReturnType<typeof vi.fn>
replace?: ReturnType<typeof vi.fn>
mutate?: ReturnType<typeof vi.fn>
set?: ReturnType<typeof vi.fn>
unset?: ReturnType<typeof vi.fn>
} = {}) {
const providerNamespace = wireNamespaces().find(view => view.ns === 'llm-pi-ai')!
const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(providerNamespace)))
const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(providerNamespace)))
const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(providerNamespace)))
const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({})))
const unset = overrides.unset ?? vi.fn(() => Promise.resolve(ok({})))
const update = overrides.update ?? vi.fn(() => Promise.resolve(remoteOk(providerNamespace)))
const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(remoteOk(providerNamespace)))
const set = overrides.set ?? vi.fn(() => Promise.resolve(remoteOk(undefined)))
const unset = overrides.unset ?? vi.fn(() => Promise.resolve(remoteOk(undefined)))
const face = {
llm: {
providers: vi.fn(() => Promise.resolve(ok({
@@ -173,24 +178,23 @@ function scriptedFace(overrides: {
models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
},
settings: {
describe: vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: wireNamespaces() }))),
describe: vi.fn(() => Promise.resolve(remoteOk({ writable: true, hasDocument: false, namespaces: wireNamespaces() }))),
update,
replace,
mutate,
},
credentials: {
describe: vi.fn((payload: { refs: string[] }) => Promise.resolve(ok({
credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
describe: vi.fn((refs: string[]) => Promise.resolve(remoteOk(
Object.fromEntries(refs.map(ref => [ref, {
configured: ref === 'OPENAI_API_KEY',
...ref === 'OPENAI_API_KEY' ? { source: 'file' } : {},
writable: true,
}])),
}))),
))),
set,
unset,
},
}
return { face, update, replace, mutate, set, unset }
return { face, update, mutate, set, unset }
}
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
@@ -218,7 +222,7 @@ function cardSeatCalls(
}
async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
const { face, update, replace, mutate, set, unset } = scripted
const { face, update, mutate, set, unset } = scripted
const mirror = new SettingsDescribeMirror(face as never)
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, mirror)
await controller.load()
@@ -232,7 +236,7 @@ async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
renderSlot: renderSlot as unknown as ModelsSectionProps['renderSlot'],
}
const view = render(<ModelsSection {...injected} />)
return { view, face, update, replace, mutate, set, unset, controller, mirror, renderSlot }
return { view, face, update, mutate, set, unset, controller, mirror, renderSlot }
}
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
@@ -245,10 +249,10 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
*/
async function mountFirstRun(overrides: Parameters<typeof scriptedFace>[0] = {}) {
const scripted = scriptedFace(overrides)
scripted.face.credentials.describe.mockImplementation((payload: { refs: string[] }) =>
Promise.resolve(ok({
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
})))
scripted.face.credentials.describe.mockImplementation((refs: string[]) =>
Promise.resolve(remoteOk(
Object.fromEntries(refs.map(ref => [ref, { configured: false, writable: true }])),
)))
return mountFace(scripted)
}
@@ -295,12 +299,12 @@ describe('ModelsSection', () => {
it('derives the draft seat\'s key fact from the page\'s conventional reference', async () => {
const scripted = scriptedFace()
scripted.face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
scripted.face.credentials.describe.mockImplementation((refs: string[]) => Promise.resolve(remoteOk(
Object.fromEntries(refs.map(ref => [ref, {
configured: ref === 'OPENAI_API_KEY' || ref === 'ANTHROPIC_API_KEY',
writable: true,
}])),
})))
)))
const { renderSlot } = await mountFace(scripted)
renderSlot.mockClear()
fireEvent.click(screen.getByRole('button', { name: en.add }))
@@ -332,7 +336,7 @@ describe('ModelsSection', () => {
user: { enabled: true },
revision: 5,
}
const update = vi.fn(() => Promise.resolve(ok(enabledNamespace)))
const update = vi.fn(() => Promise.resolve(remoteOk(enabledNamespace)))
await mountSection({ update })
const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle })
@@ -340,18 +344,18 @@ describe('ModelsSection', () => {
fireEvent.click(toggle)
await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') })
expect(update).toHaveBeenCalledWith({
ns: 'subagent-model-selection',
patch: { enabled: true },
expectedRevision: 4,
})
expect(update).toHaveBeenCalledWith(
'subagent-model-selection',
{ enabled: true },
4,
)
expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved)
})
it('reports rejected subagent model-selection updates and permits a retry', async () => {
const update = vi.fn()
.mockResolvedValueOnce(fail<SettingsNamespaceView>('revision changed'))
.mockResolvedValueOnce(ok({
.mockResolvedValueOnce(remoteFail('revision changed', 'settings-rejected'))
.mockResolvedValueOnce(remoteOk({
...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!,
value: { enabled: true },
revision: 5,
@@ -372,12 +376,12 @@ describe('ModelsSection', () => {
...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!,
value: null,
} as unknown as SettingsNamespaceView
const update = vi.fn()
const mutate = vi.fn()
render(
<SubagentModelSelectionCard
namespace={namespace}
writable={false}
api={{ settings: { update } } as never}
api={{ settings: { mutate } } as never}
controller={{ acceptNamespace: vi.fn(), load: vi.fn() } as never}
t={t}
/>,
@@ -387,7 +391,7 @@ describe('ModelsSection', () => {
expect(toggle.getAttribute('aria-checked')).toBe('false')
expect((toggle as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(toggle)
expect(update).not.toHaveBeenCalled()
expect(mutate).not.toHaveBeenCalled()
})
it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => {
@@ -420,9 +424,9 @@ describe('ModelsSection', () => {
it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => {
const { face } = scriptedFace()
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
})))
face.credentials.describe.mockImplementation((refs: string[]) => Promise.resolve(remoteOk(
Object.fromEntries(refs.map(ref => [ref, { configured: false, writable: true }])),
)))
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never))
await controller.load()
render(<ModelsSection
@@ -444,9 +448,9 @@ describe('ModelsSection', () => {
it('turns the setup card into a row once the credential reports configured', async () => {
const { face } = await mountFirstRun()
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])),
})))
face.credentials.describe.mockImplementation((refs: string[]) => Promise.resolve(remoteOk(
Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])),
)))
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never))
await controller.load()
cleanup()
@@ -503,12 +507,12 @@ describe('ModelsSection', () => {
})
it('stores a typed key write-only from the setup card without touching settings', async () => {
const { set, update, face } = await mountFirstRun()
const { set, mutate, face } = await mountFirstRun()
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
fireEvent.change(key, { target: { value: ' sk-live ' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) })
expect(update).not.toHaveBeenCalled()
await waitFor(() => { expect(set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-live') })
expect(mutate).not.toHaveBeenCalled()
// The saved key re-loads the join; the settings answer rides the shared
// mirror, so the reload shows as a directory read rather than a describe.
await waitFor(() => { expect(face.llm.providers.mock.calls.length).toBeGreaterThan(1) })
@@ -520,8 +524,8 @@ describe('ModelsSection', () => {
})
it('reuses the provider editor as a required credential-only onboarding form', async () => {
let finishSet: ((response: RpcResponse<Record<string, never>>) => void) | undefined
const set = vi.fn(() => new Promise<RpcResponse<Record<string, never>>>((resolve) => {
let finishSet: ((response: { ok: true; value: undefined }) => void) | undefined
const set = vi.fn(() => new Promise<{ ok: true; value: undefined }>((resolve) => {
finishSet = resolve
}))
const { face, mutate } = scriptedFace({ set })
@@ -567,13 +571,13 @@ describe('ModelsSection', () => {
fireEvent.click(save)
expect(await screen.findByText(en.onboardingSaving)).toBeTruthy()
expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-onboarding' })
expect(set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-onboarding')
expect(mutate).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
if (finishSet === undefined) throw new Error('credential write did not start')
await act(async () => {
finishSet?.(ok({}))
finishSet?.(remoteOk(undefined))
await Promise.resolve()
})
expect(onClose).toHaveBeenCalledWith(true)
@@ -581,7 +585,7 @@ describe('ModelsSection', () => {
it('applies customized deepseek fields as path ops', async () => {
const { mutate } = await mountDeepSeekCard({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
@@ -593,16 +597,16 @@ describe('ModelsSection', () => {
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
// Only the field that actually changed: reasoningEffort was already
// 'high' in the loaded profile, so it produces no op.
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{ op: 'set', path: ['baseURL'], value: 'https://next2' }],
expectedRevision: 0,
})
expect(mutate.mock.calls[0]).toEqual([
'llm-deepseek',
[{ op: 'set', path: ['baseURL'], value: 'https://next2' }],
0,
])
})
it('materializes inherited models and adds an arbitrary DeepSeek id', async () => {
const { mutate } = await mountDeepSeekCard({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
@@ -620,9 +624,9 @@ describe('ModelsSection', () => {
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{
expect(mutate.mock.calls[0]).toEqual([
'llm-deepseek',
[{
op: 'set',
path: ['models'],
value: [
@@ -630,8 +634,8 @@ describe('ModelsSection', () => {
{ id: 'private-preview', name: 'Private Preview', contextWindow: 131_072 },
],
}],
expectedRevision: 0,
})
0,
])
})
it('rejects duplicate DeepSeek model ids before writing', async () => {
@@ -706,7 +710,7 @@ describe('ModelsSection', () => {
it('accepts a suffixed context window and stores the plain count', async () => {
const { mutate } = await mountDeepSeekCard({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
expandRow(1)
@@ -730,9 +734,9 @@ describe('ModelsSection', () => {
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{
expect(mutate.mock.calls[0]).toEqual([
'llm-deepseek',
[{
op: 'set',
path: ['models'],
value: [
@@ -740,8 +744,8 @@ describe('ModelsSection', () => {
{ ...DEFAULT_DEEPSEEK_MODELS[1], contextWindow: 256_000 },
],
}],
expectedRevision: 0,
})
0,
])
})
it('keeps unreadable context-window text on screen and refuses the write', async () => {
@@ -773,7 +777,7 @@ describe('ModelsSection', () => {
const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] }
const overridden: SettingsNamespaceView = {
ns: 'llm-deepseek',
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue,
value: { ...stored, defaultContextWindow: 1_000_000 },
...base === undefined ? {} : { base },
user: stored,
@@ -858,7 +862,7 @@ describe('ModelsSection', () => {
// inherited row displayed text no settings layer stores — and because an
// unreadable buffer never settles, it stayed there indefinitely.
const { mutate } = await mountDeepSeekCard({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
expandRow(1)
@@ -881,7 +885,7 @@ describe('ModelsSection', () => {
it('edits an output cap per model and carries its text across a removal', async () => {
const { mutate } = await mountDeepSeekCard({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
expandRow(1)
@@ -902,15 +906,15 @@ describe('ModelsSection', () => {
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{
expect(mutate.mock.calls[0]).toEqual([
'llm-deepseek',
[{
op: 'set',
path: ['models'],
value: [{ ...DEFAULT_DEEPSEEK_MODELS[1], maxTokens: 64_000 }],
}],
expectedRevision: 0,
})
0,
])
})
it('settles a pasted id and refuses whitespace that would never match', async () => {
@@ -952,7 +956,7 @@ describe('ModelsSection', () => {
it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => {
const { mutate } = await mountDeepSeekCard({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement)
@@ -969,9 +973,9 @@ describe('ModelsSection', () => {
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{
expect(mutate.mock.calls[0]).toEqual([
'llm-deepseek',
[{
op: 'set',
path: ['models'],
value: [
@@ -979,33 +983,33 @@ describe('ModelsSection', () => {
DEFAULT_DEEPSEEK_MODELS[1],
],
}],
expectedRevision: 0,
})
0,
])
})
it('clears an inherited override with an unset op, never a whole-section replace', async () => {
// A whole-section replace would clobber sibling overrides to clear one field.
const { replace, update, mutate } = await mountDeepSeekCard()
const { mutate } = await mountDeepSeekCard()
fireEvent.click(screen.getByText(en.customized))
const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
expect(url.value).toBe('https://base')
fireEvent.change(url, { target: { value: '' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(replace).not.toHaveBeenCalled()
expect(update).not.toHaveBeenCalled()
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{ op: 'unset', path: ['baseURL'] }],
expectedRevision: 0,
})
// This editor clears one field through an unset op so it cannot clobber
// sibling overrides with a whole-section replacement.
expect(mutate.mock.calls[0]).toEqual([
'llm-deepseek',
[{ op: 'unset', path: ['baseURL'] }],
0,
])
})
it('pins the deepseek placeholder and clears typed input back to inherited', async () => {
const { face } = scriptedFace()
const bare: SettingsNamespaceView = {
ns: 'llm-deepseek',
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue,
value: {},
applies: 'live',
secrets: [],
@@ -1033,12 +1037,12 @@ describe('ModelsSection', () => {
})
it('rejects an invalid draft before writing', async () => {
const { update } = await mountDeepSeekCard()
const { mutate } = await mountDeepSeekCard()
fireEvent.click(screen.getByText(en.customized))
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } })
fireEvent.click(screen.getByText(en.apply))
await screen.findByText(/baseURL/)
expect(update).not.toHaveBeenCalled()
expect(mutate).not.toHaveBeenCalled()
})
it('edits a pi-ai profile with the curated fields only', async () => {
@@ -1057,11 +1061,11 @@ describe('ModelsSection', () => {
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
// Only the edited field travels: apiKeyEnv and headers were already stored
// with these values, so no op restates them.
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
ops: [{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }],
expectedRevision: 0,
})
expect(mutate.mock.calls[0]).toEqual([
'llm-pi-ai',
[{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }],
0,
])
})
it('adds a dormant provider with a derived reference and stores its key', async () => {
@@ -1079,12 +1083,12 @@ describe('ModelsSection', () => {
fireEvent.change(addKey, { target: { value: 'sk-ant' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
ops: [{ op: 'set', path: ['providers', 'anthropic', 'apiKeyEnv'], value: 'ANTHROPIC_API_KEY' }],
expectedRevision: 0,
})
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) })
expect(mutate.mock.calls[0]).toEqual([
'llm-pi-ai',
[{ op: 'set', path: ['providers', 'anthropic', 'apiKeyEnv'], value: 'ANTHROPIC_API_KEY' }],
0,
])
await waitFor(() => { expect(set).toHaveBeenCalledWith('ANTHROPIC_API_KEY', 'sk-ant') })
})
it('keeps pi-ai provider-native authentication when no key is entered', async () => {
@@ -1093,11 +1097,11 @@ describe('ModelsSection', () => {
await screen.findByLabelText(en.provider)
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
ops: [{ op: 'set', path: ['providers', 'anthropic'], value: {} }],
expectedRevision: 0,
})
expect(mutate.mock.calls[0]).toEqual([
'llm-pi-ai',
[{ op: 'set', path: ['providers', 'anthropic'], value: {} }],
0,
])
expect(set).not.toHaveBeenCalled()
})
@@ -1115,10 +1119,10 @@ describe('ModelsSection', () => {
} },
revision: 1,
}
const mutate = vi.fn(() => Promise.resolve(ok(afterSettings)))
const mutate = vi.fn(() => Promise.resolve(remoteOk(afterSettings)))
const set = vi.fn()
.mockResolvedValueOnce(fail('credential store unavailable', 'credential-rejected'))
.mockResolvedValueOnce(ok({}))
.mockResolvedValueOnce(remoteFail('credential store unavailable'))
.mockResolvedValueOnce(remoteOk(undefined))
const { face, controller, mirror } = await mountSection({ mutate, set })
fireEvent.click(screen.getByText(en.add))
await screen.findByLabelText(en.provider)
@@ -1126,7 +1130,7 @@ describe('ModelsSection', () => {
fireEvent.click(screen.getByText(en.apply))
await screen.findByText('credential store unavailable')
expect(mutate).toHaveBeenCalledOnce()
face.settings.describe.mockResolvedValue(ok({
face.settings.describe.mockResolvedValue(remoteOk({
writable: true,
hasDocument: false,
namespaces: wireNamespaces().map(namespace => namespace.ns === 'llm-pi-ai' ? afterSettings : namespace),
@@ -1141,7 +1145,7 @@ describe('ModelsSection', () => {
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) })
expect(mutate).toHaveBeenCalledOnce()
expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' })
expect(set).toHaveBeenLastCalledWith('ANTHROPIC_API_KEY', 'sk-ant')
})
it('switches the add card target and degrades unknown or broken targets loudly', async () => {
@@ -1161,7 +1165,7 @@ describe('ModelsSection', () => {
it('surfaces a rejected settings write and never stores the key after it', async () => {
const { set } = await mountSection({
mutate: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))),
mutate: vi.fn(() => Promise.resolve(remoteFail('llm-pi-ai: unknown pi-ai provider "bogus"', 'settings-rejected'))),
})
fireEvent.click(screen.getByText(en.add))
await screen.findByLabelText(en.provider)
@@ -1202,7 +1206,7 @@ describe('ModelsSection', () => {
// The stale-draft overwrite: two tabs open the same card, the other saves,
// and this one must be refused rather than replay its opening snapshot.
const { set } = await mountDeepSeekCard({
mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))),
mutate: vi.fn(() => Promise.resolve(remoteFail('changed since it was read', 'settings-conflict'))),
})
fireEvent.click(screen.getByText(en.customized))
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://mine' } })
@@ -1226,7 +1230,7 @@ describe('ModelsSection', () => {
it('surfaces a shadowed credential write on the card', async () => {
await mountFirstRun({
set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),
set: vi.fn(() => Promise.resolve(remoteFail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment'))),
})
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
fireEvent.change(key, { target: { value: 'sk-live' } })
@@ -1237,11 +1241,11 @@ describe('ModelsSection', () => {
it('locks the key input when the launch environment provides the credential', async () => {
const { face } = await mountSection()
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
face.credentials.describe.mockImplementation((refs: string[]) => Promise.resolve(remoteOk(
Object.fromEntries(refs.map(ref => [ref, {
configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false,
}])),
})))
)))
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) })
@@ -1250,7 +1254,7 @@ describe('ModelsSection', () => {
it('keeps a failed credential describe silent and the input usable', async () => {
const { face, set } = await mountSection()
face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never)
face.credentials.describe.mockImplementation(() => Promise.resolve(remoteFail('down', 'internal')) as never)
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
expect(editorKey.placeholder).toBe(en.keyPlaceholderNative)
@@ -1260,7 +1264,7 @@ describe('ModelsSection', () => {
})
it('requires confirmation before removing a user-added provider', async () => {
const { replace, mutate, unset } = await mountSection()
const { mutate, unset } = await mountSection()
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
expect(dialog.textContent).toContain(openaiCopy(en.deleteDescriptionWithCredential))
@@ -1280,20 +1284,20 @@ describe('ModelsSection', () => {
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }))
.getByRole('button', { name: openaiCopy(en.deleteConfirm) }))
await waitFor(() => { expect(unset).toHaveBeenCalledWith({ ref: 'OPENAI_API_KEY' }) })
await waitFor(() => { expect(unset).toHaveBeenCalledWith('OPENAI_API_KEY') })
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(unset.mock.invocationCallOrder[0]).toBeLessThan(mutate.mock.invocationCallOrder[0] as number)
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
expect(replace).not.toHaveBeenCalled()
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
ops: [{ op: 'unset', path: ['providers', 'openai'] }],
})
expect(mutate.mock.calls[0]).toEqual([
'llm-pi-ai',
[{ op: 'unset', path: ['providers', 'openai'] }],
undefined,
])
})
it('blocks duplicate deletion while the confirmed removal is pending', async () => {
let resolveRemoval!: (response: RpcResponse<SettingsNamespaceView>) => void
const mutate = vi.fn(() => new Promise<RpcResponse<SettingsNamespaceView>>((resolve) => {
let resolveRemoval!: (response: { ok: true; value: SettingsNamespaceView }) => void
const mutate = vi.fn(() => new Promise<{ ok: true; value: SettingsNamespaceView }>((resolve) => {
resolveRemoval = resolve
}))
await mountSection({ mutate })
@@ -1309,7 +1313,7 @@ describe('ModelsSection', () => {
fireEvent.click(within(dialog).getByRole('button', { name: en.close }))
expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog)
expect(mutate).toHaveBeenCalledOnce()
await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) })
await act(async () => { resolveRemoval(remoteOk(wireNamespaces()[2]!)) })
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
})
@@ -1336,7 +1340,7 @@ describe('ModelsSection', () => {
it('shows the read-only notice and disables mutations for a read-only provider', async () => {
const { face } = await mountSection()
face.settings.describe.mockImplementation(() => Promise.resolve(ok({
face.settings.describe.mockImplementation(() => Promise.resolve(remoteOk({
writable: false,
hasDocument: false,
namespaces: wireNamespaces(),
@@ -1358,7 +1362,7 @@ describe('ModelsSection', () => {
})
it('toggles the row editor closed on a second edit click and on cancel', async () => {
const { update } = await mountSection()
const { mutate } = await mountSection()
const edit = screen.getByRole('button', { name: openaiCopy(en.editProvider) })
fireEvent.click(edit)
await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) })
@@ -1368,7 +1372,7 @@ describe('ModelsSection', () => {
await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) })
fireEvent.click(screen.getByText(en.cancel))
expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
expect(update).not.toHaveBeenCalled()
expect(mutate).not.toHaveBeenCalled()
})
it('cancels the add card back to the add button', async () => {
@@ -1420,22 +1424,22 @@ describe('ModelsSection', () => {
it('removes by unsetting the profile path, never by rebuilding the section', async () => {
// The page only needs to name the profile path; rebuilding the section
// would widen the write for no benefit.
const { face, mutate, replace, controller } = await mountSection()
const { face, mutate, controller } = await mountSection()
await removeProviderProfile(
face as unknown as Parameters<typeof removeProviderProfile>[0],
controller,
{ settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] },
)
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-plain',
ops: [{ op: 'unset', path: ['ghost-profile'] }],
})
expect(replace).not.toHaveBeenCalled()
expect(mutate.mock.calls[0]).toEqual([
'llm-plain',
[{ op: 'unset', path: ['ghost-profile'] }],
undefined,
])
})
it('keeps the snapshot untouched and reports the message when a removal write is refused', async () => {
const { face, controller } = await mountSection({
mutate: vi.fn(() => Promise.resolve(fail('read-only'))),
mutate: vi.fn(() => Promise.resolve(remoteFail('read-only', 'settings-rejected'))),
})
const before = controller.store.getSnapshot().rows
const failure = await removeProviderProfile(
@@ -1449,8 +1453,8 @@ describe('ModelsSection', () => {
it('keeps a failed identified deletion recoverable in its confirmation dialog', async () => {
const mutate = vi.fn()
.mockResolvedValueOnce(fail('the host refused'))
.mockResolvedValueOnce(ok(wireNamespaces()[2]!))
.mockResolvedValueOnce(remoteFail('the host refused', 'settings-rejected'))
.mockResolvedValueOnce(remoteOk(wireNamespaces()[2]!))
const { unset } = await mountSection({ mutate })
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
@@ -1478,15 +1482,16 @@ describe('ModelsSection', () => {
fireEvent.click(within(dialog).getByRole('button', { name: providerCopy(en.deleteConfirm, target) }))
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
expect(unset).not.toHaveBeenCalled()
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
ops: [{ op: 'unset', path: ['providers', 'zombie'] }],
})
expect(mutate.mock.calls[0]).toEqual([
'llm-pi-ai',
[{ op: 'unset', path: ['providers', 'zombie'] }],
undefined,
])
})
it('does not remove provider settings when its managed credential removal is refused', async () => {
const { face, controller, mutate } = await mountSection({
unset: vi.fn(() => Promise.resolve(fail('credential is read-only', 'credential-rejected'))),
unset: vi.fn(() => Promise.resolve(remoteFail('credential is read-only'))),
})
const failure = await removeProviderProfile(
face as unknown as Parameters<typeof removeProviderProfile>[0],
@@ -3,7 +3,7 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import Schema from '@deepseek-ai/schemastery'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx'
@@ -21,11 +21,12 @@ let nextRpc = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `onboarding-${nextRpc++}` as never, result: { ok: true, value } }
}
function fail<T>(message: string): RpcResponse<T> {
return {
rpcId: `onboarding-${nextRpc++}` as never,
result: { ok: false, error: { code: 'internal', message, details: {} } },
}
/** Credentials answers over the Remote carrier, which has no envelope. */
function remoteOk<T>(value: T) {
return { ok: true as const, value }
}
function remoteFail(message: string) {
return { ok: false as const, error: { code: 'internal', message, details: {} } }
}
const DeepSeekConfig = Schema.object({
@@ -49,7 +50,7 @@ function deepSeekNamespace(apiKeyEnv: string | null): SettingsNamespaceView {
const value = apiKeyEnv === null ? {} : { apiKeyEnv }
return {
ns: 'llm-deepseek',
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue,
value,
base: value,
user: {},
@@ -81,12 +82,12 @@ function harness(options: {
let fileConfigured = false
const configured = options.configured ?? (() => fileConfigured)
const apiKeyEnv = options.apiKeyEnv === undefined ? 'DEEPSEEK_API_KEY' : options.apiKeyEnv
const mutate = vi.fn(() => Promise.resolve(ok(deepSeekNamespace(apiKeyEnv))))
const set = vi.fn((_payload: { ref: string; value: string }) => {
const mutate = vi.fn(() => Promise.resolve(remoteOk(deepSeekNamespace(apiKeyEnv))))
const set = vi.fn((_ref: string, _value: string) => {
if (options.setReject !== undefined) return Promise.reject(new Error(options.setReject))
if (options.setFailure !== undefined) return Promise.resolve(fail(options.setFailure))
if (options.setFailure !== undefined) return Promise.resolve(remoteFail(options.setFailure))
fileConfigured = true
return Promise.resolve(ok({}))
return Promise.resolve(remoteOk(undefined))
})
const face = {
llm: {
@@ -106,7 +107,7 @@ function harness(options: {
},
},
settings: {
describe: () => Promise.resolve(ok({
describe: () => Promise.resolve(remoteOk({
writable: options.settingsWritable ?? true,
hasDocument: false,
namespaces: options.settingsNamespace === false ? [] : [deepSeekNamespace(apiKeyEnv)],
@@ -115,18 +116,16 @@ function harness(options: {
},
credentials: {
describe: () => options.describeFailure === undefined
? Promise.resolve(ok({
credentials: {
DEEPSEEK_API_KEY: {
configured: configured(),
...configured() && options.credential?.source !== undefined
? { source: options.credential.source }
: {},
writable: options.credential?.writable ?? true,
},
? Promise.resolve(remoteOk({
DEEPSEEK_API_KEY: {
configured: configured(),
...configured() && options.credential?.source !== undefined
? { source: options.credential.source }
: {},
writable: options.credential?.writable ?? true,
},
}))
: Promise.resolve(fail(options.describeFailure)),
: Promise.resolve(remoteFail(options.describeFailure)),
set,
},
}
@@ -4,7 +4,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { afterEach, describe, expect, it, vi } from 'vitest'
import Schema from '@deepseek-ai/schemastery'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { ModelsSection, providerCopy } from '../src/client/ModelsSection.tsx'
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx'
@@ -45,15 +45,22 @@ function ok<T>(value: T): RpcResponse<T> {
function fail<T>(message: string, code: string): RpcResponse<T> {
return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code, message, details: {} } as never } }
}
/** Credentials answers over the Remote carrier, which has no envelope. */
function remoteOk<T>(value: T) {
return { ok: true as const, value }
}
function remoteFail(message: string, code = 'credential-rejected') {
return { ok: false as const, error: { code, message, details: {} } }
}
function piAiNamespace(
providers: Record<string, unknown>,
userProviders: Record<string, unknown> = providers,
baseProviders: Record<string, unknown> = {},
providers: Record<string, JsonValue>,
userProviders: Record<string, JsonValue> = providers,
baseProviders: Record<string, JsonValue> = {},
): SettingsNamespaceView {
return {
ns: 'llm-pi-ai',
schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as unknown,
schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as JsonValue,
// `value` is the effective section; `user` is only the layer this page
// writes. They differ whenever a composition `base` supplies something.
value: { providers },
@@ -66,11 +73,11 @@ function piAiNamespace(
}
function scriptedFace(options: {
providers?: Record<string, unknown>
providers?: Record<string, JsonValue>
/** User layer, when it differs from the effective section. */
userProviders?: Record<string, unknown>
userProviders?: Record<string, JsonValue>
/** Composition layer, for a route a `cordis.yml` pins rather than the page. */
baseProviders?: Record<string, unknown>
baseProviders?: Record<string, JsonValue>
/** Routes the adapter reports as hand-declared; the rest come back as shipped. */
declaredRoutes?: readonly string[]
discover?: ReturnType<typeof vi.fn>
@@ -82,8 +89,8 @@ function scriptedFace(options: {
}
const namespace = piAiNamespace(providers, options.userProviders ?? providers, options.baseProviders ?? {})
const discover = options.discover ?? vi.fn(() => Promise.resolve(ok({ models: [] })))
const mutate = options.mutate ?? vi.fn(() => Promise.resolve(ok(namespace)))
const set = options.set ?? vi.fn(() => Promise.resolve(ok({})))
const mutate = options.mutate ?? vi.fn(() => Promise.resolve(remoteOk(namespace)))
const set = options.set ?? vi.fn(() => Promise.resolve(remoteOk(undefined)))
const face = {
llm: {
providers: vi.fn(() => Promise.resolve(ok({
@@ -100,15 +107,13 @@ function scriptedFace(options: {
discoverModels: discover,
},
settings: {
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace] }))),
update: vi.fn(),
replace: vi.fn(),
describe: vi.fn(() => Promise.resolve(remoteOk({ writable: true, namespaces: [namespace] }))),
mutate,
},
credentials: {
describe: vi.fn((payload: { refs: string[] }) => Promise.resolve(ok({
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
}))),
describe: vi.fn((refs: string[]) => Promise.resolve(remoteOk(
Object.fromEntries(refs.map(ref => [ref, { configured: false, writable: true }])),
))),
set,
unset: vi.fn(),
},
@@ -132,11 +137,16 @@ function firstProbe(discover: ReturnType<typeof vi.fn>): unknown {
return call
}
/** The first recorded settings write; fails the case when nothing was written. */
/**
* The first recorded settings write, as one record. The Remote method takes
* three positional arguments; the cases read the write as a whole, so the
* regrouping lives here rather than in every assertion.
*/
function firstMutate(mutate: ReturnType<typeof vi.fn>): MutateCall {
const call = mutate.mock.calls[0]?.[0] as MutateCall | undefined
const call = mutate.mock.calls[0] as [string, MutateCall['ops'], number | undefined] | undefined
if (call === undefined) throw new Error('no settings write was recorded')
return call
const [ns, ops, expectedRevision] = call
return { ns, ops, ...expectedRevision === undefined ? {} : { expectedRevision } }
}
async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) {
@@ -190,7 +200,7 @@ describe('protocolChoices', () => {
const { namespace } = scriptedFace()
expect(protocolChoices(namespace, settingsSchema)).toEqual(PROTOCOLS)
expect(protocolChoices(undefined, settingsSchema)).toEqual([])
const plain = { ...namespace, schema: JSON.parse(JSON.stringify(Schema.object({}).toJSON())) as unknown }
const plain = { ...namespace, schema: JSON.parse(JSON.stringify(Schema.object({}).toJSON())) as JsonValue }
expect(protocolChoices(plain, settingsSchema)).toEqual([])
await Promise.resolve()
})
@@ -734,7 +744,7 @@ describe('hand-declared providers', () => {
// meanwhile makes this a conflict rather than an overwrite.
expectedRevision: 7,
})
expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' })
expect(set).toHaveBeenCalledWith('ACME_GATEWAY_API_KEY', 'gw-key')
})
it('scopes each card to fields a provider can actually own', async () => {
@@ -901,8 +911,8 @@ describe('hand-declared providers', () => {
it('retries only the key after the profile landed, and reports the provider on cancel', async () => {
const set = vi.fn()
.mockResolvedValueOnce(fail('credential store is read-only', 'credential-rejected'))
.mockResolvedValueOnce(ok({}))
.mockResolvedValueOnce(remoteFail('credential store is read-only'))
.mockResolvedValueOnce(remoteOk(undefined))
const { mutate, onClose } = mountCard({}, { set })
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
@@ -917,7 +927,7 @@ describe('hand-declared providers', () => {
expect(onClose).not.toHaveBeenCalled()
expect(mutate).toHaveBeenCalledTimes(1)
// The key is stored trimmed, matching the editor.
expect(set).toHaveBeenNthCalledWith(1, { ref: 'ACME_API_KEY', value: 'gw-key' })
expect(set).toHaveBeenNthCalledWith(1, 'ACME_API_KEY', 'gw-key')
// The provider exists now, so the fields describing it are settled and
// only the key can still be corrected.
@@ -932,11 +942,11 @@ describe('hand-declared providers', () => {
// first write superseded, so the Host would answer settings-conflict and
// the key could never be stored from here at all.
expect(mutate).toHaveBeenCalledTimes(1)
expect(set).toHaveBeenNthCalledWith(2, { ref: 'ACME_API_KEY', value: 'gw-key-2' })
expect(set).toHaveBeenNthCalledWith(2, 'ACME_API_KEY', 'gw-key-2')
})
it('reports the created provider when cancelled after its profile landed', async () => {
const set = vi.fn().mockResolvedValue(fail('nope', 'credential-rejected'))
const set = vi.fn().mockResolvedValue(remoteFail('nope'))
const { onClose } = mountCard({}, { set })
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
@@ -1122,7 +1132,7 @@ describe('hand-declared providers', () => {
})
it('surfaces a refused write and a rejected transport without closing', async () => {
const refused = vi.fn(() => Promise.resolve(fail('read-only settings', 'settings-rejected')))
const refused = vi.fn(() => Promise.resolve(remoteFail('read-only settings', 'settings-rejected')))
const { onClose } = mountCard({ api: { ...scriptedFace({ mutate: refused }).face } as never })
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
@@ -1150,7 +1160,7 @@ describe('hand-declared providers', () => {
})
it('reports a stored profile whose key write was refused', async () => {
const set = vi.fn(() => Promise.resolve(fail('credential is read-only', 'credential-rejected')))
const set = vi.fn(() => Promise.resolve(remoteFail('credential is read-only')))
const { onClose } = mountCard({ api: { ...scriptedFace({ set }).face } as never })
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
@@ -1363,7 +1373,7 @@ describe('API key field', () => {
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(set).toHaveBeenCalled() })
expect((set.mock.calls[0]?.[0] as { value: string }).value).toBe('sk-abc')
expect(set.mock.calls[0]?.[1]).toBe('sk-abc')
})
it('blocks the interrogation too, rather than spending a round trip on a refused key', async () => {
@@ -1,10 +1,10 @@
/** Pure first-run readiness projection over the shared Models join. */
import { describe, expect, it } from 'vitest'
import type { CredentialView } from '@deepseek-ai/dsh-api-remotes/client'
import type { CredentialInfo } from '@deepseek-ai/dsh-api-remotes/client'
import type { ModelsSettingsState, ProviderRow } from '../src/client/store.ts'
import { onboardingReadiness, providerUsable } from '../src/client/store.ts'
const missingCredential: CredentialView = { configured: false, writable: true }
const missingCredential: CredentialInfo = { configured: false, writable: true }
function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
return {
@@ -13,6 +13,17 @@ function fail<T>(message: string): RpcResponse<T> {
return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code: 'internal', message, details: {} } } }
}
/** Credentials answers over the Remote carrier, which has no envelope. */
type RemoteAnswer<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: { code: string; message: string; details: object } }
function remoteOk<T>(value: T): RemoteAnswer<T> {
return { ok: true, value }
}
function remoteFail<T>(message: string): RemoteAnswer<T> {
return { 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 },
@@ -43,8 +54,8 @@ const NAMESPACES = [
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> }>>
describeSettings?: () => Promise<RemoteAnswer<{ writable: boolean; hasDocument: boolean; namespaces: typeof NAMESPACES }>>
describeCredentials?: (refs: readonly string[]) => Promise<RemoteAnswer<Record<string, unknown>>>
} = {}) {
const seenRefs: string[][] = []
const face = {
@@ -53,19 +64,19 @@ function api(overrides: {
models: () => Promise.resolve(ok({ groups: [], failures: [] })),
},
settings: {
describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: NAMESPACES }))),
update: () => Promise.resolve(fail('unused')),
replace: () => Promise.resolve(fail('unused')),
describe: overrides.describeSettings
?? (() => Promise.resolve(remoteOk({ writable: true, hasDocument: false, namespaces: NAMESPACES }))),
mutate: () => Promise.resolve(remoteFail('the store spec issues no writes')),
},
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)
describe: (refs: readonly string[]) => {
seenRefs.push([...refs])
return (overrides.describeCredentials ?? (asked => Promise.resolve(remoteOk(
Object.fromEntries(asked.map(ref => [ref, { configured: ref === 'OPENAI_API_KEY', writable: true }])),
))))(refs)
},
set: () => Promise.resolve(ok({})),
unset: () => Promise.resolve(ok({})),
set: () => Promise.resolve(remoteOk(undefined)),
unset: () => Promise.resolve(remoteOk(undefined)),
},
}
const wire = face as never
@@ -104,7 +115,7 @@ describe('ModelsSettingsStore', () => {
})
it('degrades the credential badge, not the page, when the credential domain fails', async () => {
const { face, mirror } = api({ describeCredentials: () => Promise.resolve(fail('no provider')) })
const { face, mirror } = api({ describeCredentials: () => Promise.resolve(remoteFail('no provider')) })
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
await store.load()
const state = store.store.getSnapshot()
@@ -173,7 +184,7 @@ describe('ModelsSettingsStore', () => {
describe('edge joins', () => {
it('treats a non-object profile as having no credential reference', async () => {
const { face, mirror } = api({
describeSettings: () => Promise.resolve(ok({
describeSettings: () => Promise.resolve(remoteOk({
writable: true,
hasDocument: false,
namespaces: [{
@@ -200,7 +211,7 @@ describe('edge joins', () => {
it('describes the derived reference for a row whose profile names none', async () => {
const { face, mirror, seenRefs } = api({
describeSettings: () => Promise.resolve(ok({
describeSettings: () => Promise.resolve(remoteOk({
writable: true,
hasDocument: false,
namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never,
@@ -210,9 +221,9 @@ describe('edge joins', () => {
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
] as never,
})),
describeCredentials: refs => Promise.resolve(ok({
credentials: Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])),
})),
describeCredentials: refs => Promise.resolve(remoteOk(
Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])),
)),
})
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
await store.load()
@@ -226,7 +237,7 @@ describe('edge joins', () => {
})
it('surfaces a settings describe failure', async () => {
const { face, mirror } = api({ describeSettings: () => Promise.resolve(fail('settings down')) })
const { face, mirror } = api({ describeSettings: () => Promise.resolve(remoteFail('settings down')) })
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
await store.load()
expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' })
@@ -252,8 +263,8 @@ describe('edge joins', () => {
describeSettings: () => {
settingsCall += 1
return Promise.resolve(settingsCall === 1
? ok({ writable: true, hasDocument: false, namespaces: NAMESPACES })
: fail('settings refresh down'))
? remoteOk({ writable: true, hasDocument: false, namespaces: NAMESPACES })
: remoteFail('settings refresh down'))
},
})
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
@@ -29,8 +29,9 @@ afterEach(() => {
document.getElementById('root')?.remove()
})
function response<T>(value: T) {
return { rpcId: 'welcome-rpc' as never, result: { ok: true as const, value } }
/** The settings namespace answers over the Remote carrier, which has no envelope. */
function remoteAnswer<T>(value: T) {
return { ok: true as const, value }
}
function welcomeView(value: unknown, revision = 0) {
@@ -53,7 +54,7 @@ const useSessionPendingInteraction: WelcomeNoticeProps['useSessionPendingInterac
function mount(
version?: string,
mutateImpl: () => Promise<unknown> = () =>
Promise.resolve(response(welcomeView({ [WELCOME_NOTICE_ACK_FIELD]: WELCOME_NOTICE_VERSION }, 1))),
Promise.resolve(remoteAnswer(welcomeView({ [WELCOME_NOTICE_ACK_FIELD]: WELCOME_NOTICE_VERSION }, 1))),
) {
const appRoot = document.createElement('div')
appRoot.id = 'root'
@@ -61,7 +62,7 @@ function mount(
const mutate = vi.fn(mutateImpl)
const api = {
settings: {
describe: () => Promise.resolve(response({
describe: () => Promise.resolve(remoteAnswer({
writable: true,
hasDocument: false,
namespaces: [welcomeView(version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version })],
@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
import { Context } from '@deepseek-ai/cordis'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
@@ -11,9 +10,9 @@ import {
const schemaService = new SettingsSchemaService(new Context())
let rpc = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } }
/** The settings namespace answers over the Remote carrier, which has no envelope. */
function ok<T>(value: T) {
return { ok: true as const, value }
}
function namespace(value: unknown = {}, revision = 0) {
@@ -91,11 +90,11 @@ describe('WelcomeNoticeStore', () => {
await mirror.load()
await controller.load()
await expect(controller.acknowledge()).resolves.toBe(true)
expect(mutate).toHaveBeenCalledWith({
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
expectedRevision: 3,
})
expect(mutate).toHaveBeenCalledWith(
WELCOME_NOTICE_SETTINGS_NAMESPACE,
[{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
3,
)
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
// The write answer folded into the mirror; no re-read followed.
expect(describeCall).toHaveBeenCalledTimes(1)
@@ -32,7 +32,6 @@
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-api-remotes"
@@ -48,7 +47,6 @@
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
@@ -57,7 +55,6 @@
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
@@ -9,7 +9,6 @@
* settings scope, which keeps them unaware of one another and of other tabs.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: the settings shell's SlotMap merge (the 'settings.section' entry)
@@ -50,20 +49,20 @@ export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-co
const NS = 'settings.plugins'
/** Required services (cordis fiber inject). */
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope']
/**
* Mount the plugin configuration section and the cards this package ships.
* @param ctx - the browser plugin context.
*/
export function apply(ctx: ClientContext): void {
const { api } = ctx.get('connection') as ConnectionHandle
const t = ctx.locale.bind(NS)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugins: section dictionaries')
const bash = new BashCardController(ctx.settingsScope.bind({ namespace: SHELL_NS }))
const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS }))
const webSearch = new WebSearchCardController(ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), api)
const webSearch = new WebSearchCardController(
ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx.remote.credentials)
// The credential a card reports is not part of any settings section, so its
// scope publishes nothing when one is written. This is the only signal that
@@ -9,7 +9,7 @@
* covers everything the card shows.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
import {
@@ -39,13 +39,16 @@ export interface WebSearchSettings {
maxUses?: number
}
/** The credentials Remote methods this card reads and writes through. */
export type WebSearchCredentials = Pick<ClientRemote['credentials'], 'describe' | 'set'>
/** What the credentials domain last reported, and for which reference. */
interface CredentialState {
/** Reference this answer describes; a stale response for another one is dropped. */
ref: string
/** Whether any layer supplies a value for it. */
configured: boolean
/** Whether `credentials.set` can affect it; false disables the control. */
/** Whether `credentials/set` can affect it; false disables the control. */
writable: boolean
}
@@ -79,11 +82,11 @@ export class WebSearchCardController {
/**
* @param scope - the bound settings scope for the `web-search-deepseek` namespace.
* @param api - wire face used for the credential the section references.
* @param credentials - Remote face used for the credential the section references.
*/
constructor(
private readonly scope: SettingsScope<WebSearchSettings>,
private readonly api: Pick<IApiClient, 'credentials'>,
private readonly credentials: WebSearchCredentials,
) {
this.form = new CardForm(
scope,
@@ -122,16 +125,16 @@ export class WebSearchCardController {
this.credential = { ref, configured: false, writable: true }
this.store.set(this.projection())
}
let response: Awaited<ReturnType<IApiClient['credentials']['describe']>>
let response: Awaited<ReturnType<WebSearchCredentials['describe']>>
try {
response = await this.api.credentials.describe({ refs: [ref] })
response = await this.credentials.describe([ref])
} catch (_credentialReadFailure) {
// The card stays usable without this: the key control simply reports the
// last state it knew, and a write still reaches the Host.
return
}
if (!response.result.ok || ref !== refOf(this.scope.getSnapshot())) return
const view = response.result.value.credentials[ref]
if (!response.ok || ref !== refOf(this.scope.getSnapshot())) return
const view = response.value[ref]
const next: CredentialState = {
ref,
configured: view?.configured ?? false,
@@ -172,7 +175,7 @@ export class WebSearchCardController {
*/
private async writeKey(value: string): Promise<boolean> {
try {
await this.api.credentials.set({ ref: refOf(this.scope.getSnapshot()), value })
await this.credentials.set(refOf(this.scope.getSnapshot()), value)
} catch (_credentialWriteFailure) {
// Refusals surface through the re-read below: the Host is the only
// authority on whether the key now exists.
@@ -26,30 +26,24 @@ async function bench(served?: string[]) {
const locale = new LocaleRuntime(ctx)
locale.setLocale('zh')
ctx.provide('locale', locale)
const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } }))
const describeCredentials = vi.fn(() => Promise.resolve({ ok: false, error: { code: 'internal', message: 'no provider', details: {} } }))
const describeSettings = vi.fn(() => Promise.resolve(served === undefined
? { rpcId: 's', result: { ok: false, error: {} } }
? { ok: false, error: { code: 'internal', message: 'no provider', details: {} } }
: {
rpcId: 's',
result: {
ok: true,
value: {
writable: true,
hasDocument: true,
namespaces: served.map(ns => ({
ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0,
})),
},
ok: true,
value: {
writable: true,
hasDocument: true,
namespaces: served.map(ns => ({
ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0,
})),
},
}))
const remote = new TestRemote(ctx)
ctx.provide('connection', {
isLoopback: true,
api: {
settings: { describe: describeSettings },
credentials: { describe: describeCredentials },
},
} as never)
const remote = new TestRemote(ctx, {
credentials: { describe: describeCredentials, set: vi.fn() },
settings: { describe: describeSettings },
})
ctx.provide('connection', { isLoopback: true, api: {} } as never)
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote }
}
@@ -63,7 +57,7 @@ function declareRoot(slots: SlotRegistry): () => void {
describe('ui-settings-plugins apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope'])
})
it('registers one Plugins section and declares the tab and card slots', async () => {
@@ -30,11 +30,11 @@ function acceptWrites<T>(host: StubSettingsScope<T>): void {
function credentialsApi(configured: boolean) {
const describe = vi.fn(() => Promise.resolve({
rpcId: 'c-1' as never,
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured, writable: true } } } },
ok: true as const,
value: { DEEPSEEK_API_KEY: { configured, writable: true } },
}))
const set = vi.fn(() => Promise.resolve({ rpcId: 'c-2' as never, result: { ok: true as const, value: {} } }))
return { api: { credentials: { describe, set } } as never, describe, set }
const set = vi.fn(() => Promise.resolve({ ok: true as const, value: undefined }))
return { api: { describe, set } as never, describe, set }
}
describe('CardForm', () => {
@@ -412,13 +412,13 @@ describe('WebSearchCardController', () => {
expect(credentials.set).not.toHaveBeenCalled()
credentials.describe.mockImplementation(() => Promise.resolve({
rpcId: 'c-1' as never,
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } },
ok: true as const,
value: { DEEPSEEK_API_KEY: { configured: true, writable: true } },
}))
face.save()
await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
expect(credentials.set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'ds-secret' })
expect(credentials.set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'ds-secret')
expect(host.set).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ dirty: false, apiKeyConfigured: true })
@@ -454,8 +454,8 @@ describe('WebSearchCardController', () => {
// A key written on another surface reaches this card only through this signal.
credentials.describe.mockImplementation(() => Promise.resolve({
rpcId: 'c-1' as never,
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } },
ok: true as const,
value: { DEEPSEEK_API_KEY: { configured: true, writable: true } },
}))
controller.refreshCredential('DEEPSEEK_API_KEY')
@@ -475,7 +475,7 @@ describe('WebSearchCardController', () => {
face.save()
await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
expect(credentials.set).toHaveBeenCalledWith({ ref: 'SEARCH_KEY', value: 'ds-secret' })
expect(credentials.set).toHaveBeenCalledWith('SEARCH_KEY', 'ds-secret')
})
it('reports a key the Host did not store as a failed save', async () => {
@@ -497,7 +497,7 @@ describe('WebSearchCardController', () => {
const host = stubSettingsScope<WebSearchSettings>()
const describe = vi.fn(() => Promise.reject(new Error('offline')))
const set = vi.fn(() => Promise.reject(new Error('offline')))
const controller = new WebSearchCardController(host.scope, { credentials: { describe, set } } as never)
const controller = new WebSearchCardController(host.scope, { describe, set })
const face = controller.inject()
await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
@@ -516,10 +516,10 @@ describe('WebSearchCardController', () => {
it('ignores a credential read the Host refused', async () => {
const host = stubSettingsScope<WebSearchSettings>()
const describe = vi.fn(() => Promise.resolve({
rpcId: 'c-1' as never,
result: { ok: false as const, error: { code: 'credentials-unavailable', message: 'no provider' } },
ok: false as const,
error: { code: 'internal', message: 'no credential provider', details: {} },
}))
const controller = new WebSearchCardController(host.scope, { credentials: { describe, set: vi.fn() } } as never)
const controller = new WebSearchCardController(host.scope, { describe, set: vi.fn() })
await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false)
@@ -546,16 +546,13 @@ describe('WebSearchCardController', () => {
describe('ConfigurablePluginsTabController', () => {
function settingsApi(namespaces: string[]) {
const describe = vi.fn(() => Promise.resolve({
rpcId: 's-1' as never,
result: {
ok: true as const,
value: {
writable: true,
hasDocument: true,
namespaces: namespaces.map(ns => ({
ns, schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0,
})),
},
ok: true as const,
value: {
writable: true,
hasDocument: true,
namespaces: namespaces.map(ns => ({
ns, schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0,
})),
},
}))
return { mirror: new SettingsDescribeMirror({ settings: { describe } } as never), describe }
@@ -32,13 +32,15 @@ export type { SettingsScopeController, SettingsScopeBinder } from './settings-sc
export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-contract.ts'
export type { SettingsSchemaService } from './schema.ts'
export type { SchemaNode } from './schema.ts'
export type { SettingsDescribeFace, SettingsDescribeView, SettingsMirrorSnapshot } from './settings-mirror.ts'
export type {
SettingsDescribeFace, SettingsDescribeView, SettingsMirrorSnapshot, SettingsRemote, SettingsWireFace,
} from './settings-mirror.ts'
/**
* Required services: the wire handle for the mirror's reads and the forwarded
* settings invalidation the mirror refreshes on.
*/
export const inject = ['connection', 'remote']
export const inject = ['connection', 'remote', 'remote.settings']
/**
* Provide the settings-namespace scope service over one shared describe
@@ -52,10 +54,10 @@ export const inject = ['connection', 'remote']
export function apply(ctx: Context): void {
const schema = new SettingsSchemaService(ctx)
const connection = ctx.get('connection') as ConnectionHandle
const mirror = new SettingsDescribeMirror(
connection.api,
connection.isLoopback ? 'host' : 'memory',
)
// Captured once here, where `remote.settings` is declared in this plugin's
// own `inject`; the binder hands the same face to every scope it binds.
const wire = { settings: ctx.remote.settings }
const mirror = new SettingsDescribeMirror(wire, connection.isLoopback ? 'host' : 'memory')
ctx.effect(() => {
const disposers = [
ctx.remote.$on('settings/document-updated', () => { void mirror.load() }),
@@ -68,5 +70,5 @@ export function apply(ctx: Context): void {
void mirror.ensure()
return () => { for (const dispose of disposers) dispose() }
}, 'ui-settings: describe mirror invalidations')
new SettingsScopeBinder(ctx, { mirror, schema })
new SettingsScopeBinder(ctx, { mirror, schema, wire })
}
@@ -9,10 +9,24 @@
* through {@link SettingsDescribeMirror.acceptView}.
*/
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import type { ClientRemote, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
type SettingsFace = Pick<IApiClient, 'settings'>
/**
* The settings Remote methods browser configuration surfaces may reach: the
* redacted read plus merge, replacement, and path-addressed writes.
* Named once here so the consumers share one face instead of each re-deriving
* it from the namespace.
*/
export type SettingsRemote = Pick<ClientRemote['settings'], 'describe' | 'update' | 'replace' | 'mutate'>
/** Wire face carrying the settings Remote namespace. */
export interface SettingsWireFace {
/** The settings Remote namespace. */
settings: SettingsRemote
}
type SettingsFace = SettingsWireFace
/** The full `settings.describe` answer the mirror serves. */
export interface SettingsDescribeView {
@@ -180,10 +194,10 @@ export class SettingsDescribeMirror implements SettingsDescribeFace {
const generation = ++this.generation
let outcome: { view: SettingsDescribeView } | { failure: string }
try {
const response = await this.api.settings.describe({})
outcome = response.result.ok
? { view: response.result.value }
: { failure: response.result.error.message }
const response = await this.api.settings.describe()
outcome = response.ok
? { view: response.value }
: { failure: response.error.message }
} catch (error) {
outcome = { failure: error instanceof Error ? error.message : String(error) }
}
@@ -9,7 +9,7 @@
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConnectionHandle, IApiClient, SettingsNamespaceView, SettingsPathOpView,
ConnectionHandle, JsonValue, SettingsNamespaceView, SettingsPathOpView,
} from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
// Type-only, and deliberately NOT `@deepseek-ai/dsh-api-remotes/client`: this
@@ -30,9 +30,9 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types'
import type {} from '@deepseek-ai/dsh-settings/types'
import type { SettingsSchemaService } from './schema.ts'
import type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-contract.ts'
import { SettingsDescribeMirror, type SettingsDescribeFace } from './settings-mirror.ts'
import { SettingsDescribeMirror, type SettingsDescribeFace, type SettingsWireFace } from './settings-mirror.ts'
type SettingsFace = Pick<IApiClient, 'settings'>
type SettingsFace = SettingsWireFace
/**
* One namespace's derived view over the shared describe mirror, plus that
@@ -104,7 +104,7 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void> {
return this.write({ op: 'set', path: [field], value })
return this.write({ op: 'set', path: [field], value: value as JsonValue })
}
/**
@@ -123,25 +123,21 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
const revision = this.pendingRevision ?? this.getSnapshot().revision
let response: Awaited<ReturnType<SettingsFace['settings']['mutate']>>
try {
response = await this.api.settings.mutate({
ns: this.spec.namespace,
ops: [op],
...(revision === undefined ? {} : { expectedRevision: revision }),
})
response = await this.api.settings.mutate(this.spec.namespace, [op], revision)
} catch (_settingsWriteFailure) {
await this.recover(generation)
return
}
if (!response.result.ok) {
if (!response.ok) {
await this.recover(generation)
return
}
if (this.disposed) return
if (generation === this.writeGeneration) {
this.pendingRevision = undefined
this.mirror.acceptView(response.result.value)
this.mirror.acceptView(response.value)
} else {
this.pendingRevision = response.result.value.revision
this.pendingRevision = response.value.revision
}
})
}
@@ -235,16 +231,26 @@ declare module '@deepseek-ai/cordis' {
export class SettingsScopeBinder extends Service {
private readonly mirror: SettingsDescribeMirror
private readonly schema: SettingsSchemaService
private readonly wire: SettingsWireFace
/**
* @param ctx - the providing plugin's context.
* @param config - the shared describe mirror every bound scope derives from,
* plus the settings-owned schema operations.
* the settings-owned schema operations, and the settings Remote namespace the
* bound scopes write through. The namespace is captured here rather than read
* inside {@link bind}, because a Service reads `ctx` as its *consumer's*
* fiber: reading it there would make every caller declare `remote.settings`
* in its own `inject`.
*/
constructor(ctx: Context, config: { mirror: SettingsDescribeMirror; schema: SettingsSchemaService }) {
constructor(ctx: Context, config: {
mirror: SettingsDescribeMirror
schema: SettingsSchemaService
wire: SettingsWireFace
}) {
super(ctx, 'settingsScope')
this.mirror = config.mirror
this.schema = config.schema
this.wire = config.wire
}
/**
@@ -272,7 +278,7 @@ export class SettingsScopeBinder extends Service {
const ctx = this.ctx
const connection = ctx.get('connection') as ConnectionHandle
const controller = new SettingsScopeController<T>(
connection.api,
this.wire,
spec,
this.mirror,
connection.isLoopback ? 'host' : 'memory',
@@ -7,15 +7,11 @@ import { SettingsScopeBinder } from '../src/client/settings-scope.ts'
function bench() {
const describeCall = vi.fn().mockResolvedValue({
rpcId: 'plugin-bench' as never,
result: { ok: true, value: { writable: true, hasDocument: true, namespaces: [] } },
ok: true, value: { writable: true, hasDocument: true, namespaces: [] },
})
const ctx = new Context()
ctx.provide('connection', {
api: { settings: { describe: describeCall } },
isLoopback: true,
} as never)
const remote = new TestRemote(ctx)
ctx.provide('connection', { api: {}, isLoopback: true } as never)
const remote = new TestRemote(ctx, { settings: { describe: describeCall } })
return { ctx, describeCall, remote, fiber: ctx.plugin({ inject: [...inject], apply }) }
}
@@ -1,28 +1,25 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { SettingsDescribeMirror, type SettingsDescribeView } from '../src/client/settings-mirror.ts'
let rpc = 0
/** What a Remote call answers with: no carrier envelope, and a free-form failure code. */
type Answer<T> =
| { ok: true; value: T }
| { ok: false; error: { code: string; message: string; details: object } }
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `mirror-${rpc++}` as never, result: { ok: true, value } }
function ok<T>(value: T): Answer<T> {
return { ok: true, value }
}
function rejected<T>(message: string): RpcResponse<T> {
return {
rpcId: `mirror-${rpc++}` as never,
result: {
ok: false,
error: { code: 'settings-rejected', message, details: { ns: 'theme' } },
},
}
function rejected<T>(message: string): Answer<T> {
return { ok: false, error: { code: 'settings-rejected', message, details: { ns: 'theme' } } }
}
function view(ns: string, revision = 0): SettingsNamespaceView {
return { ns, schema: {}, value: { field: ns }, applies: 'live', secrets: [], revision }
}
function described(namespaces: SettingsNamespaceView[]): RpcResponse<SettingsDescribeView> {
function described(namespaces: SettingsNamespaceView[]): Answer<SettingsDescribeView> {
return ok({ writable: true, hasDocument: true, namespaces })
}
@@ -34,7 +31,7 @@ function deferred<T>() {
describe('SettingsDescribeMirror', () => {
it('folds loads before the wire read into it, and mid-flight loads into one rerun', async () => {
const gate = deferred<RpcResponse<SettingsDescribeView>>()
const gate = deferred<Answer<SettingsDescribeView>>()
const describeCall = vi.fn()
.mockReturnValueOnce(gate.promise)
.mockResolvedValue(described([view('theme', 1)]))
@@ -145,7 +142,7 @@ describe('SettingsDescribeMirror', () => {
})
it('starts no second run for a load issued inside the loading publish', async () => {
const gate = deferred<RpcResponse<SettingsDescribeView>>()
const gate = deferred<Answer<SettingsDescribeView>>()
const describeCall = vi.fn().mockReturnValue(gate.promise)
const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
let reentered = false
@@ -181,7 +178,7 @@ describe('SettingsDescribeMirror', () => {
})
it('re-reads after a folded write invalidates an in-flight document', async () => {
const slow = deferred<RpcResponse<SettingsDescribeView>>()
const slow = deferred<Answer<SettingsDescribeView>>()
const describeCall = vi.fn()
.mockResolvedValueOnce(described([view('theme', 4), view('locale', 1)]))
.mockReturnValueOnce(slow.promise)
@@ -200,7 +197,7 @@ describe('SettingsDescribeMirror', () => {
})
it('re-reads after a pre-answer write invalidates the in-flight document', async () => {
const slow = deferred<RpcResponse<SettingsDescribeView>>()
const slow = deferred<Answer<SettingsDescribeView>>()
const describeCall = vi.fn()
.mockReturnValueOnce(slow.promise)
.mockResolvedValueOnce(described([view('theme', 2)]))
@@ -1,7 +1,7 @@
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
import { SettingsSchemaService } from '../src/client/schema.ts'
@@ -18,26 +18,25 @@ const ENVELOPE = z.object({
preference: z.union(['light', 'dark', 'system']).default('system'),
}).toJSON()
let rpc = 0
/** What a Remote call answers with: no carrier envelope, and a free-form failure code. */
type Answer<T> =
| { ok: true; value: T }
| { ok: false; error: { code: string; message: string; details: object } }
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } }
function ok<T>(value: T): Answer<T> {
return { ok: true, value }
}
function rejected<T>(): RpcResponse<T> {
return {
rpcId: `scope-${rpc++}` as never,
result: {
ok: false,
error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } },
},
}
function rejected<T>(): Answer<T> {
return { ok: false, error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } } }
}
function view(value: unknown, revision = 0): SettingsNamespaceView {
function view(value: JsonValue, revision = 0): SettingsNamespaceView {
return {
ns: 'ui-test',
schema: ENVELOPE,
// `toJSON()` already produced the wire envelope; its declared type is the
// schema builder's, so one cast names what the Host actually sends.
schema: ENVELOPE as unknown as JsonValue,
value,
applies: 'live',
secrets: [],
@@ -45,7 +44,7 @@ function view(value: unknown, revision = 0): SettingsNamespaceView {
}
}
function described(value: unknown, revision = 0) {
function described(value: JsonValue, revision = 0) {
return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] })
}
@@ -148,7 +147,7 @@ describe('SettingsScopeController', () => {
})
it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => {
const first = deferred<RpcResponse<SettingsNamespaceView>>()
const first = deferred<Answer<SettingsNamespaceView>>()
const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4))
const mutate = vi.fn()
.mockReturnValueOnce(first.promise)
@@ -163,16 +162,16 @@ describe('SettingsScopeController', () => {
await Promise.all([dark, light])
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 })
expect(mutate).toHaveBeenNthCalledWith(1, {
ns: 'ui-test',
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
expectedRevision: 4,
})
expect(mutate).toHaveBeenNthCalledWith(2, {
ns: 'ui-test',
ops: [{ op: 'set', path: ['preference'], value: 'light' }],
expectedRevision: 5,
})
expect(mutate).toHaveBeenNthCalledWith(1,
'ui-test',
[{ op: 'set', path: ['preference'], value: 'dark' }],
4,
)
expect(mutate).toHaveBeenNthCalledWith(2,
'ui-test',
[{ op: 'set', path: ['preference'], value: 'light' }],
5,
)
})
it('folds the latest write answer into the mirror so a sibling scope sees it', async () => {
@@ -202,10 +201,11 @@ describe('SettingsScopeController', () => {
initial.resolve(described({ preference: 'system' }, 1))
await loading
expect(mutate).toHaveBeenCalledWith({
ns: 'ui-test',
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
})
expect(mutate).toHaveBeenCalledWith(
'ui-test',
[{ op: 'set', path: ['preference'], value: 'dark' }],
undefined,
)
expect(describeCall).toHaveBeenCalledTimes(2)
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 2 })
})
@@ -305,16 +305,16 @@ describe('SettingsScopeController', () => {
await expect(scope.set('preference', 'light')).resolves.toBeUndefined()
expect(mutate).toHaveBeenCalledTimes(2)
expect(mutate).toHaveBeenNthCalledWith(2, {
ns: 'ui-test',
ops: [{ op: 'set', path: ['preference'], value: 'light' }],
expectedRevision: 1,
})
expect(mutate).toHaveBeenNthCalledWith(2,
'ui-test',
[{ op: 'set', path: ['preference'], value: 'light' }],
1,
)
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 3 })
})
it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
const first = deferred<RpcResponse<SettingsNamespaceView>>()
const first = deferred<Answer<SettingsNamespaceView>>()
const mutate = vi.fn().mockReturnValue(first.promise)
const describeCall = vi.fn()
const { scope } = derivedScope({ describe: describeCall, mutate })
@@ -433,11 +433,11 @@ describe('SettingsScopeController', () => {
await scope.unset('preference')
expect(mutate).toHaveBeenCalledWith({
ns: 'ui-test',
ops: [{ op: 'unset', path: ['preference'] }],
expectedRevision: 3,
})
expect(mutate).toHaveBeenCalledWith(
'ui-test',
[{ op: 'unset', path: ['preference'] }],
3,
)
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 4 })
})
@@ -465,7 +465,7 @@ describe('SettingsScopeBinder.bind', () => {
let theme!: SettingsScope<UiTestSettings>
let locale!: SettingsScope<UiTestSettings>
new TestRemote(ctx)
await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema }).await()
await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema, wire: wire as never }).await()
expect(ctx.settingsScope.describe()).toBe(mirror)
const fiber = ctx.plugin({
inject: ['connection', 'remote', 'settingsScope'],
@@ -493,7 +493,7 @@ describe('SettingsScopeBinder.bind', () => {
ctx.provide('connection', { api: wire, isLoopback: false } as never)
let scope!: SettingsScope<UiTestSettings>
new TestRemote(ctx)
await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema }).await()
await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema, wire: wire as never }).await()
const fiber = ctx.plugin({
inject: ['connection', 'remote', 'settingsScope'],
apply: (plugin: Context) => {
@@ -42,22 +42,16 @@ async function bench(isLoopback = true) {
revision: 0,
})
const describe = vi.fn(() => Promise.resolve({
rpcId: 'theme-describe' as never,
result: {
ok: true as const,
value: { writable: true, hasDocument: true, namespaces: [namespace()] },
},
ok: true as const,
value: { writable: true, hasDocument: true, namespaces: [namespace()] },
}))
const mutate = vi.fn((request: { ops: { path: string[]; value: unknown }[] }) => {
const op = request.ops[0]!
const mutate = vi.fn((_ns: string, ops: { path: string[]; value: unknown }[]) => {
const op = ops[0]!
section[op.path[0]!] = op.value
return Promise.resolve({
rpcId: 'theme-mutate' as never,
result: { ok: true as const, value: namespace() },
})
return Promise.resolve({ ok: true as const, value: namespace() })
})
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never)
const events = new TestRemote(ctx)
ctx.provide('connection', { api: {}, isLoopback } as never)
const events = new TestRemote(ctx, { settings: { describe, mutate } })
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
return {
ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate, events,
@@ -186,7 +186,6 @@ class FakeApiClient implements IApiClient {
declare readonly skills: IApiClient['skills']
declare readonly agentPresets: IApiClient['agentPresets']
declare readonly settings: IApiClient['settings']
declare readonly credentials: IApiClient['credentials']
declare readonly llm: IApiClient['llm']
readonly host: IApiClient['host'] = {
@@ -42,6 +42,8 @@ export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot
export { FixtureSession, TestSessions } from './sessions.ts'
export { stubSettingsScope } from './settings-scope.ts'
export type { StubSettingsScope } from './settings-scope.ts'
export { scriptedSettingsRemote } from './settings-remote.ts'
export type { ScriptedNamespace, ScriptedSettingsRemote } from './settings-remote.ts'
export { TestWorkspaces } from './workspaces.ts'
export { TestRemote } from './remote.ts'
export {
@@ -4,12 +4,14 @@ import type { Context } from '@deepseek-ai/cordis'
/**
* Remote service test double for the forwarded-event path. Feature specs need
* `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded
* Host events to reach those subscribers, but not the generated namespaces or
* the wire so this double implements subscription plus an explicit `emit`
* driver available only on the concrete test object.
* Host events to reach those subscribers, but not the wire so this double
* implements subscription plus an explicit `emit` driver available only on the
* concrete test object. A spec that also calls one namespace scripts it through
* the constructor rather than reaching the real Client Remote service.
*
* `$mount` rejects: a spec that reaches a generated namespace through this
* double has outgrown it and needs the real Client Remote service.
* `$mount` rejects: a spec that needs a real generated contribution installed
* codecs, descriptors, and the wire has outgrown this double and needs the
* real Client Remote service.
*
* One deliberate asymmetry with production: a throwing listener propagates out
* of the emit instead of being contained and logged, so a spec cannot lean on
@@ -20,11 +22,22 @@ export class TestRemote {
private readonly subscriptions = new Map<string, Set<(...args: never[]) => void>>()
/**
* Register the double as `ctx.remote`.
* Register the double as `ctx.remote`, plus one service per scripted
* namespace so a plugin injecting `remote.<name>` also unparks.
* @param ctx - the spec's root Context.
* @param namespaces - scripted namespace faces reached as `ctx.remote.<name>`.
*/
constructor(ctx: Context) {
constructor(ctx: Context, namespaces: Readonly<Record<string, object>> = {}) {
for (const name of Object.keys(namespaces)) {
// A namespace named after one of the double's own members would replace
// it, and `$mount`'s rejection is the contract a spec relies on.
if (name in TestRemote.prototype || name === 'subscriptions') {
throw new TypeError(`TestRemote: scripted namespace "${name}" would shadow the double's own member`)
}
}
Object.assign(this, namespaces)
ctx.provide('remote', this)
for (const [name, face] of Object.entries(namespaces)) ctx.provide(`remote.${name}`, face)
}
/**
@@ -0,0 +1,85 @@
/** Test double for the `settings` Remote namespace a bench's plugins inject. */
import { vi } from 'vitest'
/** The minimum a scripted namespace view carries for the double's own bookkeeping. */
export interface ScriptedNamespace {
/** Namespace key the write addresses. */
ns: string
}
/** One scripted `settings` namespace face plus the controls a bench drives it with. */
export interface ScriptedSettingsRemote<View extends ScriptedNamespace> {
/**
* The namespace face handed to `TestRemote` as `settings`. A plugin injecting
* `remote.settings` unparks on it, which is what most benches need; the
* describe answer is the same one the shared mirror would read.
*/
settings: {
describe(): Promise<{ ok: true; value: { writable: boolean; hasDocument: boolean; namespaces: readonly View[] } }>
update(ns: string, patch: unknown, expectedRevision: number | undefined): Promise<
| { ok: true; value: View }
| { ok: false; error: { code: string; message: string; details: object } }
>
replace(ns: string, section: unknown, expectedRevision: number | undefined): Promise<
| { ok: true; value: View }
| { ok: false; error: { code: string; message: string; details: object } }
>
mutate(ns: string, ops: unknown, expectedRevision: number | undefined): Promise<
| { ok: true; value: View }
| { ok: false; error: { code: string; message: string; details: object } }
>
}
/** Spy behind `settings.update`, for argument assertions. */
update: ReturnType<typeof vi.fn>
/** Spy behind `settings.replace`, for argument assertions. */
replace: ReturnType<typeof vi.fn>
/** Spy behind `settings.mutate`, for argument assertions. */
mutate: ReturnType<typeof vi.fn>
/**
* Replace what the next describe answers with, as a Host commit would.
* @param namespaces - the namespace views to serve from now on.
*/
publish(namespaces: readonly View[]): void
}
/**
* Build a scripted `settings` Remote namespace for a bench. Each write answers
* with the addressed namespace unchanged, so a bench that only needs its
* plugins to activate scripts nothing; one asserting a write reads the
* corresponding spy or replaces the face.
* @param namespaces - namespace views the first describe answers with.
* @param options - deployment facts the describe answer reports.
* @returns the face and its controls.
*/
export function scriptedSettingsRemote<View extends ScriptedNamespace>(
namespaces: readonly View[] = [],
options: { writable?: boolean; hasDocument?: boolean } = {},
): ScriptedSettingsRemote<View> {
let served = namespaces
const writable = options.writable ?? true
const hasDocument = options.hasDocument ?? false
const answer = (ns: string) => {
const view = served.find(candidate => candidate.ns === ns)
return Promise.resolve(view === undefined
? {
ok: false as const,
error: { code: 'settings-rejected', message: `no scripted namespace "${ns}"`, details: { ns } },
}
: { ok: true as const, value: view })
}
const update = vi.fn((ns: string, _patch: unknown, _expectedRevision: number | undefined) => answer(ns))
const replace = vi.fn((ns: string, _section: unknown, _expectedRevision: number | undefined) => answer(ns))
const mutate = vi.fn((ns: string, _ops: unknown, _expectedRevision: number | undefined) => answer(ns))
return {
settings: {
describe: () => Promise.resolve({ ok: true as const, value: { writable, hasDocument, namespaces: served } }),
update: (ns, patch, expectedRevision) => update(ns, patch, expectedRevision),
replace: (ns, section, expectedRevision) => replace(ns, section, expectedRevision),
mutate: (ns, ops, expectedRevision) => mutate(ns, ops, expectedRevision),
},
update,
replace,
mutate,
publish(next) { served = next },
}
}
@@ -6,6 +6,7 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { TestRemote } from '../src/remote.ts'
import { scriptedSettingsRemote } from '../src/settings-remote.ts'
describe('TestRemote', () => {
it('delivers a forwarded event to its subscribers and stops after disposal', async () => {
@@ -40,4 +41,58 @@ describe('TestRemote', () => {
await expect(remote.$mount()).rejects.toThrow('needs the real Client Remote service')
await ctx.fiber.dispose()
})
it('reaches a scripted namespace as ctx.remote.<name> and as its own service', async () => {
const ctx = new Context()
const credentials = { describe: () => Promise.resolve({ ok: true as const, value: {} }) }
const remote = new TestRemote(ctx, { credentials })
expect((remote as unknown as { credentials: unknown }).credentials).toBe(credentials)
expect(ctx.get('remote.credentials')).toBe(credentials)
await ctx.fiber.dispose()
})
it('refuses a scripted namespace that would shadow one of its own members', async () => {
const ctx = new Context()
// Accepting this would replace the very refusal the case above pins.
expect(() => new TestRemote(ctx, { $mount: {} })).toThrow('would shadow')
expect(() => new TestRemote(ctx, { subscriptions: {} })).toThrow('would shadow')
await ctx.fiber.dispose()
})
})
describe('scriptedSettingsRemote', () => {
it('serves, writes, and replaces its scripted namespace list', async () => {
const first = { ns: 'first', revision: 1 }
const second = { ns: 'second', revision: 2 }
const remote = scriptedSettingsRemote([first])
await expect(remote.settings.describe()).resolves.toEqual({
ok: true,
value: { writable: true, hasDocument: false, namespaces: [first] },
})
await expect(remote.settings.update('first', {}, undefined)).resolves.toEqual({ ok: true, value: first })
await expect(remote.settings.replace('missing', {}, undefined)).resolves.toMatchObject({
ok: false,
error: { code: 'settings-rejected', details: { ns: 'missing' } },
})
await expect(remote.settings.mutate('first', [], undefined)).resolves.toEqual({ ok: true, value: first })
expect(remote.update).toHaveBeenCalledWith('first', {}, undefined)
expect(remote.replace).toHaveBeenCalledWith('missing', {}, undefined)
expect(remote.mutate).toHaveBeenCalledWith('first', [], undefined)
remote.publish([second])
await expect(remote.settings.describe()).resolves.toEqual({
ok: true,
value: { writable: true, hasDocument: false, namespaces: [second] },
})
})
it('reports explicit deployment facts', async () => {
const remote = scriptedSettingsRemote([], { writable: false, hasDocument: true })
await expect(remote.settings.describe()).resolves.toEqual({
ok: true,
value: { writable: false, hasDocument: true, namespaces: [] },
})
})
})