diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 7dc91083c9..8ab833eaf5 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 1ec6cc38aed1bebff6b6ecb40faee7ae3ba9e412 -README.zh.md: 6602152790a1d433371e27b274a4eb8c9e3cfcd8 +README.md: b605fd2adc13ab6a1a4b727d116fc4e8b9973b10 +README.zh.md: efd86b9d5deb21bdec298d305c1fef0e687ce6f4 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 1ec6cc38ae..b605fd2adc 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. -`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime. +`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, the composition `base` and raw `user` layers, revision, writability, host/memory mode), serializes `set` and `unset` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. A field is overridden when it is PRESENT in `user` — an override equal to the composition default is still an override, which comparing values could not see — and `unset` is how a form clears one back to `base`. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime. ## Slot declaration injection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 6602152790..efd86b9d5d 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -4,7 +4,7 @@ 客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 -`bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。 +`bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、组装 `base` 层与原始 `user` 层、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 与 `unset` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。字段是否被覆盖,取决于它是否**出现**在 `user` 中——与组装默认值相同的覆盖仍然是覆盖,比较值是看不出来的——而 `unset` 就是表单把某个字段清回 `base` 的方式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。 ## Slot 声明注入 diff --git a/packages/client/runtime/src/client/settings-scope.ts b/packages/client/runtime/src/client/settings-scope.ts index 91b6c7ec3a..e441371359 100644 --- a/packages/client/runtime/src/client/settings-scope.ts +++ b/packages/client/runtime/src/client/settings-scope.ts @@ -2,7 +2,7 @@ import type { Context } from 'cordis' import type { - ConnectionHandle, IApiClient, SettingsNamespaceView, + ConnectionHandle, IApiClient, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-client-connection/client' import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form' import { createSnapshotStore, type SnapshotStore } from './contract/store.ts' @@ -17,6 +17,17 @@ export interface SettingsScopeSnapshot { status: 'loading' | 'ready' | 'unavailable' /** Last accepted schema-resolved section; undefined before the first acceptance. */ value: T | undefined + /** + * Composition layer the Host resolved {@link value} over, when the owning + * plugin declared one. What a field reverts to once cleared. + */ + base: unknown + /** + * Raw user layer as stored, when one exists. A field's PRESENCE here is what + * marks it overridden — an override whose value equals the composition + * default is still an override, and comparing values could not see it. + */ + user: unknown /** Namespace revision fencing the next write; undefined before the first Host view. */ revision: number | undefined /** Whether the Host document accepts writes; memory mode never does. */ @@ -60,6 +71,13 @@ export interface SettingsScope { * @returns settlement after the write and any latest-write recovery read. */ set(field: string, value: unknown): Promise + /** + * Queue one field clear, so the field re-inherits the composition layer. + * Shares {@link set}'s ordering, revision, and recovery contract. + * @param field - scalar field inside the namespace section. + * @returns settlement after the clear and any latest-write recovery read. + */ + unset(field: string): Promise } type SettingsFace = Pick @@ -90,6 +108,8 @@ export class SettingsScopeController implements SettingsScope { this.store = createSnapshotStore>({ status: persistence === 'host' ? 'loading' : 'unavailable', value: undefined, + base: undefined, + user: undefined, revision: undefined, writable: false, mode: persistence, @@ -127,6 +147,20 @@ 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 }) + } + + /** + * Queue one field clear; see {@link SettingsScope.unset} for the ordering, + * revision, and recovery contract. + * @param field - scalar field inside the namespace section. + * @returns settlement after the clear and any latest-write recovery read. + */ + unset(field: string): Promise { + return this.write({ op: 'unset', path: [field] }) + } + + private write(op: SettingsPathOpView): Promise { this.readGeneration += 1 const generation = ++this.writeGeneration return this.enqueue(async () => { @@ -135,7 +169,7 @@ export class SettingsScopeController implements SettingsScope { try { response = await this.api.settings.mutate({ ns: this.spec.namespace, - ops: [{ op: 'set', path: [field], value }], + ops: [op], ...(revision === undefined ? {} : { expectedRevision: revision }), }) } catch (_settingsWriteFailure) { @@ -200,6 +234,8 @@ export class SettingsScopeController implements SettingsScope { const decoded = publish ? this.decode(view) : undefined this.store.update((draft) => { draft.revision = view.revision + draft.base = view.base + draft.user = view.user if (writable !== undefined) draft.writable = writable if (decoded === undefined) return draft.status = 'ready' diff --git a/packages/client/runtime/tests/settings-scope.spec.ts b/packages/client/runtime/tests/settings-scope.spec.ts index db980bf6d1..168ed7e784 100644 --- a/packages/client/runtime/tests/settings-scope.spec.ts +++ b/packages/client/runtime/tests/settings-scope.spec.ts @@ -293,6 +293,77 @@ describe('SettingsScopeController', () => { expect(describeCall).not.toHaveBeenCalled() expect(mutate).not.toHaveBeenCalled() }) + + it('carries the composition base and the user layer into the snapshot', async () => { + const layered: SettingsNamespaceView = { + ...view({ preference: 'dark' }, 3), + base: { preference: 'system' }, + user: { preference: 'dark' }, + } + const describeCall = vi.fn() + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [layered] })) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + + await scope.load() + + expect(scope.getSnapshot()).toMatchObject({ + value: { preference: 'dark' }, + base: { preference: 'system' }, + user: { preference: 'dark' }, + }) + }) + + it('reports an inherited field as absent from the user layer', async () => { + const inherited: SettingsNamespaceView = { ...view({ preference: 'system' }, 1), base: { preference: 'system' } } + const describeCall = vi.fn() + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [inherited] })) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + + await scope.load() + + expect(scope.getSnapshot().user).toBeUndefined() + }) + + it('clears one field through an unset op fenced by the held revision', async () => { + const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'system' }, 4))) + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + ) + await scope.load() + + await scope.unset('preference') + + expect(mutate).toHaveBeenCalledWith({ + ns: 'ui-test', + ops: [{ op: 'unset', path: ['preference'] }], + expectedRevision: 3, + }) + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 4 }) + }) + + it('recovers the Host state when the latest clear is refused', async () => { + const mutate = vi.fn().mockResolvedValueOnce(rejected()) + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' }, 3)) + .mockResolvedValueOnce(described({ preference: 'light' }, 5)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + ) + await scope.load() + + await scope.unset('preference') + + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 5 }) + }) }) describe('bindSettingsScope', () => { diff --git a/packages/client/test-runtime/src/settings-scope.ts b/packages/client/test-runtime/src/settings-scope.ts index c901221018..377ed84210 100644 --- a/packages/client/test-runtime/src/settings-scope.ts +++ b/packages/client/test-runtime/src/settings-scope.ts @@ -8,6 +8,8 @@ export interface StubSettingsScope { scope: SettingsScope /** Spy behind `scope.set`; resolves immediately. */ set: ReturnType + /** Spy behind `scope.unset`; resolves immediately. */ + unset: ReturnType /** @returns how many listeners are currently subscribed (disposal assertions). */ listenerCount(): number /** @@ -25,10 +27,12 @@ export interface StubSettingsScope { */ export function stubSettingsScope(): StubSettingsScope { let snapshot: SettingsScopeSnapshot = { - status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host', + status: 'loading', value: undefined, base: undefined, user: undefined, + revision: undefined, writable: false, mode: 'host', } const listeners = new Set<() => void>() const set = vi.fn(() => Promise.resolve()) + const unset = vi.fn(() => Promise.resolve()) return { scope: { getSnapshot: () => snapshot, @@ -37,8 +41,10 @@ export function stubSettingsScope(): StubSettingsScope { return () => { listeners.delete(listener) } }, set, + unset, }, set, + unset, listenerCount: () => listeners.size, publish: (next) => { snapshot = { ...snapshot, ...next } diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 05827a63a1..e991bb4dc5 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -7,6 +7,7 @@ * stack — this suite is the fixture the migrated feature specs rely on. */ import { afterEach, describe, expect, it, vi } from 'vitest' +import { stubSettingsScope } from '../src/settings-scope.ts' import { cleanup } from '@testing-library/react' import { defineStore } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' @@ -635,3 +636,32 @@ describe('single-slot mounting edge arms', () => { await runtime.dispose() }) }) + +describe('stubbed settings scope', () => { + it('records both write kinds and publishes a Host acceptance to its listeners', async () => { + const host = stubSettingsScope<{ preference: string }>() + let notified = 0 + const stop = host.scope.subscribe(() => { notified += 1 }) + expect(host.listenerCount()).toBe(1) + expect(host.scope.getSnapshot()).toMatchObject({ + status: 'loading', base: undefined, user: undefined, + }) + + await host.scope.set('preference', 'dark') + await host.scope.unset('preference') + host.publish({ + status: 'ready', + value: { preference: 'system' }, + base: { preference: 'system' }, + revision: 2, + writable: true, + }) + + expect(host.set).toHaveBeenCalledWith('preference', 'dark') + expect(host.unset).toHaveBeenCalledWith('preference') + expect(notified).toBe(1) + expect(host.scope.getSnapshot()).toMatchObject({ status: 'ready', revision: 2, writable: true }) + stop() + expect(host.listenerCount()).toBe(0) + }) +})