Merge upstream master into feat/produced-files-folder

This commit is contained in:
ZiyaZhang
2026-08-11 05:22:59 -07:00
265 changed files with 3387 additions and 1747 deletions

View File

@@ -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: 69634d4ca577e9fa5c508a5fb2b50333290154b1
README.zh.md: 9e03cc1903b5e9dc1d13e07bf8394a6e7aee9209
README.md: be04f56ac5151c756aa6d4e2233461cc173fe261
README.zh.md: 372922b8b02f694512505c97bf08b9ab480912a5

View File

@@ -2,7 +2,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, list and scope state, and the shared event window and history paging used by registered conversation view targets. 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 Session and Workspace owners 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. Contract: api-contracts v3 §4. 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.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. 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 Session and Workspace owners and hands each generic `host/remote-event` frame to `ctx.remote.$dispatch`; domain packages subscribe to their owner events through `ctx.remote.$on` and decide which caches or session rows they invalidate. 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. Contract: api-contracts v3 §4. 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.
## Slot declaration injection

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把注册表失效帧桥接为类型化 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 scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把每个通用 `host/remote-event` 帧交给 `ctx.remote.$dispatch`;各领域包通过 `ctx.remote.$on` 订阅自身 owner 事件,并自行决定使哪些缓存或会话行失效。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定api-contracts v3 §4。每个 `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、默认值与实时服务归领域包所有而非把产品政策放入运行时。
## Slot 声明注入

View File

@@ -33,7 +33,8 @@
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-typert-registry"
"@deepseek-ai/dsh-typert-registry",
"@deepseek-ai/dsh-api-gateway"
],
"platform": "web",
"immediately": true
@@ -44,7 +45,6 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
@@ -59,19 +59,20 @@
"zustand": "~4.4.7"
},
"peerDependencies": {
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
"@types/react": "~18.3.1"
},
"files": [
"lib/index.js",

View File

@@ -0,0 +1,63 @@
/**
* The settings-namespace scope contract. The type lives here, in the common
* dependency of every feature that owns a preference, while the implementation
* and its Host transport live with the Settings surface
* (`dsh-client-ui-settings`): a feature service accepts a scope through
* `attachSettings` without depending on the surface that binds it, which would
* otherwise close a reference cycle.
*/
/** Client-side sync state of one settings namespace. */
export interface SettingsScopeSnapshot<T> {
/**
* `loading` until the first accepted section, `ready` while one stands, and
* `unavailable` when the namespace is not exposed to this client or the
* connection keeps preferences process-local (memory mode).
*/
status: 'loading' | 'ready' | 'unavailable'
/** Last accepted schema-resolved section; undefined before the first acceptance. */
value: T | undefined
/** 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. */
writable: boolean
/** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */
mode: 'host' | 'memory'
}
/** Domain-owned description of one settings namespace consumed by a browser plugin. */
export interface SettingsScopeSpec<T> {
/** Settings namespace registered by the owning Host plugin. */
namespace: string
/**
* Narrow one wire section; undefined keeps the last accepted value. The
* default validates the section against the namespace's own serialized wire
* schema, so domains add a decoder only to narrow beyond that schema.
*/
decode?: (section: unknown) => T | undefined
}
/**
* Reactive owner handle over one namespace's durable section — the browser
* mirror of the Host-side `SettingsScope` owner seam. Domain services read
* and observe the snapshot and route explicit user choices through `set`.
*/
export interface SettingsScope<T> {
/** @returns the current sync snapshot (stable reference until the next change). */
getSnapshot(): SettingsScopeSnapshot<T>
/**
* Observe snapshot replacements.
* @param listener - invoked after each snapshot change.
* @returns the disposer removing this listener.
*/
subscribe(listener: () => void): () => void
/**
* Queue one field write. Rapid writes preserve mutation order, each carries
* the latest known namespace revision, and only the latest settlement may
* publish; a rejected or failed latest write reloads Host state instead.
* @param field - scalar field inside the namespace section.
* @param value - JSON-shaped value selected by the user.
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void>
}

View File

@@ -1,6 +1,10 @@
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
import type { Context } from '@deepseek-ai/cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather
// than api-remotes': that face imports a Host-tsdown-generated artifact, and this
// project sits in the Host build graph.
import type {} from '@deepseek-ai/dsh-api-gateway/client'
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from './slots.ts'
@@ -42,9 +46,12 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export { bindSettingsScope, SettingsScopeController } from './settings-scope.ts'
export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-scope.ts'
export { resolveWorkspacePath } from './workspaces/path.ts'
// Contract only: the scope implementation and its Host transport belong to
// dsh-client-ui-settings (see that package's settings-scope.ts).
export type {
SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec,
} from './contract/settings-scope.ts'
export type { Session } from './sessions/session.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type { AgentContext, ISessions } from './contract/sessions.ts'
@@ -150,47 +157,6 @@ declare module '@deepseek-ai/cordis' {
* @param key - the mutated SlotMap key.
*/
'slots/changed'(key: string): void
/**
* The host command registry changed (host/commands-changed passthrough).
* Pure invalidation signal: subscribers refetch `command.list` in the
* background rather than diffing.
* @mode emit
*/
'commands/changed'(): void
/**
* One settings namespace's resolved value changed on the host
* (host/settings-changed passthrough). Subscribers refetch
* `settings.describe`; the frame carries no values.
* @mode emit
* @param ns - the namespace whose resolved value changed.
*/
'settings/changed'(ns: string): void
/**
* One credential reference's state changed on the host
* (host/credentials-changed passthrough). The ref is an
* environment-variable NAME — never a value.
* @mode emit
* @param ref - the reference whose configured state changed.
*/
'credentials/changed'(ref: string): void
/**
* The host provider topology changed (host/models-changed passthrough).
* Subscribers refetch `llm.providers`/`llm.models`/`session.models`.
* @mode emit
*/
'models/changed'(): void
/**
* One session's agent preset changed (host/session-preset-changed
* passthrough), so everything its composition decides — the command
* catalog, the skill catalog — is stale for that session and no other.
* Every connected client observes it, not only the one that issued the
* switch. Subscribers refetch their own session-keyed caches; the frame
* carries no catalog.
* @mode emit
* @param sessionId - the session whose composition changed.
* @param agentPreset - the preset it now runs.
*/
'session/preset-changed'(sessionId: SessionId, agentPreset: string): void
/**
* A connection generation was (re-)established. Wire-derived caches must
* treat their state as stale and repull (commands directory; the queue
@@ -213,7 +179,7 @@ declare module '@deepseek-ai/cordis' {
}
/** Required services: the wire handle and Client TypeRT registry. */
export const inject = ['connection', 'typert']
export const inject = ['connection', 'typert', 'remote']
/** Mounts the browser runtime services and connection stream.
* @param ctx - Client Cordis context.
@@ -241,17 +207,12 @@ export function apply(ctx: Context): void {
onHostEnvelope: (envelope) => {
sessions.handleHostEnvelope(envelope)
workspaces.handleHostEnvelope(envelope)
// Typed-event bridge: the session layer ignores registry frames (no
// session routing); consumers (command directory caches, the settings
// and model services) subscribe on ctx.
// Forwarded-event bridge: the session layer ignores registry frames (no
// session routing). This plugin owns the frame sink, so it hands the
// decoded frame straight to the Remote service, which fans it out to
// `ctx.remote.$on` subscribers; no consumer reads a frame.
const frame = envelope.payload
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
else if (frame.type === 'host/session-preset-changed') {
ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset)
}
else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
if (frame.type === 'host/remote-event') ctx.remote.$dispatch(frame.event, frame.args)
},
onConnected: () => {
sessions.handleConnected()

View File

@@ -800,14 +800,6 @@ export class SessionManager {
}
return
}
case 'host/session-preset-changed': {
// Every connected client observes the switch here; only the tab that
// issued it also gets the RPC echo. The merge keeps the row's own
// updatedAt and lowers `blank` only, so re-applying the switching
// tab's own frame is a no-op.
this.noteAgentPreset(frame.sessionId, frame.agentPreset)
return
}
case 'host/session-removed': {
const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)

View File

@@ -1,261 +0,0 @@
/** Host-backed settings-namespace synchronization for browser plugins. */
import type { Context } from '@deepseek-ai/cordis'
import type {
ConnectionHandle, IApiClient, SettingsNamespaceView,
} 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'
/** Client-side sync state of one settings namespace. */
export interface SettingsScopeSnapshot<T> {
/**
* `loading` until the first accepted section, `ready` while one stands, and
* `unavailable` when the namespace is not exposed to this client or the
* connection keeps preferences process-local (memory mode).
*/
status: 'loading' | 'ready' | 'unavailable'
/** Last accepted schema-resolved section; undefined before the first acceptance. */
value: T | undefined
/** 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. */
writable: boolean
/** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */
mode: 'host' | 'memory'
}
/** Domain-owned description of one settings namespace consumed by a browser plugin. */
export interface SettingsScopeSpec<T> {
/** Settings namespace registered by the owning Host plugin. */
namespace: string
/**
* Narrow one wire section; undefined keeps the last accepted value. The
* default validates the section against the namespace's own serialized wire
* schema, so domains add a decoder only to narrow beyond that schema.
*/
decode?: (section: unknown) => T | undefined
}
/**
* Reactive owner handle over one namespace's durable section — the browser
* mirror of the Host-side `SettingsScope` owner seam. Domain services read
* and observe the snapshot and route explicit user choices through `set`.
*/
export interface SettingsScope<T> {
/** @returns the current sync snapshot (stable reference until the next change). */
getSnapshot(): SettingsScopeSnapshot<T>
/**
* Observe snapshot replacements.
* @param listener - invoked after each snapshot change.
* @returns the disposer removing this listener.
*/
subscribe(listener: () => void): () => void
/**
* Queue one field write. Rapid writes preserve mutation order, each carries
* the latest known namespace revision, and only the latest settlement may
* publish; a rejected or failed latest write reloads Host state instead.
* @param field - scalar field inside the namespace section.
* @param value - JSON-shaped value selected by the user.
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void>
}
type SettingsFace = Pick<IApiClient, 'settings'>
/**
* Serializes one namespace's Host reads and writes behind a snapshot store.
* Reads never block plugin activation; writes carry the latest known
* namespace revision and teardown waits for the operation already crossing
* the wire.
*/
export class SettingsScopeController<T> implements SettingsScope<T> {
private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
private tail: Promise<void> = Promise.resolve()
private readGeneration = 0
private writeGeneration = 0
private disposed = false
/**
* @param api - settings wire face.
* @param spec - namespace identity and optional narrowing decoder.
* @param persistence - remote browsers remain process-local because settings RPCs are loopback-only.
*/
constructor(
private readonly api: SettingsFace,
private readonly spec: SettingsScopeSpec<T>,
private readonly persistence: 'host' | 'memory' = 'host',
) {
this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
status: persistence === 'host' ? 'loading' : 'unavailable',
value: undefined,
revision: undefined,
writable: false,
mode: persistence,
})
}
/** @returns the current sync snapshot (stable reference until the next change). */
getSnapshot(): SettingsScopeSnapshot<T> {
return this.store.getSnapshot()
}
/**
* Observe snapshot replacements.
* @param listener - invoked after each snapshot change.
* @returns the disposer removing this listener.
*/
subscribe(listener: () => void): () => void {
return this.store.subscribe(listener)
}
/**
* Queue a Host refresh; a newer read or user write suppresses stale publication.
* @returns settlement after the queued read completes or is skipped.
*/
load(): Promise<void> {
const generation = ++this.readGeneration
return this.enqueue(() => this.read(generation))
}
/**
* Queue one field write; see {@link SettingsScope.set} for the ordering,
* revision, and recovery contract.
* @param field - scalar field inside the namespace section.
* @param value - JSON-shaped value selected by the user.
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void> {
this.readGeneration += 1
const generation = ++this.writeGeneration
return this.enqueue(async () => {
const revision = this.getSnapshot().revision
let response: Awaited<ReturnType<SettingsFace['settings']['mutate']>>
try {
response = await this.api.settings.mutate({
ns: this.spec.namespace,
ops: [{ op: 'set', path: [field], value }],
...(revision === undefined ? {} : { expectedRevision: revision }),
})
} catch (_settingsWriteFailure) {
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
return
}
if (!response.result.ok) {
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
return
}
this.accept(response.result.value, generation === this.writeGeneration)
})
}
/**
* Stop queued operations and wait for the current wire call to settle.
* @returns settlement after the controller reaches quiescence.
*/
async dispose(): Promise<void> {
this.disposed = true
this.readGeneration += 1
this.writeGeneration += 1
await this.tail
}
private enqueue(operation: () => Promise<void>): Promise<void> {
if (this.persistence === 'memory' || this.disposed) return Promise.resolve()
const task = this.tail.then(async () => {
if (this.disposed) return
await operation()
})
// The returned task carries its own settlement to the caller; the queue
// tail is kept fulfilled so one failed subscriber cannot strand later operations.
this.tail = task.catch(() => {})
return task
}
private async read(generation: number): Promise<void> {
let response: Awaited<ReturnType<SettingsFace['settings']['describe']>>
try {
response = await this.api.settings.describe({})
} catch (_settingsReadFailure) {
return
}
if (!response.result.ok || this.disposed) return
const { namespaces, writable } = response.result.value
const view = namespaces.find(candidate => candidate.ns === this.spec.namespace)
const publish = generation === this.readGeneration
if (view === undefined) {
if (publish) {
this.store.update((draft) => {
draft.status = 'unavailable'
draft.writable = writable
})
}
return
}
this.accept(view, publish, writable)
}
private accept(view: SettingsNamespaceView, publish: boolean, writable?: boolean): void {
const decoded = publish ? this.decode(view) : undefined
this.store.update((draft) => {
draft.revision = view.revision
if (writable !== undefined) draft.writable = writable
if (decoded === undefined) return
draft.status = 'ready'
draft.value = decoded
})
}
private decode(view: SettingsNamespaceView): T | undefined {
if (this.spec.decode !== undefined) return this.spec.decode(view.value)
// Sections are plain objects by construction; schemastery alone would
// resolve null or an array through object defaults instead of refusing.
if (typeof view.value !== 'object' || view.value === null || Array.isArray(view.value)) return undefined
let failure: string | undefined
try {
failure = validateDraft(rehydrateSchema(view.schema), view.value)
} catch (_malformedSchemaEnvelope) {
// A schema envelope this client cannot rehydrate vouches for no section;
// the value is treated exactly like a schema-invalid one.
return undefined
}
return failure === undefined ? view.value as T : undefined
}
}
/**
* Bind one namespace scope to settings and connection invalidations on the
* caller's plugin lifecycle. Listeners exist before the initial background
* read starts, so activation never blocks on the settings transport.
* @param ctx - owning browser plugin context.
* @param spec - domain-owned namespace contract.
* @returns the bound scope consumed by the domain's services and rows.
*/
export function bindSettingsScope<T>(
ctx: Context,
spec: SettingsScopeSpec<T>,
): SettingsScope<T> {
const connection = ctx.get('connection') as ConnectionHandle
const controller = new SettingsScopeController<T>(
connection.api,
spec,
connection.isLoopback ? 'host' : 'memory',
)
ctx.effect(() => {
const refresh = (namespace?: string): void => {
if (namespace !== undefined && namespace !== spec.namespace) return
void controller.load()
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('connection/reset', () => { refresh() }),
]
void controller.load()
return async () => {
for (const dispose of disposers) dispose()
await controller.dispose()
}
}, `runtime: ${spec.namespace} settings scope`)
return controller
}

View File

@@ -87,23 +87,6 @@ describe('list store projection', () => {
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
})
it('learns a preset switch from the host frame, not only from the tab that issued it', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
// Every connected client gets this frame; only the switching tab gets the
// RPC echo. A client that ignored the payload would keep labelling the
// session with the composition it replaced.
b.svc.handleHostEnvelope({
rpcId: 'r1' as never,
payload: { type: 'host/session-preset-changed', sessionId: sid('s1'), agentPreset: 'minimal' } as never,
})
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.blank).toBe(true)
})
it('reflects live increments (host stream via manager) into the store', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])

View File

@@ -1,352 +0,0 @@
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-client-connection/client'
import {
bindSettingsScope, SettingsScopeController, type SettingsScope,
} from '../src/client/settings-scope.ts'
interface UiTestSettings {
preference: 'light' | 'dark' | 'system'
}
const ENVELOPE = z.object({
preference: z.union(['light', 'dark', 'system']).default('system'),
}).toJSON()
let rpc = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } }
}
function rejected<T>(): RpcResponse<T> {
return {
rpcId: `scope-${rpc++}` as never,
result: {
ok: false,
error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } },
},
}
}
function view(value: unknown, revision = 0): SettingsNamespaceView {
return {
ns: 'ui-test',
schema: ENVELOPE,
value,
applies: 'live',
secrets: [],
revision,
}
}
function described(value: unknown, revision = 0) {
return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] })
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
/** Record each distinct published section, starting from the current one. */
function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings | undefined> {
const seen: Array<UiTestSettings | undefined> = [scope.getSnapshot().value]
scope.subscribe(() => {
const value = scope.getSnapshot().value
if (value !== seen[seen.length - 1]) seen.push(value)
})
return seen
}
describe('SettingsScopeController', () => {
it('starts loading and publishes a schema-valid section with revision and writability', async () => {
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
expect(scope.getSnapshot()).toEqual({
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
})
await scope.load()
expect(scope.getSnapshot()).toEqual({
status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host',
})
})
it('keeps the last good value across invalid, rejected, and failed reads while tracking revisions', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'dark' }, 3))
.mockResolvedValueOnce(described({ preference: 'sepia' }, 4))
.mockResolvedValueOnce(described(null, 5))
.mockResolvedValueOnce(described('scalar', 6))
.mockResolvedValueOnce(described(['queue'], 7))
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
const good = trackValues(scope)
for (let i = 0; i < 7; i++) await scope.load()
expect(scope.getSnapshot()).toMatchObject({
status: 'ready', value: { preference: 'dark' }, revision: 7,
})
expect(good).toEqual([undefined, { preference: 'dark' }])
})
it('treats a schema envelope it cannot rehydrate as vouching for no section', async () => {
const broken = { ...view({ preference: 'dark' }, 2), schema: null }
const describeCall = vi.fn()
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] }))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
})
it('suppresses a superseded read of an unexposed namespace', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
const statuses: string[] = []
scope.subscribe(() => { statuses.push(scope.getSnapshot().status) })
const stale = scope.load()
const fresh = scope.load()
await Promise.all([stale, fresh])
expect(statuses).not.toContain('unavailable')
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
})
it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
expect(scope.getSnapshot().status).toBe('ready')
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } })
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 })
})
it('applies a custom decode override in place of the wire schema', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
.mockResolvedValueOnce(described({ preference: 'dark' }, 2))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{
namespace: 'ui-test',
decode: section => (section as UiTestSettings).preference === 'dark'
? section as UiTestSettings
: undefined,
},
)
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 })
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 })
})
it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => {
const first = deferred<RpcResponse<SettingsNamespaceView>>()
const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4))
const mutate = vi.fn()
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
await scope.load()
const dark = scope.set('preference', 'dark')
const light = scope.set('preference', 'light')
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
first.resolve(ok(view({ preference: 'dark' }, 5)))
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,
})
})
it('recovers the latest rejected or thrown write from Host state', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
.mockResolvedValueOnce(described({ preference: 'light' }, 3))
const mutate = vi.fn()
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
await scope.set('preference', 'dark')
await scope.set('preference', 'system')
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
})
it('does not recover superseded rejected or thrown writes', async () => {
const describeCall = vi.fn()
const mutate = vi.fn()
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
await Promise.all([
scope.set('preference', 'dark'),
scope.set('preference', 'system'),
scope.set('preference', 'light'),
])
expect(describeCall).not.toHaveBeenCalled()
expect(published.map(section => section?.preference)).toEqual([undefined, 'light'])
})
it('keeps the write queue usable when a subscriber throws', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
let thrown = false
scope.subscribe(() => {
if (thrown) return
thrown = true
throw new Error('subscriber failed')
})
await expect(scope.load()).rejects.toThrow('subscriber failed')
await expect(scope.load()).resolves.toBeUndefined()
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 })
})
it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
const first = deferred<RpcResponse<SettingsNamespaceView>>()
const mutate = vi.fn().mockReturnValue(first.promise)
const describeCall = vi.fn()
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
const dark = scope.set('preference', 'dark')
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
const light = scope.set('preference', 'light')
let stopped = false
const stop = scope.dispose().then(() => { stopped = true })
await Promise.resolve()
expect(stopped).toBe(false)
first.resolve(ok(view({ preference: 'dark' }, 1)))
await Promise.all([dark, light, stop])
await scope.set('preference', 'system')
await scope.load()
expect(mutate).toHaveBeenCalledOnce()
expect(describeCall).not.toHaveBeenCalled()
expect(published).toEqual([undefined])
})
it('keeps a remote browser in memory mode without Host calls', async () => {
const describeCall = vi.fn()
const mutate = vi.fn()
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
'memory',
)
expect(scope.getSnapshot()).toEqual({
status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory',
})
await scope.load()
await scope.set('preference', 'dark')
await scope.dispose()
expect(describeCall).not.toHaveBeenCalled()
expect(mutate).not.toHaveBeenCalled()
})
})
describe('bindSettingsScope', () => {
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
const initial = deferred<ReturnType<typeof described>>()
const describeCall = vi.fn()
.mockReturnValueOnce(initial.promise)
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
.mockResolvedValueOnce(described({ preference: 'system' }, 3))
const ctx = new Context()
ctx.provide('connection', {
api: { settings: { describe: describeCall } },
isLoopback: true,
} as never)
let scope!: SettingsScope<UiTestSettings>
const fiber = ctx.plugin({
inject: ['connection'],
apply: (plugin: Context) => {
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
},
})
await fiber.await()
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() })
ctx.emit('settings/changed', 'unrelated')
ctx.emit('settings/changed', 'ui-test')
ctx.emit('connection/reset')
initial.resolve(described({ preference: 'dark' }, 1))
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
await vi.waitFor(() => {
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 })
})
await fiber.dispose()
ctx.emit('settings/changed', 'ui-test')
await Promise.resolve()
expect(describeCall).toHaveBeenCalledTimes(3)
})
it('binds a remote browser in memory mode without starting a settings read', async () => {
const describeCall = vi.fn()
const ctx = new Context()
ctx.provide('connection', {
api: { settings: { describe: describeCall } },
isLoopback: false,
} as never)
let scope!: SettingsScope<UiTestSettings>
const fiber = ctx.plugin({
inject: ['connection'],
apply: (plugin: Context) => {
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
},
})
await fiber.await()
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false })
await fiber.dispose()
expect(describeCall).not.toHaveBeenCalled()
})
})

View File

@@ -1,26 +1,63 @@
/**
* Wire-to-typed-event bridge: host/commands-changed
* → ctx 'commands/changed'; host/session-preset-changed →
* ctx 'session/preset-changed'; each established connection generation
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
* Wire-to-typed-event bridge: a `host/remote-event` frame is handed verbatim to
* the Remote service's `$dispatch` (its fan-out to `ctx.remote.$on` is
* api-gateway's own coverage); each established connection generation emits
* `connection/reset` for generation-scoped cache invalidation.
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
// Type-only: the api-remotes facade carries both the allowlist's selection seat
// and the owner packages' `./types` declarations, which together give `$on` its
// key face and per-event listener signatures.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import * as RuntimeClient from '../src/client/index.ts'
import { FakeApiClient } from './fake-api.ts'
/**
* Compile-time face of `ctx.remote.$on`, asserted by type-checking this file
* rather than by running it: the allowlist narrows the key set, and each
* listener's parameters come from the owner package's own cordis `Events`
* declaration (so a brand cannot be flattened on the way to a consumer).
* @param ctx - any client Context carrying the Remote service.
*/
function forwardedEventContracts(ctx: Context): void {
ctx.remote.$on('settings/document-updated', (namespace, source) => {
// @ts-expect-error -- the brand survives the wire: a bare string is not a SettingsNamespace
const bare: typeof namespace = 'plain-string'
void bare; void namespace; void source
})
ctx.remote.$on('credentials/updated', () => {})
ctx.remote.$on('commands/change', () => {})
ctx.remote.$on('llm/adapters-updated', () => {})
ctx.remote.$on('agent-preset/selected', (sessionId, agentPreset) => {
void sessionId; void agentPreset
})
// @ts-expect-error -- client-local event outside the allowlist
ctx.remote.$on('slots/changed', () => {})
// @ts-expect-error -- declared host event the allowlist does not select
ctx.remote.$on('skills/change', () => {})
}
void forwardedEventContracts
interface Bench {
ctx: Context
sinks: ConnectionSinks | undefined
/** Every `$dispatch` the runtime made, as `[event, ...args]`. */
dispatched: unknown[][]
}
async function mount(): Promise<Bench> {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
const api = new FakeApiClient()
const bench: Bench = { ctx, sinks: undefined }
const bench: Bench = { ctx, sinks: undefined, dispatched: [] }
// Stands in for api-gateway's Remote service: this spec owns the carrier's
// handoff, not the fan-out behind it.
ctx.reflect.provide('remote', {
$dispatch: (event: string, args: readonly unknown[]) => { bench.dispatched.push([event, ...args]) },
})
const handle: ConnectionHandle = {
api,
isLoopback: true,
@@ -37,50 +74,52 @@ async function mount(): Promise<Bench> {
},
}
ctx.reflect.provide('connection', handle)
ctx.reflect.provide('remote', {})
await ctx.plugin(RuntimeClient).await()
return bench
}
describe('wire event bridge', () => {
it('broadcasts commands/changed on a host/commands-changed frame, not on other host frames', async () => {
it('republishes a forwarded host event verbatim, and routes no other host frame there', async () => {
const bench = await mount()
let changed = 0
bench.ctx.on('commands/changed', () => { changed++ })
bench.sinks?.onHostEnvelope?.({ rpcId: 'r1' as never, payload: { type: 'host/commands-changed' } })
expect(changed).toBe(1)
const seen = bench.dispatched
bench.sinks?.onHostEnvelope?.({
rpcId: 'r1' as never,
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
})
expect(seen).toEqual([['commands/change']])
bench.sinks?.onHostEnvelope?.({
rpcId: 'r2' as never,
payload: { type: 'host/session-status', sessionId: 's1' as never, running: true },
})
expect(changed).toBe(1)
expect(seen).toEqual([['commands/change']])
})
it('broadcasts the settings/credentials/models invalidations with their frame payloads', async () => {
it('carries each forwarded event name with its own argument list, unfiltered', async () => {
const bench = await mount()
const seen: unknown[][] = []
bench.ctx.on('settings/changed', ns => seen.push(['settings', ns]))
bench.ctx.on('credentials/changed', ref => seen.push(['credentials', ref]))
bench.ctx.on('models/changed', () => seen.push(['models']))
bench.sinks?.onHostEnvelope?.({ rpcId: 'r3' as never, payload: { type: 'host/settings-changed', ns: 'llm-pi-ai' } })
bench.sinks?.onHostEnvelope?.({ rpcId: 'r4' as never, payload: { type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' } })
bench.sinks?.onHostEnvelope?.({ rpcId: 'r5' as never, payload: { type: 'host/models-changed' } })
expect(seen).toEqual([
['settings', 'llm-pi-ai'],
['credentials', 'OPENAI_API_KEY'],
['models'],
])
})
const seen = bench.dispatched
it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => {
const bench = await mount()
const seen: Array<[string, string]> = []
bench.ctx.on('session/preset-changed', (sessionId, agentPreset) => { seen.push([sessionId, agentPreset]) })
bench.sinks?.onHostEnvelope?.({
rpcId: 'r1' as never,
payload: { type: 'host/session-preset-changed', sessionId: 's1' as never, agentPreset: 'minimal' },
rpcId: 'r3' as never,
payload: { type: 'host/remote-event', event: 'settings/document-updated', args: ['llm-pi-ai', 7] },
})
expect(seen).toEqual([['s1', 'minimal']])
bench.sinks?.onHostEnvelope?.({
rpcId: 'r4' as never,
payload: { type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
})
// The carrier does not second-guess the name: selecting what a consumer can
// receive is the allowlist's job, and dropping an unsubscribed name is the
// Remote service's. This plugin republishes whatever the frame carried.
bench.sinks?.onHostEnvelope?.({
rpcId: 'r5' as never,
payload: { type: 'host/remote-event', event: 'nobody/listening', args: ['ignored'] },
})
expect(seen).toEqual([
['settings/document-updated', 'llm-pi-ai', 7],
['credentials/updated', 'OPENAI_API_KEY'],
['nobody/listening', 'ignored'],
])
})
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {

View File

@@ -23,9 +23,6 @@
{
"path": "../connection"
},
{
"path": "../schema-form"
},
{
"path": "../../host/apiproxy"
},
@@ -61,6 +58,9 @@
},
{
"path": "../../typert/registry"
},
{
"path": "../../api/gateway"
}
],
"exclude": [