mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(client): use settings Remote namespaces
This commit is contained in:
@@ -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([])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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'] = {
|
||||
|
||||
@@ -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:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [] },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user