diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 82079543d3..2474825ffd 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -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((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([]) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index c89df410bc..cf7a720ac8 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -317,17 +317,6 @@ async function bootPreview(origin: string, browser: Browser): Promise { type Result = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } } interface PreviewApi { skills: { list(payload: { sessionId: string }): Promise> } - settings: { - describe(payload: object): Promise }>> - update(payload: { ns: string; patch: object; expectedRevision: number }): Promise> - } - credentials: { - set(payload: { ref: string; value: string }): Promise> - unset(payload: { ref: string }): Promise> - describe(payload: { refs: string[] }): Promise - }>> - } } interface PreviewTransport { fetch(input: string, init: RequestInit): Promise @@ -373,22 +362,44 @@ async function bootPreview(origin: string, browser: Browser): Promise { 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 (endpoint: string, args: object): Promise => { + 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 + 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>( + '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) diff --git a/apps/web/tests/startup-rpc-budget.e2e.ts b/apps/web/tests/startup-rpc-budget.e2e.ts index 59faec6512..f6655ffbf3 100644 --- a/apps/web/tests/startup-rpc-budget.e2e.ts +++ b/apps/web/tests/startup-rpc-budget.e2e.ts @@ -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) }) }) diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 836c54e2ab..aef2bcc642 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -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:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 473abfbe88..8638c54b9f 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -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> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - agentPresetsRemote, commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, + agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, dynamicRemote, + fileReferencesRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, subagentsRemote, sessionRemote, workspaceRemote, ]) { diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 9d738aa3f8..56f664d499 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -66,6 +66,9 @@ { "path": "../session-controller/tsconfig.client.json" }, + { + "path": "../settings-controller" + }, { "path": "../workspace-controller/tsconfig.client.json" }, diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index 98d7196358..a0eae81529 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -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'] = { diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 395e443ea3..2be6924acf 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -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:^" } } diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 113e72212c..4d4826da79 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -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, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index c3bb6b503d..02eb11fe9c 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -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 { + 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 { + return { + ok: false, + error: { + code: 'settings-rejected', + message: 'fixture: the minimal readiness settings descriptor is read-only', + details: { ns }, + }, + } + }, + replace(ns: string): ConnectionRpcResult { + return { + ok: false, + error: { + code: 'settings-rejected', + message: 'fixture: the minimal readiness settings descriptor is read-only', + details: { ns }, + }, + } + }, + mutate(ns: string): ConnectionRpcResult { + // 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> { + 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 { + fixtureCredentials.set(ref, true) + return { ok: true, value: undefined } + }, + unset(ref: string): RpcResult { + 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[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) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 8136254f1c..c8b66fbc77 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -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, diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index c4bd0d03ff..a2cb5f78b8 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -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'] = { diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index b69d775510..de8cbf42d9 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -297,6 +297,8 @@ type FixtureTestApi = ReturnType['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 } @@ -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> + set(ref: string, value: string): Promise> + unset(ref: string): Promise> +} + +/** The settings Remote reads the fixture serves, addressed like the credential half. */ +interface FixtureSettingsRemote { + describe(): Promise> + update(ns: string, patch: unknown, expectedRevision?: number): Promise> + replace(ns: string, section: unknown, expectedRevision?: number): Promise> +} + +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> => { + const result = await api.credentialRemote.describe(refs) + if (!result.ok) throw new Error('credential describe failed') + return result.value as Record + } + 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 () => { diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 1cead76b09..4a5121b97b 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -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) diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index cb409edde0..6d5533c9b7 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -33,6 +33,12 @@ { "path": "../../core/tools" }, + { + "path": "../../credentials/credentials" + }, + { + "path": "../../settings/settings" + }, { "path": "../../host/apiproxy" }, diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index a63f97269e..71cf197ca3 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -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, diff --git a/packages/client/locale/tests/document-language.client.spec.ts b/packages/client/locale/tests/document-language.client.spec.ts index 89ef9823bb..b75502116e 100644 --- a/packages/client/locale/tests/document-language.client.spec.ts +++ b/packages/client/locale/tests/document-language.client.spec.ts @@ -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 } diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index b8bcecdcce..dda3c9d26e 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -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() }) diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index 0d034cd833..099d92b186 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -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 = createSnapshotStore(INITIAL) constructor( - private readonly api: Pick, + private readonly api: SettingsWireFace & Pick, private readonly remote: Pick, /** * Called after this page changes the roster DIRECTORY, so the other diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index cbae273243..398b781e67 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -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, + api: SettingsWireFace, id: string, ): Promise { 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, + private readonly api: SettingsWireFace, private readonly remote: Pick, private readonly describeFace: SettingsDescribeFace, ) {} diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 39936daa80..2cde10fc4e 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -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 () => { diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index ca253c69fa..8d925f4978 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -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 { +): SettingsWireFace & Pick { 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 + } as unknown as SettingsWireFace & Pick } /** @@ -579,7 +580,7 @@ describe('deleting', () => { await controller.load() presets.clear() const broken = new AgentPresetSectionController( - { agentPresets: {}, settings: {}, host: {} } as unknown as Pick, + { agentPresets: {}, settings: {}, host: {} } as unknown as SettingsWireFace & Pick, { agentPresets: { list: () => Promise.reject(new Error('gone')), diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 74ef828a35..4d8544dfcf 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -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 } @@ -28,7 +29,7 @@ import { AgentPresetSeatController } from '../src/client/seat-store.ts' type SeatSession = Pick -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 }]), }) diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index cc0fc0cfb6..037c6d15bd 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -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:^", diff --git a/packages/client/ui-permission-presets/src/client/index.ts b/packages/client/ui-permission-presets/src/client/index.ts index 404d201039..3b2a405951 100644 --- a/packages/client/ui-permission-presets/src/client/index.ts +++ b/packages/client/ui-permission-presets/src/client/index.ts @@ -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 => controller.load() const select = (preset: string): Promise => controller.select(preset) const injected = (): PermissionRowInjected => ({ diff --git a/packages/client/ui-permission-presets/src/client/settings-store.ts b/packages/client/ui-permission-presets/src/client/settings-store.ts index 2015cfe0b9..a139df2a7c 100644 --- a/packages/client/ui-permission-presets/src/client/settings-store.ts +++ b/packages/client/ui-permission-presets/src/client/settings-store.ts @@ -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, + 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 diff --git a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts index ef41a39c00..1d43249c75 100644 --- a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts @@ -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: { diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index 5ab109883c..de7f6d4411 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -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(value: T) { - return { rpcId: 'test', result: { ok: true as const, value } } + return { ok: true as const, value } } const dictionary: Record = 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: {} }, }), }, }) diff --git a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts index 8ddc1033ea..b607bcea99 100644 --- a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts @@ -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(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 diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index d26af3c58a..bf2174d571 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -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 } } diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 286a2d52c9..9c2271db4b 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -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, diff --git a/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts index 93b7825ba2..85c55c6ad0 100644 --- a/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts +++ b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts @@ -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 { - 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', () => { diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index 1c5b447c62..89da5433ef 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -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 } } diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index 836fc8efdc..024173af2b 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -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.`; 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 + 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 } diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index a425677489..c27f5379a8 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -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 + api: ModelsWire /** Settings schema and immutable path callbacks. */ schema: SettingsSchemaOperations /** Feature copy. */ diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 6776a097dd..b12fb9fc01 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -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 + 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, + api: Pick, controller: ModelsSettingsStore, target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string }, ): Promise { 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. diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index eb7a67cd03..675c852125 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -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 `_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 + 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>(() => draftAt(schema, namespace, settingsPath)) const [keyDraft, setKeyDraft] = useState('') - const [keyState, setKeyState] = useState(undefined) + const [keyState, setKeyState] = useState(undefined) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(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 diff --git a/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx index 3b62f28e60..8f69f41087 100644 --- a/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx +++ b/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx @@ -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 + 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) => { diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index 3633674b15..b975c91f1f 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -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, }) diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index df5bcb5dcd..d3b71ec6ea 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -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 + +/** + * 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 { + /** 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 `_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, + 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 = {} + let credentials: Record = {} 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) } diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index c4d569f77b..7045e4831d 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -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({ diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 1a72d7c444..512a6573e5 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -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(message: string, code = 'settings-rejected'): RpcResponse { result: { ok: false, error: { code, message, details: { ns: 'x' } } as never }, } } +/** Credentials answers over the Remote carrier, which has no envelope. */ +function remoteOk(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 - replace?: ReturnType mutate?: ReturnType set?: ReturnType unset?: ReturnType } = {}) { 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[0] @@ -218,7 +222,7 @@ function cardSeatCalls( } async function mountFace(scripted: ReturnType) { - 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) { renderSlot: renderSlot as unknown as ModelsSectionProps['renderSlot'], } const view = render() - 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[0] = {}) { @@ -245,10 +249,10 @@ async function mountSection(overrides: Parameters[0] = {}) */ async function mountFirstRun(overrides: Parameters[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('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( , @@ -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( { 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(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>) => void) | undefined - const set = vi.fn(() => new Promise>>((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(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(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(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(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(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(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) => void - const mutate = vi.fn(() => new Promise>((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[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[0], diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index e849904345..d45736cd6d 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -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(value: T): RpcResponse { return { rpcId: `onboarding-${nextRpc++}` as never, result: { ok: true, value } } } -function fail(message: string): RpcResponse { - 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(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, }, } diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 89c30f710b..e6168398af 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -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(value: T): RpcResponse { function fail(message: string, code: string): RpcResponse { 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(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, - userProviders: Record = providers, - baseProviders: Record = {}, + providers: Record, + userProviders: Record = providers, + baseProviders: Record = {}, ): 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 + providers?: Record /** User layer, when it differs from the effective section. */ - userProviders?: Record + userProviders?: Record /** Composition layer, for a route a `cordis.yml` pins rather than the page. */ - baseProviders?: Record + baseProviders?: Record /** Routes the adapter reports as hand-declared; the rest come back as shipped. */ declaredRoutes?: readonly string[] discover?: ReturnType @@ -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): 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): 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[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 () => { diff --git a/packages/client/ui-settings-models/tests/readiness.client.spec.ts b/packages/client/ui-settings-models/tests/readiness.client.spec.ts index f01e821767..3bea0c072d 100644 --- a/packages/client/ui-settings-models/tests/readiness.client.spec.ts +++ b/packages/client/ui-settings-models/tests/readiness.client.spec.ts @@ -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 { return { diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index c477c25993..677ca14418 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -13,6 +13,17 @@ function fail(message: string): RpcResponse { 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 = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: { code: string; message: string; details: object } } +function remoteOk(value: T): RemoteAnswer { + return { ok: true, value } +} +function remoteFail(message: string): RemoteAnswer { + 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> - describeSettings?: () => Promise> - describeCredentials?: (refs: string[]) => Promise }>> + describeSettings?: () => Promise> + describeCredentials?: (refs: readonly string[]) => Promise>> } = {}) { 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) diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index c9fe3498ce..1a48af9d1c 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -29,8 +29,9 @@ afterEach(() => { document.getElementById('root')?.remove() }) -function response(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(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 = () => - 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 })], diff --git a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts index efcb2aefdb..d0927e34f0 100644 --- a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts @@ -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(value: T): RpcResponse { - return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } } +/** The settings namespace answers over the Remote carrier, which has no envelope. */ +function ok(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) diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 7edd06ff4e..75b87d26fb 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -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:^", diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index aababb7d89..11dc449b3e 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -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 diff --git a/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts b/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts index e329359230..924ba688d5 100644 --- a/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts @@ -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 + /** 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, - private readonly api: Pick, + 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> + let response: Awaited> 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 { 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. diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index b1ecdfa132..bba43edcbc 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -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 () => { diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index 396678698b..505079be0c 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -30,11 +30,11 @@ function acceptWrites(host: StubSettingsScope): 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() 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() 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 } diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 66eca29c22..23f289e527 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -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 }) } diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts index bd4aedfc16..f7a6c15a22 100644 --- a/packages/client/ui-settings/src/client/settings-mirror.ts +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -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 +/** + * 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 + +/** 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) } } diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 1b06940577..d4ddd632e5 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -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 +type SettingsFace = SettingsWireFace /** * One namespace's derived view over the shared describe mirror, plus that @@ -104,7 +104,7 @@ export class SettingsScopeController implements SettingsScope { * @returns settlement after the write and any latest-write recovery read. */ set(field: string, value: unknown): Promise { - 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 implements SettingsScope { const revision = this.pendingRevision ?? this.getSnapshot().revision let response: Awaited> 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( - connection.api, + this.wire, spec, this.mirror, connection.isLoopback ? 'host' : 'memory', diff --git a/packages/client/ui-settings/tests/plugin.client.spec.ts b/packages/client/ui-settings/tests/plugin.client.spec.ts index ebd385b78d..a5a7a85053 100644 --- a/packages/client/ui-settings/tests/plugin.client.spec.ts +++ b/packages/client/ui-settings/tests/plugin.client.spec.ts @@ -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 }) } } diff --git a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts index 6439a3000e..92a5c0fa11 100644 --- a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts @@ -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 = + | { ok: true; value: T } + | { ok: false; error: { code: string; message: string; details: object } } -function ok(value: T): RpcResponse { - return { rpcId: `mirror-${rpc++}` as never, result: { ok: true, value } } +function ok(value: T): Answer { + return { ok: true, value } } -function rejected(message: string): RpcResponse { - return { - rpcId: `mirror-${rpc++}` as never, - result: { - ok: false, - error: { code: 'settings-rejected', message, details: { ns: 'theme' } }, - }, - } +function rejected(message: string): Answer { + 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 { +function described(namespaces: SettingsNamespaceView[]): Answer { return ok({ writable: true, hasDocument: true, namespaces }) } @@ -34,7 +31,7 @@ function deferred() { describe('SettingsDescribeMirror', () => { it('folds loads before the wire read into it, and mid-flight loads into one rerun', async () => { - const gate = deferred>() + const gate = deferred>() 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>() + const gate = deferred>() 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>() + const slow = deferred>() 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>() + const slow = deferred>() const describeCall = vi.fn() .mockReturnValueOnce(slow.promise) .mockResolvedValueOnce(described([view('theme', 2)])) diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index 0f3933f61d..b6788f0dba 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -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 = + | { ok: true; value: T } + | { ok: false; error: { code: string; message: string; details: object } } -function ok(value: T): RpcResponse { - return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } } +function ok(value: T): Answer { + return { ok: true, value } } -function rejected(): RpcResponse { - return { - rpcId: `scope-${rpc++}` as never, - result: { - ok: false, - error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } }, - }, - } +function rejected(): Answer { + 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>() + const first = deferred>() 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>() + const first = deferred>() 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 let locale!: SettingsScope 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 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) => { diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index 121444af93..b030523f43 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -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, diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 95d113bbfd..dc37fd7c14 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -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'] = { diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 4c19296f65..6a0b88387f 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -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 { diff --git a/packages/test-support/client-runtime/src/remote.ts b/packages/test-support/client-runtime/src/remote.ts index d90baf7e0f..9a0249245c 100644 --- a/packages/test-support/client-runtime/src/remote.ts +++ b/packages/test-support/client-runtime/src/remote.ts @@ -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 void>>() /** - * Register the double as `ctx.remote`. + * Register the double as `ctx.remote`, plus one service per scripted + * namespace so a plugin injecting `remote.` also unparks. * @param ctx - the spec's root Context. + * @param namespaces - scripted namespace faces reached as `ctx.remote.`. */ - constructor(ctx: Context) { + constructor(ctx: Context, namespaces: Readonly> = {}) { + 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) } /** diff --git a/packages/test-support/client-runtime/src/settings-remote.ts b/packages/test-support/client-runtime/src/settings-remote.ts new file mode 100644 index 0000000000..0a98a8f621 --- /dev/null +++ b/packages/test-support/client-runtime/src/settings-remote.ts @@ -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 { + /** + * 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 + /** Spy behind `settings.replace`, for argument assertions. */ + replace: ReturnType + /** Spy behind `settings.mutate`, for argument assertions. */ + mutate: ReturnType + /** + * 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( + namespaces: readonly View[] = [], + options: { writable?: boolean; hasDocument?: boolean } = {}, +): ScriptedSettingsRemote { + 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 }, + } +} diff --git a/packages/test-support/client-runtime/tests/remote.client.spec.ts b/packages/test-support/client-runtime/tests/remote.client.spec.ts index df376a83eb..1cc3e0a372 100644 --- a/packages/test-support/client-runtime/tests/remote.client.spec.ts +++ b/packages/test-support/client-runtime/tests/remote.client.spec.ts @@ -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. 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: [] }, + }) + }) })