From 29d6066870fd35488b4bf60f9237939dd8b0def1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 16:46:14 +0800 Subject: [PATCH 01/34] feat(ui-settings): add SettingsDescribeMirror single describe source --- .../ui-settings/src/client/settings-mirror.ts | 164 ++++++++++++++++++ .../tests/settings-mirror.client.spec.ts | 140 +++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 packages/client/ui-settings/src/client/settings-mirror.ts create mode 100644 packages/client/ui-settings/tests/settings-mirror.client.spec.ts diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts new file mode 100644 index 0000000000..a1dfe8c5a8 --- /dev/null +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -0,0 +1,164 @@ +/** + * Client mirror of the Host settings document: the one `settings.describe` + * reader in the browser. Every settings consumer derives from this store — + * per-namespace scopes through `SettingsScopeBinder.bind`, cross-namespace + * surfaces through the binder's read-only describe face — so startup cost and + * freshness are properties of this class, not of how many features own a + * preference. The Host stays the fact source: the mirror re-reads on the + * invalidations its owning plugin subscribes to and folds write answers in + * through {@link SettingsDescribeMirror.acceptView}. + */ + +import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' + +type SettingsFace = Pick + +/** The full `settings.describe` answer the mirror serves. */ +export interface SettingsDescribeView { + /** Every namespace a live Host plugin registered, as the Host reported it. */ + namespaces: readonly SettingsNamespaceView[] + /** Whether the settings provider accepts writes. */ + writable: boolean + /** Whether a native settings document exists for the Host to open. */ + hasDocument: boolean +} + +/** Mirror state every derived settings surface renders from. */ +export interface SettingsMirrorSnapshot { + /** + * `unavailable` is the terminal non-loopback state; `ready` persists across + * later failed refreshes (the held view keeps serving); `idle` means no + * answer is held and no read is running, so `ensure` will start one. + */ + status: 'idle' | 'loading' | 'ready' | 'unavailable' + /** The last good answer; undefined until the first success. */ + view: SettingsDescribeView | undefined + /** The latest refresh failure message, cleared by the next success. */ + error: string | null +} + +/** + * Serializes every Host `settings.describe` read behind one snapshot store. + * Concurrent {@link load} calls fold into the in-flight read plus one rerun, + * so an invalidation arriving mid-read is never lost and never duplicated. + */ +export class SettingsDescribeMirror { + private readonly store: SnapshotStore + private inFlight: Promise | undefined + private rerun = false + private generation = 0 + + /** + * @param api - settings wire face. + * @param persistence - remote browsers stay process-local because settings RPCs are loopback-only. + */ + constructor( + private readonly api: SettingsFace, + private readonly persistence: 'host' | 'memory' = 'host', + ) { + this.store = createSnapshotStore({ + status: persistence === 'host' ? 'idle' : 'unavailable', + view: undefined, + error: null, + }) + } + + /** @returns the current sync snapshot (stable reference until the next change). */ + getSnapshot(): SettingsMirrorSnapshot { + 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) + } + + /** + * Refresh from the Host. A call during an in-flight read marks one rerun + * after it settles instead of racing a second wire read. + * @returns settlement after this call's freshness is reflected. + */ + load(): Promise { + if (this.persistence === 'memory') return Promise.resolve() + if (this.inFlight !== undefined) { + this.rerun = true + return this.inFlight + } + const run = this.run().finally(() => { this.inFlight = undefined }) + this.inFlight = run + return run + } + + /** + * Resolve once an answer is held (or the mirror is terminally unavailable), + * reading only from `idle`. The cheap idempotent entry for surfaces that + * render on first use. + * @returns settlement of the current or newly started read, if any. + */ + ensure(): Promise { + if (this.persistence === 'memory') return Promise.resolve() + if (this.inFlight !== undefined) return this.inFlight + if (this.getSnapshot().status === 'idle') return this.load() + return Promise.resolve() + } + + /** + * Fold one write answer's namespace view into the held view without a wire + * read. A no-op until a first answer exists — a write cannot precede the + * read that supplied its `expectedRevision`. + * @param view - the namespace view a settings write answered with. + */ + acceptView(view: SettingsNamespaceView): void { + const before = this.store.getSnapshot() + if (before.view === undefined) return + const namespaces = before.view.namespaces.some(row => row.ns === view.ns) + ? before.view.namespaces.map(row => row.ns === view.ns ? view : row) + : [...before.view.namespaces, view] + this.store.set({ ...before, view: { ...before.view, namespaces } }) + } + + /** + * Convenience row lookup on the held view. + * @param ns - namespace identity. + * @returns the namespace view, or undefined while unanswered or unregistered. + */ + namespace(ns: string): SettingsNamespaceView | undefined { + return this.store.getSnapshot().view?.namespaces.find(row => row.ns === ns) + } + + private async run(): Promise { + do { + this.rerun = false + const generation = ++this.generation + const before = this.store.getSnapshot() + if (before.status === 'idle') this.store.set({ ...before, status: 'loading' }) + 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 } + } catch (error) { + outcome = { failure: error instanceof Error ? error.message : String(error) } + } + if (generation !== this.generation) continue + if ('view' in outcome) { + this.store.set({ status: 'ready', view: outcome.view, error: null }) + } else { + const held = this.store.getSnapshot() + // No answer yet: fall back to idle so `ensure` retries; with one, the + // held view keeps serving and only the error field reports the miss. + this.store.set({ + status: held.view === undefined ? 'idle' : 'ready', + view: held.view, + error: outcome.failure, + }) + } + } while (this.rerun) + } +} diff --git a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts new file mode 100644 index 0000000000..20c0cc55ee --- /dev/null +++ b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import { SettingsDescribeMirror, type SettingsDescribeView } from '../src/client/settings-mirror.ts' + +let rpc = 0 + +function ok(value: T): RpcResponse { + return { rpcId: `mirror-${rpc++}` as never, result: { ok: true, value } } +} + +function rejected(message: string): RpcResponse { + return { + rpcId: `mirror-${rpc++}` as never, + result: { + ok: false, + error: { code: 'settings-rejected', message, details: {} }, + }, + } +} + +function view(ns: string, revision = 0): SettingsNamespaceView { + return { ns, schema: {}, value: { field: ns }, applies: 'live', secrets: [], revision } +} + +function described(namespaces: SettingsNamespaceView[]): RpcResponse { + return ok({ writable: true, hasDocument: true, namespaces }) +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { resolve = res }) + return { promise, resolve } +} + +describe('SettingsDescribeMirror', () => { + it('folds concurrent load calls into the in-flight read plus one rerun', async () => { + const gate = deferred>() + const describeCall = vi.fn() + .mockReturnValueOnce(gate.promise) + .mockResolvedValue(described([view('theme', 1)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const first = mirror.load() + const second = mirror.load() + const third = mirror.load() + gate.resolve(described([view('theme', 0)])) + await Promise.all([first, second, third]) + expect(describeCall).toHaveBeenCalledTimes(2) + expect(mirror.getSnapshot().status).toBe('ready') + expect(mirror.namespace('theme')?.revision).toBe(1) + }) + + it('keeps the last good view when a later refresh fails, recording the failure', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described([view('theme', 2)])) + .mockRejectedValueOnce(new Error('host gone')) + .mockResolvedValueOnce(rejected('busy')) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + await mirror.load() + expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: null }) + await mirror.load() + expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: 'host gone' }) + expect(mirror.namespace('theme')?.revision).toBe(2) + await mirror.load() + expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: 'busy' }) + expect(mirror.getSnapshot().view?.namespaces).toHaveLength(1) + }) + + it('returns to idle after a first read that never succeeded, so ensure retries', async () => { + const describeCall = vi.fn() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(described([view('theme', 1)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + await mirror.ensure() + expect(mirror.getSnapshot()).toMatchObject({ status: 'idle', view: undefined, error: 'offline' }) + await mirror.ensure() + expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: null }) + expect(describeCall).toHaveBeenCalledTimes(2) + }) + + it('treats ensure as a no-op once ready', async () => { + const describeCall = vi.fn().mockResolvedValue(described([view('theme', 1)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + await mirror.ensure() + await mirror.ensure() + await mirror.ensure() + expect(describeCall).toHaveBeenCalledTimes(1) + }) + + it('memory persistence is terminally unavailable and never touches the wire', async () => { + const describeCall = vi.fn() + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never, 'memory') + await mirror.ensure() + await mirror.load() + expect(mirror.getSnapshot()).toEqual({ status: 'unavailable', view: undefined, error: null }) + expect(describeCall).not.toHaveBeenCalled() + }) + + it('acceptView folds one write answer into the held view without a wire read', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described([view('theme', 1), view('locale', 4)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + await mirror.load() + const seen: number[] = [] + mirror.subscribe(() => { seen.push(mirror.namespace('theme')?.revision ?? -1) }) + mirror.acceptView(view('theme', 9)) + expect(mirror.namespace('theme')?.revision).toBe(9) + expect(mirror.namespace('locale')?.revision).toBe(4) + expect(seen).toEqual([9]) + expect(describeCall).toHaveBeenCalledTimes(1) + }) + + it('acceptView before any answer is a no-op instead of inventing a document', () => { + const describeCall = vi.fn() + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + mirror.acceptView(view('theme', 1)) + expect(mirror.getSnapshot()).toEqual({ status: 'idle', view: undefined, error: null }) + }) + + it('acceptView appends a namespace the held view has not seen yet', async () => { + const describeCall = vi.fn().mockResolvedValueOnce(described([view('theme', 1)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + await mirror.load() + mirror.acceptView(view('fresh-ns', 0)) + expect(mirror.namespace('fresh-ns')).toBeDefined() + expect(mirror.getSnapshot().view?.namespaces).toHaveLength(2) + }) + + it('suppresses a stale answer that lost to a newer generation', async () => { + const slow = deferred>() + const describeCall = vi.fn() + .mockReturnValueOnce(slow.promise) + .mockResolvedValue(described([view('theme', 8)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const first = mirror.load() + const second = mirror.load() + slow.resolve(described([view('theme', 1)])) + await Promise.all([first, second]) + expect(mirror.namespace('theme')?.revision).toBe(8) + }) +}) From 4db77d398808005f3f6567e76aeb6d446ae16629 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 16:57:47 +0800 Subject: [PATCH 02/34] fix(ui-settings): clear the mirror in-flight slot in the rerun check's segment --- .../ui-settings/src/client/settings-mirror.ts | 66 +++++++++++-------- .../tests/settings-mirror.client.spec.ts | 14 ++++ 2 files changed, 51 insertions(+), 29 deletions(-) diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts index a1dfe8c5a8..398895c9cb 100644 --- a/packages/client/ui-settings/src/client/settings-mirror.ts +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -89,7 +89,7 @@ export class SettingsDescribeMirror { this.rerun = true return this.inFlight } - const run = this.run().finally(() => { this.inFlight = undefined }) + const run = this.run() this.inFlight = run return run } @@ -132,33 +132,41 @@ export class SettingsDescribeMirror { } private async run(): Promise { - do { - this.rerun = false - const generation = ++this.generation - const before = this.store.getSnapshot() - if (before.status === 'idle') this.store.set({ ...before, status: 'loading' }) - 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 } - } catch (error) { - outcome = { failure: error instanceof Error ? error.message : String(error) } - } - if (generation !== this.generation) continue - if ('view' in outcome) { - this.store.set({ status: 'ready', view: outcome.view, error: null }) - } else { - const held = this.store.getSnapshot() - // No answer yet: fall back to idle so `ensure` retries; with one, the - // held view keeps serving and only the error field reports the miss. - this.store.set({ - status: held.view === undefined ? 'idle' : 'ready', - view: held.view, - error: outcome.failure, - }) - } - } while (this.rerun) + // The in-flight slot must clear in the same synchronous segment that + // observes `rerun` false (and on abrupt exit): a `.finally()` on the + // returned promise runs one microtask later, and a `load()` landing in + // that gap would mark a rerun nobody reads, losing the read. + try { + do { + this.rerun = false + const generation = ++this.generation + const before = this.store.getSnapshot() + if (before.status === 'idle') this.store.set({ ...before, status: 'loading' }) + 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 } + } catch (error) { + outcome = { failure: error instanceof Error ? error.message : String(error) } + } + if (generation !== this.generation) continue + if ('view' in outcome) { + this.store.set({ status: 'ready', view: outcome.view, error: null }) + } else { + const held = this.store.getSnapshot() + // No answer yet: fall back to idle so `ensure` retries; with one, the + // held view keeps serving and only the error field reports the miss. + this.store.set({ + status: held.view === undefined ? 'idle' : 'ready', + view: held.view, + error: outcome.failure, + }) + } + } while (this.rerun) + } finally { + this.inFlight = undefined + } } } 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 20c0cc55ee..058c401d5e 100644 --- a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts @@ -125,6 +125,20 @@ describe('SettingsDescribeMirror', () => { expect(mirror.getSnapshot().view?.namespaces).toHaveLength(2) }) + it('never loses a load landing between a run settling and its slot clearing', async () => { + // Regression: with the in-flight slot cleared by a promise .finally(), + // a load() in the one-microtask gap after the rerun check marked a rerun + // nobody read, and that refresh never reached the wire. + const describeCall = vi.fn().mockResolvedValue(described([view('theme', 1)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + void mirror.load() + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) + void mirror.load() + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(2) }) + void mirror.load() + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) }) + }) + it('suppresses a stale answer that lost to a newer generation', async () => { const slow = deferred>() const describeCall = vi.fn() From fd61fa889b697fae3e519863553985bfd79cc8d2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 16:57:48 +0800 Subject: [PATCH 03/34] refactor(ui-settings): derive settings scopes from the describe mirror --- .../client/ui-settings/src/client/index.ts | 53 +++- .../ui-settings/src/client/settings-scope.ts | 138 ++++++----- .../ui-settings/tests/plugin.client.spec.ts | 45 +++- .../tests/settings-scope.client.spec.ts | 232 ++++++++---------- 4 files changed, 250 insertions(+), 218 deletions(-) diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 2ace9e56b1..b3c149c938 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -1,35 +1,64 @@ /** * Settings domain base plugin, browser half. Provides `ctx.settingsScope`, the - * settings-namespace Host transport every preference row binds its durable - * section through, and owns the canonical slot-type contract for the settings - * surface. It depends on no `ui-*` presentation package, so any feature that - * owns a preference can reach it: the settings SHELL — the `sidebar.settings` - * occupant, its navigation, and the chrome — lives in ui-settings-general, - * because a shell dependency on ui-sidebar would close a reference cycle - * through ui-layout and ui-theme. Export discipline: packages/client/AGENTS.md. + * settings-namespace scope service every preference row binds its durable + * section through, and owns the one `settings.describe` reader in the browser: + * the describe mirror, whose invalidation subscriptions + * (`settings/document-updated`, `connection/reset`) live here so every derived + * surface refreshes from a single wire read. It depends on no `ui-*` + * presentation package, so any feature that owns a preference can reach it: + * the settings SHELL — the `sidebar.settings` occupant, its navigation, and + * the chrome — lives in ui-settings-general, because a shell dependency on + * ui-sidebar would close a reference cycle through ui-layout and ui-theme. + * Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' +// Type-only pair supplying `$on` and its key face without dragging a build +// artifact into the Host graph (rationale beside the same pair in +// settings-scope.ts). +import type {} from '@deepseek-ai/dsh-api-remotes/types' +import type {} from '@deepseek-ai/dsh-settings/types' import { SettingsScopeBinder } from './settings-scope.ts' +import { SettingsDescribeMirror } from './settings-mirror.ts' export type { SettingsGeneralItemOwnerProps, SettingsHeaderOwnerProps, SettingsOnboardingOwnerProps, SettingsPluginsTabOwnerProps, SettingsSectionOwnerProps, SettingsTriggerOwnerProps, } from './contract/slots.ts' export { SettingsScopeController, SettingsScopeBinder } from './settings-scope.ts' +export { SettingsDescribeMirror } from './settings-mirror.ts' +export type { SettingsDescribeView, SettingsMirrorSnapshot } from './settings-mirror.ts' /** - * Required services: none. The transport is resolved per caller through - * `this.ctx` at `bind` time, so this plugin waits for nothing. + * Required services: the wire handle for the mirror's reads and the forwarded + * settings invalidation the mirror refreshes on. */ -export const inject = [] +export const inject = ['connection', 'remote'] /** - * Provide the settings-namespace scope service. + * Provide the settings-namespace scope service over one shared describe + * mirror, and keep that mirror fresh on the two signals that can move the + * settings document: a document commit and a (re)connect. * * Constructing the service in this plugin's fiber keeps its traced methods * bound to each consuming plugin's context. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - new SettingsScopeBinder(ctx) + const connection = ctx.get('connection') as ConnectionHandle + const mirror = new SettingsDescribeMirror( + connection.api, + connection.isLoopback ? 'host' : 'memory', + ) + ctx.effect(() => { + const disposers = [ + (ctx.get('remote') as ClientContext['remote']).$on('settings/document-updated', () => { void mirror.load() }), + ctx.on('connection/reset', () => { void mirror.load() }), + ] + // The first connection also emits connection/reset; the in-flight fold + // makes this eager read and that reset converge to one wire call. + void mirror.ensure() + return () => { for (const dispose of disposers) dispose() } + }, 'ui-settings: describe mirror invalidations') + new SettingsScopeBinder(ctx, { mirror }) } diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 4668c4924b..47ebb3daa3 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -1,8 +1,11 @@ /** * Host transport for the settings-namespace scope contract. The contract types * live in `dsh-client-runtime` (the common dependency of every feature that - * owns a preference); this file owns the wire behavior and the invalidation - * subscription, both of which are Settings-surface concerns. + * owns a preference); this file owns the per-namespace derivation over the + * shared {@link SettingsDescribeMirror} and the serialized write path, both of + * which are Settings-surface concerns. Reads never touch the wire here: the + * mirror is the one `settings.describe` reader, and every scope is a selector + * over its snapshot. */ import { Service } from '@deepseek-ai/cordis' @@ -22,8 +25,8 @@ import { // Client half declares `ctx.remote` with no generated import, and the // allowlist's `types` subpath is a pure-type source file, so the pair supplies // `$on` and its key face without dragging a build artifact in. The runtime -// `remote` injection belongs to whoever calls bindSettingsScope: the -// subscription is registered on the caller's own context. +// `remote` injection belongs to the providing plugin's apply, which registers +// the mirror's invalidation subscriptions. import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-remotes/types' // The forwarded event's own declaration: `$on`'s key face is @@ -31,29 +34,39 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types' // never — the owning package's client-safe, type-only subpath supplies the // cordis `Events` entry (and with it the branded `SettingsNamespace`). import type {} from '@deepseek-ai/dsh-settings/types' +import { SettingsDescribeMirror } from './settings-mirror.ts' + type SettingsFace = Pick /** - * 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. + * One namespace's derived view over the shared describe mirror, plus that + * namespace's serialized Host writes. Writes carry the latest known namespace + * revision, fold their answers back into the mirror, and teardown waits for + * the operation already crossing the wire. */ export class SettingsScopeController implements SettingsScope { private readonly store: SnapshotStore> private tail: Promise = Promise.resolve() - private readGeneration = 0 private writeGeneration = 0 private disposed = false + private readonly unsubscribe: (() => void) | undefined + /** + * Revision answered by a superseded write still ahead of the mirror: the + * mirror only folds the LATEST settlement in, so a queued successor takes + * its fence from here first. + */ + private pendingRevision: number | undefined /** - * @param api - settings wire face. + * @param api - settings wire face (writes only; reads ride the mirror). * @param spec - namespace identity and optional narrowing decoder. + * @param mirror - the shared describe mirror this scope derives from. * @param persistence - remote browsers remain process-local because settings RPCs are loopback-only. */ constructor( private readonly api: SettingsFace, private readonly spec: SettingsScopeSpec, + private readonly mirror: SettingsDescribeMirror, private readonly persistence: 'host' | 'memory' = 'host', ) { this.store = createSnapshotStore>({ @@ -65,6 +78,10 @@ export class SettingsScopeController implements SettingsScope { writable: false, mode: persistence, }) + if (persistence === 'host') { + this.unsubscribe = mirror.subscribe(() => { this.derive() }) + this.derive() + } } /** @returns the current sync snapshot (stable reference until the next change). */ @@ -81,15 +98,6 @@ export class SettingsScopeController implements SettingsScope { 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 { - 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. @@ -112,10 +120,9 @@ export class SettingsScopeController implements SettingsScope { } private write(op: SettingsPathOpView): Promise { - this.readGeneration += 1 const generation = ++this.writeGeneration return this.enqueue(async () => { - const revision = this.getSnapshot().revision + const revision = this.pendingRevision ?? this.getSnapshot().revision let response: Awaited> try { response = await this.api.settings.mutate({ @@ -124,25 +131,39 @@ export class SettingsScopeController implements SettingsScope { ...(revision === undefined ? {} : { expectedRevision: revision }), }) } catch (_settingsWriteFailure) { - if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) + await this.recover(generation) return } if (!response.result.ok) { - if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) + await this.recover(generation) return } - this.accept(response.result.value, generation === this.writeGeneration) + if (this.disposed) return + if (generation === this.writeGeneration) { + this.pendingRevision = undefined + this.mirror.acceptView(response.result.value) + } else { + this.pendingRevision = response.result.value.revision + } }) } + /** Reload Host state for the latest failed write; superseded failures leave recovery to it. */ + private async recover(generation: number): Promise { + if (this.disposed || generation !== this.writeGeneration) return + this.pendingRevision = undefined + await this.mirror.load() + } + /** - * Stop queued operations and wait for the current wire call to settle. + * Stop queued operations, stop deriving, and wait for the current wire call + * to settle. * @returns settlement after the controller reaches quiescence. */ async dispose(): Promise { this.disposed = true - this.readGeneration += 1 this.writeGeneration += 1 + this.unsubscribe?.() await this.tail } @@ -158,36 +179,25 @@ export class SettingsScopeController implements SettingsScope { return task } - private async read(generation: number): Promise { - let response: Awaited> - 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 + private derive(): void { + if (this.disposed) return + const mirrored = this.mirror.getSnapshot() + if (mirrored.view === undefined) return + const { writable } = mirrored.view + const view = mirrored.view.namespaces.find(candidate => candidate.ns === this.spec.namespace) if (view === undefined) { - if (publish) { - this.store.update((draft) => { - draft.status = 'unavailable' - draft.writable = writable - }) - } + 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 + const decoded = this.decode(view) this.store.update((draft) => { draft.revision = view.revision draft.base = view.base draft.user = view.user - if (writable !== undefined) draft.writable = writable + draft.writable = writable if (decoded === undefined) return draft.status = 'ready' draft.value = decoded @@ -225,20 +235,24 @@ declare module '@deepseek-ai/cordis' { * (`packages/client/tsdown.client.ts`). */ export class SettingsScopeBinder extends Service { + private readonly mirror: SettingsDescribeMirror + /** * @param ctx - the providing plugin's context. + * @param config - the shared describe mirror every bound scope derives from. */ - constructor(ctx: Context) { + constructor(ctx: Context, config: { mirror: SettingsDescribeMirror }) { super(ctx, 'settingsScope') + this.mirror = config.mirror } /** - * Bind one namespace scope to settings and connection invalidations on the - * CALLER's plugin lifecycle — the service proxy binds `this.ctx` to the - * caller at call time, so the scope's disposer belongs to the calling fiber. - * Listeners exist before the initial background read starts, so activation - * never blocks on the settings transport. The caller injects `connection` - * for the transport and `remote` for the forwarded settings invalidation. + * Bind one namespace scope on the CALLER's plugin lifecycle — the service + * proxy binds `this.ctx` to the caller at call time, so the scope's disposer + * belongs to the calling fiber. The scope derives from the shared mirror + * (whose invalidation subscriptions live with the providing plugin), so + * binding adds no wire read of its own and activation never blocks on the + * settings transport. * @param spec - domain-owned namespace contract. * @returns the bound scope consumed by the domain's services and rows. */ @@ -248,20 +262,12 @@ export class SettingsScopeBinder extends Service { const controller = new SettingsScopeController( connection.api, spec, + this.mirror, connection.isLoopback ? 'host' : 'memory', ) ctx.effect(() => { - const refresh = (namespace?: string): void => { - if (namespace !== undefined && namespace !== spec.namespace) return - void controller.load() - } - const disposers = [ - (ctx.get('remote') as Context['remote']).$on('settings/document-updated', refresh), - ctx.on('connection/reset', () => { refresh() }), - ] - void controller.load() + void this.mirror.ensure() return async () => { - for (const dispose of disposers) dispose() await controller.dispose() } }, `ui-settings: ${spec.namespace} settings scope`) diff --git a/packages/client/ui-settings/tests/plugin.client.spec.ts b/packages/client/ui-settings/tests/plugin.client.spec.ts index 1643e9580f..c63bf1fe00 100644 --- a/packages/client/ui-settings/tests/plugin.client.spec.ts +++ b/packages/client/ui-settings/tests/plugin.client.spec.ts @@ -1,29 +1,56 @@ /** * The settings domain base plugin's own mounting behavior: it stands up - * `ctx.settingsScope` for every feature that owns a preference row, and the - * service retires with its fiber. + * `ctx.settingsScope` over one shared describe mirror, keeps that mirror + * fresh on settings-document and connection-reset invalidations, and retires + * both the service and the subscriptions with its fiber. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, SettingsScopeBinder } from '../src/client/index.ts' -/** Boot the browser half over a bare root context; it injects nothing. */ +/** Boot the browser half over a fake loopback connection and test remote. */ function bench() { + const describeCall = vi.fn().mockResolvedValue({ + rpcId: 'plugin-bench' as never, + result: { ok: true, value: { writable: true, hasDocument: true, namespaces: [] } }, + }) const ctx = new Context() - return { ctx, fiber: ctx.plugin({ inject: [...inject], apply }) } + ctx.provide('connection', { + api: { settings: { describe: describeCall } }, + isLoopback: true, + } as never) + new TestRemote(ctx) + return { ctx, describeCall, fiber: ctx.plugin({ inject: [...inject], apply }) } } describe('settings domain base plugin', () => { - it('mounts the scope service under settingsScope', async () => { - const { ctx, fiber } = bench() + it('mounts the scope service under settingsScope and reads once eagerly', async () => { + const { ctx, describeCall, fiber } = bench() await fiber.await() expect(ctx.get('settingsScope')).toBeInstanceOf(SettingsScopeBinder) + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) }) - it('fiber disposal retires the service', async () => { - const { ctx, fiber } = bench() + it('refreshes the mirror on document commits and connection resets, once each', async () => { + const { ctx, describeCall, fiber } = bench() await fiber.await() + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) + ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(2) }) + ctx.emit('connection/reset') + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) }) + }) + + it('fiber disposal retires the service and its invalidation subscriptions', async () => { + const { ctx, describeCall, fiber } = bench() + await fiber.await() + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) await fiber.dispose() expect(ctx.get('settingsScope')).toBeUndefined() + ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) + ctx.emit('connection/reset') + await Promise.resolve() + expect(describeCall).toHaveBeenCalledTimes(1) }) }) 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 429002028a..1e4c473ef1 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -5,6 +5,7 @@ import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-re import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' import { SettingsScopeController, SettingsScopeBinder } from '../src/client/settings-scope.ts' +import { SettingsDescribeMirror } from '../src/client/settings-mirror.ts' interface UiTestSettings { preference: 'light' | 'dark' | 'system' @@ -52,6 +53,17 @@ function deferred() { return { promise, resolve, reject } } +/** A host-mode mirror plus a controller derived from it, over one fake wire. */ +function derivedScope( + api: { describe?: ReturnType; mutate?: ReturnType }, + spec: { namespace: string; decode?: (section: unknown) => UiTestSettings | undefined } = { namespace: 'ui-test' }, +) { + const wire = { settings: api } as never + const mirror = new SettingsDescribeMirror(wire) + const scope = new SettingsScopeController(wire, spec, mirror) + return { mirror, scope } +} + /** Record each distinct published section, starting from the current one. */ function trackValues(scope: SettingsScope): Array { const seen: Array = [scope.getSnapshot().value] @@ -63,16 +75,13 @@ function trackValues(scope: SettingsScope): Array { - it('starts loading and publishes a schema-valid section with revision and writability', async () => { + it('starts loading and derives a schema-valid section with revision and writability', async () => { const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3)) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall }) expect(scope.getSnapshot()).toEqual({ status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host', }) - await scope.load() + await mirror.load() expect(scope.getSnapshot()).toEqual({ status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host', }) @@ -87,12 +96,9 @@ describe('SettingsScopeController', () => { .mockResolvedValueOnce(described(['queue'], 7)) .mockResolvedValueOnce(rejected()) .mockRejectedValueOnce(new Error('offline')) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall }) const good = trackValues(scope) - for (let i = 0; i < 7; i++) await scope.load() + for (let i = 0; i < 7; i++) await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 7, }) @@ -103,45 +109,22 @@ describe('SettingsScopeController', () => { const broken = { ...view({ preference: 'dark' }, 2), schema: null } const describeCall = vi.fn() .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] })) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) - await scope.load() + const { mirror, scope } = derivedScope({ describe: describeCall }) + await mirror.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( - { 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( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) - await scope.load() + const { mirror, scope } = derivedScope({ describe: describeCall }) + await mirror.load() expect(scope.getSnapshot().status).toBe('ready') - await scope.load() + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } }) - await scope.load() + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 }) }) @@ -149,18 +132,15 @@ describe('SettingsScopeController', () => { const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'light' }, 1)) .mockResolvedValueOnce(described({ preference: 'dark' }, 2)) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { - namespace: 'ui-test', - decode: section => (section as UiTestSettings).preference === 'dark' - ? section as UiTestSettings - : undefined, - }, - ) - await scope.load() + const { mirror, scope } = derivedScope({ describe: describeCall }, { + namespace: 'ui-test', + decode: section => (section as UiTestSettings).preference === 'dark' + ? section as UiTestSettings + : undefined, + }) + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 }) - await scope.load() + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 }) }) @@ -170,12 +150,9 @@ describe('SettingsScopeController', () => { const mutate = vi.fn() .mockReturnValueOnce(first.promise) .mockResolvedValueOnce(ok(view({ preference: 'light' }, 6))) - const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) const published = trackValues(scope) - await scope.load() + await mirror.load() const dark = scope.set('preference', 'dark') const light = scope.set('preference', 'light') await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) @@ -195,6 +172,19 @@ describe('SettingsScopeController', () => { }) }) + it('folds the latest write answer into the mirror so a sibling scope sees it', async () => { + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 4)) + const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 5))) + const wire = { settings: { describe: describeCall, mutate } } as never + const mirror = new SettingsDescribeMirror(wire) + const writer = new SettingsScopeController(wire, { namespace: 'ui-test' }, mirror) + const sibling = new SettingsScopeController(wire, { namespace: 'ui-test' }, mirror) + await mirror.load() + await writer.set('preference', 'dark') + expect(describeCall).toHaveBeenCalledTimes(1) + expect(sibling.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 5 }) + }) + it('recovers the latest rejected or thrown write from Host state', async () => { const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'system' }, 2)) @@ -202,52 +192,45 @@ describe('SettingsScopeController', () => { const mutate = vi.fn() .mockResolvedValueOnce(rejected()) .mockRejectedValueOnce(new Error('offline')) - const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) const published = trackValues(scope) + await mirror.load() 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 describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 2)) const mutate = vi.fn() .mockResolvedValueOnce(rejected()) .mockRejectedValueOnce(new Error('offline')) .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) - const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) const published = trackValues(scope) + await mirror.load() 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']) + expect(describeCall).toHaveBeenCalledTimes(1) + expect(published.map(section => section?.preference)).toEqual([undefined, 'system', '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( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall }) 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() + await expect(mirror.load()).rejects.toThrow('subscriber failed') + await expect(mirror.load()).resolves.toBeUndefined() expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 }) }) @@ -255,10 +238,7 @@ describe('SettingsScopeController', () => { const first = deferred>() const mutate = vi.fn().mockReturnValue(first.promise) const describeCall = vi.fn() - const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - ) + const { scope } = derivedScope({ describe: describeCall, mutate }) const published = trackValues(scope) const dark = scope.set('preference', 'dark') await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) @@ -270,24 +250,34 @@ describe('SettingsScopeController', () => { 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('stops deriving from the mirror after dispose', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' }, 1)) + .mockResolvedValueOnce(described({ preference: 'light' }, 2)) + const { mirror, scope } = derivedScope({ describe: describeCall }) + await mirror.load() + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' } }) + await scope.dispose() + await mirror.load() + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 }) + }) + it('keeps a remote browser in memory mode without Host calls', async () => { const describeCall = vi.fn() const mutate = vi.fn() + const wire = { settings: { describe: describeCall, mutate } } as never + const mirror = new SettingsDescribeMirror(wire, 'memory') const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - 'memory', - ) + wire, { namespace: 'ui-test' }, mirror, 'memory') expect(scope.getSnapshot()).toEqual({ status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory', }) - await scope.load() + await mirror.load() await scope.set('preference', 'dark') await scope.dispose() expect(describeCall).not.toHaveBeenCalled() @@ -302,12 +292,9 @@ describe('SettingsScopeController', () => { } const describeCall = vi.fn() .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [layered] })) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall }) - await scope.load() + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, @@ -320,12 +307,9 @@ describe('SettingsScopeController', () => { 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' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall }) - await scope.load() + await mirror.load() expect(scope.getSnapshot().user).toBeUndefined() }) @@ -333,11 +317,8 @@ describe('SettingsScopeController', () => { 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() + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() await scope.unset('preference') @@ -354,64 +335,53 @@ describe('SettingsScopeController', () => { 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() + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() await scope.unset('preference') expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 5 }) }) }) + describe('SettingsScopeBinder.bind', () => { - it('subscribes before the initial read and converges to the latest queued invalidation', async () => { - const initial = deferred>() - const describeCall = vi.fn() - .mockReturnValueOnce(initial.promise) - .mockResolvedValueOnce(described({ preference: 'light' }, 2)) - .mockResolvedValueOnce(described({ preference: 'system' }, 3)) + it('shares one mirror read across bound scopes and disposes each with its fiber', async () => { + const describeCall = vi.fn().mockResolvedValue(described({ preference: 'dark' }, 1)) + const wire = { settings: { describe: describeCall } } + const mirror = new SettingsDescribeMirror(wire as never) const ctx = new Context() - ctx.provide('connection', { - api: { settings: { describe: describeCall } }, - isLoopback: true, - } as never) - let scope!: SettingsScope + ctx.provide('connection', { api: wire, isLoopback: true } as never) + let theme!: SettingsScope + let locale!: SettingsScope new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, { mirror }).await() const fiber = ctx.plugin({ inject: ['connection', 'remote', 'settingsScope'], apply: (plugin: Context) => { - scope = plugin.settingsScope.bind({ namespace: 'ui-test' }) + theme = plugin.settingsScope.bind({ namespace: 'ui-test' }) + locale = plugin.settingsScope.bind({ namespace: 'ui-test' }) }, }) await fiber.await() - await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() }) - ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0]) - ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) - 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 }) + expect(theme.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } }) + expect(locale.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } }) }) + expect(describeCall).toHaveBeenCalledTimes(1) await fiber.dispose() - ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) - await Promise.resolve() - expect(describeCall).toHaveBeenCalledTimes(3) + await mirror.load() + expect(theme.getSnapshot()).toMatchObject({ revision: 1 }) }) it('binds a remote browser in memory mode without starting a settings read', async () => { const describeCall = vi.fn() + const wire = { settings: { describe: describeCall } } + const mirror = new SettingsDescribeMirror(wire as never, 'memory') const ctx = new Context() - ctx.provide('connection', { - api: { settings: { describe: describeCall } }, - isLoopback: false, - } as never) + ctx.provide('connection', { api: wire, isLoopback: false } as never) let scope!: SettingsScope new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, { mirror }).await() const fiber = ctx.plugin({ inject: ['connection', 'remote', 'settingsScope'], apply: (plugin: Context) => { From a2c001eb3e35e2e819bd263cf2476734f9d1a349 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 17:02:32 +0800 Subject: [PATCH 04/34] test(client): bench downstream settings consumers on the real ui-settings apply --- .../client/locale/tests/apply.client.spec.ts | 9 ++++++--- .../tests/apply.client.spec.ts | 4 ++-- .../tests/settings-mirror.client.spec.ts | 2 +- .../ui-theme/tests/apply.client.spec.ts | 20 ++++++++++++++----- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index dd38786073..2378ae796a 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -4,7 +4,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, SETTINGS_NS, @@ -47,7 +47,7 @@ async function bench() { ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never) // The settings transport and the forwarded-event port the plugin injects. new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, describe, mutate, setHostPreference: (next: string | undefined) => { preference = next; revision += 1 }, @@ -133,7 +133,10 @@ describe('locale apply', () => { it('loads and refreshes the explicit Host preference after nonblocking activation', async () => { const b = await bench() + // The shared mirror read once at bench time; a Host-side change reaches it + // through the document invalidation, exactly as production announces one. b.setHostPreference('en') + b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const locale = b.ctx.get('locale') as LocaleRuntime @@ -144,7 +147,7 @@ describe('locale apply', () => { b.setHostPreference('en') b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) - expect(b.describe).toHaveBeenCalledTimes(3) + expect(b.describe).toHaveBeenCalledTimes(4) }) it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { 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 2934097b94..c5516ff4bc 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -6,7 +6,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' -import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import type { ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, @@ -52,7 +52,7 @@ async function bench(served?: string[]) { credentials: { describe: describeCredentials }, }, } as never) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings } } 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 058c401d5e..972b3cea73 100644 --- a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts @@ -13,7 +13,7 @@ function rejected(message: string): RpcResponse { rpcId: `mirror-${rpc++}` as never, result: { ok: false, - error: { code: 'settings-rejected', message, details: {} }, + error: { code: 'settings-rejected', message, details: { ns: 'theme' } }, }, } } diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index fb84c9860d..1629ebe342 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' -import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' import type { AppearanceRowInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts' @@ -56,7 +56,7 @@ async function bench(isLoopback = true) { ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never) // The settings transport and the forwarded-event port the plugin injects. new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate, setHostPreference: (next: string) => { preference = next }, @@ -127,13 +127,19 @@ describe('ui-theme apply', () => { it('loads Host settings at boot, refreshes its namespace, and keeps remote browsers process-local', async () => { const b = await bench() + // The shared mirror read once at bench time; a Host-side change reaches it + // through the document invalidation, exactly as production announces one. b.setHostPreference('dark') + b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const theme = b.ctx.get('theme') as ThemeRuntime await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) + // The mirror refreshes on every document commit (ns-agnostic); the scope's + // derived value only moves when its own namespace changed. b.ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0]) - expect(b.describe).toHaveBeenCalledOnce() + await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledTimes(3) }) + expect(theme.getTheme().preference).toBe('dark') b.setHostPreference('light') b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') }) @@ -151,12 +157,15 @@ describe('ui-theme apply', () => { expect(remote.mutate).not.toHaveBeenCalled() }) - it('activates before a slow initial settings read and converges when it settles', async () => { + it('activates before a slow settings refresh and converges when it settles', async () => { const b = await bench() b.setHostPreference('dark') const describe = b.describe.getMockImplementation()! const pending = deferred>>() b.describe.mockImplementationOnce(() => pending.promise) + // The refresh hangs on the wire; the mirror keeps serving the last good + // answer, so activation never blocks on the settings transport. + b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() const theme = b.ctx.get('theme') as ThemeRuntime @@ -169,9 +178,10 @@ describe('ui-theme apply', () => { it('ignores an invalid preference crossing the settings wire', async () => { const b = await bench() b.setHostPreference('sepia') + b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) await b.ctx.plugin({ inject: [...inject], apply }).await() const theme = b.ctx.get('theme') as ThemeRuntime - await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledOnce() }) + await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledTimes(2) }) expect(theme.getTheme().preference).toBe('system') }) From 85616ec627a37334607b8499e3329b0d785328d7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 17:04:16 +0800 Subject: [PATCH 05/34] test(web): pin the cold-boot settings.describe budget --- apps/web/tests/startup-rpc-budget.e2e.ts | 58 ++++++++++++++++++++++++ apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 3 files changed, 60 insertions(+) create mode 100644 apps/web/tests/startup-rpc-budget.e2e.ts diff --git a/apps/web/tests/startup-rpc-budget.e2e.ts b/apps/web/tests/startup-rpc-budget.e2e.ts new file mode 100644 index 0000000000..5f8d7dd0e1 --- /dev/null +++ b/apps/web/tests/startup-rpc-budget.e2e.ts @@ -0,0 +1,58 @@ +// Cold-boot RPC budget. The describe mirror (packages/client/ui-settings) is +// the one `settings.describe` reader in the browser, so startup describe +// traffic stays bounded no matter how many client plugins own a preference. +// A regression here means a consumer bypassed the mirror — grep for +// `settings.describe(` outside ui-settings' client sources. +// +// Zero model calls: the lane only boots chrome, so no replay fixture mounts. +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage } from './support.ts' + +/** + * Itemized so the budget stays explainable. The mirror reads twice: once + * eagerly at bind time over HTTP, and once on the first-connection reset — + * that second read closes the window where a document commit lands between + * the eager read and the SSE subscription and its invalidation is lost. + * Beside it, the direct callers not yet migrated: welcome notice (1) + models + * onboarding (1) + plugin-directory tab at bind and at reset (2) + + * agent-preset settings row on reset (1). Batch 2 migrates those onto the + * mirror and tightens this to 2. + */ +const DESCRIBE_BUDGET = 7 + +let scaffold: WebScaffold +let browser: Browser +let page: Page + +beforeAll(async () => { + scaffold = await launchWebScaffold() + browser = await chromium.launch() +}) + +afterAll(async () => { + await page?.close() + await browser?.close() + await scaffold?.close() +}) + +describe('startup RPC budget', () => { + it('keeps cold-boot settings.describe within the mirror budget', async () => { + page = await newEnglishPage(browser) + watchConsole(page) + const calls: string[] = [] + page.on('request', (request) => { + const url = new URL(request.url()) + if (url.pathname.startsWith('/api/')) calls.push(url.pathname.slice('/api/'.length)) + }) + await page.goto(scaffold.baseUrl) + // Boot settles when the workspace picker is interactive; the trailing wait + // 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 + expect(describeCount, `startup /api calls:\n${calls.join('\n')}`).toBeLessThanOrEqual(DESCRIBE_BUDGET) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 6e706c7123..11cd1ec807 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -24,6 +24,7 @@ "exclude": [ "tests/scaffold.ts", "tests/scaffold-hermetic.e2e.ts", + "tests/startup-rpc-budget.e2e.ts", "tests/minimal-preset.snapshot.ts", "tests/message-feedback-protocol.snapshot.ts", "tests/live-interactions.e2e.ts", diff --git a/tsconfig.host.json b/tsconfig.host.json index 459036247a..80c91c2a14 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -13,6 +13,7 @@ "apps/web/tests/declared-reasoning.e2e.ts", "apps/web/tests/support.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", + "apps/web/tests/startup-rpc-budget.e2e.ts", "apps/web/tests/minimal-preset.snapshot.ts", "apps/web/tests/message-feedback-protocol.snapshot.ts", "apps/web/tests/live-interactions.e2e.ts", From bb3128f266268c9918a35b75cca2f7b9203841d7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 17:11:03 +0800 Subject: [PATCH 06/34] refactor(ui-settings-models): welcome notice reads through the settings scope --- .../ui-settings-models/src/client/index.ts | 34 +-- .../src/client/welcome-store.ts | 166 +++++++------ .../tests/apply.client.spec.ts | 60 +++-- .../tests/welcome-notice.client.spec.tsx | 52 ++-- .../tests/welcome-store.client.spec.ts | 235 ++++++++---------- 5 files changed, 283 insertions(+), 264 deletions(-) diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index dc7f32e370..e45e52a4e8 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -22,7 +22,7 @@ import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx' import type { DeepSeekOnboardingInjected } from './DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from './WelcomeNotice.tsx' import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx' -import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts' +import { decodeWelcomeSection, WelcomeNoticeStore } from './welcome-store.ts' import { ModelsSettingsStore } from './store.ts' import { en, zh, type ModelsKey } from './locales.ts' import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts' @@ -56,7 +56,7 @@ 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'] +export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] /** * Register the Models section once the `settings.section` declaration is on @@ -85,10 +85,12 @@ export function apply(ctx: ClientContext): void { api: connection.api, t, }) - const welcomeController = new WelcomeNoticeStore( - connection.api, - connection.isLoopback ? 'host' : 'memory', - ) + // The scope's own memory mode is what keeps a remote browser process-local, + // so the store needs no isLoopback branch of its own. + const welcomeController = new WelcomeNoticeStore(ctx.settingsScope.bind({ + namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, + decode: decodeWelcomeSection, + })) const welcomeInjected = (): WelcomeNoticeInjected => ({ controller: welcomeController, hooks: { welcome: welcomeController.store }, @@ -96,23 +98,21 @@ export function apply(ctx: ClientContext): void { }) // Pushed invalidations converge every open surface without polling: any - // settings/credentials/topology change refetches once the page loaded. + // settings/credentials/topology change refetches once the page loaded. The + // welcome notice follows its settings scope, so the shared mirror already + // keeps it fresh without a subscription here. ctx.effect(() => { const refreshModels = (): void => { refreshIfLoaded(controller) } - const refreshAll = (): void => { - refreshModels() - refreshWelcomeIfLoaded(welcomeController) - } const disposers = [ - ctx.remote.$on('settings/document-updated', (ns) => { - refreshModels() - if (ns === WELCOME_NOTICE_SETTINGS_NAMESPACE) refreshWelcomeIfLoaded(welcomeController) - }), + ctx.remote.$on('settings/document-updated', () => { refreshModels() }), ctx.remote.$on('credentials/updated', refreshModels), ctx.remote.$on('llm/adapters-updated', refreshModels), - ctx.on('connection/reset', refreshAll), + ctx.on('connection/reset', refreshModels), ] - return () => { for (const dispose of disposers) dispose() } + return () => { + welcomeController.dispose() + for (const dispose of disposers) dispose() + } }, 'ui-settings-models: pushed invalidations') ctx.slots.inject('settings.section', () => ctx.slots.register({ diff --git a/packages/client/ui-settings-models/src/client/welcome-store.ts b/packages/client/ui-settings-models/src/client/welcome-store.ts index 6e139f1a43..9edd54a9cb 100644 --- a/packages/client/ui-settings-models/src/client/welcome-store.ts +++ b/packages/client/ui-settings-models/src/client/welcome-store.ts @@ -1,10 +1,14 @@ -/** Welcome-notice state, durable when the browser may use Host settings. */ +/** + * Welcome-notice state derived from the welcome settings scope. The scope is + * the transport: a loopback browser follows the durable Host section, while a + * remote browser's memory-mode scope never answers and the acknowledgement + * stays process-local here. + */ -import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { - WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, + WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_VERSION, } from '../onboarding-copy.ts' /** State rendered by the welcome step. */ @@ -14,113 +18,111 @@ export interface WelcomeNoticeState { error: string | null } -function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} +/** The welcome section as the notice reads it. */ +export type WelcomeSection = Record -function acknowledgementOf(view: SettingsNamespaceView): string | undefined { - if (typeof view.value !== 'object' || view.value === null) return undefined - const value = (view.value as Record)[WELCOME_NOTICE_ACK_FIELD] - return typeof value === 'string' ? value : undefined +/** + * Accept any object section verbatim; a malformed durable value reads as an + * empty section, so the notice treats it as unacknowledged instead of leaving + * the scope stuck on its previous value. + * @param section - the wire section value. + * @returns the section object, or an empty one for non-object values. + */ +export function decodeWelcomeSection(section: unknown): WelcomeSection { + return typeof section === 'object' && section !== null && !Array.isArray(section) + ? section as WelcomeSection + : {} } /** Coordinates durable Host acknowledgement or a process-local remote fallback. */ export class WelcomeNoticeStore { /** uSES-safe state source shared by the registered welcome step. */ readonly store: SnapshotStore = createSnapshotStore({ - status: 'idle', acknowledged: false, error: null, + status: 'idle' as const, acknowledged: false, error: null, }) - private generation = 0 + private localAcknowledged = false + private saving = false + private following: (() => void) | undefined /** - * @param api - settings wire face used for durable reads and writes. - * @param persistence - remote browsers use memory because settings is loopback-only. + * @param scope - the welcome settings namespace scope; its memory mode is + * what keeps a remote browser process-local. */ - constructor( - private readonly api: Pick, - private readonly persistence: 'host' | 'memory' = 'host', - ) {} + constructor(private readonly scope: SettingsScope) {} - /** Load the acknowledgement from Host settings or initialize process-local state. */ + /** Begin following the bound scope (idempotent) and publish its current answer. */ async load(): Promise { - const generation = ++this.generation - if (this.persistence === 'memory') { - this.store.update((state) => { state.status = 'ready'; state.error = null }) - return - } - this.store.update((state) => { state.status = 'loading'; state.error = null }) - try { - const response = await this.api.settings.describe({}) - if (!response.result.ok) throw new Error(response.result.error.message) - const view = response.result.value.namespaces.find( - candidate => candidate.ns === WELCOME_NOTICE_SETTINGS_NAMESPACE, - ) - if (view === undefined) throw new Error('welcome acknowledgement settings are unavailable') - if (generation !== this.generation) return - this.store.update((state) => { - state.status = 'ready' - state.acknowledged = acknowledgementOf(view) === WELCOME_NOTICE_VERSION - state.error = null - }) - } catch (error) { - if (generation !== this.generation) return - this.store.update((state) => { - state.status = 'error' - state.acknowledged = false - state.error = messageOf(error) - }) - } + this.following ??= this.scope.subscribe(() => { this.derive() }) + this.derive() } /** - * Persist this copy version, or advance only this process for a remote browser. - * @returns true when the selected persistence mode accepted the acknowledgement. + * Persist this copy version, or advance only this process for a remote + * browser. Success is judged against the state the write left behind, so a + * refused or failed write reports false after its recovery read settles. + * @returns true when the selected persistence mode holds the acknowledgement. */ async acknowledge(): Promise { - const generation = ++this.generation - if (this.persistence === 'memory') { - this.store.update((state) => { - state.status = 'ready' - state.acknowledged = true - state.error = null - }) + if (this.scope.getSnapshot().mode === 'memory') { + this.localAcknowledged = true + this.derive() return true } + this.saving = true this.store.update((state) => { state.status = 'saving'; state.error = null }) try { - const response = await this.api.settings.mutate({ - ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, - ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }], + await this.scope.set(WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_VERSION) + } finally { + this.saving = false + } + this.derive() + const { acknowledged } = this.store.getSnapshot() + if (!acknowledged) { + this.store.update((state) => { + state.status = 'error' + state.error = 'the acknowledgement did not persist' }) - if (!response.result.ok) throw new Error(response.result.error.message) - if (generation === this.generation) { - this.store.update((state) => { - state.status = 'ready' - state.acknowledged = true - state.error = null - }) - } - return true - } catch (error) { - if (generation === this.generation) { + } + return acknowledged + } + + /** Stop following the scope. */ + dispose(): void { + this.following?.() + this.following = undefined + } + + private derive(): void { + if (this.saving) return + const scope = this.scope.getSnapshot() + if (scope.mode === 'memory') { + this.store.update((state) => { + state.status = 'ready' + state.acknowledged = this.localAcknowledged + state.error = null + }) + return + } + switch (scope.status) { + case 'loading': + this.store.update((state) => { state.status = 'loading'; state.error = null }) + return + case 'unavailable': this.store.update((state) => { state.status = 'error' state.acknowledged = false - state.error = messageOf(error) + state.error = 'welcome acknowledgement settings are unavailable' + }) + return + case 'ready': { + const acknowledged = scope.value?.[WELCOME_NOTICE_ACK_FIELD] === WELCOME_NOTICE_VERSION + this.store.update((state) => { + state.status = 'ready' + state.acknowledged = acknowledged + state.error = null }) } - return false } } } - -/** - * Refresh only after welcome state has left idle. A memory-mode load retains - * acknowledgement so reconnect does not reopen a process-local notice. - * @param controller - welcome state owner whose current status decides whether to load. - */ -export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void { - if (controller.store.getSnapshot().status === 'idle') return - void controller.load() -} 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 39ba3e4b65..5267f72974 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -5,7 +5,11 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote, usePinnedBrowserLanguages } 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 { + WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, +} from '../src/onboarding-copy.ts' import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' @@ -14,7 +18,7 @@ import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') -async function bench(isLoopback = true) { +async function bench(isLoopback = true, settings?: object) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) @@ -22,9 +26,10 @@ async function bench(isLoopback = true) { // The plugins inject `remote`; forwarded events reach them through the // same `$dispatch` handoff the connection sink makes. new TestRemote(ctx) - // The apply path only captures the wire face; no call leaves this fake - // until a section actually loads. - ctx.provide('connection', { api: {}, isLoopback } as never) + // Without a settings face the mirror's reads fail and stay contained; the + // Models join itself never fetches until a section actually loads. + ctx.provide('connection', { api: settings === undefined ? {} : { settings }, isLoopback } as never) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale } } @@ -43,7 +48,7 @@ function declare(slots: SlotRegistry): () => void { describe('ui-settings-models apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope']) }) it('registers the models nav entry for declarations before or after apply', async () => { @@ -204,8 +209,31 @@ describe('pushed invalidations', () => { expect(load).toHaveBeenCalledTimes(1) }) - it('routes only the onboarding namespace invalidation into welcome state', async () => { - const b = await bench() + it('welcome state follows the shared mirror across document commits', async () => { + // The welcome notice derives from its settings scope: a document commit + // reaches it through the mirror's one refresh, with no routing here. + 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, + }], + }, + }, + })), + } + const b = await bench(true, settings) declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.onboarding') @@ -214,14 +242,14 @@ describe('pushed invalidations', () => { entry.inject as unknown as () => import('../src/client/WelcomeNotice.tsx').WelcomeNoticeInjected )() - injected.hooks.welcome.update((state) => { state.status = 'ready' }) - const load = vi.spyOn(injected.controller, 'load').mockResolvedValue() - - b.ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1]) - expect(load).not.toHaveBeenCalled() - b.ctx.remote.$dispatch('settings/document-updated', ['ui-onboarding', 2]) - expect(load).toHaveBeenCalledOnce() - b.ctx.emit('connection/reset') - expect(load).toHaveBeenCalledTimes(2) + await injected.controller.load() + await vi.waitFor(() => { + expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false }) + }) + acknowledgement.current = WELCOME_NOTICE_VERSION + b.ctx.remote.$dispatch('settings/document-updated', ['ui-onboarding', 1]) + await vi.waitFor(() => { + expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) + }) }) }) 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 8b8858c64a..7554e1b77f 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 @@ -2,9 +2,13 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { + SettingsDescribeMirror, SettingsScopeController, +} from '@deepseek-ai/dsh-client-ui-settings/client' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' import type { WelcomeNoticeProps } from '../src/client/WelcomeNotice.tsx' -import { WelcomeNoticeStore } from '../src/client/welcome-store.ts' +import { decodeWelcomeSection, WelcomeNoticeStore } from '../src/client/welcome-store.ts' +import type { WelcomeSection } from '../src/client/welcome-store.ts' import { en, zh } from '../src/client/locales.ts' import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE, @@ -20,7 +24,24 @@ function response(value: T) { return { rpcId: 'welcome-rpc' as never, result: { ok: true as const, value } } } -function mount(version?: string, mutateImpl: () => Promise = () => Promise.resolve(response({}))) { +function welcomeView(value: unknown, revision = 0) { + return { + ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, + schema: {}, + value, + base: {}, + user: {}, + applies: 'live' as const, + secrets: [], + revision, + } +} + +function mount( + version?: string, + mutateImpl: () => Promise = () => + Promise.resolve(response(welcomeView({ [WELCOME_NOTICE_ACK_FIELD]: WELCOME_NOTICE_VERSION }, 1))), +) { const appRoot = document.createElement('div') appRoot.id = 'root' document.body.append(appRoot) @@ -30,21 +51,19 @@ function mount(version?: string, mutateImpl: () => Promise = () => Prom describe: () => Promise.resolve(response({ writable: true, hasDocument: false, - namespaces: [{ - ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, - schema: {}, - value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version }, - base: {}, - user: {}, - applies: 'live' as const, - secrets: [], - revision: 0, - }], + namespaces: [welcomeView(version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version })], })), mutate, }, } - const controller = new WelcomeNoticeStore(api as never) + const mirror = new SettingsDescribeMirror(api as never) + const scope = new SettingsScopeController( + api as never, + { namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection }, + mirror, + ) + const controller = new WelcomeNoticeStore(scope) + void mirror.load() const complete = vi.fn() const unusedHook = (() => { throw new Error('unused standard hook') }) as never const props: WelcomeNoticeProps = { @@ -57,7 +76,7 @@ function mount(version?: string, mutateImpl: () => Promise = () => Prom useWelcome: bindSnapshotSelector(controller.store), t: key => zh[key], } - return { ...render(), complete, controller, mutate, appRoot } + return { ...render(), complete, controller, mirror, mutate, appRoot } } describe('WelcomeNotice', () => { @@ -100,7 +119,10 @@ describe('WelcomeNotice', () => { it('skips itself when this exact version was already acknowledged', async () => { const h = mount(WELCOME_NOTICE_VERSION) - await act(async () => { await h.controller.load() }) + await act(async () => { + await h.mirror.load() + await h.controller.load() + }) expect(screen.queryByRole('dialog')).toBeNull() expect(h.complete).toHaveBeenCalledOnce() }) 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 e1fa7572c3..7eee8ae300 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,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' -import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from '../src/client/welcome-store.ts' +import { + SettingsDescribeMirror, SettingsScopeController, +} from '@deepseek-ai/dsh-client-ui-settings/client' +import { decodeWelcomeSection, WelcomeNoticeStore } from '../src/client/welcome-store.ts' import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, } from '../src/onboarding-copy.ts' @@ -10,31 +13,42 @@ function ok(value: T): RpcResponse { return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } } } -function namespace(version?: string) { +function namespace(value: unknown = {}, revision = 0) { return { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, schema: {}, - value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version }, - base: {}, - user: {}, + value, applies: 'live' as const, secrets: [], - revision: 0, + revision, } } -function deferred() { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((res, rej) => { resolve = res; reject = rej }) - return { promise, resolve, reject } +function acknowledgedNamespace(version: string, revision = 1) { + return namespace({ [WELCOME_NOTICE_ACK_FIELD]: version }, revision) +} + +/** The welcome store over a real mirror-derived scope and a fake wire. */ +function buildWelcome( + api: { describe?: ReturnType; mutate?: ReturnType }, + persistence: 'host' | 'memory' = 'host', +) { + const wire = { settings: api } as never + const mirror = new SettingsDescribeMirror(wire, persistence) + const scope = new SettingsScopeController( + wire, + { namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection }, + mirror, + persistence, + ) + return { mirror, controller: new WelcomeNoticeStore(scope) } } describe('WelcomeNoticeStore', () => { it('acknowledges in memory without calling loopback-only settings APIs', async () => { - const describe = vi.fn() + const describeCall = vi.fn() const mutate = vi.fn() - const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory') + const { controller } = buildWelcome({ describe: describeCall, mutate }, 'memory') await controller.load() expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null }) @@ -42,7 +56,7 @@ describe('WelcomeNoticeStore', () => { expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null }) await controller.load() expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null }) - expect(describe).not.toHaveBeenCalled() + expect(describeCall).not.toHaveBeenCalled() expect(mutate).not.toHaveBeenCalled() }) @@ -52,148 +66,101 @@ describe('WelcomeNoticeStore', () => { ['older-copy', false], [WELCOME_NOTICE_VERSION, true], ] as const) { - const api = { - settings: { - describe: vi.fn(() => Promise.resolve(ok({ - writable: true, hasDocument: false, namespaces: [namespace(version)], - }))), - }, - } - const controller = new WelcomeNoticeStore(api as never) + const describeCall = vi.fn(() => Promise.resolve(ok({ + writable: true, + hasDocument: false, + namespaces: [version === undefined ? namespace() : acknowledgedNamespace(version)], + }))) + const { mirror, controller } = buildWelcome({ describe: describeCall }) + await mirror.load() await controller.load() expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged }) } }) - it('persists the owner version through one idempotent path mutation', async () => { - const mutate = vi.fn(() => Promise.resolve(ok(namespace(WELCOME_NOTICE_VERSION)))) - const controller = new WelcomeNoticeStore({ settings: { mutate } } as never) + it('persists the owner version through one revision-fenced mutation', async () => { + const describeCall = vi.fn(() => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [namespace({}, 3)], + }))) + const mutate = vi.fn(() => Promise.resolve(ok(acknowledgedNamespace(WELCOME_NOTICE_VERSION, 4)))) + const { mirror, controller } = buildWelcome({ describe: describeCall, mutate }) + 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(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) + // The write answer folded into the mirror; no re-read followed. + expect(describeCall).toHaveBeenCalledTimes(1) }) - it('keeps the notice pending when loading or persistence fails', async () => { - const load = new WelcomeNoticeStore({ - settings: { describe: () => Promise.reject(new Error('offline')) }, - } as never) - await load.load() - expect(load.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'offline' }) - - const save = new WelcomeNoticeStore({ - settings: { mutate: () => Promise.reject(new Error('disk full')) }, - } as never) - await expect(save.acknowledge()).resolves.toBe(false) - expect(save.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'disk full' }) - - const nonError = new WelcomeNoticeStore({ - // Durable/wire failures are unknown; exercise containment of a non-Error rejection. - settings: { describe: () => Promise.reject(new Error('offline string')) }, - } as never) - await nonError.load() - expect(nonError.store.getSnapshot().error).toBe('offline string') + it('keeps the notice pending while the settings read has not answered', async () => { + const describeCall = vi.fn(() => Promise.reject(new Error('offline'))) + const { mirror, controller } = buildWelcome({ describe: describeCall }) + await mirror.load() + await controller.load() + // No answer stands, so the step renders nothing and never acknowledges. + expect(controller.store.getSnapshot()).toEqual({ status: 'loading', acknowledged: false, error: null }) }) - it('reports business failures, missing namespaces, and malformed durable values', async () => { - for (const describe of [ - () => Promise.resolve({ - rpcId: 'failed' as never, - result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } }, - }), - () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })), - ]) { - const controller = new WelcomeNoticeStore({ settings: { describe } } as never) - await controller.load() - expect(controller.store.getSnapshot().status).toBe('error') - } + it('reports a failed or refused persistence attempt after its recovery read', async () => { + const describeCall = vi.fn(() => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [namespace()], + }))) + const mutate = vi.fn(() => Promise.reject(new Error('disk full'))) + const { mirror, controller } = buildWelcome({ describe: describeCall, mutate }) + await mirror.load() + await controller.load() + await expect(controller.acknowledge()).resolves.toBe(false) + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'error', + acknowledged: false, + error: 'the acknowledgement did not persist', + }) + // The failed latest write triggered one mirror recovery read. + expect(describeCall).toHaveBeenCalledTimes(2) + }) + it('reports a missing namespace as an error instead of a silent skip', async () => { + const describeCall = vi.fn(() => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [], + }))) + const { mirror, controller } = buildWelcome({ describe: describeCall }) + await mirror.load() + await controller.load() + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'error', + error: 'welcome acknowledgement settings are unavailable', + }) + }) + + it('reads malformed durable values as unacknowledged', async () => { for (const value of [null, 42, { [WELCOME_NOTICE_ACK_FIELD]: 42 }]) { - const controller = new WelcomeNoticeStore({ - settings: { describe: () => Promise.resolve(ok({ - writable: true, - hasDocument: false, - namespaces: [{ ...namespace(), value }], - })) }, - } as never) + const describeCall = vi.fn(() => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [namespace(value)], + }))) + const { mirror, controller } = buildWelcome({ describe: describeCall }) + await mirror.load() await controller.load() expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false }) } - - const save = new WelcomeNoticeStore({ - settings: { mutate: () => Promise.resolve({ - rpcId: 'failed-save' as never, - result: { - ok: false, - error: { - code: 'settings-rejected', - message: 'denied', - details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE }, - }, - }, - }) }, - } as never) - await expect(save.acknowledge()).resolves.toBe(false) - expect(save.store.getSnapshot().error).toBe('denied') }) - it('lets the latest load win over stale success and failure', async () => { - const first = deferred>() - const describe = vi.fn() - .mockImplementationOnce(() => first.promise) - .mockImplementationOnce(() => Promise.resolve(ok({ - writable: true, hasDocument: false, namespaces: [namespace()], - }))) - const controller = new WelcomeNoticeStore({ settings: { describe } } as never) - const stale = controller.load() + it('follows a later document change without an own read', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [namespace()] })) + .mockResolvedValueOnce(ok({ + writable: true, hasDocument: false, + namespaces: [acknowledgedNamespace(WELCOME_NOTICE_VERSION)], + })) + const { mirror, controller } = buildWelcome({ describe: describeCall }) + await mirror.load() await controller.load() - first.resolve(ok({ - writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)], - })) - await stale - expect(controller.store.getSnapshot().acknowledged).toBe(false) - - const failed = deferred>() - describe - .mockImplementationOnce(() => failed.promise) - .mockImplementationOnce(() => Promise.resolve(ok({ - writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)], - }))) - const staleFailure = controller.load() - await controller.load() - failed.reject('stale failure') - await staleFailure - expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true, error: null }) - }) - - it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => { - const write = deferred>() - const describe = vi.fn(() => Promise.resolve(ok({ - writable: true, hasDocument: false, namespaces: [namespace()], - }))) - const controller = new WelcomeNoticeStore({ - settings: { mutate: () => write.promise, describe }, - } as never) - refreshWelcomeIfLoaded(controller) - expect(describe).not.toHaveBeenCalled() - const staleWrite = controller.acknowledge() - await controller.load() - write.resolve(ok(namespace(WELCOME_NOTICE_VERSION))) - await expect(staleWrite).resolves.toBe(true) - expect(controller.store.getSnapshot().acknowledged).toBe(false) - refreshWelcomeIfLoaded(controller) - await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(2) }) - - const failedWrite = deferred>() - const staleFailure = new WelcomeNoticeStore({ - settings: { mutate: () => failedWrite.promise, describe }, - } as never) - const pending = staleFailure.acknowledge() - await staleFailure.load() - failedWrite.reject('late failure') - await expect(pending).resolves.toBe(false) - expect(staleFailure.store.getSnapshot().status).toBe('ready') + expect(controller.store.getSnapshot()).toMatchObject({ acknowledged: false }) + await mirror.load() + expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) }) }) From 232e4beeaeef9ae4d4514f596e721ba358a8052e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 17:15:10 +0800 Subject: [PATCH 07/34] refactor(ui-settings-plugins): plugin tab derives served namespaces from the mirror --- apps/web/tests/startup-rpc-budget.e2e.ts | 8 +-- .../ui-settings-plugins/src/client/index.ts | 17 ++--- .../src/client/tab-store.ts | 63 +++++++------------ .../tests/stores.client.spec.ts | 60 ++++++------------ .../client/ui-settings/src/client/index.ts | 2 +- .../ui-settings/src/client/settings-mirror.ts | 30 ++++++++- .../ui-settings/src/client/settings-scope.ts | 13 +++- 7 files changed, 89 insertions(+), 104 deletions(-) diff --git a/apps/web/tests/startup-rpc-budget.e2e.ts b/apps/web/tests/startup-rpc-budget.e2e.ts index 5f8d7dd0e1..33938d6088 100644 --- a/apps/web/tests/startup-rpc-budget.e2e.ts +++ b/apps/web/tests/startup-rpc-budget.e2e.ts @@ -16,12 +16,10 @@ import { newEnglishPage } from './support.ts' * eagerly at bind time over HTTP, and once on the first-connection reset — * that second read closes the window where a document commit lands between * the eager read and the SSE subscription and its invalidation is lost. - * Beside it, the direct callers not yet migrated: welcome notice (1) + models - * onboarding (1) + plugin-directory tab at bind and at reset (2) + - * agent-preset settings row on reset (1). Batch 2 migrates those onto the - * mirror and tightens this to 2. + * Beside it, the direct callers not yet migrated: models onboarding (1) + + * agent-preset settings row on reset (1). Their migration tightens this to 2. */ -const DESCRIBE_BUDGET = 7 +const DESCRIBE_BUDGET = 4 let scaffold: WebScaffold let browser: Browser diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 82dea6d796..184511ead1 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -72,26 +72,17 @@ export function apply(ctx: ClientContext): void { 'ui-settings-plugins: credential invalidations', ) - // Which namespaces the Host serves is a registration fact the wire does not - // announce, so the directory re-reads on the two signals that can carry a - // changed composition: a settings document commit and a reconnect. + // Which namespaces the Host serves comes from the shared describe mirror, + // whose owning plugin already refreshes it on document commits and + // reconnects — the tab only derives. const configurable = new ConfigurablePluginsTabController( - api, () => ctx.slots.entries('settings.plugin.item')) + ctx.settingsScope.describe(), () => ctx.slots.entries('settings.plugin.item')) ctx.effect(() => () => { configurable.dispose() }, 'ui-settings-plugins: tab directory') - ctx.effect( - () => ctx.remote.$on('settings/document-updated', () => { void configurable.load() }), - 'ui-settings-plugins: served-namespace invalidations', - ) - ctx.effect( - () => ctx.on('connection/reset', () => { void configurable.load() }), - 'ui-settings-plugins: served-namespace reconnect', - ) // A card registered after the first read joins the list without a wire call. ctx.effect( () => ctx.slots.subscribe('settings.plugin.item', () => { configurable.refresh() }), 'ui-settings-plugins: card ledger', ) - void configurable.load() let tabsVersion = -1 let tabsRevision = -1 diff --git a/packages/client/ui-settings-plugins/src/client/tab-store.ts b/packages/client/ui-settings-plugins/src/client/tab-store.ts index a4ed4439f2..ff9d7b4b74 100644 --- a/packages/client/ui-settings-plugins/src/client/tab-store.ts +++ b/packages/client/ui-settings-plugins/src/client/tab-store.ts @@ -10,7 +10,7 @@ * trace and does not count toward the empty line. */ -import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' @@ -42,47 +42,23 @@ export interface ConfigurablePluginsTabFace { } } -/** Reads the served namespaces and pairs them with the cards that claim them. */ +/** Derives the served namespaces from the shared describe mirror and pairs them with the cards that claim them. */ export class ConfigurablePluginsTabController { private readonly store = createSnapshotStore({ loaded: false, namespaces: [] }) - /** Last Host answer; kept so a slot mutation republishes without a wire read. */ - private served: readonly string[] = [] - private loaded = false - private generation = 0 private disposed = false + private readonly unsubscribe: () => void /** - * @param api - settings wire face. + * @param describeFace - the shared mirror's read-only face; its refreshes + * (document commits, reconnects) are what keep the served set current. * @param entries - reads the cards currently registered into the section's slot. */ constructor( - private readonly api: Pick, + private readonly describeFace: SettingsDescribeFace, private readonly entries: () => readonly StoredEntry[], - ) {} - - /** Opaque read of {@link disposed}: control flow cannot narrow it across awaits. */ - private isDisposed(): boolean { - return this.disposed - } - - /** - * Re-read the served namespaces from the Host and republish. - * @returns settlement after the read, or immediately once disposed. - */ - async load(): Promise { - if (this.isDisposed()) return - const generation = ++this.generation - let response: Awaited> - try { - response = await this.api.settings.describe({}) - } catch (_settingsReadFailure) { - // The tab keeps the namespaces it last knew; the next invalidation - // or reconnect reads again. - return - } - if (this.isDisposed() || generation !== this.generation || !response.result.ok) return - this.served = response.result.value.namespaces.map(view => view.ns) - this.loaded = true + ) { + this.unsubscribe = describeFace.subscribe(() => { this.publish() }) + void describeFace.ensure() this.publish() } @@ -92,10 +68,10 @@ export class ConfigurablePluginsTabController { this.publish() } - /** Stop publishing; an in-flight read settles without touching the store. */ + /** Stop publishing and stop following the mirror. */ dispose(): void { this.disposed = true - this.generation += 1 + this.unsubscribe() } /** @@ -107,17 +83,20 @@ export class ConfigurablePluginsTabController { } private publish(): void { - const served = new Set(this.served) + if (this.disposed) return + const mirrored = this.describeFace.getSnapshot() + const loaded = mirrored.view !== undefined + const served = new Set(mirrored.view?.namespaces.map(view => view.ns) ?? []) const namespaces = this.entries().flatMap(entry => entry.options.key !== undefined && served.has(entry.options.key) ? [entry.options.key] : []) const previous = this.store.getSnapshot() - // Every settings-document commit re-reads, and most of them change nothing - // this section shows. An observable source must keep its snapshot - // reference until the fact moves, or each unrelated save re-renders the - // whole card list (packages/client/AGENTS.md reactive rule 5). - if (previous.loaded === this.loaded + // Every settings-document commit refreshes the mirror, and most commits + // change nothing this section shows. An observable source must keep its + // snapshot reference until the fact moves, or each unrelated save + // re-renders the whole card list (packages/client/AGENTS.md reactive rule 5). + if (previous.loaded === loaded && previous.namespaces.length === namespaces.length && previous.namespaces.every((ns, index) => ns === namespaces[index])) return - this.store.set({ loaded: this.loaded, namespaces }) + this.store.set({ loaded, namespaces }) } } 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 9901bc5eb1..481c6e3679 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -8,6 +8,7 @@ import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-clie import { CardForm, numberField, textField } from '../src/client/card-form.ts' import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts' import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts' +import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/client' import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts' import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts' @@ -555,7 +556,7 @@ describe('ConfigurablePluginsTabController', () => { }, }, })) - return { api: { settings: { describe } } as never, describe } + return { mirror: new SettingsDescribeMirror({ settings: { describe } } as never), describe } } /** Slot ledger stand-in: one stored entry per registered card key. */ @@ -565,9 +566,9 @@ describe('ConfigurablePluginsTabController', () => { it('dispatches the served namespaces a card claims, in card registration order', async () => { const settings = settingsApi(['bash', 'ui-theme', 'agent-loop']) - const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('agent-loop', 'bash')) + const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('agent-loop', 'bash')) - await controller.load() + await settings.mirror.ensure() // ui-theme is served but claimed by no card here — another surface owns // it. The order is the cards', not the Host's: plugin activation can @@ -578,9 +579,9 @@ describe('ConfigurablePluginsTabController', () => { it('never dispatches a card whose namespace this deployment does not serve', async () => { const settings = settingsApi(['bash']) - const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash', 'web-search-deepseek')) + const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash', 'web-search-deepseek')) - await controller.load() + await settings.mirror.ensure() expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash']) }) @@ -588,8 +589,8 @@ describe('ConfigurablePluginsTabController', () => { it('takes a card registered after the read without asking the Host again', async () => { const settings = settingsApi(['bash']) let entries = ledger() - const controller = new ConfigurablePluginsTabController(settings.api, () => entries) - await controller.load() + const controller = new ConfigurablePluginsTabController(settings.mirror, () => entries) + await settings.mirror.ensure() expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([]) entries = ledger('bash') @@ -599,34 +600,33 @@ describe('ConfigurablePluginsTabController', () => { expect(settings.describe).toHaveBeenCalledOnce() }) - it('keeps the namespaces it knew when a read fails', async () => { + it('keeps the namespaces it knew when a refresh fails', async () => { const settings = settingsApi(['bash']) - const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash')) - await controller.load() + const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash')) + await settings.mirror.ensure() settings.describe.mockRejectedValueOnce(new Error('offline')) - await controller.load() + await settings.mirror.load() expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash']) }) - it('publishes nothing once disposed, and never claims it was answered', async () => { + it('stops following the mirror once disposed, and never claims it was answered', async () => { const settings = settingsApi(['bash']) - const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash')) + const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash')) controller.dispose() - await controller.load() + await settings.mirror.load() expect(controller.inject().hooks.configurablePlugins.getSnapshot()) .toEqual({ loaded: false, namespaces: [] }) - expect(settings.describe).not.toHaveBeenCalled() }) it('ignores a slot-ledger change that arrives after disposal', async () => { const settings = settingsApi(['bash']) let entries = ledger() - const controller = new ConfigurablePluginsTabController(settings.api, () => entries) - await controller.load() + const controller = new ConfigurablePluginsTabController(settings.mirror, () => entries) + await settings.mirror.ensure() controller.dispose() entries = ledger('bash') @@ -635,33 +635,11 @@ describe('ConfigurablePluginsTabController', () => { expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([]) }) - it('drops a read a newer one superseded', async () => { - // The section re-reads on every settings-document invalidation, so a slow - // first answer must not overwrite the newer one that already landed. - const settings = settingsApi(['bash']) - const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash', 'agent-loop')) - const slow = Promise.withResolvers() - settings.describe.mockReturnValueOnce(slow.promise as never) - const stale = controller.load() - - await controller.load() - expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash']) - slow.resolve({ - rpcId: 's-0', - result: { ok: true, value: { writable: true, hasDocument: true, namespaces: [ - { ns: 'agent-loop', schema: {}, value: {}, applies: 'live', secrets: [], revision: 0 }, - ] } }, - }) - await stale - - expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash']) - }) - it('reports the Host answered even when it serves nothing this tab shows', async () => { const settings = settingsApi(['ui-theme']) - const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash')) + const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash')) - await controller.load() + await settings.mirror.ensure() expect(controller.inject().hooks.configurablePlugins.getSnapshot()) .toEqual({ loaded: true, namespaces: [] }) diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index b3c149c938..f1e968174f 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -27,7 +27,7 @@ export type { } from './contract/slots.ts' export { SettingsScopeController, SettingsScopeBinder } from './settings-scope.ts' export { SettingsDescribeMirror } from './settings-mirror.ts' -export type { SettingsDescribeView, SettingsMirrorSnapshot } from './settings-mirror.ts' +export type { SettingsDescribeFace, SettingsDescribeView, SettingsMirrorSnapshot } from './settings-mirror.ts' /** * Required services: the wire handle for the mirror's reads and the forwarded diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts index 398895c9cb..61dc21e287 100644 --- a/packages/client/ui-settings/src/client/settings-mirror.ts +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -38,12 +38,40 @@ export interface SettingsMirrorSnapshot { error: string | null } +/** + * The mirror as cross-namespace surfaces consume it: current answer, + * subscription, first-use read, and the write-answer fold. `load` stays off + * this face — invalidation refreshes belong to the mirror's owning plugin. + */ +export interface SettingsDescribeFace { + /** @returns the current sync snapshot (stable reference until the next change). */ + getSnapshot(): SettingsMirrorSnapshot + /** + * Observe snapshot replacements. + * @param listener - invoked after each snapshot change. + * @returns the disposer removing this listener. + */ + subscribe(listener: () => void): () => void + /** + * Resolve once an answer is held (or the mirror is terminally unavailable), + * reading only from `idle`. + * @returns settlement of the current or newly started read, if any. + */ + ensure(): Promise + /** + * Fold one write answer's namespace view into the held view without a wire + * read. + * @param view - the namespace view a settings write answered with. + */ + acceptView(view: SettingsNamespaceView): void +} + /** * Serializes every Host `settings.describe` read behind one snapshot store. * Concurrent {@link load} calls fold into the in-flight read plus one rerun, * so an invalidation arriving mid-read is never lost and never duplicated. */ -export class SettingsDescribeMirror { +export class SettingsDescribeMirror implements SettingsDescribeFace { private readonly store: SnapshotStore private inFlight: Promise | undefined private rerun = false diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 47ebb3daa3..72a472a162 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -34,7 +34,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types' // never — the owning package's client-safe, type-only subpath supplies the // cordis `Events` entry (and with it the branded `SettingsNamespace`). import type {} from '@deepseek-ai/dsh-settings/types' -import { SettingsDescribeMirror } from './settings-mirror.ts' +import { SettingsDescribeMirror, type SettingsDescribeFace } from './settings-mirror.ts' type SettingsFace = Pick @@ -256,6 +256,17 @@ export class SettingsScopeBinder extends Service { * @param spec - domain-owned namespace contract. * @returns the bound scope consumed by the domain's services and rows. */ + /** + * The shared mirror's read-only face for cross-namespace surfaces (schema + * introspection, the served-namespace directory). Per-namespace consumers + * use {@link bind}; both derive from the same snapshot, so they can never + * disagree about the document. + * @returns the describe face over the shared mirror. + */ + describe(): SettingsDescribeFace { + return this.mirror + } + bind(spec: SettingsScopeSpec): SettingsScope { const ctx = this.ctx const connection = ctx.get('connection') as ConnectionHandle From e3f484f62ffa71259d7b4b368ee901b5cdb8ac9c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 17:19:10 +0800 Subject: [PATCH 08/34] refactor(ui-permission-presets): permission row derives from the describe mirror --- .../ui-permission-presets/src/client/index.ts | 26 +-- .../src/client/settings-store.ts | 134 +++++++------- .../tests/browser-plugin.client.spec.ts | 2 + .../permission-presets-row.client.spec.tsx | 17 +- .../tests/settings-store.client.spec.ts | 163 ++++++++---------- 5 files changed, 167 insertions(+), 175 deletions(-) diff --git a/packages/client/ui-permission-presets/src/client/index.ts b/packages/client/ui-permission-presets/src/client/index.ts index aec82bf9d9..ce6ecfc32a 100644 --- a/packages/client/ui-permission-presets/src/client/index.ts +++ b/packages/client/ui-permission-presets/src/client/index.ts @@ -33,9 +33,7 @@ import { import { displayPermissionPreset, FULL_ACCESS_PRESET, } from './presentation.ts' -import { - PERMISSION_SETTINGS_NS, PermissionPresetSettingsController, refreshPermissionIfLoaded, -} from './settings-store.ts' +import { PermissionPresetSettingsController } from './settings-store.ts' export type { PermissionRowInjected, PermissionRowProps } from './PermissionRow.tsx' export type { @@ -43,7 +41,7 @@ export type { } from './settings-store.ts' /** Required services (cordis fiber inject). */ -export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote'] +export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote', 'settingsScope'] const ACCESS_NS = 'permission.access' @@ -113,7 +111,10 @@ 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 - const controller = new PermissionPresetSettingsController(connection.api) + // The row follows the shared describe mirror, whose owning plugin already + // refreshes it on document commits and reconnects. + const controller = new PermissionPresetSettingsController( + ctx.settingsScope.describe(), connection.api) const load = (): Promise => controller.load() const select = (preset: string): Promise => controller.select(preset) const injected = (): PermissionRowInjected => ({ @@ -122,20 +123,7 @@ export function apply(ctx: ClientContext): void { select, }) - ctx.effect(() => { - const refresh = (): void => { refreshPermissionIfLoaded(controller) } - const disposers = [ - ctx.remote.$on('settings/document-updated', (ns) => { - if (ns !== PERMISSION_SETTINGS_NS) return - refresh() - }), - ctx.on('connection/reset', () => { refresh() }), - ] - return () => { - controller.dispose() - for (const dispose of disposers) dispose() - } - }, 'ui-permission: settings invalidations') + ctx.effect(() => () => { controller.dispose() }, 'ui-permission: settings row directory') ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', 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 6e7199f1be..5f61ed595a 100644 --- a/packages/client/ui-permission-presets/src/client/settings-store.ts +++ b/packages/client/ui-permission-presets/src/client/settings-store.ts @@ -1,7 +1,9 @@ /** - * Permission default-settings controller. The host descriptor supplies the - * current value and the dynamic preset enum; writes target only - * `defaultPreset` and carry the descriptor revision. + * Permission default-settings controller. The permission descriptor comes + * from the shared describe mirror (the dynamic preset enum lives in the + * namespace schema, which per-namespace scopes do not carry); writes target + * only `defaultPreset`, carry the descriptor revision, and fold their answer + * back into the mirror. */ import type { @@ -10,6 +12,7 @@ import type { import { createSnapshotStore, type SnapshotStore, } from '@deepseek-ai/dsh-client-runtime/client' +import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' import { nodeAtPath, rehydrateSchema, type SchemaNode, } from '@deepseek-ai/dsh-client-schema-form' @@ -75,7 +78,7 @@ export function permissionDefaultOf(view: SettingsNamespaceView): { return { currentValue: value, options } } -/** Controller joining Settings reads, writes, and pushed invalidations. */ +/** Controller deriving the row from the shared mirror and writing the default through it. */ export class PermissionPresetSettingsController { /** Row snapshot consumed through a bound selector hook. */ readonly store: SnapshotStore = createSnapshotStore({ @@ -87,42 +90,32 @@ export class PermissionPresetSettingsController { revision: 0, }) - private generation = 0 - private view: SettingsNamespaceView | undefined - - /** @param api - Settings wire face. */ - constructor(private readonly api: Pick) {} + private following: (() => void) | undefined + private saving = false + private disposed = false /** - * Refresh the permission descriptor. Latest request wins. - * @returns nothing; {@link store} carries success or failure. + * @param describeFace - the shared mirror's read-only face (descriptor and schema source). + * @param api - settings wire face for the `defaultPreset` write. + */ + constructor( + private readonly describeFace: SettingsDescribeFace, + private readonly api: Pick, + ) {} + + /** + * Begin following the mirror (idempotent) and reflect its current answer. + * @returns settlement once the snapshot reflects the mirror. */ async load(): Promise { - const generation = ++this.generation + if (this.disposed) return + this.following ??= this.describeFace.subscribe(() => { this.derive() }) this.store.update((state) => { state.status = 'loading' state.error = null }) - try { - const response = await this.api.settings.describe({}) - if (!response.result.ok) throw new Error(response.result.error.message) - if (generation !== this.generation) return - const view = response.result.value.namespaces.find(entry => entry.ns === PERMISSION_SETTINGS_NS) - if (view === undefined) { - this.view = undefined - this.store.update((state) => { - state.status = 'unavailable' - state.writable = false - state.currentValue = '' - state.options = [] - }) - return - } - this.accept(view, response.result.value.writable) - } catch (error) { - if (generation !== this.generation) return - this.fail(error) - } + await this.describeFace.ensure() + this.derive() } /** @@ -131,10 +124,11 @@ export class PermissionPresetSettingsController { * @returns nothing; {@link store} carries success or failure. */ async select(preset: string): Promise { - const view = this.view const state = this.store.getSnapshot() - if (view === undefined || !state.writable) return - const generation = ++this.generation + const view = this.describeFace.getSnapshot().view?.namespaces + .find(entry => entry.ns === PERMISSION_SETTINGS_NS) + if (view === undefined || !state.writable || this.saving) return + this.saving = true this.store.update((draft) => { draft.status = 'saving' draft.error = null @@ -145,32 +139,59 @@ export class PermissionPresetSettingsController { ops: [{ op: 'set', path: ['defaultPreset'], value: preset }], expectedRevision: view.revision, }) - if (generation !== this.generation) return if (!response.result.ok) throw new Error(response.result.error.message) - this.accept(response.result.value, true) + 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) } catch (error) { - if (generation !== this.generation) return + this.saving = false + if (this.disposed) return this.fail(error) } } - /** Stop in-flight responses from publishing after plugin disposal. */ + /** Stop following the mirror; later publishes leave the snapshot alone. */ dispose(): void { - this.generation += 1 - this.view = undefined + this.disposed = true + this.following?.() + this.following = undefined } - private accept(view: SettingsNamespaceView, writable: boolean): void { - const resolved = permissionDefaultOf(view) - this.view = view - this.store.update((state) => { - state.status = 'ready' - state.error = null - state.writable = writable - state.currentValue = resolved.currentValue - state.options = resolved.options - state.revision = view.revision - }) + private derive(): void { + if (this.disposed || this.saving) return + const mirrored = this.describeFace.getSnapshot() + if (mirrored.view === undefined) { + // A held failure with no answer is a failed row; without one the read + // is still in flight and the row keeps its loading state. + if (mirrored.error !== null) this.fail(new Error(mirrored.error)) + return + } + const view = mirrored.view.namespaces.find(entry => entry.ns === PERMISSION_SETTINGS_NS) + if (view === undefined) { + this.store.update((state) => { + state.status = 'unavailable' + state.writable = false + state.currentValue = '' + state.options = [] + }) + return + } + try { + const resolved = permissionDefaultOf(view) + const { writable } = mirrored.view + this.store.update((state) => { + state.status = 'ready' + state.error = null + state.writable = writable + state.currentValue = resolved.currentValue + state.options = resolved.options + state.revision = view.revision + }) + } catch (error) { + this.fail(error) + } } private fail(error: unknown): void { @@ -180,12 +201,3 @@ export class PermissionPresetSettingsController { }) } } - -/** - * Refetch only after the row has opened once. - * @param controller - permission settings controller. - */ -export function refreshPermissionIfLoaded(controller: PermissionPresetSettingsController): void { - if (controller.store.getSnapshot().status === 'idle') return - void controller.load() -} 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 e3968cf002..19ae476a37 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,6 +13,7 @@ import { describe, expect, it } from 'vitest' import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } 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' import { @@ -58,6 +59,7 @@ async function bench() { }, }, } as never) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() let decoration: CommandDecoration | undefined ctx.provide('commandUi', { decorate(c: CommandDecoration) { 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 9df3920bd5..e4cbedbf6b 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 @@ -5,8 +5,15 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx' import { en } from '../src/client/locales.ts' +import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/client' import { PermissionPresetSettingsController } from '../src/client/settings-store.ts' +/** Controller over a real mirror derived from the same fake wire. */ +function derivedController(api: { settings: object }) { + const wire = api as never + return new PermissionPresetSettingsController(new SettingsDescribeMirror(wire), wire) +} + afterEach(cleanup) const SCHEMA = { @@ -58,7 +65,7 @@ function mount(controller: PermissionPresetSettingsController) { describe('PermissionRow', () => { it('loads the descriptor, opens the menu, and selects a new default', async () => { const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1)))) - const controller = new PermissionPresetSettingsController({ + const controller = derivedController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate, @@ -85,7 +92,7 @@ describe('PermissionRow', () => { it('requires explicit acknowledgement before saving Full access', async () => { const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1)))) - const controller = new PermissionPresetSettingsController({ + const controller = derivedController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate, @@ -109,7 +116,7 @@ describe('PermissionRow', () => { }) it('hides an unavailable namespace and disables a read-only provider', async () => { - const absent = new PermissionPresetSettingsController({ + const absent = derivedController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })), mutate: vi.fn(), @@ -119,7 +126,7 @@ describe('PermissionRow', () => { await waitFor(() => { expect(rendered.container.textContent).toBe('') }) rendered.unmount() - const readonly = new PermissionPresetSettingsController({ + const readonly = derivedController({ settings: { describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })), mutate: vi.fn(), @@ -134,7 +141,7 @@ describe('PermissionRow', () => { writable: boolean namespaces: SettingsNamespaceView[] }>>>() - const controller = new PermissionPresetSettingsController({ + const controller = derivedController({ settings: { describe: () => describe.promise, mutate: () => Promise.resolve({ 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 e4e218fe86..b8a4725a5c 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 @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/client' import { - PermissionPresetSettingsController, permissionDefaultOf, refreshPermissionIfLoaded, + PermissionPresetSettingsController, permissionDefaultOf, } from '../src/client/settings-store.ts' const SCHEMA = { @@ -30,6 +31,13 @@ function ok(value: T) { return { rpcId: 'test', result: { ok: true as const, value } } } +/** The permission controller over a real mirror and one fake wire. */ +function permissionController(api: object) { + const wire = { settings: api } as never + const mirror = new SettingsDescribeMirror(wire) + return { mirror, controller: new PermissionPresetSettingsController(mirror, wire) } +} + describe('permission settings store', () => { it('derives dynamic options and host labels from the descriptor schema', () => { expect(permissionDefaultOf(view('read-only'))).toEqual({ @@ -92,9 +100,7 @@ describe('permission settings store', () => { namespaces: [view('read-only', 4)], }))) const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5)))) - const controller = new PermissionPresetSettingsController({ - settings: { describe, mutate } as never, - }) + const { controller } = permissionController({ describe, mutate }) await controller.load() expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', @@ -113,126 +119,105 @@ describe('permission settings store', () => { currentValue: 'workspace-write', revision: 5, }) + // The write answer folded into the mirror; no re-read followed. + expect(describe).toHaveBeenCalledTimes(1) }) it('hides the row when the namespace is absent and contains write failures', async () => { const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))) - const controller = new PermissionPresetSettingsController({ - settings: { describe, mutate: vi.fn() } as never, - }) + const { controller } = permissionController({ describe, mutate: vi.fn() }) await controller.load() expect(controller.store.getSnapshot().status).toBe('unavailable') - const failing = new PermissionPresetSettingsController({ - settings: { - 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: {} }, - }, - }), - } as never, - }) + 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: {} }, + }, + }), + }).controller await failing.load() await failing.select('workspace-write') expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'stale' }) }) - it('contains read failures, no-ops without a writable view, and ignores stale responses', async () => { - const first = Promise.withResolvers>>() - const describe = vi.fn() - .mockImplementationOnce(() => first.promise) - .mockResolvedValueOnce(ok({ writable: false, hasDocument: false, namespaces: [view('read-only', 2)] })) + it('contains read failures and no-ops without a writable view', async () => { const mutate = vi.fn() - const controller = new PermissionPresetSettingsController({ - settings: { describe, mutate } as never, - }) - const stale = controller.load() - await controller.load() - first.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('workspace-write', 1)] })) - await stale - expect(controller.store.getSnapshot()).toMatchObject({ + const readOnly = permissionController({ + describe: () => Promise.resolve(ok({ + writable: false, hasDocument: false, namespaces: [view('read-only', 2)], + })), + mutate, + }).controller + await readOnly.load() + expect(readOnly.store.getSnapshot()).toMatchObject({ currentValue: 'read-only', writable: false, revision: 2, }) - await controller.select('workspace-write') + await readOnly.select('workspace-write') expect(mutate).not.toHaveBeenCalled() - const rejected = new PermissionPresetSettingsController({ - settings: { - describe: () => Promise.resolve({ - rpcId: 'test', - result: { ok: false as const, error: { code: 'internal', message: 'offline', details: {} } }, - }), - mutate, - } as never, - }) + const rejected = permissionController({ + describe: () => Promise.resolve({ + rpcId: 'test', + result: { ok: false as const, error: { code: 'internal', message: 'offline', details: {} } }, + }), + mutate, + }).controller await rejected.select('workspace-write') await rejected.load() expect(rejected.store.getSnapshot()).toMatchObject({ status: 'error', error: 'offline' }) + expect(mutate).not.toHaveBeenCalled() - const thrown = new PermissionPresetSettingsController({ - settings: { - // Promise consumers must contain unknown rejection values from a - // transport implementation, including non-Error legacy clients. - // oxlint-disable-next-line typescript/prefer-promise-reject-errors - describe: () => Promise.reject('disconnected'), - mutate, - } as never, - }) + const thrown = permissionController({ + // Promise consumers must contain unknown rejection values from a + // transport implementation, including non-Error legacy clients. + describe: () => Promise.reject('disconnected' as never), + mutate, + }).controller await thrown.load() expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' }) }) - it('disposal suppresses in-flight reads and writes, and loaded invalidations refetch', async () => { + it('follows a mirror refresh without an own read once loaded', async () => { + const describe = vi.fn() + .mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [view('read-only', 1)] })) + .mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [view('workspace-write', 2)] })) + const { mirror, controller } = permissionController({ describe, mutate: vi.fn() }) + await controller.load() + expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'read-only' }) + + await mirror.load() + + expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'workspace-write', revision: 2 }) + }) + + it('disposal stops deriving and suppresses in-flight writes', async () => { const read = Promise.withResolvers>>() - const describe = vi.fn(() => read.promise) - const idle = new PermissionPresetSettingsController({ settings: { describe, mutate: vi.fn() } as never }) - refreshPermissionIfLoaded(idle) - expect(describe).not.toHaveBeenCalled() + const { mirror, controller: idle } = permissionController({ describe: () => read.promise, mutate: vi.fn() }) const loading = idle.load() idle.dispose() read.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })) - await loading + await Promise.all([loading, mirror.load()]) expect(idle.store.getSnapshot().status).toBe('loading') - const rejectedRead = Promise.withResolvers>>() - const disposedRead = new PermissionPresetSettingsController({ - settings: { describe: () => rejectedRead.promise, mutate: vi.fn() } as never, - }) - const reading = disposedRead.load() - disposedRead.dispose() - rejectedRead.reject(new Error('late read')) - await reading - expect(disposedRead.store.getSnapshot().status).toBe('loading') - const mutation = Promise.withResolvers>>() - const activeDescribe = vi.fn(() => Promise.resolve(ok({ - writable: true, - hasDocument: false, - namespaces: [view('read-only')], - }))) - const active = new PermissionPresetSettingsController({ - settings: { - describe: activeDescribe, - mutate: () => mutation.promise, - } as never, + const { controller: active } = permissionController({ + describe: () => Promise.resolve(ok({ + writable: true, + hasDocument: false, + namespaces: [view('read-only')], + })), + mutate: () => mutation.promise, }) await active.load() - refreshPermissionIfLoaded(active) - await vi.waitFor(() => { expect(activeDescribe).toHaveBeenCalledTimes(2) }) const saving = active.select('workspace-write') active.dispose() mutation.resolve(ok(view('workspace-write', 1))) @@ -240,11 +225,9 @@ describe('permission settings store', () => { expect(active.store.getSnapshot().status).toBe('saving') const rejectedMutation = Promise.withResolvers>>() - const disposedWrite = new PermissionPresetSettingsController({ - settings: { - describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), - mutate: () => rejectedMutation.promise, - } as never, + const { controller: disposedWrite } = permissionController({ + describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), + mutate: () => rejectedMutation.promise, }) await disposedWrite.load() const writing = disposedWrite.select('workspace-write') From 27abf194138c031cba36e79a7be51e8937e7f4d8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 17:33:00 +0800 Subject: [PATCH 09/34] refactor(ui-settings-models): models page reads settings through the mirror --- .../ui-settings-models/src/client/index.ts | 2 +- .../ui-settings-models/src/client/store.ts | 31 ++++++++---- .../tests/components.client.spec.tsx | 31 ++++++++---- .../tests/onboarding-dialog.client.spec.tsx | 3 +- .../tests/provider-form.client.spec.tsx | 5 +- .../tests/store.client.spec.ts | 50 ++++++++++--------- 6 files changed, 72 insertions(+), 50 deletions(-) diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index e45e52a4e8..22d18857db 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -68,7 +68,7 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries') const connection = ctx.get('connection') as ConnectionHandle - const controller = new ModelsSettingsStore(connection.api) + const controller = new ModelsSettingsStore(connection.api, ctx.settingsScope.describe()) const useSnapshot = bindSnapshotSelector(controller.store) // Registration-time text (the nav label thunk) and the inject faces share // one bound translate; copy freshness rides the locale revision. diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 4389b9a6cb..fd7243acc1 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -11,6 +11,7 @@ import type { } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' import { getPath, hasPath, nodeAtPath, rehydrateSchema } from '@deepseek-ai/dsh-client-schema-form' /** @@ -106,14 +107,19 @@ export class ModelsSettingsStore { private generation = 0 /** - * @param api - the wire face (settings/credentials/llm domains). + * @param api - the wire face (credentials/llm domains, and settings writes). + * @param describeFace - the shared mirror's read-only face (namespace views and writability). */ - constructor(private readonly api: Pick) {} + constructor( + private readonly api: Pick, + private readonly describeFace: SettingsDescribeFace, + ) {} /** - * Refresh the whole page snapshot: directory and namespaces in parallel, - * then one batched credential describe over every referenced ref. A - * failure keeps the last good rows and surfaces the error. + * Refresh the whole page snapshot: the provider directory and the mirror's + * settings answer in parallel, then one batched credential describe over + * every referenced ref. A failure keeps the last good rows and surfaces the + * error. * @returns nothing; the snapshot carries the outcome. */ async load(): Promise { @@ -121,17 +127,20 @@ export class ModelsSettingsStore { this.store.update((s) => { s.status = 'loading'; s.error = null }) let providers: ConfigurableProviderView[] let writable: boolean - let views: SettingsNamespaceView[] + let views: readonly SettingsNamespaceView[] try { - const [providersResponse, settingsResponse] = await Promise.all([ + const [providersResponse] = await Promise.all([ this.api.llm.providers({}), - this.api.settings.describe({}), + this.describeFace.ensure(), ]) if (!providersResponse.result.ok) throw new Error(providersResponse.result.error.message) - if (!settingsResponse.result.ok) throw new Error(settingsResponse.result.error.message) + const mirrored = this.describeFace.getSnapshot() + if (mirrored.view === undefined) { + throw new Error(mirrored.error ?? 'settings have not answered yet') + } providers = providersResponse.result.value.providers - writable = settingsResponse.result.value.writable - views = settingsResponse.result.value.namespaces + writable = mirrored.view.writable + views = mirrored.view.namespaces } catch (error) { if (generation !== this.generation) return this.store.update((s) => { 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 66ba8d33a4..14780afc35 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -14,6 +14,7 @@ import { DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels, } from '../src/client/DeepSeekModelsEditor.tsx' import { apiKeyFailure } from '../src/client/apiKey.ts' +import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/client' import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' import type { ProviderRow } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' @@ -185,7 +186,8 @@ type WireFace = ConstructorParameters[0] async function mountFace(scripted: ReturnType) { const { face, update, replace, mutate, set, unset } = scripted - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const mirror = new SettingsDescribeMirror(face as never) + const controller = new ModelsSettingsStore(face as unknown as WireFace, mirror) await controller.load() const injected: ModelsSectionInjected = { controller, @@ -194,7 +196,7 @@ async function mountFace(scripted: ReturnType) { t, } const view = render() - return { view, face, update, replace, mutate, set, unset, controller } + return { view, face, update, replace, mutate, set, unset, controller, mirror } } async function mountSection(overrides: Parameters[0] = {}) { @@ -265,7 +267,7 @@ describe('ModelsSection', () => { face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])), }))) - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, new SettingsDescribeMirror(face as never)) await controller.load() render( { face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])), }))) - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, new SettingsDescribeMirror(face as never)) await controller.load() cleanup() render( { 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(face.settings.describe.mock.calls.length).toBeGreaterThan(1) }) + // 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) }) expect((await screen.findByRole('status')).textContent).toBe( providerCopy(en.savedProvider, { provider: 'deepseek-official', displayName: 'DeepSeek' }), ) @@ -952,7 +956,7 @@ describe('ModelsSection', () => { const set = vi.fn() .mockResolvedValueOnce(fail('credential store unavailable', 'credential-rejected')) .mockResolvedValueOnce(ok({})) - const { face, controller } = await mountSection({ mutate, set }) + const { face, controller, mirror } = await mountSection({ mutate, set }) fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-ant' } }) @@ -964,7 +968,12 @@ describe('ModelsSection', () => { hasDocument: false, namespaces: wireNamespaces().map(namespace => namespace.ns === 'llm-pi-ai' ? afterSettings : namespace), })) - await act(async () => { await controller.load() }) + // The refreshed settings answer reaches the page through the mirror's own + // refresh (the document commit's invalidation in production). + await act(async () => { + await mirror.load() + await controller.load() + }) expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) }) @@ -1007,7 +1016,7 @@ describe('ModelsSection', () => { const unhandled = vi.fn() process.on('unhandledRejection', unhandled) try { - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, new SettingsDescribeMirror(face as never)) await controller.load() render( { it('renders the load failure with a retry control', async () => { const face = scriptedFace() face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never - const controller = new ModelsSettingsStore(face.face as unknown as WireFace) + const controller = new ModelsSettingsStore(face.face as unknown as WireFace, new SettingsDescribeMirror(face.face as never)) await controller.load() render( { hasDocument: false, namespaces: wireNamespaces(), }))) - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, new SettingsDescribeMirror(face as never)) await controller.load() cleanup() render( { it('loads on first render of an idle controller', async () => { const { face } = scriptedFace() - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, new SettingsDescribeMirror(face as never)) render( { throw new Error('unused standard hook') }) as never 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 246c7d64b1..ab96d4aeca 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 @@ -9,6 +9,7 @@ import { ModelsSection, providerCopy } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx' import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx' import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx' +import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/client' import { ModelsSettingsStore, deriveKeyRef, protocolChoices } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' @@ -139,7 +140,7 @@ function firstMutate(mutate: ReturnType): MutateCall { async function mountSection(options: Parameters[0] = {}) { const scripted = scriptedFace(options) - const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace) + const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace, new SettingsDescribeMirror(scripted.face as never)) await controller.load() const injected: ModelsSectionInjected = { controller, @@ -637,7 +638,7 @@ describe('provider rows', () => { active: true, }], }))) as never - const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace) + const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace, new SettingsDescribeMirror(scripted.face as never)) await controller.load() render( Promise.resolve(ok({})), }, } - return { face: face as never, seenRefs } + const wire = face as never + return { face: wire, mirror: new SettingsDescribeMirror(wire), seenRefs } } describe('ModelsSettingsStore', () => { it('joins rows with configured, removable, and credential state', async () => { - const { face, seenRefs } = api() - const store = new ModelsSettingsStore(face) + const { face, mirror, seenRefs } = api() + const store = new ModelsSettingsStore(face, mirror) await store.load() const state = store.store.getSnapshot() expect(state.status).toBe('ready') @@ -99,8 +101,8 @@ describe('ModelsSettingsStore', () => { }) it('degrades the credential badge, not the page, when the credential domain fails', async () => { - const { face } = api({ describeCredentials: () => Promise.resolve(fail('no provider')) }) - const store = new ModelsSettingsStore(face) + const { face, mirror } = api({ describeCredentials: () => Promise.resolve(fail('no provider')) }) + const store = new ModelsSettingsStore(face, mirror) await store.load() const state = store.store.getSnapshot() expect(state.status).toBe('ready') @@ -109,10 +111,10 @@ describe('ModelsSettingsStore', () => { }) it('settles a credential transport rejection without leaving the store loading', async () => { - const { face } = api({ + const { face, mirror } = api({ describeCredentials: () => Promise.reject(new Error('credential transport down')), }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, mirror) await expect(store.load()).resolves.toBeUndefined() expect(store.store.getSnapshot()).toMatchObject({ status: 'ready', @@ -121,22 +123,22 @@ describe('ModelsSettingsStore', () => { }) it('stringifies a non-Error credential transport rejection', async () => { - const { face } = api({ + const { face, mirror } = api({ // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario describeCredentials: () => Promise.reject('credential transport refusal'), }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, mirror) await expect(store.load()).resolves.toBeUndefined() expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal') }) it('surfaces a directory failure and keeps the last good rows', async () => { - const { face } = api() - const store = new ModelsSettingsStore(face) + const { face, mirror } = api() + const store = new ModelsSettingsStore(face, mirror) await store.load() expect(store.store.getSnapshot().rows).toHaveLength(4) const broken = api({ providers: () => Promise.resolve(fail('directory down')) }) - const failing = new ModelsSettingsStore(broken.face) + const failing = new ModelsSettingsStore(broken.face, broken.mirror) await failing.load() expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'directory down' }) // The first store's snapshot is untouched by the second's failure. @@ -147,7 +149,7 @@ describe('ModelsSettingsStore', () => { let release: (() => void) | undefined const gate = new Promise((resolve) => { release = resolve }) let call = 0 - const { face } = api({ + const { face, mirror } = api({ providers: async () => { call += 1 if (call === 1) { @@ -157,7 +159,7 @@ describe('ModelsSettingsStore', () => { return ok({ providers: DIRECTORY }) }, }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, mirror) const first = store.load() const second = store.load() release?.() @@ -168,7 +170,7 @@ describe('ModelsSettingsStore', () => { describe('edge joins', () => { it('treats a non-object profile as having no credential reference', async () => { - const { face } = api({ + const { face, mirror } = api({ describeSettings: () => Promise.resolve(ok({ writable: true, hasDocument: false, @@ -187,7 +189,7 @@ describe('edge joins', () => { ] as never, })), }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, mirror) await store.load() const state = store.store.getSnapshot() expect(state.rows[0]).toMatchObject({ configured: true, removable: false }) @@ -195,7 +197,7 @@ describe('edge joins', () => { }) it('skips the credential describe entirely when no row names a reference', async () => { - const { face, seenRefs } = api({ + const { face, mirror, seenRefs } = api({ describeSettings: () => Promise.resolve(ok({ writable: true, hasDocument: false, @@ -207,15 +209,15 @@ describe('edge joins', () => { ] as never, })), }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, mirror) await store.load() expect(seenRefs).toEqual([]) expect(store.store.getSnapshot().status).toBe('ready') }) it('surfaces a settings describe failure', async () => { - const { face } = api({ describeSettings: () => Promise.resolve(fail('settings down')) }) - const store = new ModelsSettingsStore(face) + const { face, mirror } = api({ describeSettings: () => Promise.resolve(fail('settings down')) }) + const store = new ModelsSettingsStore(face, mirror) await store.load() expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' }) }) @@ -223,8 +225,8 @@ describe('edge joins', () => { it('stringifies a non-Error load failure', async () => { // The wire can surface non-Error throwables; the store must stringify them. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario - const { face } = api({ providers: () => Promise.reject('plain refusal') }) - const store = new ModelsSettingsStore(face) + const { face, mirror } = api({ providers: () => Promise.reject('plain refusal') }) + const store = new ModelsSettingsStore(face, mirror) await store.load() expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' }) }) @@ -233,7 +235,7 @@ describe('edge joins', () => { let release: (() => void) | undefined const gate = new Promise((resolve) => { release = resolve }) let call = 0 - const { face } = api({ + const { face, mirror } = api({ providers: async () => { call += 1 if (call === 1) { @@ -243,7 +245,7 @@ describe('edge joins', () => { return ok({ providers: DIRECTORY }) }, }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, mirror) const first = store.load() const second = store.load() await second From 608fb895a6ac5d8bff6af569320e3715c5c6bf9f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 17:33:01 +0800 Subject: [PATCH 10/34] refactor(ui-agent-preset): settings row reads writability through the mirror --- .../ui-agent-preset/src/client/index.ts | 4 +- .../src/client/settings-store.ts | 42 +++++++++-------- .../tests/apply.client.spec.ts | 4 +- .../tests/settings-store.client.spec.ts | 47 +++++++++++-------- 4 files changed, 56 insertions(+), 41 deletions(-) diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index f327479660..e28039fe45 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -46,7 +46,7 @@ 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'] +export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] /** * Mount the General-settings row. @@ -54,7 +54,7 @@ export const inject = ['slots', 'locale', 'connection', 'remote'] */ export function apply(ctx: ClientContext): void { const { api } = ctx.get('connection') as ConnectionHandle - const controller = new AgentPresetSettingsController(api) + const controller = new AgentPresetSettingsController(api, 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>() 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 9589d5c2bb..e2762f0a59 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -9,6 +9,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SettingsDescribeFace } 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' @@ -191,7 +192,14 @@ export class AgentPresetSettingsController { /** Row snapshot the renderer subscribes to. */ readonly store: SnapshotStore = createSnapshotStore(INITIAL) - constructor(private readonly api: IApiClient) {} + /** + * @param api - the agent-preset and settings wire faces (roster and default write). + * @param describeFace - the shared mirror's read-only face (writability source). + */ + constructor( + private readonly api: IApiClient, + private readonly describeFace: SettingsDescribeFace, + ) {} private set(patch: Partial): void { this.store.set({ ...this.store.getSnapshot(), ...patch }) @@ -212,24 +220,20 @@ export class AgentPresetSettingsController { this.set({ status: 'unavailable', options: [], currentValue: '' }) return } - try { - // The roster says what may be chosen; `settings.describe` says whether - // this browser may write the choice down. A non-loopback browser reaches - // neither method, so a refused describe leaves the row read-only rather - // than offering a control whose write the Host would refuse. - const described = await this.api.settings.describe({}) - this.set({ - status: 'ready', - error: null, - writable: described.result.ok && described.result.value.writable, - options: presetOptions(presets), - // A roster can mark nothing default: settings can name a preset that - // was since deleted, and the picker still has to show something. - currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id, - }) - } catch (error) { - this.set({ status: 'error', error: messageOf(error) }) - } + // The roster says what may be chosen; the shared mirror says whether this + // browser may write the choice down. A non-loopback browser's mirror never + // answers, so the row stays read-only rather than offering a control + // whose write the Host would refuse. + await this.describeFace.ensure() + this.set({ + status: 'ready', + error: null, + writable: this.describeFace.getSnapshot().view?.writable ?? false, + options: presetOptions(presets), + // A roster can mark nothing default: settings can name a preset that + // was since deleted, and the picker still has to show something. + currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id, + }) } /** 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 7ff4741648..0a500c508d 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -11,6 +11,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote, usePinnedBrowserLanguages } 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 } from '@deepseek-ai/dsh-client-ui-agent-preset/client' import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx' @@ -117,6 +118,7 @@ async function bench() { }, }, } as never) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault } } @@ -178,7 +180,7 @@ function sessionsDouble(state: { describe('ui-agent-preset apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope']) }) it('registers the General row and the settings section', async () => { 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 54fd600a60..87744cd826 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 @@ -7,9 +7,15 @@ import { describe, expect, it } from 'vitest' import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/client' import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf, } from '../src/client/settings-store.ts' + +/** Controller over a real mirror derived from the same fake wire. */ +function derivedController(api: IApiClient) { + return new AgentPresetSettingsController(api, new SettingsDescribeMirror(api)) +} import { AgentPresetSeatController } from '../src/client/seat-store.ts' import type { SeatSessionSummary } from '../src/client/seat-store.ts' @@ -60,7 +66,7 @@ function fakeApi( describe('the agent-preset settings controller', () => { it('disables the control when this browser may not write settings', async () => { - const controller = new AgentPresetSettingsController(fakeApi([ + const controller = derivedController(fakeApi([ { id: 'standard', trust: 'system', isDefault: true }, ], { readOnly: true })) @@ -74,7 +80,7 @@ describe('the agent-preset settings controller', () => { }) it('derives options and the current default from one roster call', async () => { - const controller = new AgentPresetSettingsController(fakeApi([ + const controller = derivedController(fakeApi([ { id: 'standard', trust: 'system', isDefault: true }, { id: 'mine', trust: 'user', isDefault: false }, ])) @@ -91,7 +97,7 @@ describe('the agent-preset settings controller', () => { }) it('offers no broken preset: the pickers choose the NEXT session\'s composition', async () => { - const controller = new AgentPresetSettingsController(fakeApi([ + const controller = derivedController(fakeApi([ { id: 'standard', trust: 'system', isDefault: true }, { id: 'damaged', trust: 'user', isDefault: false, broken: 'the composition is not valid YAML' }, ] as never)) @@ -105,7 +111,7 @@ describe('the agent-preset settings controller', () => { }) it('carries the display metadata a preset published', async () => { - const controller = new AgentPresetSettingsController(fakeApi([ + const controller = derivedController(fakeApi([ { id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' }, ] as never)) @@ -119,7 +125,7 @@ describe('the agent-preset settings controller', () => { }) it('reports an empty roster as unavailable, not as an error', async () => { - const controller = new AgentPresetSettingsController(fakeApi([])) + const controller = derivedController(fakeApi([])) await controller.load() @@ -131,7 +137,7 @@ describe('the agent-preset settings controller', () => { it('writes only the default field, into the agent-presets namespace', async () => { const writes: Recorded[] = [] - const controller = new AgentPresetSettingsController(fakeApi([ + const controller = derivedController(fakeApi([ { id: 'standard', trust: 'system', isDefault: true }, { id: 'minimal', trust: 'system', isDefault: false }, ], { writes })) @@ -144,7 +150,7 @@ describe('the agent-preset settings controller', () => { }) it('restores the previous value and surfaces the message when the write fails', async () => { - const controller = new AgentPresetSettingsController(fakeApi([ + const controller = derivedController(fakeApi([ { id: 'standard', trust: 'system', isDefault: true }, { id: 'minimal', trust: 'system', isDefault: false }, ], { failWrite: 'read-only settings' })) @@ -160,7 +166,7 @@ describe('the agent-preset settings controller', () => { it('ignores a pick that is already the default', async () => { const writes: Recorded[] = [] - const controller = new AgentPresetSettingsController(fakeApi([ + const controller = derivedController(fakeApi([ { id: 'standard', trust: 'system', isDefault: true }, ], { writes })) await controller.load() @@ -171,7 +177,7 @@ describe('the agent-preset settings controller', () => { }) it('surfaces a roster failure without claiming the deployment has no presets', async () => { - const controller = new AgentPresetSettingsController(fakeApi([], { failList: 'host down' })) + const controller = derivedController(fakeApi([], { failList: 'host down' })) await controller.load() @@ -183,7 +189,7 @@ describe('the agent-preset settings controller', () => { it('shows the first preset when the roster marks none default', async () => { // Settings can name a preset that was since deleted; the picker still has // to show something rather than an empty control. - const controller = new AgentPresetSettingsController(fakeApi([ + const controller = derivedController(fakeApi([ { id: 'standard', trust: 'system', isDefault: false }, { id: 'mine', trust: 'user', isDefault: false }, ])) @@ -195,7 +201,7 @@ describe('the agent-preset settings controller', () => { it('ignores a load while one is already in flight', async () => { const writes: Recorded[] = [] - const controller = new AgentPresetSettingsController(fakeApi( + const controller = derivedController(fakeApi( [{ id: 'standard', trust: 'system', isDefault: true }], { writes })) await Promise.all([controller.load(), controller.load()]) @@ -211,7 +217,7 @@ describe('the agent-preset settings controller', () => { }) it('reports a transport that rejects rather than answering', async () => { - const controller = new AgentPresetSettingsController({ + const controller = derivedController({ agentPresets: { list: () => Promise.reject(new Error('socket closed')) }, } as unknown as IApiClient) @@ -221,7 +227,7 @@ describe('the agent-preset settings controller', () => { }) it('reports a transport that rejects mid-write and keeps the old default showing', async () => { - const controller = new AgentPresetSettingsController(fakeApi([ + const controller = derivedController(fakeApi([ { id: 'standard', trust: 'system', isDefault: true }, { id: 'mine', trust: 'user', isDefault: false }, ], { failWriteWith: new Error('socket closed') })) @@ -434,7 +440,7 @@ describe('the new-session chip controller', () => { expect(controller.store.getSnapshot().error).toBe('socket closed') }) - it('reports a refused describe as a failure rather than a half-read row', async () => { + it('degrades to a read-only row while the mirror holds no answer', async () => { const api = { agentPresets: { list: () => Promise.resolve({ @@ -442,16 +448,19 @@ describe('the new-session chip controller', () => { result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } }, }), }, - // The roster answered; `settings.describe` is what rejected, and the row - // cannot claim a writable default it never confirmed. + // The roster answered; the mirror's read is what failed, so the row + // shows the current default without offering a write it never confirmed. settings: { describe: () => Promise.reject(new Error('socket closed')) }, } as unknown as IApiClient - const controller = new AgentPresetSettingsController(api) + const controller = derivedController(api) await controller.load() - expect(controller.store.getSnapshot().status).toBe('error') - expect(controller.store.getSnapshot().error).toBe('socket closed') + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', + writable: false, + currentValue: 'standard', + }) }) From 8ea21a166c9a6ce58b07deea26d86d9d54138fc2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 17:33:02 +0800 Subject: [PATCH 11/34] refactor(ui-settings-general): document action derives from the mirror --- .../ui-settings-general/src/client/index.ts | 12 +-- .../src/client/settings-document-store.ts | 76 ++++++++++--------- .../tests/apply.client.spec.ts | 14 ++-- .../tests/components.client.spec.tsx | 27 ++++--- .../settings-document-store.client.spec.ts | 48 ++++++------ .../tests/shell.client.spec.ts | 4 +- 6 files changed, 100 insertions(+), 81 deletions(-) diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index e1a94934c7..1a12b95722 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -25,7 +25,7 @@ import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' import { GeneralSection } from './GeneralSection.tsx' import { SettingsDocumentAction } from './SettingsDocumentAction.tsx' import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx' -import { refreshDocumentIfLoaded, SettingsDocumentStore } from './settings-document-store.ts' +import { SettingsDocumentStore } from './settings-document-store.ts' import { en, zh, type SettingsKey } from './locales.ts' export type { @@ -54,7 +54,7 @@ const NS = 'settings' * ui-settings' apply, whose activation order relative to this one is NOT * constrained; registrations depend on their slots through `slots.inject()`. */ -export const inject = ['slots', 'locale', 'connection'] +export const inject = ['slots', 'locale', 'connection', 'settingsScope'] /** * Register the `settings` dictionaries, the chrome content, and the General @@ -69,8 +69,10 @@ export function apply(ctx: ClientContext): void { // locale/change re-registration wiring. const t = ctx.locale.bind(NS) const connection = ctx.get('connection') as ConnectionHandle + // The action follows the shared describe mirror, whose owning plugin + // already refreshes it on document commits and reconnects. const documentController = connection.isLoopback - ? new SettingsDocumentStore(connection.api) + ? new SettingsDocumentStore(connection.api, ctx.settingsScope.describe()) : undefined const documentInjected = documentController === undefined ? undefined @@ -78,9 +80,7 @@ export function apply(ctx: ClientContext): void { const useSnapshot = bindSnapshotSelector(documentController.store) return (): SettingsDocumentActionInjected => ({ controller: documentController, useSnapshot }) })() - ctx.effect(() => ctx.on('connection/reset', () => { - refreshDocumentIfLoaded(documentController) - }), 'ui-settings-general: metadata invalidations') + ctx.effect(() => () => { documentController?.dispose() }, 'ui-settings-general: document action directory') // The settings shell: this package occupies the sidebar-owned hole and // declares the settings slots. Ledger → nav-row projection as an observable // source (uSES contract: getSnapshot returns the cached rows until the diff --git a/packages/client/ui-settings-general/src/client/settings-document-store.ts b/packages/client/ui-settings-general/src/client/settings-document-store.ts index dde604d5ae..2e8c8eb192 100644 --- a/packages/client/ui-settings-general/src/client/settings-document-store.ts +++ b/packages/client/ui-settings-general/src/client/settings-document-store.ts @@ -2,6 +2,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' /** Browser state of the Host-owned settings document. */ export interface SettingsDocumentState { @@ -17,51 +18,37 @@ function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) } -/** Loads local-document availability and invokes the pathless Host-owned open operation. */ +/** Derives local-document availability from the shared mirror and invokes the pathless Host-owned open operation. */ export class SettingsDocumentStore { /** uSES-safe state source shared by the registered header action. */ readonly store: SnapshotStore = createSnapshotStore({ status: 'idle', opening: false, error: null, }) - private generation = 0 + private following: (() => void) | undefined /** - * @param api - loopback settings wire face that reports and opens the provider document. + * @param api - loopback settings wire face that opens the provider document. + * @param describeFace - the shared mirror's read-only face (`hasDocument` source). */ - constructor(private readonly api: Pick) {} + constructor( + private readonly api: Pick, + private readonly describeFace: SettingsDescribeFace, + ) {} /** - * Load whether the current provider owns a local document. - * @returns after the latest metadata response updates the store. + * Begin following the mirror (idempotent) and reflect whether the current + * provider owns a local document. + * @returns settlement once the snapshot reflects the mirror. */ async load(): Promise { - const generation = ++this.generation + this.following ??= this.describeFace.subscribe(() => { this.derive() }) this.store.update((state) => { state.status = 'loading' state.error = null }) - try { - const { result } = await this.api.settings.describe({}) - if (generation !== this.generation) return - if (!result.ok) { - this.store.update((state) => { - state.status = 'unavailable' - state.error = result.error.message - }) - return - } - this.store.update((state) => { - state.status = result.value.hasDocument ? 'ready' : 'unavailable' - state.error = null - }) - } catch (error) { - if (generation !== this.generation) return - this.store.update((state) => { - state.status = 'unavailable' - state.error = messageOf(error) - }) - } + await this.describeFace.ensure() + this.derive() } /** @@ -84,13 +71,30 @@ export class SettingsDocumentStore { this.store.update((state) => { state.opening = false }) } } -} -/** - * Refresh document availability after reconnect only when a surface has already requested it. - * @param controller - optional loopback document state owner. - */ -export function refreshDocumentIfLoaded(controller: SettingsDocumentStore | undefined): void { - if (controller === undefined || controller.store.getSnapshot().status === 'idle') return - void controller.load() + /** Stop following the mirror. */ + dispose(): void { + this.following?.() + this.following = undefined + } + + private derive(): void { + const mirrored = this.describeFace.getSnapshot() + if (mirrored.view === undefined) { + // A held failure with no answer means the document cannot be located; + // without one the read is still in flight and loading stands. + if (mirrored.error !== null) { + this.store.update((state) => { + state.status = 'unavailable' + state.error = mirrored.error + }) + } + return + } + const { hasDocument } = mirrored.view + this.store.update((state) => { + state.status = hasDocument ? 'ready' : 'unavailable' + state.error = null + }) + } } 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 d6c3ffff02..ee65055ff7 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -4,7 +4,8 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote, usePinnedBrowserLanguages } 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 } from '@deepseek-ai/dsh-client-ui-settings-general/client' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' @@ -48,6 +49,8 @@ async function bench(isLoopback = true) { api: { settings: { describe: settingsDescribe, openDocument: settingsOpenDocument } }, isLoopback, } as never) + new TestRemote(ctx) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument } } @@ -75,7 +78,7 @@ function generalEntry(slots: SlotRegistry) { describe('ui-settings-general apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'settingsScope']) }) it('fills all five seats for declarations before or after apply', async () => { @@ -149,16 +152,17 @@ describe('ui-settings-general apply', () => { expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置') }) - it('refreshes loaded document availability on reconnect without reading it eagerly', async () => { + it('reads availability from the shared mirror and follows its reconnect refresh', async () => { const b = await bench() declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.action')[0]! const { controller } = (entry.inject as unknown as () => SettingsDocumentActionInjected)() - b.ctx.emit('connection/reset') - expect(b.settingsDescribe).not.toHaveBeenCalled() + // The mirror read once at its own boot; the action's load adds no read. + await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledOnce() }) await controller.load() expect(b.settingsDescribe).toHaveBeenCalledOnce() + expect(controller.store.getSnapshot().status).toBe('ready') b.ctx.emit('connection/reset') await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) }) }) 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 874bd44630..d4f788383a 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -7,7 +7,14 @@ import { GeneralSection } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import type { TriggerContentProps } from '../src/client/chrome.tsx' import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' +import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/client' import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' + +/** Store over a real mirror derived from the same fake wire. */ +function derivedDocumentStore(api: object) { + const wire = api as never + return new SettingsDocumentStore(wire, new SettingsDescribeMirror(wire)) +} import { en } from '../src/client/locales.ts' afterEach(cleanup) @@ -64,7 +71,7 @@ describe('SettingsDocumentAction', () => { rpcId: 'document-open' as never, result: { ok: true as const, value: { opened: true as const } }, })) - const controller = new SettingsDocumentStore({ + const controller = derivedDocumentStore({ settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 'document-action' as never, @@ -87,7 +94,7 @@ describe('SettingsDocumentAction', () => { await waitFor(() => { expect(openDocument).toHaveBeenCalledWith({}) }) }) - it('stays absent without a document and retries availability after remount', async () => { + 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, @@ -97,12 +104,9 @@ describe('SettingsDocumentAction', () => { rpcId: 'document-action-ready' as never, result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } }, }) - const controller = new SettingsDocumentStore({ - settings: { - describe, - openDocument: vi.fn(), - }, - } as never) + const wire = { settings: { describe, openDocument: vi.fn() } } as never + const mirror = new SettingsDescribeMirror(wire) + const controller = new SettingsDocumentStore(wire, mirror) const first = render( { controller={controller} useSnapshot={bindSnapshotSelector(controller.store)} />) + // A remount alone re-reads nothing; availability moves with the mirror's + // own refresh (a document commit or reconnect in production). + await waitFor(() => { expect(controller.store.getSnapshot().status).toBe('unavailable') }) + expect(describe).toHaveBeenCalledTimes(1) + await mirror.load() expect(await screen.findByRole('button', { name: 'Open configuration file' })).toBeTruthy() expect(describe).toHaveBeenCalledTimes(2) }) it('keeps the action available and reports a native-open failure', async () => { - const controller = new SettingsDocumentStore({ + const controller = derivedDocumentStore({ settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 'document-action' 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 b514da85da..a8a42953ee 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 @@ -1,7 +1,14 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' +import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/client' import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' +/** Store over a real mirror derived from the same fake wire. */ +function derivedDocumentStore(api: object) { + const wire = api as never + return new SettingsDocumentStore(wire, new SettingsDescribeMirror(wire)) +} + function response(hasDocument = false): RpcResponse<{ writable: boolean hasDocument: boolean @@ -34,7 +41,7 @@ describe('SettingsDocumentStore', () => { it('loads provider metadata and asks the settings domain to open its document', async () => { const describe = vi.fn(() => Promise.resolve(response(true))) const openDocument = vi.fn(() => Promise.resolve(opened())) - const controller = new SettingsDocumentStore({ settings: { describe, openDocument } } as never) + const controller = derivedDocumentStore({ settings: { describe, openDocument } } as never) await controller.load() expect(controller.store.getSnapshot()).toEqual({ status: 'ready', opening: false, error: null, @@ -45,7 +52,7 @@ describe('SettingsDocumentStore', () => { it('marks absent or failed metadata unavailable without opening anything', async () => { const openDocument = vi.fn(() => Promise.resolve(opened())) - const absent = new SettingsDocumentStore({ + const absent = derivedDocumentStore({ settings: { describe: () => Promise.resolve(response()), openDocument }, } as never) await absent.load() @@ -53,13 +60,13 @@ describe('SettingsDocumentStore', () => { expect(absent.store.getSnapshot().status).toBe('unavailable') expect(openDocument).not.toHaveBeenCalled() - const failed = new SettingsDocumentStore({ + const failed = derivedDocumentStore({ settings: { describe: () => Promise.reject(new Error('offline')), openDocument }, } as never) await failed.load() expect(failed.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' }) - const rejected = new SettingsDocumentStore({ + const rejected = derivedDocumentStore({ settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openDocument }, } as never) await rejected.load() @@ -71,7 +78,7 @@ describe('SettingsDocumentStore', () => { it('collapses concurrent open gestures and recovers after a failure', async () => { let resolveOpen!: (response: RpcResponse<{ opened: true }>) => void const openDocument = vi.fn(() => new Promise>((resolve) => { resolveOpen = resolve })) - const controller = new SettingsDocumentStore({ + const controller = derivedDocumentStore({ settings: { describe: () => Promise.resolve(response(true)), openDocument }, } as never) await controller.load() @@ -88,23 +95,15 @@ describe('SettingsDocumentStore', () => { }) }) - it('ignores stale metadata completions and reports non-Error native failures', async () => { - let resolveFirst!: (value: ReturnType) => void - const first = new Promise>((resolve) => { resolveFirst = resolve }) - const describe = vi.fn() - .mockReturnValueOnce(first) - .mockResolvedValueOnce(response(true)) + it('reports non-Error native failures and recovers availability via a mirror refresh', async () => { let rejectOpen!: (reason?: unknown) => void - const controller = new SettingsDocumentStore({ + const controller = derivedDocumentStore({ settings: { - describe, + describe: vi.fn(() => Promise.resolve(response(true))), openDocument: () => new Promise((_, reject) => { rejectOpen = reject }), }, } as never) - const stale = controller.load() await controller.load() - resolveFirst(response()) - await stale expect(controller.store.getSnapshot().status).toBe('ready') const opening = controller.open() rejectOpen('native unavailable') @@ -113,20 +112,21 @@ describe('SettingsDocumentStore', () => { status: 'ready', opening: false, error: 'native unavailable', }) - let rejectFirst!: (error: Error) => void - const rejectedFirst = new Promise>((_, reject) => { rejectFirst = reject }) - const caught = new SettingsDocumentStore({ + // A first read that failed leaves the action unavailable with the miss + // recorded; the mirror's next refresh (a commit or reconnect) recovers it. + const wire = { settings: { describe: vi.fn() - .mockReturnValueOnce(rejectedFirst) + .mockRejectedValueOnce(new Error('offline')) .mockResolvedValueOnce(response(true)), openDocument: vi.fn(), }, - } as never) - const staleRejection = caught.load() + } as never + const mirror = new SettingsDescribeMirror(wire) + const caught = new SettingsDocumentStore(wire, mirror) await caught.load() - rejectFirst(new Error('stale offline')) - await staleRejection + expect(caught.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' }) + await mirror.load() expect(caught.store.getSnapshot()).toMatchObject({ status: 'ready', error: null }) }) }) 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 32c354deda..2b50a33596 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -2,6 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '../src/client/index.ts' import type { SettingsRootInjected } from '../src/client/shell-contract.ts' import { SettingsRoot } from '../src/client/SettingsRoot.tsx' @@ -22,6 +23,7 @@ async function bench() { isLoopback: false, } as never) ctx.provide('remote', { $on: () => () => {} } as never) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry } } @@ -49,7 +51,7 @@ const CHILD_SPECS = { describe('ui-settings apply', () => { it('declares only the slot registry (a pure composition face, no locale)', () => { - expect(inject).toEqual(['slots', 'locale', 'connection']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'settingsScope']) }) it('registers the shell and declares every child slot, before or after the declaration', async () => { From 6b027e9cecfa7ff35409b7a6077143b9375748c9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 17:33:11 +0800 Subject: [PATCH 12/34] test(web): tighten the startup describe budget to the mirror's two reads --- apps/web/tests/startup-rpc-budget.e2e.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/startup-rpc-budget.e2e.ts b/apps/web/tests/startup-rpc-budget.e2e.ts index 33938d6088..22d109d1d9 100644 --- a/apps/web/tests/startup-rpc-budget.e2e.ts +++ b/apps/web/tests/startup-rpc-budget.e2e.ts @@ -12,14 +12,12 @@ import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts import { newEnglishPage } from './support.ts' /** - * Itemized so the budget stays explainable. The mirror reads twice: once - * eagerly at bind time over HTTP, and once on the first-connection reset — - * that second read closes the window where a document commit lands between - * the eager read and the SSE subscription and its invalidation is lost. - * Beside it, the direct callers not yet migrated: models onboarding (1) + - * agent-preset settings row on reset (1). Their migration tightens this to 2. + * Both reads are the mirror's: once eagerly at bind time over HTTP, and once + * on the first-connection reset — that second read closes the window where a + * document commit lands between the eager read and the SSE subscription and + * its invalidation is lost. Every settings consumer derives from these two. */ -const DESCRIBE_BUDGET = 4 +const DESCRIBE_BUDGET = 2 let scaffold: WebScaffold let browser: Browser From 4e4dc8a9e56c2881a19fec54771feee0b5b0d89f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 17:39:09 +0800 Subject: [PATCH 13/34] docs(ui-settings): document the describe mirror and its budget --- ...6-08-17-settings-describe-mirror.i18n.yaml | 6 ++++ .../2026-08-17-settings-describe-mirror.md | 32 +++++++++++++++++++ .../2026-08-17-settings-describe-mirror.zh.md | 32 +++++++++++++++++++ packages/client/ui-settings/README.i18n.yaml | 4 +-- packages/client/ui-settings/README.md | 2 +- packages/client/ui-settings/README.zh.md | 2 +- .../ui-settings/src/client/settings-scope.ts | 20 ++++++------ 7 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md create mode 100644 .agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml new file mode 100644 index 0000000000..245a6770c7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md +2026-08-17-settings-describe-mirror.md: c845c8ab65c526f27d09d1efdfd584a757ef00ba +2026-08-17-settings-describe-mirror.zh.md: 1229aaf9900178b6a86d7f3cdedeebc2ac99663c diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md new file mode 100644 index 0000000000..c845c8ab65 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md @@ -0,0 +1,32 @@ +# Agent Note: Settings describe mirror + +Status: implemented + +English | [中文](2026-08-17-settings-describe-mirror.zh.md) + +## Problem + +A cold web boot issued `settings.describe` fifteen times inside ~200ms, and the count grew by two with every client plugin that owned a preference. Two mechanisms stacked: `SettingsScopeBinder.bind()` started a full-document read per bound scope (six scopes in the product composition, plus the plugin-directory tab, the welcome gate, and the models onboarding join), and `onConnected` emits `connection/reset` on the FIRST connection too, so every one of those readers immediately re-read the answer it had fetched milliseconds earlier. Each reader also carried its own invalidation subscriptions and its own `refreshIfLoaded`-style guard, and fifteen independent reads could in principle land on fifteen different document revisions. + +## Decision + +**One reader, many derivations.** `dsh-client-ui-settings` owns `SettingsDescribeMirror`, the single `settings.describe` reader in the browser: one snapshot store holding the whole answer, refreshed by the owning plugin's two subscriptions (`settings/document-updated`, `connection/reset`). Concurrent `load()` calls fold into the in-flight read plus at most one rerun — the in-flight slot clears inside the run's own try/finally, in the same synchronous segment that observes the rerun flag, because a `.finally()` on the returned promise runs one microtask later and a `load()` landing in that gap marked a rerun nobody read. + +`bind()` still returns the unchanged `SettingsScope` face, but the controller is now a selector over the mirror: no read path of its own, the same decode rules, and the write queue kept. A committed write folds its answered view back into the mirror (`acceptView`), so sibling scopes see the new revision with no re-read; a failed latest write triggers one mirror recovery read. Cross-namespace surfaces — the plugin-directory tab, the permission row (its dynamic enum lives in the namespace SCHEMA, which scopes deliberately do not carry), the models join, the agent-preset row's writability, and `hasDocument` — consume `ctx.settingsScope.describe()`, a read-only face (`getSnapshot`/`subscribe`/`ensure`/`acceptView`). + +The cold-boot budget is pinned at two reads by `apps/web/tests/startup-rpc-budget.e2e.ts`: the mirror's eager bind-time read, plus the first-connection reset read, which is kept deliberately — it closes the window where a document commit lands between the eager HTTP read and the SSE subscription and its invalidation is lost. The plan's original target of one read is unreachable without either accepting that lost-invalidation window or delaying the first read until after the SSE stream opens. + +## Alternatives considered + +- **Single-flight sharing inside `bind()` only** — deduplicates the concurrent bursts but keeps N direct readers, N subscription sets, and the revision skew; readers outside the binder (welcome, models, tab, permission) gain nothing. Rejected as treating the symptom. +- **Boot-payload embedding** (host inlines the describe answer into the page boot) — saves the first read but adds a second acquisition path with its own staleness rules on top of the mirror it would still need. Deferred; it composes with the mirror if ever wanted. +- **Per-namespace `settings.describe(ns)`** — shrinks each answer but keeps one read per consumer, so the fan-out and the growth rate stay. Rejected. +- **One read (no first-reset re-read)** — reachable only by accepting the lost-invalidation window between the eager HTTP read and the SSE subscription, or by delaying the first read until the stream opens; both trade correctness or first-paint freshness for one loopback request. Rejected in favor of the pinned two. + +## Consequences + +- Startup `settings.describe` went 15 → 2, and a new preference-owning plugin adds zero reads. +- Every derived surface shows the same document revision at any moment; the per-reader guards (`refreshWelcomeIfLoaded`, `refreshPermissionIfLoaded`, `refreshDocumentIfLoaded`) and their subscriptions are gone. +- The mirror refreshes on every document commit regardless of namespace, so an external settings edit now costs one background read even while no settings surface is open — the price of surfaces that open already fresh. The per-namespace `ns !== spec.namespace` filters are gone with the per-scope subscriptions. +- `credentials.describe` (3 startup calls), `agentPreset.list` (2), and `llm.providers` are separate sources and stay direct; the same mirror pattern fits them if they ever need it. +- A new direct `settings.describe` caller in client code is a budget regression; the e2e's failure message says to grep for callers outside `ui-settings`. diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md new file mode 100644 index 0000000000..1229aaf990 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md @@ -0,0 +1,32 @@ +# Agent Note:Settings describe 镜像 + +Status: implemented + +[English](2026-08-17-settings-describe-mirror.md) | 中文 + +## 问题 + +一次冷启动的 web boot 在约 200ms 内发出十五次 `settings.describe`,且每新增一个持有偏好设置的客户端插件,该计数再加二。两个机制叠加:`SettingsScopeBinder.bind()` 为每个绑定的 scope 启动一次全量文档读取(产品组合中有六个 scope,外加插件目录 tab、welcome 门与 models onboarding join),而 `onConnected` 在**首次**连接时同样发出 `connection/reset`,于是上述每个读取方都立即重读了几毫秒前刚取到的应答。每个读取方还各自持有失效订阅与各自的 `refreshIfLoaded` 式防护,且十五次独立读取原则上可能落在十五个不同的文档 revision 上。 + +## 决定 + +**一个读取方,多个派生面。**`dsh-client-ui-settings` 持有 `SettingsDescribeMirror`——浏览器中唯一的 `settings.describe` 读取方:一个持有完整应答的快照 store,由所属插件的两个订阅(`settings/document-updated`、`connection/reset`)负责刷新。并发的 `load()` 调用折叠进在飞读取加至多一次尾随重读——在飞槽位在 run 自身 try/finally 内、与读取 rerun 标志相同的同步段中清空,因为挂在返回 promise 上的 `.finally()` 要晚一个微任务执行,落入该间隙的 `load()` 会标记一个无人读取的 rerun。 + +`bind()` 返回的 `SettingsScope` 面保持不变,但 controller 现在是镜像上的 selector:自身没有读路径,decode 规则不变,写队列保留。提交成功的写入把应答的 view 折回镜像(`acceptView`),兄弟 scope 无需重读即可看到新 revision;失败的最新写入触发一次镜像恢复读取。跨命名空间的表面——插件目录 tab、permission 行(其动态枚举位于命名空间 **schema** 中,而 scope 有意不携带 schema)、models join、agent-preset 行的可写性、以及 `hasDocument`——消费 `ctx.settingsScope.describe()` 只读面(`getSnapshot`/`subscribe`/`ensure`/`acceptView`)。 + +冷启动预算由 `apps/web/tests/startup-rpc-budget.e2e.ts` 钉在两次读取:镜像在绑定时的急切读取,加上首连 reset 触发的读取——后者是有意保留的:它关闭了「文档提交落在急切 HTTP 读取与 SSE 订阅之间、其失效通知丢失」的窗口。方案最初的一次读取目标,若不接受该失效丢失窗口、或不把首次读取推迟到 SSE 流建立之后,无法达成。 + +## 考虑过的备选 + +- **仅在 `bind()` 内做 single-flight 共享**——能去重并发风暴,但仍保留 N 个直连读取方、N 套订阅以及 revision 偏差;binder 之外的读取方(welcome、models、tab、permission)毫无受益。以治标为由否决。 +- **boot 载荷内嵌**(宿主把 describe 应答内联进页面 boot)——省下首次读取,却在镜像仍然需要的前提下增加第二条带自身陈旧规则的取数路径。推迟;若将来需要,它可与镜像叠加。 +- **按命名空间的 `settings.describe(ns)`**——缩小单次应答,但每个消费者仍各读一次,扇出与增长率原样保留。否决。 +- **一次读取(去掉首连 reset 重读)**——只有接受「急切 HTTP 读取与 SSE 订阅之间的失效丢失窗口」、或把首次读取推迟到流建立之后才可达成;两者都在用正确性或首屏新鲜度换一次环回请求。否决,保留钉住的两次。 + +## 后果 + +- 启动期 `settings.describe` 从 15 次降到 2 次,新增持有偏好设置的插件带来零次新增读取。 +- 任一时刻每个派生面看到的都是同一份文档 revision;各读取方的防护(`refreshWelcomeIfLoaded`、`refreshPermissionIfLoaded`、`refreshDocumentIfLoaded`)及其订阅随之消失。 +- 镜像对任何命名空间的文档提交都会刷新,因此在没有任何设置表面打开时,一次外部设置编辑现在也花费一次后台读取——这是「表面打开即新鲜」的代价。随着各 scope 订阅的删除,按命名空间的 `ns !== spec.namespace` 过滤一并消失。 +- `credentials.describe`(启动 3 次)、`agentPreset.list`(2 次)与 `llm.providers` 是另外的数据源,保持直连;若将来需要,同一镜像模式对它们同样适用。 +- 客户端代码中新增直连 `settings.describe` 调用即是预算回归;e2e 的失败信息会提示在 `ui-settings` 之外 grep 调用方。 diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index ec7c333250..c40381e91e 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/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/ui-settings/README.md -README.md: 950585c4957cd59fe3a38dc37cdd4084f7c5541c -README.zh.md: dce8dbf5c8e0939142fed8a3df84c47acd7c8a1e +README.md: 4c5519677ace060a7378c22581dce3dbcb8b3495 +README.zh.md: b3565355d88f54278d97b56557dcbe626503f121 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 950585c495..4c5519677a 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The settings domain's base layer, with two roles and no presentation of its own. It provides `ctx.settingsScope`, the Host transport every preference row binds its durable namespace section through, and it declares the settings slot types registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), `settings.plugins.tab` (feature-owned pages inside the Plugins section), and `settings.onboarding` (ordered feature-owned pages). It depends on no `ui-*` presentation package, so any feature that owns a preference can reach it; the settings SHELL — the `sidebar.settings` occupant, its navigation, and the chrome — lives in ui-settings-general, because a shell dependency on ui-sidebar would close a reference graph cycle through ui-layout and ui-theme. The shell's own contract types live beside the shell for the same reason. -The plugin injects nothing and waits for nothing: `ctx.settingsScope.bind(spec)` resolves the wire face through the CALLER's context at call time, so the bound scope's disposer belongs to the calling fiber, and the caller injects `connection` for the transport and `remote` for the invalidation. Listeners exist before the first background read starts, so a row's activation never blocks on the settings transport. A bound scope reloads on the forwarded `settings/document-updated` event for its own namespace and on `connection/reset`. Writes carry one field path and the last known namespace revision as `expectedRevision`; a rejected or failed write re-reads unless a newer write already superseded it, and a stale read never publishes over a newer one. Without a `decode` in the spec, a section that is not a plain object, fails its rehydrated schema, or carries a schema envelope this client cannot rehydrate publishes no value at all, so a row renders its own absent state instead of a half-decoded one. +The plugin injects `connection` and `remote` and owns the one `settings.describe` reader in the browser: a shared mirror holding the whole answer, refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included — that read closes the window where a commit lands between the eager read and the SSE subscription). `ctx.settingsScope.bind(spec)` returns a per-namespace scope DERIVED from the mirror on the CALLER's context — the scope's disposer belongs to the calling fiber, binding adds no wire read, a row's activation never blocks on the settings transport, and every derived surface shows the same document revision at any moment. Cross-namespace surfaces (schema introspection, the served-namespace directory, `hasDocument`) read the same mirror through `ctx.settingsScope.describe()`, a read-only face (`getSnapshot`/`subscribe`/`ensure`/`acceptView`). Writes stay per-scope: one field path fenced by the namespace revision as `expectedRevision`; a committed write folds its answer back into the mirror with no re-read, a rejected or failed latest write triggers one mirror recovery read, and a superseded one leaves recovery to its successor. Without a `decode` in the spec, a section that is not a plain object, fails its rehydrated schema, or carries a schema envelope this client cannot rehydrate publishes no value at all, so a row renders its own absent state instead of a half-decoded one. The cold-boot read count is pinned by `apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it. ## Model Experience diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index dce8dbf5c8..b3565355d8 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -4,7 +4,7 @@ 设置领域的底座,承担两项职责,本身不含任何呈现内容。它提供 `ctx.settingsScope`——每个偏好设置行绑定自己那份持久化命名空间分区所用的宿主传输层;并声明由注册方填充的设置 slot 类型:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)、`settings.plugins.tab`(“插件”分区内由各功能持有的页面)和 `settings.onboarding`(由各功能持有的有序页面)。它不依赖任何 `ui-*` 呈现包,因此任何持有偏好设置的功能都能够到它;设置**外壳**——`sidebar.settings` 占位方、它的导航与界面框架——位于 ui-settings-general,因为外壳一旦依赖 ui-sidebar,就会经 ui-layout 与 ui-theme 闭合出一条引用图环路。外壳自身的契约类型出于同一原因与外壳放在一起。 -该插件不注入任何服务、也不等待任何服务:`ctx.settingsScope.bind(spec)` 在调用时经**调用方**的 context 解析线路面,因此绑定所得 scope 的 disposer 归调用方 fiber 所有,而由调用方注入 `connection` 取得传输层、注入 `remote` 取得失效通知。监听器在首次后台读取启动之前就已存在,因此某一行的激活绝不会阻塞在设置传输层上。已绑定的 scope 会在收到属于自己命名空间的转发 `settings/document-updated` 事件时、以及在 `connection/reset` 时重新读取。写入携带单一字段路径以及最近已知的命名空间 revision 作为 `expectedRevision`;被拒绝或失败的写入会重新读取,除非已有更新的写入取代了它,而过期的读取绝不会覆盖发布更新的结果。若 spec 未提供 `decode`,则分区不是普通对象、未通过其重建后的 schema 校验、或携带本客户端无法重建的 schema 信封时,一律不发布任何值,于是行渲染自己的缺失状态,而不是一份半解码的值。 +该插件注入 `connection` 与 `remote`,并持有浏览器中唯一的 `settings.describe` 读取方:一面持有完整应答的共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内——这次读取关闭了「提交落在急切读取与 SSE 订阅之间、其失效通知丢失」的窗口)。`ctx.settingsScope.bind(spec)` 在**调用方**的 context 上返回一个由镜像**派生**的按命名空间 scope——scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上,且任一时刻每个派生面看到的都是同一份文档 revision。跨命名空间的表面(schema 内省、已服务命名空间目录、`hasDocument`)通过 `ctx.settingsScope.describe()` 读同一面镜像,这是一个只读面(`getSnapshot`/`subscribe`/`ensure`/`acceptView`)。写入仍归各 scope:单一字段路径,以命名空间 revision 作为 `expectedRevision` 围栏;提交成功的写入将应答折回镜像、不再重读,被拒绝或失败的最新写入触发一次镜像恢复读取,被取代的写入则把恢复留给后继者。若 spec 未提供 `decode`,则分区不是普通对象、未通过其重建后的 schema 校验、或携带本客户端无法重建的 schema 信封时,一律不发布任何值,于是行渲染自己的缺失状态,而不是一份半解码的值。冷启动读取次数由 `apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。 ## 模型体验 diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 72a472a162..e2f996e6c1 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -246,16 +246,6 @@ export class SettingsScopeBinder extends Service { this.mirror = config.mirror } - /** - * Bind one namespace scope on the CALLER's plugin lifecycle — the service - * proxy binds `this.ctx` to the caller at call time, so the scope's disposer - * belongs to the calling fiber. The scope derives from the shared mirror - * (whose invalidation subscriptions live with the providing plugin), so - * binding adds no wire read of its own and activation never blocks on the - * settings transport. - * @param spec - domain-owned namespace contract. - * @returns the bound scope consumed by the domain's services and rows. - */ /** * The shared mirror's read-only face for cross-namespace surfaces (schema * introspection, the served-namespace directory). Per-namespace consumers @@ -267,6 +257,16 @@ export class SettingsScopeBinder extends Service { return this.mirror } + /** + * Bind one namespace scope on the CALLER's plugin lifecycle — the service + * proxy binds `this.ctx` to the caller at call time, so the scope's disposer + * belongs to the calling fiber. The scope derives from the shared mirror + * (whose invalidation subscriptions live with the providing plugin), so + * binding adds no wire read of its own and activation never blocks on the + * settings transport. + * @param spec - domain-owned namespace contract. + * @returns the bound scope consumed by the domain's services and rows. + */ bind(spec: SettingsScopeSpec): SettingsScope { const ctx = this.ctx const connection = ctx.get('connection') as ConnectionHandle From 94135092a5f775fb99a9a177e4ed5b8ec4efdd6b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 17 Aug 2026 18:55:28 +0800 Subject: [PATCH 14/34] fix(locale): open in English when the browser names no shipped language The provisional locale fell back to zh, so a browser asking for neither zh nor en (fr, de) opened the product in Chinese. Resolve to en instead, and use en as the dictionary fallback: the shipped zh/en dictionaries declare identical key sets, so one constant serves both roles. Add scripts/locale-dictionary-parity.spec.ts to gate that symmetry, and set the asserted locale explicitly in specs that had relied on the old zh fallback through a dead usePinnedBrowserLanguages call (those files declare no jsdom environment, so browser detection never ran there). --- ...1-browser-derived-initial-locale.i18n.yaml | 4 +- ...26-07-31-browser-derived-initial-locale.md | 20 ++- ...07-31-browser-derived-initial-locale.zh.md | 20 ++- apps/web/index.html | 2 +- apps/web/tests/settings-chrome.e2e.ts | 28 +++- packages/client/locale/src/client/index.ts | 14 +- .../client/locale/tests/apply.client.spec.ts | 33 +++-- .../client/locale/tests/locale.client.spec.ts | 56 +++++-- .../tests/apply.client.spec.ts | 9 +- .../tests/apply.client.spec.ts | 8 +- .../tests/browser-plugin.client.spec.ts | 4 + .../tests/browser-plugin.client.spec.ts | 7 +- .../tests/apply.client.spec.ts | 8 +- .../tests/apply.client.spec.ts | 9 +- .../tests/apply.client.spec.ts | 9 +- .../ui-theme/tests/apply.client.spec.ts | 9 +- .../ui-workspace/tests/apply.client.spec.ts | 8 +- scripts/locale-dictionary-parity.spec.ts | 137 ++++++++++++++++++ 18 files changed, 306 insertions(+), 79 deletions(-) create mode 100644 scripts/locale-dictionary-parity.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml index ea66531864..c1ac4af1e9 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.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 .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md -2026-07-31-browser-derived-initial-locale.md: 072f91b730cfc9eaeead7701d2b20d12b443acb3 -2026-07-31-browser-derived-initial-locale.zh.md: 97f0f0007474c21fb618a085b506bc919586f624 +2026-07-31-browser-derived-initial-locale.md: 94f32b136f20c7ab7fb8241a0ac9adf6249a4380 +2026-07-31-browser-derived-initial-locale.zh.md: 8d879b9b11ad42feed9ffd2ec3e5a16d1dcd9b8c diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md index 072f91b730..94f32b136f 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md @@ -8,29 +8,35 @@ English | [中文](2026-07-31-browser-derived-initial-locale.zh.md) The Settings Language row opened every first visit in Chinese: `LocaleRuntime` read `dsh.locale` from localStorage and fell straight back to `zh` when nothing was stored. The browser already states which languages its user reads — `navigator.languages` is that statement — and the app ignored it, so an English reader met a Chinese product and had to find a Chinese-labelled settings row to escape it. The fallback was doing two jobs at once: the last resort for an unresolvable locale, and the answer for every user who had simply never chosen. +Reading the browser fixed the readers whose browser names a language this app ships, but left the residual case wrong: a browser asking for neither `zh` nor `en` (`fr`, `de`) still fell back to `zh`. Those readers are the least likely to read Chinese. + ## Decision -**The provisional locale resolves through the browser, then `FALLBACK_LOCALE`; an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active. +**The provisional locale resolves through the browser, then `FALLBACK_LOCALE` (`en`); an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active. + +**One constant serves both the opening locale and the dictionary fallback, because the dictionaries are symmetric.** `FALLBACK_LOCALE` answers both "which language does the UI open in when the browser names none we ship" and "which dictionary backs a key the active locale misses". Those are different questions, and splitting them into two constants would be right if either answer had to differ — but every shipped `zh`/`en` pair declares identical key sets, so the fallback step always resolves and both answers are `en`, the source language of the copy. `scripts/locale-dictionary-parity.spec.ts` gates the symmetry the shared constant depends on: a key added to one side only fails that spec by name, instead of surfacing later as a bare key such as `list.aria` in a running UI. **Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express. -**`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language (`en-US` on the CI runners), so gating on `navigator` would have let a node boot of the client tree resolve to `en` instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`. +**`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language, so gating on `navigator` would let a node boot of the client tree resolve to the machine's language instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`. **An explicit choice is durable.** `setLocale` writes through the Host settings API, so a user who picked a language keeps it across browser origins and system languages that share the same DSH home. Nothing writes the detected locale back: detection is re-derived every boot and stays invisible to the “has the user chosen?” question. -**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale and asserts its English browser produces an English settings surface—the assembled-app proof of this feature. +**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale twice: an `en-US` browser and an `fr-FR` one both reach an English surface. The `fr-FR` scenario is the one that pins the fallback — an `en-US` browser would land on English under detection or fallback alike, so only an unshipped language distinguishes them, and the zh scenarios prove detection still overrides the fallback. ## Alternatives considered - **`Intl.DateTimeFormat().resolvedOptions().locale` or a single `navigator.language` read**: both collapse the user's ordered preference list to one tag, so a `['de', 'en', 'zh']` reader gets zh instead of en. The list is the part of the browser statement worth reading. - **Persisting the detected locale on first boot**: it would make detection a one-time event and let a stale first visit outlive a changed browser language, and it destroys the distinction the resolution order rests on — a stored value would no longer mean "the user chose this". - **Full BCP 47 negotiation (`Intl.LocaleMatcher`-style lookup, region and script weighting)**: with exactly two shipped locales that differ in language, primary-subtag matching is the whole of the correct answer; a negotiation layer would be untestable surface with no behavior to justify it. -- **A cordis config key for the default locale**: the deployment does not vary here — the fallback is the product's answer for "no signal at all", not a knob. Repo policy reserves `Config` fields for deployment-varying choices with a current consumer. +- **A cordis config key for the fallback locale**: the deployment does not vary here — the fallback is the product's answer for "no signal at all", not a knob. Repo policy reserves `Config` fields for deployment-varying choices with a current consumer. +- **Two constants, one for the opening locale and one for the dictionary fallback**: it separates two genuinely different questions, and would be required if the answers differed. They do not: the dictionaries are symmetric, so both are `en`, and a second constant would be two names for one value plus a rule nothing enforces. The symmetry itself is worth enforcing, so it is gated directly instead. +- **Keeping `zh` as the dictionary fallback while opening in `en`**: it reads as the conservative choice, but with symmetric dictionaries it never resolves a key that `en` would not, so it buys nothing; and where it would matter — a key present only in `zh` — rendering Chinese text inside an otherwise English UI is worse than the bare key a reviewer would notice. - **Keeping the e2e lane's zh scenarios on storage pinning (`dsh.locale=zh`)**: it would keep the suite green while removing the only place the browser-derived path runs in an assembled app; pinning the browser language instead exercises the new resolution end to end. ## Consequences -- A first visit from an English browser lands in English, and the Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction. -- `FALLBACK_LOCALE` narrows to its real job — the dictionary fallback and the no-signal answer — and stops standing in for "the user has not chosen". -- Tests that construct a `LocaleRuntime` under jsdom now depend on the environment's `navigator`: specs asserting localized copy declare their browser with one suite-level `usePinnedBrowserLanguages('zh-CN')` (dsh-client-test-runtime), and any future spec asserting a default must do the same. This package's own specs stub the globals directly, because they need shapes the helper deliberately cannot express (absent `languages`, a list decoupled from `language`, no `window` at all). +- A first visit from an English browser lands in English, a Chinese browser in Chinese, and a browser naming neither lands in English rather than Chinese. The Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction. +- Dictionary resolution reverses direction: a key missing from the active locale now falls to `en`, not `zh`. With symmetric dictionaries no shipped key changes behavior, which is why the parity gate exists — it is the assumption that reversal rests on. +- Non-browser runs of the client tree (node boots, the non-jsdom unit lane) now open in `en`. Specs that assert shipped Chinese copy must set `setLocale('zh')` explicitly on the runtime they construct; a suite-level `usePinnedBrowserLanguages('zh-CN')` only works in files that also declare `@vitest-environment jsdom`, because without a `window` the detection path never reads `navigator` at all. Seven `*.client.spec.ts` files carried such a dead pin and were relying on the old `zh` fallback instead. - Detection cost is one array walk per service construction and no implicit settings write; an explicit Host preference may cause one live convergence after plugin activation. diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md index 97f0f00074..8d879b9b11 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md @@ -8,29 +8,35 @@ Status: implemented 设置里的语言行在每一次首访时都以中文开场:`LocaleRuntime` 从 localStorage 读取 `dsh.locale`,读不到就直接回落到 `zh`。浏览器本已声明其使用者阅读哪些语言——`navigator.languages` 就是这份声明——而应用对此视而不见,于是英文读者迎面撞上一个中文产品,还得先找到一行中文标签的设置项才能脱身。回落值当时同时承担两份职责:既是无法解析出 locale 时的最后兜底,也是所有从未做过选择的用户拿到的答案。 +读取浏览器修好了那些浏览器声明了本应用所提供语言的读者,但残余情形依然是错的:既不请求 `zh` 也不请求 `en` 的浏览器(`fr`、`de`)仍会回落到 `zh`。这些读者恰恰最不可能阅读中文。 + ## Decision -**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE` 解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。 +**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE`(`en`)解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。 + +**开场 locale 与字典回落值共用一个常量,因为两侧字典是对称的。** `FALLBACK_LOCALE` 同时回答「浏览器未声明任何本应用提供的语言时,界面以哪种语言开场」与「当前 locale 的字典缺失某个 key 时由哪本字典兜住」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的——但每一对已提供的 `zh`/`en` 字典都声明了完全相同的 key 集合,因此回落这一步总能解析成功,两个答案都是 `en`,也就是文案的源语言。`scripts/locale-dictionary-parity.spec.ts` 为这个共用常量所依赖的对称性设了门禁:只加在一侧的 key 会让该用例指名失败,而不是日后在运行中的界面里显现为形如 `list.aria` 的裸 key。 **浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN` 与 `zh-TW` 同归 `zh`、`en-GB` 归 `en`;而只请求本应用不提供的语言(`fr`、`de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主——DOM 库把它标注为必然存在,所以这份容忍带一条窄口径 lint 例外,与 `localStorage` 守卫表达的环境边界不信任同源。 -**判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言(CI runner 上是 `en-US`),因此以 `navigator` 把关会让 node 启动客户端树时解析成 `en`,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`。 +**判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言,因此以 `navigator` 把关会让 node 启动客户端树时解析成机器语言,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`。 **显式选择具有持久性。** `setLocale` 通过 Host settings API 写入,因此选过语言的用户可在共享同一 DSH home 的不同浏览器 origin 与系统语言之间保留原选择。没有任何代码把探测到的 locale 写回:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。 -**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 使用没有显式 locale 的全新 Host home,断言其英文浏览器会生成英文 settings 界面:这是本功能在组装后应用中的证据。 +**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 两次使用没有显式 locale 的全新 Host home:`en-US` 浏览器与 `fr-FR` 浏览器都会抵达英文界面。真正钉住回落值的是 `fr-FR` 那个场景——`en-US` 浏览器无论走探测还是走回落都会落在英文,因此只有本应用不提供的语言才能区分二者,而中文场景则证明探测仍然覆盖回落值。 ## Alternatives considered - **`Intl.DateTimeFormat().resolvedOptions().locale` 或单读 `navigator.language`**:两者都把用户的有序偏好列表塌缩成一个标签,于是 `['de', 'en', 'zh']` 的读者拿到的是 zh 而非 en。列表恰恰是浏览器这份声明里最值得读的部分。 - **首次启动即持久化探测结果**:那会把探测变成一次性事件,让一次陈旧的首访凌驾于此后改变的浏览器语言之上,也摧毁了整个解析顺序所依赖的区分——存储值将不再意味着「用户选了它」。 - **完整的 BCP 47 协商(`Intl.LocaleMatcher` 式查找、地区与文字权重)**:在只提供两个语言互异的 locale 时,主子标签匹配就是正确答案的全部;协商层只会带来无行为支撑、也无从测试的表面积。 -- **为默认 locale 增加一个 Cordis 配置键**:此处部署之间并无差异——回落值是产品对「完全没有信号」给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费方、且随部署变化的选择。 +- **为回落 locale 增加一个 Cordis 配置键**:此处部署之间并无差异——回落值是产品对「完全没有信号」给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费方、且随部署变化的选择。 +- **拆成两个常量,一个管开场 locale、一个管字典回落**:它区分了两个确实不同的问题,若两个答案不同也确有必要。但它们并不不同:字典是对称的,因此两者都是 `en`,第二个常量只会是同一个值的两个名字,外加一条无人强制的规则。对称性本身值得强制,所以直接为它设门禁。 +- **开场用 `en`、字典回落仍保留 `zh`**:这看起来是保守选择,但在字典对称的前提下,它能解析的 key 与 `en` 完全相同,因此毫无收益;而在它真正会起作用的情形——某个 key 只存在于 `zh`——在整体英文的界面里渲染出中文文本,比让 reviewer 一眼看见裸 key 更糟。 - **让 e2e 车道的中文场景继续钉存储项(`dsh.locale=zh`)**:那会让套件保持绿色,却抹掉浏览器推导路径在组装后应用中唯一的运行处;改钉浏览器语言才能端到端地演练新的解析过程。 ## Consequences -- 来自英文浏览器的首访落在英文界面,而语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。 -- `FALLBACK_LOCALE` 收窄回它真正的职责——字典回落与无信号时的答案——不再兼职充当「用户尚未选择」。 -- 在 jsdom 下构造 `LocaleRuntime` 的测试现在依赖环境的 `navigator`:断言本地化文案的用例以一行套件级 `usePinnedBrowserLanguages('zh-CN')`(dsh-client-test-runtime)声明其浏览器,今后任何断言默认值的用例同样如此。本包自己的用例直接给全局打桩,因为它们需要该 helper 刻意不表达的形状(`languages` 缺失、列表与 `language` 解耦、完全没有 `window`)。 +- 来自英文浏览器的首访落在英文界面,中文浏览器落在中文界面,而两者皆未声明的浏览器落在英文而非中文界面。语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。 +- 字典解析方向发生反转:当前 locale 缺失的 key 现在回落到 `en` 而非 `zh`。在字典对称的前提下,没有任何已提供的 key 行为发生变化——这正是那道对称性门禁存在的原因:它是这次反转所依赖的前提。 +- 客户端树的非浏览器运行(node 启动、非 jsdom 单测车道)现在以 `en` 开场。断言已提供中文文案的用例必须在其构造的 runtime 上显式调用 `setLocale('zh')`;套件级的 `usePinnedBrowserLanguages('zh-CN')` 仅在同时声明了 `@vitest-environment jsdom` 的文件中生效,因为没有 `window` 时探测路径根本不会读取 `navigator`。此前有七个 `*.client.spec.ts` 文件带着这样一条失效的固定语句,实际依赖的是旧的 `zh` 回落值。 - 探测的代价是每次服务构造遍历一次数组,且不会隐式写入 settings;插件激活后,显式 Host 偏好可能引发一次实时收敛。 diff --git a/apps/web/index.html b/apps/web/index.html index a14de72d40..1ce5ff35ff 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1,5 +1,5 @@ - + diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 876574a009..74c47d7a40 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -455,7 +455,9 @@ describe('web e2e: settings modal and General preferences', () => { it('opens an English browser in English without any stored preference', async () => { // A fresh Host home has no locale preference, so its surface follows the - // browser rather than the product fallback. + // browser. English is also FALLBACK_LOCALE, so this scenario alone cannot + // distinguish detection from the default — the zh scenarios above supply + // the discriminating half (a Chinese browser must NOT land on the default). const fresh = await launchWebScaffold({}) const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' }) const enTripwire = watchConsole(enPage) @@ -478,6 +480,30 @@ describe('web e2e: settings modal and General preferences', () => { } }, 90_000) + it('opens a browser asking for no shipped language in English', async () => { + // The product default for "no usable signal": a French browser ships + // neither zh nor en, so resolution falls to FALLBACK_LOCALE (en) rather + // than to Chinese. + const fresh = await launchWebScaffold({}) + const frPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'fr-FR' }) + const frTripwire = watchConsole(frPage) + onTestFailed(() => saveFailureShot(frPage, 'web-e2e-settings-unshipped-language')) + try { + await frPage.goto(fresh.baseUrl, { waitUntil: 'load' }) + await frPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + expect(await frPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() + await frPage.getByRole('button', { name: 'Settings', exact: true }).click() + const dialog = frPage.getByRole('dialog', { name: 'Settings' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) + expect(frTripwire.pageErrors).toEqual([]) + expect(frTripwire.warnings).toEqual([]) + } finally { + await frPage.close() + await fresh.close() + } + }, 90_000) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md', 'plugins.expected.md']) diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 8291187175..abea65ac9e 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -86,8 +86,14 @@ declare module '@deepseek-ai/cordis' { } } -/** Fallback locale consulted after the active locale misses (also the last-resort initial locale). */ -export const FALLBACK_LOCALE: LocaleId = 'zh' +/** + * English is both the locale the UI opens in when the browser names no shipped + * language (and for non-browser runs), and the dictionary consulted after the + * active locale misses a key. One constant serves both because the shipped + * `zh`/`en` dictionaries carry identical key sets, so neither direction can + * leave a key unresolved; English is the source language of the copy. + */ +export const FALLBACK_LOCALE: LocaleId = 'en' /** Shared namespace for shell-level texts. */ export const COMMON_NS = 'common' @@ -103,8 +109,8 @@ const LOCALES: readonly LocaleDefinition[] = Object.freeze([ /** * Dictionary registry plus locale preference. Lookup chain per key: the - * entry's namespace in the active locale -> that namespace's zh fallback -> - * the shared common namespace (active, then zh) -> the key itself (missing + * entry's namespace in the active locale -> that namespace's en fallback -> + * the shared common namespace (active, then en) -> the key itself (missing * text stays visible, fail loud in the UI rather than blank). Reads go * through {@link getLocale}; writes only through {@link setLocale}; * continuous sync through the `locale/change` event, or through the diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index dd38786073..a792bfe53a 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -2,7 +2,7 @@ * Language row registration, snapshot projection into the row store, and * recovery after an HMR collapse of the declaring entry. */ import { Context } from '@deepseek-ai/cordis' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' @@ -73,12 +73,10 @@ function faceOf(slots: SlotRegistry) { } describe('locale apply', () => { - // A fresh service opens in the browser's language, so these wiring specs - // pin one to keep their zh baseline independent of the test environment. - beforeEach(() => { - vi.stubGlobal('navigator', { languages: ['zh-CN'], language: 'zh-CN' }) - }) - + // These are wiring specs, not default-language specs: each one that reads + // localized copy sets its locale explicitly via setLocale/Host preference + // rather than leaning on FALLBACK_LOCALE. This file has no jsdom environment, + // so there is no `window` and no browser-language detection to stub. afterEach(() => { vi.unstubAllGlobals() }) @@ -95,6 +93,9 @@ describe('locale apply', () => { // Base dictionaries are registered: the (ns, locale) seats are occupied. expect(() => locale.register('common', 'zh', {})).toThrow('already has locale') expect(() => locale.register('common', 'en', {})).toThrow('already has locale') + // Both dictionaries resolve; read each under its own active locale. + expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language') + locale.setLocale('zh') expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言') const entry = before.slots.entries(SLOT).find(e => e.component === LanguageRow)! expect(entry.options).toMatchObject({ id: 'language', order: 0 }) @@ -110,9 +111,14 @@ describe('locale apply', () => { it('projects service snapshots into the row store and routes face writes back', async () => { const b = await bench() + // Open at zh so the pre-inject switch to en below is a real change: with + // FALLBACK_LOCALE = en it would otherwise be a no-op and never exercise + // the unbound-actions arm or persist. + b.setHostPreference('zh') declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const locale = b.ctx.get('locale') as LocaleRuntime + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) // An event ahead of any inject hits the unbound-actions arm. locale.setLocale('en') @@ -133,17 +139,20 @@ describe('locale apply', () => { it('loads and refreshes the explicit Host preference after nonblocking activation', async () => { const b = await bench() - b.setHostPreference('en') + // Preference must differ from the provisional locale (FALLBACK_LOCALE = en + // with no window), or clearing it below would be unobservable. + b.setHostPreference('zh') declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const locale = b.ctx.get('locale') as LocaleRuntime - await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) + // Cleared preference falls back to the provisional locale. b.setHostPreference(undefined) b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) - await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) - b.setHostPreference('en') - b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) + b.setHostPreference('zh') + b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) expect(b.describe).toHaveBeenCalledTimes(3) }) diff --git a/packages/client/locale/tests/locale.client.spec.ts b/packages/client/locale/tests/locale.client.spec.ts index 86f1ce2922..eb279f1295 100644 --- a/packages/client/locale/tests/locale.client.spec.ts +++ b/packages/client/locale/tests/locale.client.spec.ts @@ -3,8 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' -import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' - +import { FALLBACK_LOCALE, LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' const make = (host?: StubSettingsScope): { ctx: Context svc: LocaleRuntime @@ -37,31 +36,34 @@ describe('LocaleRuntime', () => { vi.unstubAllGlobals() }) - it('translates through the active-locale -> zh -> key chain', () => { + it('translates through the active-locale -> en -> key chain', () => { const { svc } = make() - svc.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' }) - svc.register('ns', 'en', { hello: 'Hello' }) + svc.register('ns', 'zh', { hello: '你好' }) + svc.register('ns', 'en', { hello: 'Hello', onlyEn: 'English only' }) const t = svc.bind('ns') expect(svc.getLocale().active).toBe('zh') expect(t('hello')).toBe('你好') + // The active locale misses this key; the en fallback supplies it. + expect(t('onlyEn')).toBe('English only') svc.setLocale('en') expect(t('hello')).toBe('Hello') - expect(t('onlyZh')).toBe('仅中文') expect(t('missing.key')).toBe('missing.key') }) it('falls through to the common vocabulary after the namespace misses (production keys)', () => { const { svc } = make() // The shipped common pair is registered by apply; the bench registers it - // directly to pin the production chain: ns -> common -> zh -> key. + // directly to pin the production chain: ns -> common -> en -> key. svc.register('common', 'zh', { retry: '重试' }) svc.register('common', 'en', { retry: 'Retry' }) - svc.register('ns', 'zh', { own: '自有' }) + svc.register('ns', 'en', { own: 'Own' }) const t = svc.bind('ns') expect(t('retry')).toBe('重试') + // zh is active and `ns` has no zh dictionary at all: the en fallback answers. + expect(t('own')).toBe('Own') svc.setLocale('en') expect(t('retry')).toBe('Retry') - expect(t('own')).toBe('自有') + expect(t('own')).toBe('Own') // common itself must not recurse: a miss inside common echoes the key. // (Wide-string ns hits the untyped bind overload — the typed one rejects // unknown keys at compile time, which is the point of the typed registry contract.) @@ -206,21 +208,21 @@ describe('LocaleRuntime', () => { expect(make().svc.getLocale().active).toBe('en') vi.stubGlobal('navigator', { language: 'en-US' }) expect(make().svc.getLocale().active).toBe('en') - // No shipped language anywhere in the browser's preferences: zh remains - // the product default rather than an arbitrary near-match. + // No shipped language anywhere in the browser's preferences: en is the + // product default rather than an arbitrary near-match. stubLanguages('fr-FR', 'de') - expect(make().svc.getLocale().active).toBe('zh') + expect(make().svc.getLocale().active).toBe('en') }) - it('runs outside a browser (node boots): the fallback decides and the machine language does not', () => { + it('runs outside a browser (node boots): the default decides and the machine language does not', () => { vi.stubGlobal('window', undefined) // Node exposes its own global navigator; without a window it must not // reach the resolution at all. - stubLanguages('en-US') + stubLanguages('zh-CN') const { svc } = make() - expect(svc.getLocale().active).toBe('zh') - svc.setLocale('en') expect(svc.getLocale().active).toBe('en') + svc.setLocale('zh') + expect(svc.getLocale().active).toBe('zh') }) it('lets an explicit in-process preference replace the browser-derived value', () => { @@ -230,6 +232,28 @@ describe('LocaleRuntime', () => { expect(svc.getLocale().active).toBe('zh') }) + it('serves English as both the opening locale and the dictionary fallback', () => { + // One constant covers both jobs: the locale the UI opens in with no usable + // browser signal, and the dictionary backing a key the active locale + // misses. Safe to share only because the shipped zh/en dictionaries carry + // identical key sets (asserted below on a registered pair). + expect(FALLBACK_LOCALE).toBe('en') + vi.stubGlobal('window', undefined) + const { svc } = make() + // A key present only in en resolves for a zh reader through the fallback. + svc.register('ns', 'zh', {}) + svc.register('ns', 'en', { onlyEn: 'English only' }) + svc.setLocale('zh') + expect(svc.getLocale().active).toBe('zh') + expect(svc.bind('ns')('onlyEn')).toBe('English only') + // The reverse no longer resolves: a zh-only key is unreachable from en, so + // the key itself surfaces (fail loud) rather than silently rendering zh. + svc.register('ns2', 'zh', { onlyZh: '仅中文' }) + svc.register('ns2', 'en', {}) + svc.setLocale('en') + expect(svc.bind('ns2')('onlyZh')).toBe('onlyZh') + }) + it('exposes the two shipped locales with self-described labels', () => { const { svc } = make() expect(svc.getLocale().locales).toEqual([ 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 7ff4741648..7d998c7e11 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client' import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx' @@ -21,9 +21,6 @@ import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSectio import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') const ROSTER_ONE = { rpcId: 'r', @@ -77,6 +74,10 @@ async function bench() { const moveDefault = (): void => { ROSTER = ROSTER_MOVED } await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) // The plugins inject `remote`; forwarded events reach them through the // same `$dispatch` handoff the connection sink makes. diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index fa3f141283..f5d04fcfd6 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -7,15 +7,11 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { createScope, scopeOf, SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') const sid = (k: string): SessionId => k as SessionId @@ -37,6 +33,10 @@ async function bench() { scopeOf: (c: Context) => scopeOf(c), }) const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) return { ctx, slots, locale } } diff --git a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts index cf5d506024..18e19d7e40 100644 --- a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts @@ -39,6 +39,10 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() + // These specs assert the shipped Chinese copy. There is no jsdom `window` in + // this lane, so browser-language detection never runs and the locale comes + // from FALLBACK_LOCALE (en): state the asserted locale explicitly. + ctx.locale.setLocale('zh') const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() return { ctx, fiber } diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 03bb3aa535..aad5dda614 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -104,7 +104,12 @@ async function bench() { return () => { seats.delete(options.name) } }, }) - ctx.provide('locale', new LocaleRuntime(ctx)) + const localeRuntime = new LocaleRuntime(ctx) + // This spec asserts the shipped Chinese copy. There is no jsdom `window` in + // this lane, so browser-language detection never runs and the locale comes + // from FALLBACK_LOCALE (en): state the asserted locale explicitly. + localeRuntime.setLocale('zh') + ctx.provide('locale', localeRuntime) const scopes = new Map() const addressed = new Set() ctx.provide('sessions', { 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 d6c3ffff02..537bead7af 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -4,16 +4,12 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') /** The seats this plugin fills for a loopback browser (slot name → expected component). */ const SEATS = [ @@ -28,6 +24,10 @@ async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) const settingsDescribe = vi.fn(() => Promise.resolve({ rpcId: 'settings-general' as never, 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 39ba3e4b65..507c4f9aaf 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -4,20 +4,21 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client' import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) // The plugins inject `remote`; forwarded events reach them through the // same `$dispatch` handoff the connection sink makes. 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 2934097b94..14b82dac66 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -5,16 +5,13 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import type { ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') /** * @param served - namespaces the Host describes; omitted answers a failed read, @@ -24,6 +21,10 @@ async function bench(served?: string[]) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } })) const describeSettings = vi.fn(() => Promise.resolve(served === undefined diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index fb84c9860d..60aa1f349b 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -5,7 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' import type { AppearanceRowInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' @@ -13,9 +13,6 @@ import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-sett import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { createAppearanceRowStore } from '../src/client/settings-store.ts' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') const SLOT = 'settings.general.item' @@ -29,6 +26,10 @@ async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) let preference = 'system' const namespace = () => ({ diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 016af313f8..4a819c8587 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -2,15 +2,11 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') async function bench() { const ctx = new Context() @@ -37,6 +33,10 @@ async function bench() { } as never) ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never) const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, startSession, rename, diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts new file mode 100644 index 0000000000..d48fc3e808 --- /dev/null +++ b/scripts/locale-dictionary-parity.spec.ts @@ -0,0 +1,137 @@ +/** + * Gate for the invariant `FALLBACK_LOCALE` rests on: every shipped dictionary + * declares the same keys in `zh` and `en`. + * + * The locale runtime resolves a key through the active locale, then through + * the single fallback locale (`en`), then surfaces the key itself. With + * symmetric dictionaries that middle step always resolves, so one constant can + * serve as both the opening locale and the dictionary fallback. A key added to + * only one side breaks that: a reader of the other language sees a bare key + * such as `list.aria` instead of text. This gate fails on the asymmetry rather + * than waiting for the bare key to reach a UI. + */ + +import type { Dirent } from 'node:fs' +import { readdirSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +const root = fileURLToPath(new URL('..', import.meta.url)) + +/** Every `locales*.ts` module under a client package's `src/`. */ +function dictionaryModules(): string[] { + const files: string[] = [] + for (const group of ['client', 'extensions']) { + const groupRoot = resolve(root, 'packages', group) + let packages: string[] + try { + packages = readdirSync(groupRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + } catch { + continue + } + for (const pkg of packages) { + const srcRoot = resolve(groupRoot, pkg, 'src') + walk(srcRoot, files) + } + } + return files.sort() +} + +function walk(dir: string, out: string[]): void { + let entries: Dirent[] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const full = resolve(dir, entry.name) + if (entry.isDirectory()) { + walk(full, out) + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { + if (/^locales?(\.[\w-]+)?\.ts$/.test(entry.name) || dir.endsWith('/locales')) out.push(full) + } + } +} + +/** + * Keys of every top-level `export const ...= { ... }` object literal, + * read from the AST so the gate never executes package code. + * @param file - absolute path of the dictionary module. + * @returns exported dictionary name mapped to its declared keys. + */ +function exportedDictionaries(file: string): Map { + const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.ESNext, true) + const found = new Map() + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue + const exported = statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) === true + if (!exported) continue + for (const decl of statement.declarationList.declarations) { + if (!ts.isIdentifier(decl.name)) continue + const initializer = unwrap(decl.initializer) + if (initializer === undefined || !ts.isObjectLiteralExpression(initializer)) continue + const keys: string[] = [] + for (const prop of initializer.properties) { + if (!ts.isPropertyAssignment(prop)) continue + if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text) + } + found.set(decl.name.text, keys.sort()) + } + } + return found +} + +/** Look through `satisfies`/`as`/parenthesized wrappers to the literal. */ +function unwrap(node: ts.Expression | undefined): ts.Expression | undefined { + let current = node + while ( + current !== undefined + && (ts.isSatisfiesExpression(current) || ts.isAsExpression(current) || ts.isParenthesizedExpression(current)) + ) { + current = current.expression + } + return current +} + +/** Pair a `zh` export with the `en` export covering the same namespace. */ +function counterpart(name: string): string | undefined { + if (name === 'zh') return 'en' + if (name.startsWith('zh') && name.length > 2) return `en${name.slice(2)}` + if (name.endsWith('Zh')) return `${name.slice(0, -2)}En` + return undefined +} + +describe('shipped locale dictionaries', () => { + it('declares the same keys in zh and en, so the single fallback locale always resolves', () => { + const modules = dictionaryModules() + // Guard the discovery itself: an empty sweep would pass every assertion + // below while checking nothing. + expect(modules.length).toBeGreaterThan(20) + + const mismatches: string[] = [] + let comparedPairs = 0 + for (const file of modules) { + const dicts = exportedDictionaries(file) + for (const [name, zhKeys] of dicts) { + const enName = counterpart(name) + if (enName === undefined) continue + const enKeys = dicts.get(enName) + if (enKeys === undefined) continue + comparedPairs++ + const rel = file.slice(root.length) + const zhOnly = zhKeys.filter(key => !enKeys.includes(key)) + const enOnly = enKeys.filter(key => !zhKeys.includes(key)) + if (zhOnly.length > 0) mismatches.push(`${rel} ${name} has keys absent from ${enName}: ${zhOnly.join(', ')}`) + if (enOnly.length > 0) mismatches.push(`${rel} ${enName} has keys absent from ${name}: ${enOnly.join(', ')}`) + } + } + + expect(comparedPairs).toBeGreaterThan(20) + expect(mismatches).toEqual([]) + }) +}) From 49351cbf0ee08f19ea6fde860eee615d7155fe9e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 01:16:29 +0800 Subject: [PATCH 15/34] feat(subagent): support named Claude Code provider instances --- ...ubagent-providers-in-shared-host.i18n.yaml | 4 +- ...oduct-subagent-providers-in-shared-host.md | 12 +- ...ct-subagent-providers-in-shared-host.zh.md | 14 +- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 14 +- ...ude-code-and-codex-subagent-backends.zh.md | 14 +- ...product-subagent-named-instances.i18n.yaml | 6 + ...-08-18-product-subagent-named-instances.md | 48 ++++++ ...-18-product-subagent-named-instances.zh.md | 48 ++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- .../product-subagent-both.cordis.snapshot.yml | 29 +++- .../product-subagent-both.cordis.yml | 33 ++++- .../subagent/subagent-claude-code/cordis.yml | 31 +++- .../subagent/subagent-claude-code/driver.ts | 8 +- .../tool-schemas.expected.json | 27 +++- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 39 +++-- .../subagent-claude-code/README.zh.md | 39 +++-- .../subagent-claude-code/src/index.ts | 29 +++- .../tests/loader-composition.e2e.ts | 23 ++- .../tests/real-product.spec.ts | 136 +++++++++++++++-- .../tests/subagent-claude-code.spec.ts | 137 +++++++++++++++++- 24 files changed, 602 insertions(+), 109 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md create mode 100644 .agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml index 84752e27ff..9559eaa59a 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.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 .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: 452ff1cca7e4e5f91f8c35092761ebe83f3ff174 -2026-08-10-product-subagent-providers-in-shared-host.zh.md: a62bf6faa3c9bba5326da1de20ecbc2946c02bcc +2026-08-10-product-subagent-providers-in-shared-host.md: 2798431709307e50a1ee16c7fc595bcead223f59 +2026-08-10-product-subagent-providers-in-shared-host.zh.md: 981b1e2cd305c1410dcd744e3aea5028eb283806 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md index 452ff1cca7..2798431709 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md @@ -6,23 +6,23 @@ English | [中文](2026-08-10-product-subagent-providers-in-shared-host.zh.md) ## Problem -The [Codex and Claude Code provider contracts](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md) were first shipped as independently installable packages that a deployment loaded beside the common subagent tool. Agent Presets later became the ordinary owner of one agent's model-visible tools, but a preset cannot safely own these product providers: `ctx.subagents` is a process registry, provider names are unique, and host consumers resolve the same registry across sessions. Requiring a person to edit both a Profile and a Preset would also make a generic preset row incomplete by itself. +The [Codex and Claude Code provider contracts](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md) were first shipped as independently installable packages that a deployment loaded beside the common subagent tool. Agent Presets later became the ordinary owner of one agent's model-visible tools, but a preset cannot safely own these product providers: `ctx.subagents` is a process registry, provider names are unique within the Host, and host consumers resolve the same registry across sessions. Repeated preset composition would therefore contend for the same configured names. Requiring a person to edit both a Profile and a Preset would also make a generic preset row incomplete by itself. The placement decision must preserve two independent facts. Loading a provider must not start or authenticate a product, while enabling a tool must remain per preset so two sessions can expose different products. A global product switch, a provider instance per agent, or pre-enumerated combination presets would each create a second owner for one of those facts. ## Decision -Product providers remain process-scoped host-plane registrations. The [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) supersedes only this note's former base-bundle installation choice: production `dsh-base` neither depends on nor mounts them. A Profile that opts in installs the selected provider package and mounts it once on the host plane. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows for `subagent_codex` and `subagent_claude_code`, so a preset can expose neither tool, either one, or both without changing the provider registry. +Product providers remain process-scoped host-plane registrations. The [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) supersedes only this note's former base-bundle installation choice: production `dsh-base` neither depends on nor mounts them. A Profile that opts in installs the selected provider package and mounts the required instances on the host plane. The [named-instance decision](../feature/2026-08-18-product-subagent-named-instances.md) owns each row's registry identity: Claude Code accepts multiple unique `providerName` values while preserving `claude-code` as its default; Codex still registers only its `codex` default. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows whose `provider` and `toolName` values expose exactly the configured instances needed by one agent without changing the Host registry. This note continues to own why a mounted product provider belongs on the host plane while its model-facing tool belongs to an Agent Preset. The production-install exclusion decision owns which Profiles install those optional packages. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. -The providers use products already selected by the host environment. Codex starts `codex` from `PATH`; Claude Code resolves `claude` through the shared subprocess execution world and passes the exact path to the official SDK. Profile loading does not install a product, create product state, probe a version, or test authentication. It may supply each mounted Provider's deployment configuration, including the product-specific `permissionMode` values owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving those choices into an Agent Preset or model-facing tool. Missing commands and product failures remain local to the attempted delegation. +The providers use products already selected by the host environment. Codex starts `codex` from `PATH`; Claude Code resolves `claude` through the shared subprocess execution world and passes the exact path to the official SDK. Profile loading does not install a product, create product state, probe a version, or test authentication. It may supply each mounted Provider instance's deployment configuration, including the product-specific `permissionMode` values owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving those choices into an Agent Preset or model-facing tool. Missing commands and product failures remain local to the attempted delegation. Only a Profile that selects the Claude Code provider carries the Claude Agent SDK's optional platform CLI payload. Production still resolves the host `claude`; the SDK payload remains provider-package installation cost rather than the production executable. ## Verification -The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition explicitly mounts both optional providers and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove the Codex-only and dual-provider opt-in paths register the selected providers without starting a product process. Keyless ACP snapshots pin the model-visible tool schemas for one and both products, while provider tests separately prove native executable resolution, failure, cancellation, and process-tree quiescence. +The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition explicitly mounts both optional providers and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove the Codex-only path and a Host containing the default Codex instance plus two named Claude instances register without starting a product process. Keyless ACP snapshots pin the model-visible tool schemas for one product and for independently named product tools, while provider tests separately prove native executable resolution, configuration isolation, failure, cancellation, and process-tree quiescence. ## Alternatives considered @@ -30,12 +30,12 @@ The base bundle test proves production `dsh-base` contains neither product provi **Store global or per-Profile product enable switches.** A process switch competes with the Preset as owner of model-visible tools and cannot express two sessions using different combinations. Availability and authentication are deployment facts, not another persisted product state. -**Mount a provider inside every Agent Preset.** Provider names belong to a process registry, so the second session would collide with the first. Host consumers also need the registry independently of any one agent's lifetime. +**Mount providers inside every Agent Preset.** Provider names belong to a process registry, so repeated session composition would collide on the same configured names. Host consumers also need the registry independently of any one agent's lifetime. **Ship four product-combination presets.** Four identities duplicate complete compositions to represent two independent tool rows. Ordinary rows already express the full matrix without adding roster or maintenance state. ## Consequences -A user installs each selected product provider in a Profile and exposes its tool through the same Agent Preset authoring path as other plugins. Each new session receives exactly the tools its chosen preset contributes. Profiles that do not select a product provider carry no corresponding package or module-loading footprint; loading a selected provider still starts no product process, login, model call, or product home. +A user installs each selected product provider in a Profile, mounts the required named instances, and exposes their tools through the same Agent Preset authoring path as other plugins. Each new session receives exactly the tools its chosen preset contributes. Profiles that do not select a product provider carry no corresponding package or module-loading footprint; loading selected instances still starts no product process, login, model call, or product home. The Host registry remains the single provider authority and each Preset remains the single model-tool authority. The trade-off is a two-layer opt-in: the Profile owns installation and host-plane registration, while the Preset owns per-agent exposure. Selecting the Claude provider also accepts its current SDK optional-payload installation cost. diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md index a62bf6faa3..981b1e2cd3 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md @@ -6,36 +6,36 @@ Status: implemented ## 问题 -[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)最初以可独立安装的包交付,由部署环境在通用 subagent 工具旁加载。Agent Preset 后来成为单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有这些产品提供方:`ctx.subagents` 是进程级注册表,提供方名称唯一,而宿主消费方会跨会话解析同一个注册表。如果要求用户同时编辑 Profile 和 Preset,也会使通用 preset 行本身不完整。 +[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)最初以可独立安装的包交付,由部署环境在通用 subagent 工具旁加载。Agent Preset 后来成为单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有这些产品提供方:`ctx.subagents` 是进程级注册表,提供方名称在 Host 内唯一,而宿主消费方会跨会话解析同一个注册表。因此,重复组装 preset 会争用同一组已配置名称。如果要求用户同时编辑 Profile 和 Preset,也会使通用 preset 配置项本身不完整。 归属决策必须同时保留两个彼此独立的事实:加载提供方不得启动产品,也不得对产品执行身份验证;而工具是否启用仍须按 preset 决定,这样两个会话才能暴露不同的产品。全局产品开关、按 agent 创建提供方实例或预先枚举的组合 preset,都会为其中一个事实另设第二责任方。 ## 决策 -产品提供方仍是进程级的 host plane(宿主平面)注册。[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只取代本说明原先由 base bundle 安装提供方的选择:生产 `dsh-base` 既不依赖也不挂载它们。选择产品集成的 Profile 会安装目标提供方包,并在 host plane 挂载一次。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 分别通过普通的 `dsh-tool-subagent` 行贡献 `subagent_codex` 与 `subagent_claude_code`,因此一个 preset 可以不暴露任何工具、只暴露其中一个或同时暴露两者,而无需更改提供方注册表。 +产品提供方仍是进程级的 host plane(宿主平面)注册。[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只取代本说明原先由 base bundle 安装提供方的选择:生产 `dsh-base` 既不依赖也不挂载它们。选择产品集成的 Profile 会安装目标提供方包,并在 host plane 挂载所需实例。[命名实例决策](../feature/2026-08-18-product-subagent-named-instances.md)负责每个配置项的注册身份:Claude Code 接受多个唯一的 `providerName`,同时保留 `claude-code` 作为默认值;Codex 仍只注册默认的 `codex`。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 通过普通 `dsh-tool-subagent` 配置项的 `provider` 与 `toolName` 准确公开单个 agent 所需的已配置实例,而无需更改 Host 注册表。 本说明继续负责解释为什么已经挂载的产品提供方属于 host plane,而面向模型的工具属于 Agent Preset。生产安装排除决策负责哪些 Profile 安装这些可选包。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 -这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本或测试身份验证。它可以提供每个已挂载 Provider 的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的产品专属 `permissionMode` 值,但不会把这些选择移入 Agent Preset 或面向模型的工具。命令缺失和产品故障仍局限于发生问题的那次委派。 +这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本或测试身份验证。它可以提供每个已挂载 Provider 实例的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的产品专属 `permissionMode` 值,但不会把这些选择移入 Agent Preset 或面向模型的工具。命令缺失和产品故障仍局限于发生问题的那次委派。 只有选择 Claude Code 提供方的 Profile 才会携带 Claude Agent SDK 的可选平台 CLI(命令行界面)载荷。生产环境仍解析宿主提供的 `claude`;这份 SDK 载荷是提供方包的安装成本,而不是生产可执行文件。 ## 验证 -base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置行。Web 组装显式挂载两个可选提供方,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明 Codex-only 与双提供方按需启用路径会注册选中的提供方,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定单个产品与两个产品同时启用时的模型可见工具 schema,提供方测试则另行证明原生可执行文件解析、失败、取消和进程树完全停稳。 +base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装显式挂载两个可选提供方,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明 Codex-only 路径以及包含默认 Codex 实例与两个命名 Claude 实例的 Host 会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定单个产品与独立命名产品工具的模型可见 schema,提供方测试则另行证明原生可执行文件解析、配置隔离、失败、取消和进程树完全停稳。 ## 考虑过的替代方案 -**将产品提供方保留为 Profile 层的按需启用项。** 这样可缩小默认依赖闭包,但要求用户同时编辑 Profile 与 Preset。生产安装排除决策接受这项安装取舍;本说明保留的要求是,任何被选中的提供方都在 host plane 挂载一次,而不是放入 preset。 +**将产品提供方保留为 Profile 层的按需启用项。** 这样可缩小默认依赖闭包,但要求用户同时编辑 Profile 与 Preset。生产安装排除决策接受这项安装取舍;本说明保留的要求是,任何被选中的提供方实例都在 host plane 挂载,而不是放入 preset。 **存储全局或按 Profile 配置的产品启用开关。** 进程级开关会与 Preset 争夺模型可见工具的责任归属,也无法表示两个会话使用不同组合。可用性与身份验证属于部署事实,并非另一份需要持久化的产品状态。 -**在每个 Agent Preset 内挂载一个提供方。** 提供方名称属于进程级注册表,因此第二个会话会与第一个冲突。宿主消费方也需要独立于任何单个 agent 的生命周期使用该注册表。 +**在每个 Agent Preset 内挂载提供方。** 提供方名称属于进程级注册表,因此重复组装会话会在同一组已配置名称上发生冲突。宿主消费方也需要独立于任何单个 agent 的生命周期使用该注册表。 **交付四个产品组合 preset。** 四个身份会复制完整组装,只为表示两条独立的工具行。普通行已经能表达完整矩阵,无需新增名单或维护状态。 ## 后果 -用户在 Profile 中安装每个被选中的产品提供方,再通过与其他插件相同的 Agent Preset 创作路径暴露它的工具。每个新会话只会获得其所选 preset 所贡献的工具。没有选择产品提供方的 Profile 不承担对应包或模块的加载开销;加载已选择的提供方仍不会启动产品进程、登录、调用模型或创建产品主目录。 +用户在 Profile 中安装每个被选中的产品提供方,挂载所需命名实例,再通过与其他插件相同的 Agent Preset 创作路径公开这些实例的工具。每个新会话只会获得其所选 preset 所贡献的工具。没有选择产品提供方的 Profile 不承担对应包或模块的加载开销;加载已选择的实例仍不会启动产品进程、登录、调用模型或创建产品主目录。 宿主注册表仍是提供方的唯一权威,每个 Preset 仍是模型工具的唯一权威。代价是两层按需启用:Profile 负责安装与 host plane 注册,Preset 负责按 agent 暴露。选择 Claude 提供方还会接受当前 SDK 可选载荷的安装成本。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 777fff4e2a..9e3d221765 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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 .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: f65c0626ad22db8f3e7d2a543c7aa87e58df54d4 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 97ac527b8e89cc07d65aa28102ba43d648b1b64c +2026-08-04-claude-code-and-codex-subagent-backends.md: fb672f5c326ad240964e1e6c051a4f18d8d1552e +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 11f5c8f1c47be9e80386832efbe0b8b5675b3437 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index f65c0626ad..fb672f5c32 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -12,12 +12,12 @@ The product integrations must not become second owners for task text, cwd, cance ## Decision -The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and diagnostic production. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. +The harness publishes two sibling one-shot provider packages whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity and static tool binding, the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and diagnostic production. Claude Code accepts multiple named instances; Codex still registers its single default name. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation. ```text -fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product process +configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product process foreground <- final product outcome background -> ctx.jobs / dsh-tool-jobs -> Job id / state / notice / controls both -> provider disposal -> dsh-subprocess -> whole-tree exit @@ -48,9 +48,9 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp ## Claude Code provider -`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. A Windows `.cmd` or `.bat` path crosses `cmd.exe /v:off` as a quoted per-spawn environment expansion, so percent, ampersand, and exclamation path components remain data without changing the shared subprocess contract. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. +`@deepseek-ai/dsh-subagent-claude-code` registers a Profile-selected provider name that defaults to `claude-code` and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` executable name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. A Windows `.cmd` or `.bat` path crosses `cmd.exe /v:off` as a quoted per-spawn environment expansion, so percent, ampersand, and exclamation path components remain data without changing the shared subprocess contract. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. -The public configuration contains an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a five-value native `permissionMode` that defaults to `dontAsk`. Each run creates its own `AbortController`, sets `persistSession: false`, disables `AskUserQuestion`, and passes the resolved mode to the SDK; only `bypassPermissions` receives the SDK's explicit dangerous confirmation. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. Remaining permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of waiting for a user interface the provider does not own. +The public configuration contains a non-empty `providerName`, an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a five-value native `permissionMode` that defaults to `dontAsk`. Each named instance retains those resolved values for its own runs. Each run creates its own `AbortController`, sets `persistSession: false`, disables `AskUserQuestion`, and passes the resolved mode to the SDK; only `bypassPermissions` receives the SDK's explicit dangerous confirmation. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. Remaining permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of waiting for a user interface the provider does not own. The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every SDK error subtype, an error-marked success, a missing result, iterator failure, protocol failure, or process failure becomes `error`. When a permission denial or unattended callback contributes to that failure, the result may additionally carry the bounded, non-assistant diagnostic owned by the non-interactive permissions decision. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted` without permission detail. @@ -60,7 +60,7 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract ## Distribution and evidence -Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped explicit Profile configuration, verifies both fixed one-shot tools expose optional background scheduling alongside generic Job controls, and starts neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. +Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped explicit Profile configuration, verifies the default Codex instance and two named Claude Code instances expose independent one-shot tools alongside generic Job controls, and starts neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, thread-level `never` overriding ambient `on-request`, automatic-review startup, unattended command rejection with safe diagnostic and no file side effect, explicit dangerous-bypass writing in suite-owned temporary storage, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`. @@ -78,7 +78,7 @@ The project owner's distribution authorization is scoped to the official `@anthr **A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and process-tree concern. A new helper would duplicate ownership without deleting either private product adapter, so each adapter calls the existing seams directly. -**A model-visible product selector.** Product availability and authentication are deployment facts. Two fixed tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service. +**A model-visible product selector.** Product availability, instance configuration, and authentication are deployment facts. Profile-bound tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service. **Product doubles as required evidence.** Doubles cover exhaustive private protocol branches but do not prove package exports, official distributions, authentication, or real process behavior. Required evidence drives each official product against a loopback model fixture. @@ -88,7 +88,7 @@ The project owner's distribution authorization is scoped to the official `@anthr ## Consequences -Users delegate through two stable one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence. +Users delegate through Profile-configured one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); named instance identity and tool binding are owned by the [named-instance decision](2026-08-18-product-subagent-named-instances.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence. Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Product-native configuration makes behavior depend on the deployment's installed product, account state, workspace settings, and selected Provider mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 97ac527b8e..11f5c8f1c4 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -12,12 +12,12 @@ Status: implemented ## 决策 -harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责各产品提供方的 Profile 模式选择与诊断生产。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 +harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责 Profile 选择的提供方身份与静态工具绑定,[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责各产品提供方的 Profile 模式选择与诊断生产。Claude Code 接受多个命名实例;Codex 仍只注册单个默认名称。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。 ```text -fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product process +configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product process foreground <- final product outcome background -> ctx.jobs / dsh-tool-jobs -> Job id / state / notice / controls both -> provider disposal -> dsh-subprocess -> whole-tree exit @@ -48,9 +48,9 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 ## Claude Code 提供方 -`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。Windows `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境展开值穿过 `cmd.exe /v:off`,因此路径中的百分号、与号和感叹号仍只是数据,且无需改变共享子进程约定。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 +`@deepseek-ai/dsh-subagent-claude-code` 注册由 Profile 选择、默认值为 `claude-code` 的提供方名称,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定的 `claude` 可执行文件名称,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。Windows `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境展开值穿过 `cmd.exe /v:off`,因此路径中的百分号、与号和感叹号仍只是数据,且无需改变共享子进程约定。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 -公开配置包含显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `dontAsk` 的五值原生 `permissionMode`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false`、禁用 `AskUserQuestion`,并把已解析模式传给 SDK;只有 `bypassPermissions` 会取得 SDK 的显式危险确认。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。其余权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败,而不会等待本提供方不负责的用户界面。 +公开配置包含非空的 `providerName`、显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `dontAsk` 的五值原生 `permissionMode`。每个命名实例会为自己的运行保留这些已解析值。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false`、禁用 `AskUserQuestion`,并把已解析模式传给 SDK;只有 `bypassPermissions` 会取得 SDK 的显式危险确认。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。其余权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败,而不会等待本提供方不负责的用户界面。 只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。当权限拒绝或无人值守回调参与了该失败时,结果还可以携带由非交互权限决策负责的有界、非 assistant 诊断。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`,且不附带权限说明。 @@ -60,7 +60,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 ## 分发与证据 -每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示的显式 Profile 配置,在同一个上下文中验证两个固定一次性工具会与通用 Job 控制工具一起公开可选后台调度,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 +每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示的显式 Profile 配置,验证默认 Codex 实例与两个命名 Claude Code 实例会和通用 Job 控制工具一起公开彼此独立的一次性工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、带安全诊断且不产生文件副作用的无人值守命令拒绝、测试拥有临时存储中的显式危险绕过写入、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 @@ -78,7 +78,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl **共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和进程树的全部共享职责。新辅助包无法删除任一私有产品适配器,只会造成责任重复,因此每个适配器都会直接调用现有 seam。 -**面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 +**面向模型的产品选择器。** 产品可用性、实例配置和身份验证属于部署事实。由 Profile 绑定的工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 **以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture。 @@ -88,7 +88,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl ## 后果 -用户通过官方产品集成支持的两个稳定一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。 +用户通过由 Profile 配置、并由官方产品集成支持的一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责;命名实例身份与工具绑定由[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。 每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态、工作区设置和所选提供方模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.i18n.yaml new file mode 100644 index 0000000000..57c1c7ef60 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md +2026-08-18-product-subagent-named-instances.md: 6b069727ebba7ebcf34444f9ebdb287c00bf315d +2026-08-18-product-subagent-named-instances.zh.md: dffa009296afde44126725fd65a2fc58977377fc diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md new file mode 100644 index 0000000000..6b069727eb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md @@ -0,0 +1,48 @@ +# Agent Note: Product subagent named instances + +Status: implemented + +English | [中文](2026-08-18-product-subagent-named-instances.zh.md) + +## Problem + +A Profile can mount one Cordis plugin package in multiple rows, but the Claude Code product provider previously registered every row as `claude-code`. A second row therefore failed as a duplicate before its distinct permission mode, environment, or process-release settings could become usable. Deriving an implicit name from those settings would create a second identity rule, while choosing a provider during a tool call would let model input select deployment authority. + +The existing subagent registry already owns unique provider names, reversible registration, lifecycle events, and holder-owned published runs. The existing `dsh-tool-subagent` configuration already binds one provider name to one model-visible tool name. Product providers need to expose the missing Profile-owned identity without adding another registry or selection protocol. + +## Decision + +The Claude Code provider Config owns a non-empty `providerName` whose default remains `claude-code`. The resolved name is fixed when the plugin row loads and becomes the Provider object's `name`; registration, lookup, lifecycle events, run logs, and HMR removal therefore use the same value. Each mounted row retains its own `permissionMode`, `env`, `disposeGraceMs`, and run resources. The Codex provider still registers its single `codex` default name. + +Profiles may mount multiple Claude Code rows when every row uses a distinct `providerName`. Each `dsh-tool-subagent` row continues to bind its existing `provider` field to that exact name and exposes an independently configured `toolName`. Tool calls carry no provider selector, alias, or permission input. A duplicate provider name fails through the existing `DUPLICATE_PROVIDER` path and leaves the first registration intact. + +Removing one provider row blocks new starts and removes only tools bound to that name. Runs already published by the removed instance remain owned by their holders and settle or dispose independently. Sibling instances remain registered and keep their own environment, native permission mode, cancellation controller, product process, and cleanup grace. + +### Ownership and lifecycle + +| Fact or operation | Owner | Result | +| --- | --- | --- | +| Provider instance name | Product Provider Config | One immutable registry name per mounted row, with the existing default when omitted | +| Name uniqueness and lifecycle events | `ctx.subagents` | Duplicate registration fails; disposal removes only the matching name | +| Model-visible tool name and binding | `dsh-tool-subagent` Config | One static tool resolves one configured provider name | +| Permission, environment, and process cleanup | One Provider instance | Concurrent runs and sibling instances do not share deployment configuration or run resources | + +## Verification + +Claude Code package tests pin the default and custom names, empty-name rejection, duplicate rollback, actual-name diagnostics, two concurrent instances with different permission modes, environments, and cleanup grace, cancellation isolation, and removal of one instance while its published run remains valid. The official SDK/CLI loopback test runs two named instances in one Host against separate model fixtures and proves independent unload and process-tree quiescence. The public Loader composition mounts two Claude Code rows and two distinct tools without starting either product, while the keyless ACP snapshot pins both static tool schemas and the absence of a dynamic provider parameter. + +## Alternatives considered + +**Derive names from the product or permission mode.** An implicit suffix would make identity change when deployment settings change and could still collide across equivalent rows. The Profile supplies the identity explicitly. + +**Let a tool call choose the provider.** That would make model input select a permission and environment instance. Separate tool rows keep authorization and exposure static in configuration. + +**Create a product-instance catalog or alias registry.** The existing subagent registry already owns names, uniqueness, lookup, events, and disposal. Another directory would duplicate state without a distinct consumer. + +**Automatically rename duplicate rows.** Silent suffixing would make tool bindings and lifecycle diagnostics depend on load order. Duplicate names continue to fail loudly. + +## Consequences + +A Profile can expose several Claude Code tools backed by separate native permission modes and environments while existing configurations continue to resolve `claude-code`. Provider and tool names remain independent configuration facts, so changing one requires updating the binding that refers to it. + +The design adds no runtime renaming, model-visible selector, generated tool name, persistent instance directory, shared process pool, or compatibility alias. Correct multi-instance configurations require unique provider names and unique tool names; duplicate tool-name waiting remains a separate limitation. diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md new file mode 100644 index 0000000000..dffa009296 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md @@ -0,0 +1,48 @@ +# Agent Note: 产品 subagent 命名实例 + +Status: implemented + +[English](2026-08-18-product-subagent-named-instances.md) | 中文 + +## 问题 + +Profile 可以用多个配置项挂载同一个 Cordis 插件包,但 Claude Code 产品提供方此前会把每个配置项都注册为 `claude-code`。因此,第二个配置项会在其独立权限模式、环境或进程释放设置可用前因名称重复而失败。根据这些设置隐式派生名称会建立第二套身份规则,而在工具调用期间选择提供方会让模型输入决定部署权限。 + +现有 subagent 注册表已经拥有提供方名称唯一性、可逆注册、生命周期事件和由持有方拥有的已发布运行。现有 `dsh-tool-subagent` 配置也已经把一个提供方名称绑定到一个模型可见工具名称。产品提供方只需公开缺失的 Profile 所有身份,无需增加另一套注册表或选择协议。 + +## 决策 + +Claude Code 提供方 Config 拥有非空的 `providerName`,其默认值仍为 `claude-code`。插件配置项加载时会固定解析后的名称,并把它作为 Provider 对象的 `name`;注册、查找、生命周期事件、运行日志和 HMR(热模块替换)移除因此使用同一个值。每个已挂载配置项保留自己的 `permissionMode`、`env`、`disposeGraceMs` 和运行资源。Codex 提供方仍只注册默认名称 `codex`。 + +当每个配置项使用不同的 `providerName` 时,Profile 可以挂载多个 Claude Code 配置项。每个 `dsh-tool-subagent` 配置项继续用已有的 `provider` 字段绑定这个准确名称,并公开独立配置的 `toolName`。工具调用不携带提供方选择器、别名或权限输入。重复提供方名称沿用现有 `DUPLICATE_PROVIDER` 路径失败,而且不会替换第一个注册项。 + +移除一个提供方配置项会阻止新的启动,并且只移除绑定到该名称的工具。该实例已经发布的运行仍由其持有方拥有,并会独立结算或 dispose(资源释放)。兄弟实例继续保持注册,并保留各自的环境、原生权限模式、取消控制器、产品进程和清理宽限期。 + +### 所有权与生命周期 + +| 事实或操作 | 责任方 | 结果 | +| --- | --- | --- | +| 提供方实例名称 | 产品提供方 Config | 每个已挂载配置项拥有一个不可变注册名称;省略时使用现有默认值 | +| 名称唯一性与生命周期事件 | `ctx.subagents` | 重复注册失败;资源释放只移除匹配名称 | +| 模型可见工具名称与绑定 | `dsh-tool-subagent` Config | 一个静态工具解析一个已配置的提供方名称 | +| 权限、环境与进程清理 | 一个提供方实例 | 并发运行与兄弟实例不共享部署配置或运行资源 | + +## 验证 + +Claude Code 包测试固定默认与自定义名称、空名称拒绝、重复注册回滚、实际名称诊断、使用不同权限模式、环境与清理宽限期的两个并发实例、取消隔离,以及移除一个实例后其已发布运行仍然有效。官方 SDK/CLI 回环测试会在同一个 Host 中针对独立模型 fixture(测试前置数据)运行两个命名实例,并证明独立卸载与进程树完全停稳。公共 Loader 组合会挂载两个 Claude Code 配置项与两个不同工具,而且不启动任一产品;无密钥 ACP 快照固定两个静态工具 schema,并证明没有动态提供方参数。 + +## 考虑过的替代方案 + +**根据产品或权限模式派生名称。** 隐式后缀会让部署设置变化同时改变身份,而且等价配置项之间仍可能冲突。Profile 会显式提供身份。 + +**让工具调用选择提供方。** 这会让模型输入选择权限与环境实例。独立工具配置项会让授权与公开范围保持静态配置。 + +**建立产品实例目录或别名注册表。** 现有 subagent 注册表已经拥有名称、唯一性、查找、事件和资源释放。另一套目录没有独立消费方,只会复制状态。 + +**自动重命名重复配置项。** 静默添加后缀会让工具绑定与生命周期诊断依赖加载顺序。重复名称继续快速失败。 + +## 结果 + +Profile 可以公开多个由不同原生权限模式与环境支持的 Claude Code 工具,而现有配置仍会解析为 `claude-code`。提供方名称与工具名称继续是彼此独立的配置事实,因此修改其中一项时必须同时更新引用它的绑定。 + +本设计不增加运行时改名、模型可见选择器、自动生成的工具名称、持久实例目录、共享进程池或兼容别名。正确的多实例配置要求提供方名称与工具名称都保持唯一;重复工具名称的等待问题仍是独立限制。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9136d2952b..8767173399 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: ebef0167d5ecbe0c71d201cd2cc07962ba89c48d -config-catalog.zh.md: 4b2ffba0e931c4c515097950e3e69b5744cb5f37 +config-catalog.md: 33fc261e971f9055f666e5005080e01b31c6d708 +config-catalog.zh.md: 24ad1fdb5d0d2eb7470785de7b913d7b33f6c9aa diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ebef0167d5..33fc261e97 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2083,6 +2083,8 @@ Requires: `subagents` · `subprocess` ```ts config-catalog /** Deployment-owned permission, environment, and process-release settings. */ export interface Config { + /** Provider name on `ctx.subagents` (default `claude-code`). */ + providerName?: string /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. @@ -2103,7 +2105,7 @@ export interface Config { export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number] ``` -Source: [`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/subagent/subagent-claude-code/src/index.ts) +Source: [`packages/subagent/subagent-claude-code/src/index.ts:37`](../packages/subagent/subagent-claude-code/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4b2ffba0e9..24ad1fdb5d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2085,6 +2085,8 @@ export type PermissionPolicy = 'allow' | 'reject' ```ts config-catalog /** Deployment-owned permission, environment, and process-release settings. */ export interface Config { + /** Provider name on `ctx.subagents` (default `claude-code`). */ + providerName?: string /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. @@ -2105,7 +2107,7 @@ export interface Config { export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number] ``` -来源:[`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/subagent/subagent-claude-code/src/index.ts) +来源:[`packages/subagent/subagent-claude-code/src/index.ts:37`](../packages/subagent/subagent-claude-code/src/index.ts) diff --git a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml index c4af1894e1..d69d35fa22 100644 --- a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml +++ b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml @@ -1,4 +1,4 @@ -# Keyless twin of product-subagent-both.cordis.yml: preserve both product +# Keyless twin of product-subagent-both.cordis.yml: preserve all named product # tools while replacing only the external model adapter. - id: base name: '@deepseek-ai/cordis-plugin-include' @@ -22,10 +22,20 @@ name: '@deepseek-ai/dsh-subagent-codex' config: permissionMode: approve-for-me - - id: subagent-claude-code + - id: subagent-claude-safe name: '@deepseek-ai/dsh-subagent-claude-code' config: - permissionMode: acceptEdits + providerName: claude-safe + permissionMode: dontAsk + env: + DSH_CLAUDE_INSTANCE: safe + - id: subagent-claude-bypass + name: '@deepseek-ai/dsh-subagent-claude-code' + config: + providerName: claude-bypass + permissionMode: bypassPermissions + env: + DSH_CLAUDE_INSTANCE: bypass - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: @@ -33,10 +43,17 @@ toolName: subagent_codex backgroundMode: one-shot maxDepth: provider-managed - - id: tool-subagent-claude-code + - id: tool-subagent-claude-safe name: '@deepseek-ai/dsh-tool-subagent' config: - provider: claude-code - toolName: subagent_claude_code + provider: claude-safe + toolName: subagent_claude_safe + backgroundMode: one-shot + maxDepth: provider-managed + - id: tool-subagent-claude-bypass + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-bypass + toolName: subagent_claude_bypass backgroundMode: one-shot maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-both.cordis.yml b/examples/acp-agent/product-subagent-both.cordis.yml index 837fea1f75..710dfc7ad7 100644 --- a/examples/acp-agent/product-subagent-both.cordis.yml +++ b/examples/acp-agent/product-subagent-both.cordis.yml @@ -1,6 +1,6 @@ -# Add both native product providers and the same independent one-shot tool rows -# an Agent Preset may contribute. Loading the composition starts neither -# product; the scenario pins both model-visible schemas. +# Add the native Codex provider, two named Claude Code instances, and the +# independent one-shot tool rows an Agent Preset may contribute. Loading the +# composition starts neither product; the scenario pins all three schemas. - id: base name: '@deepseek-ai/cordis-plugin-include' config: @@ -11,10 +11,20 @@ name: '@deepseek-ai/dsh-subagent-codex' config: permissionMode: approve-for-me - - id: subagent-claude-code + - id: subagent-claude-safe name: '@deepseek-ai/dsh-subagent-claude-code' config: - permissionMode: acceptEdits + providerName: claude-safe + permissionMode: dontAsk + env: + DSH_CLAUDE_INSTANCE: safe + - id: subagent-claude-bypass + name: '@deepseek-ai/dsh-subagent-claude-code' + config: + providerName: claude-bypass + permissionMode: bypassPermissions + env: + DSH_CLAUDE_INSTANCE: bypass - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: @@ -22,10 +32,17 @@ toolName: subagent_codex backgroundMode: one-shot maxDepth: provider-managed - - id: tool-subagent-claude-code + - id: tool-subagent-claude-safe name: '@deepseek-ai/dsh-tool-subagent' config: - provider: claude-code - toolName: subagent_claude_code + provider: claude-safe + toolName: subagent_claude_safe + backgroundMode: one-shot + maxDepth: provider-managed + - id: tool-subagent-claude-bypass + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-bypass + toolName: subagent_claude_bypass backgroundMode: one-shot maxDepth: provider-managed diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml index 2e08c0036d..c9a4f71848 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml @@ -1,4 +1,4 @@ -# Test-only composition of both public opt-in providers and one-shot task tools. +# Test-only composition of Codex plus two named Claude instances and their tools. # The owning e2e boots this tree but never invokes a model or product process. - id: fixture name: './fixture.ts' @@ -12,10 +12,21 @@ - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' -- id: subagent-claude-code +- id: subagent-claude-safe name: '@deepseek-ai/dsh-subagent-claude-code' config: - permissionMode: acceptEdits + providerName: claude-safe + permissionMode: dontAsk + env: + DSH_CLAUDE_INSTANCE: safe + +- id: subagent-claude-bypass + name: '@deepseek-ai/dsh-subagent-claude-code' + config: + providerName: claude-bypass + permissionMode: bypassPermissions + env: + DSH_CLAUDE_INSTANCE: bypass - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' @@ -25,11 +36,19 @@ backgroundMode: one-shot maxDepth: 'provider-managed' -- id: tool-subagent-claude-code +- id: tool-subagent-claude-safe name: '@deepseek-ai/dsh-tool-subagent' config: - provider: claude-code - toolName: subagent_claude_code + provider: claude-safe + toolName: subagent_claude_safe + backgroundMode: one-shot + maxDepth: 'provider-managed' + +- id: tool-subagent-claude-bypass + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-bypass + toolName: subagent_claude_bypass backgroundMode: one-shot maxDepth: 'provider-managed' diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts index c10e9d110c..92dd44fca1 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts @@ -23,8 +23,12 @@ const ctx = await boot( ) try { - const providerNames = ['codex', 'claude-code'] as const - const toolNames = ['subagent_codex', 'subagent_claude_code'] as const + const providerNames = ['codex', 'claude-safe', 'claude-bypass'] as const + const toolNames = [ + 'subagent_codex', + 'subagent_claude_safe', + 'subagent_claude_bypass', + ] as const const providers = providerNames.map((providerName) => { const provider = ctx.subagents.getProvider(providerName) if (provider === undefined) { diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json index e668036737..74690c0165 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json @@ -307,7 +307,32 @@ } }, { - "name": "subagent_claude_code", + "name": "subagent_claude_bypass", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_claude_safe", "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.", "parameters": { "type": "object", diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 0185afd2de..8f08087709 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/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/subagent/subagent-claude-code/README.md -README.md: be3b2262addc487e545fed1f792600a9a5ca24c0 -README.zh.md: 7ea1b8ca7243790afd387b04d776088cea012718 +README.md: bc33d97fb6d7224138e01fa86c3ce28b00df08b8 +README.zh.md: ad7cca3e9da654ae7d4d13739ff81992c7670e04 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index be3b2262ad..bc33d97fb6 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, resolves the native `claude` executable through the shared subprocess service, submits one self-contained text task, and returns either the strict final answer or safe failure detail through the shared [`dsh-subagent`](../subagent/README.md) result contract. +This package registers a Profile-named Claude Code subagent provider whose default name is `claude-code`. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, resolves the native `claude` executable through the shared subprocess service, submits one self-contained text task, and returns either the strict final answer or safe failure detail through the shared [`dsh-subagent`](../subagent/README.md) result contract. ## Start and ownership @@ -26,6 +26,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | Key | Default | Meaning | |---|---|---| +| `providerName` | `claude-code` | Non-empty registry name on `ctx.subagents`; each mounted instance needs a unique value. | | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | | `permissionMode` | `dontAsk` | Native non-interactive permission policy fixed for every run from this Provider instance. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | @@ -40,15 +41,24 @@ The provider advertises no optional start-time capabilities and reports `inherit Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data. The pinned SDK's fixed flags then occupy cmd's command tail and contain no cmd metacharacters; they are not ordinary Windows argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. -Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-claude-code` and mount it once on the host plane; loading the provider starts no Claude process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. +Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-claude-code` and may mount one or more host-plane rows with distinct `providerName`, `permissionMode`, and `env` values; omitting `providerName` keeps the `claude-code` default. Loading an instance starts no Claude process until a bound tool calls it. Each `dsh-tool-subagent` row names one provider and needs its own `toolName`, so the model sees static tools rather than a dynamic provider selector. Full Agent Presets carry a matching default product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. -The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider row, and enables the preset tool row instead of mounting duplicate Job services. +The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider and tool rows, and does not mount duplicate Job services. ```yaml -- id: subagent-claude-code +- id: subagent-claude-safe name: '@deepseek-ai/dsh-subagent-claude-code' config: - permissionMode: acceptEdits + providerName: claude-safe + permissionMode: dontAsk + env: + ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY + +- id: subagent-claude-bypass + name: '@deepseek-ai/dsh-subagent-claude-code' + config: + providerName: claude-bypass + permissionMode: bypassPermissions env: ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY @@ -58,18 +68,26 @@ The standalone composition below shows the complete explicit capability. A Profi - id: tool-jobs name: '@deepseek-ai/dsh-tool-jobs' -- id: tool-subagent-claude-code +- id: tool-subagent-claude-safe name: '@deepseek-ai/dsh-tool-subagent' config: - provider: claude-code - toolName: subagent_claude_code + provider: claude-safe + toolName: subagent_claude_safe + backgroundMode: one-shot + maxDepth: provider-managed + +- id: tool-subagent-claude-bypass + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-bypass + toolName: subagent_claude_bypass backgroundMode: one-shot maxDepth: provider-managed ``` ## Product compatibility and evidence -The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`. Production runs the native `claude` installation. The keyless real-product test uses the SDK-distributed Claude Code 2.1.220 CLI as a deterministic fixture, routed through the same native executable-resolution and Windows batch-shim path; it does not claim compatibility with every independently installed version. Loader composition proves that both product packages coexist without starting either product. +The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`. Production runs the native `claude` installation. The keyless real-product test uses the SDK-distributed Claude Code 2.1.220 CLI as a deterministic fixture, routed through the same native executable-resolution and Windows batch-shim path; it does not claim compatibility with every independently installed version. Loader composition proves that two named Claude instances and the Codex package coexist without starting either product. The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate. @@ -79,7 +97,7 @@ The project owner's identity-scoped distribution authorization covers the offici #### What the model sees -The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd; its model, system instructions, tools, sandbox, and authentication come from the host's native Claude settings and product installation, while the Provider's Profile configuration fixes the query's non-interactive permission mode. +The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd; its model, system instructions, tools, sandbox, and authentication come from the host's native Claude settings and product installation, while the selected Provider instance's Profile configuration fixes the query's environment and non-interactive permission mode. #### Token effect @@ -106,6 +124,7 @@ Append-only: foreground adds one result after the reusable parent prefix, while ## Known Limitations and Deferred Work - **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. +- **Static instance selection** — Profile rows fix provider names and tool bindings; calls cannot choose a provider dynamically, and every exposed tool needs a unique `toolName`. - **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode. - **Product installation and account state remain native** — a missing or incompatible `claude`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. - **The SDK platform CLI remains in the install closure** — production ignores it in favor of the host `claude`, but the current SDK optional dependency is still installed and supplies the keyless compatibility fixture. Removing that payload belongs to the separate product installation-closure follow-up. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 7ea1b8ca72..ad7cca3e9d 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务解析原生 `claude` 可执行文件,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回严格的最终答案或安全的失败说明。 +本包(package)注册由 Profile 命名、默认名称为 `claude-code` 的 Claude Code subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务解析原生 `claude` 可执行文件,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回严格的最终答案或安全的失败说明。 ## 启动与所有权 @@ -26,6 +26,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | 配置键 | 默认值 | 含义 | |---|---|---| +| `providerName` | `claude-code` | `ctx.subagents` 中的非空注册名称;每个已挂载实例都需要唯一值。 | | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | | `permissionMode` | `dontAsk` | 为该提供方实例的每次运行固定原生非交互权限策略。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | @@ -40,15 +41,24 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据。锁定版本的 SDK 随后把固定命令行选项放在 cmd 的命令尾部;这些选项不含 cmd 元字符,也并不是普通的 Windows argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 -生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-claude-code`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Claude 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 +生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-claude-code`,并可在 host plane(宿主平面)挂载一个或多个具有不同 `providerName`、`permissionMode` 与 `env` 的配置项;省略 `providerName` 时仍使用默认的 `claude-code`。加载实例本身不会在绑定工具调用前启动 Claude 进程。每个 `dsh-tool-subagent` 配置项指定一个提供方,并需要独立的 `toolName`,因此模型看到的是静态工具,而不是动态提供方选择器。完整 Agent Preset 携带对应的默认产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 -下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 行,只新增产品提供方行并启用 preset 工具行,禁止重复挂载 Job 服务。 +下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 配置项,新增产品提供方与工具配置项,而且不重复挂载 Job 服务。 ```yaml -- id: subagent-claude-code +- id: subagent-claude-safe name: '@deepseek-ai/dsh-subagent-claude-code' config: - permissionMode: acceptEdits + providerName: claude-safe + permissionMode: dontAsk + env: + ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY + +- id: subagent-claude-bypass + name: '@deepseek-ai/dsh-subagent-claude-code' + config: + providerName: claude-bypass + permissionMode: bypassPermissions env: ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY @@ -58,18 +68,26 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK - id: tool-jobs name: '@deepseek-ai/dsh-tool-jobs' -- id: tool-subagent-claude-code +- id: tool-subagent-claude-safe name: '@deepseek-ai/dsh-tool-subagent' config: - provider: claude-code - toolName: subagent_claude_code + provider: claude-safe + toolName: subagent_claude_safe + backgroundMode: one-shot + maxDepth: provider-managed + +- id: tool-subagent-claude-bypass + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-bypass + toolName: subagent_claude_bypass backgroundMode: one-shot maxDepth: provider-managed ``` ## 产品兼容性与证据 -运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`。生产运行使用原生 `claude` 安装。无密钥真实产品测试使用由 SDK 分发的 Claude Code 2.1.220 CLI 作为确定性 fixture(测试前置数据),并通过同一套原生可执行文件解析路径与 Windows batch shim 路径运行;这项测试不声称兼容每个独立安装的版本。Loader 组合证明两个产品包能够共存且不会启动任一产品。 +运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`。生产运行使用原生 `claude` 安装。无密钥真实产品测试使用由 SDK 分发的 Claude Code 2.1.220 CLI 作为确定性 fixture(测试前置数据),并通过同一套原生可执行文件解析路径与 Windows batch shim 路径运行;这项测试不声称兼容每个独立安装的版本。Loader 组合证明两个命名 Claude 实例可与 Codex 包共存,而且不会启动任一产品。 限定于项目所有者身份的分发授权涵盖官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会认定其中声明的条款属于宽松许可;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。 @@ -79,7 +97,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK #### 模型看到的内容 -Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自宿主机原生 Claude 设置与产品安装,而提供方的 Profile 配置会固定该 query 的非交互权限模式。 +Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自宿主机原生 Claude 设置与产品安装,而所选提供方实例的 Profile 配置会固定该 query 的环境与非交互权限模式。 #### 对 token 的影响 @@ -106,6 +124,7 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 ## 已知限制与后续工作 - **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。 +- **静态选择实例**:Profile 配置项固定提供方名称与工具绑定;调用无法动态选择提供方,而且每个公开工具都需要唯一的 `toolName`。 - **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 - **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 - **SDK 平台 CLI 仍在安装闭包内**:生产环境会忽略它,改用宿主提供的 `claude`,但当前 SDK 的可选依赖仍会安装,并提供无密钥兼容性 fixture。移除该载荷属于独立的产品安装闭包后续项。 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index 4095ca8f8e..806e51873a 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -1,7 +1,7 @@ /** - * Fixed Claude Code one-shot subagent provider. Every accepted run invokes - * the official Agent SDK in the delegating Session's workspace and places - * the SDK-spawned real CLI under the shared subprocess owner. + * Profile-named Claude Code one-shot subagent provider. Every accepted run + * invokes the official Agent SDK in the delegating Session's workspace and + * places the SDK-spawned real CLI under the shared subprocess owner. * * @module @deepseek-ai/dsh-subagent-claude-code */ @@ -29,10 +29,14 @@ import { export const name = 'subagent-claude-code' export const inject = ['subagents', 'subprocess'] +const DEFAULT_PROVIDER_NAME = 'claude-code' + /* jscpd:ignore-start -- sibling product providers intentionally expose * overlapping deployment-owned fields without adding a shared config owner. */ /** Deployment-owned permission, environment, and process-release settings. */ export interface Config { + /** Provider name on `ctx.subagents` (default `claude-code`). */ + providerName?: string /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. @@ -50,6 +54,7 @@ export interface Config { } export const Config: z = z.object({ + providerName: z.string().min(1).default(DEFAULT_PROVIDER_NAME), env: z.dict(z.string()).default({}), permissionMode: z.union([...CLAUDE_CODE_PERMISSION_MODES]) .default(DEFAULT_CLAUDE_CODE_PERMISSION_MODE), @@ -62,11 +67,11 @@ type ResolvedConfig = Required /* jscpd:ignore-start -- Cordis registration and shared-seam plumbing mirror * the Codex sibling; each product's lifecycle remains package-private. */ class ClaudeCodeProvider implements SubagentProvider { - readonly name = 'claude-code' readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES readonly inheritsParentContext = false constructor( + readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig, ) {} @@ -96,7 +101,7 @@ class ClaudeCodeProvider implements SubagentProvider { spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), onError: (error, stopReason) => { this.ctx.logger.warn( - `subagent-claude-code: child run failed (${stopReason}): ${error.message}`, + `subagent-claude-code "${this.name}": child run failed (${stopReason}): ${error.message}`, ) }, } @@ -105,12 +110,13 @@ class ClaudeCodeProvider implements SubagentProvider { } /** - * Register the fixed `claude-code` provider. + * Register one Profile-named Claude Code provider. * @param ctx - context carrying shared subagent and subprocess services. - * @param config - permission mode, child environment, and disposal grace. + * @param config - registry name, permission mode, child environment, and disposal grace. */ export function apply(ctx: Context, config: Config): void { const resolved: ResolvedConfig = { + providerName: config.providerName ?? DEFAULT_PROVIDER_NAME, env: config.env as Record, permissionMode: config.permissionMode ?? DEFAULT_CLAUDE_CODE_PERMISSION_MODE, disposeGraceMs: config.disposeGraceMs as number, @@ -125,6 +131,13 @@ export function apply(ctx: Context, config: Config): void { `subagent-claude-code: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`, ) } - ctx.subagents.registerProvider(new ClaudeCodeProvider(ctx, resolved)) + if (resolved.providerName.length === 0) { + throw new TypeError('subagent-claude-code providerName must be non-empty') + } + ctx.subagents.registerProvider(new ClaudeCodeProvider( + resolved.providerName, + ctx, + resolved, + )) } /* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts index 37d02657cb..c63612154d 100644 --- a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts @@ -15,7 +15,7 @@ const configPath = join(fixtureDir, 'cordis.yml') const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) describe('product-provider public Loader composition', () => { - it('loads both opt-in packages, one-shot task tools, and job controls without starting either product', async () => { + it('loads two named Claude instances, their tools, and Codex without starting either product', async () => { const { stdout, stderr } = await runLoaderSmoke({ label: 'product-provider Loader composition', tempDirPrefix: 'dsh-product-provider-loader-', @@ -31,7 +31,7 @@ describe('product-provider public Loader composition', () => { expect(stderr).toBe('') expect(JSON.parse(stdout)).toEqual({ - registeredProviders: ['codex', 'claude-code'], + registeredProviders: ['codex', 'claude-safe', 'claude-bypass'], providers: [ { name: 'codex', @@ -44,7 +44,17 @@ describe('product-provider public Loader composition', () => { inheritsParentContext: false, }, { - name: 'claude-code', + name: 'claude-safe', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, + { + name: 'claude-bypass', capabilities: { outputSchema: false, depthLimit: false, @@ -61,7 +71,12 @@ describe('product-provider public Loader composition', () => { required: ['description', 'prompt'], }, { - name: 'subagent_claude_code', + name: 'subagent_claude_safe', + parameterNames: ['description', 'prompt', 'run_in_background'], + required: ['description', 'prompt'], + }, + { + name: 'subagent_claude_bypass', parameterNames: ['description', 'prompt', 'run_in_background'], required: ['description', 'prompt'], }, diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index a2e7111ece..e552ebddbc 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -119,19 +119,23 @@ interface RealHarness { readonly handles: SubprocessHandle[] readonly spawnSpecs: SubprocessSpawnSpec[] readonly parent: Agent + readonly providerName: string readonly workspace: string readonly env: Record readonly executable: string } -async function realHarness( - behavior: MessagesBehavior, - permissionMode?: ClaudeCodePermissionMode, - nativeAllow: readonly string[] = [], -): Promise<{ - readonly harness: RealHarness +interface RealInstanceFixture { readonly fixture: MessagesFixture -}> { + readonly workspace: string + readonly env: Record + readonly executable: string +} + +async function realInstanceFixture( + behavior: MessagesBehavior, + nativeAllow: readonly string[] = [], +): Promise { const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-real-')) roots.push(root) const workspace = join(root, 'workspace') @@ -176,6 +180,16 @@ async function realHarness( ALL_PROXY: '', NO_PROXY: '127.0.0.1,localhost', } + return { fixture, workspace, env, executable } +} + +interface RealRuntime { + readonly ctx: Context + readonly handles: SubprocessHandle[] + readonly spawnSpecs: SubprocessSpawnSpec[] +} + +async function realRuntime(): Promise { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SubagentRuntime) @@ -189,18 +203,42 @@ async function realHarness( handles.push(handle) return handle }) + return { ctx, handles, spawnSpecs } +} + +async function realHarness( + behavior: MessagesBehavior, + permissionMode?: ClaudeCodePermissionMode, + nativeAllow: readonly string[] = [], + providerName = 'claude-code', +): Promise<{ + readonly harness: RealHarness + readonly fixture: MessagesFixture +}> { + const instance = await realInstanceFixture(behavior, nativeAllow) + const { ctx, handles, spawnSpecs } = await realRuntime() await ctx.plugin(claudeCode, { - env, + providerName, + env: instance.env, ...permissionMode === undefined ? {} : { permissionMode }, disposeGraceMs: 3_000, }) const parent = { id: 'real-parent', - session: { header: { cwd: workspace } }, + session: { header: { cwd: instance.workspace } }, } as unknown as Agent return { - harness: { ctx, handles, spawnSpecs, parent, workspace, env, executable }, - fixture, + harness: { + ctx, + handles, + spawnSpecs, + parent, + providerName, + workspace: instance.workspace, + env: instance.env, + executable: instance.executable, + }, + fixture: instance.fixture, } } @@ -221,7 +259,7 @@ function startRequest( prompt: string, signal = new AbortController().signal, ) { - return harness.ctx.subagents.start('claude-code', { + return harness.ctx.subagents.start(harness.providerName, { prompt: [{ type: 'text', text: prompt }], parent: harness.parent, signal, @@ -294,6 +332,80 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 await expectQuiescent(harness.handles) }) + it('runs two named instances concurrently and unloads one without revoking its run', async () => { + const safeInstance = await realInstanceFixture({ kind: 'hold' }) + const bypassInstance = await realInstanceFixture({ + kind: 'complete', + text: 'NAMED_BYPASS_RESULT', + }) + const { ctx, handles, spawnSpecs } = await realRuntime() + const safeFiber = await ctx.plugin(claudeCode, { + providerName: 'claude-safe', + env: safeInstance.env, + permissionMode: 'dontAsk', + disposeGraceMs: 3_000, + }) + const bypassFiber = await ctx.plugin(claudeCode, { + providerName: 'claude-bypass', + env: bypassInstance.env, + permissionMode: 'bypassPermissions', + disposeGraceMs: 3_000, + }) + const safeParent = { + id: 'safe-parent', + session: { header: { cwd: safeInstance.workspace } }, + } as unknown as Agent + const bypassParent = { + id: 'bypass-parent', + session: { header: { cwd: bypassInstance.workspace } }, + } as unknown as Agent + const safeController = new AbortController() + + const [safeRun, bypassRun] = await Promise.all([ + ctx.subagents.start('claude-safe', { + prompt: [{ type: 'text', text: 'Hold the safe instance.' }], + parent: safeParent, + signal: safeController.signal, + }), + ctx.subagents.start('claude-bypass', { + prompt: [{ type: 'text', text: 'Complete the bypass instance.' }], + parent: bypassParent, + signal: new AbortController().signal, + }), + ]) + await safeInstance.fixture.requestStarted + await safeFiber.dispose() + expect(ctx.subagents.list()).toEqual(['claude-bypass']) + await expect(ctx.subagents.start('claude-safe', { + prompt: [{ type: 'text', text: 'This start must fail.' }], + parent: safeParent, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'NO_PROVIDER' }) + + await expect(bypassRun.result).resolves.toEqual({ + output: [{ type: 'text', text: 'NAMED_BYPASS_RESULT' }], + stopReason: 'completed', + }) + safeController.abort(new Error('cancel only the published safe run')) + await expect(safeRun.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + await Promise.all([safeRun.dispose(), bypassRun.dispose()]) + expect(safeInstance.fixture.requests).toHaveLength(1) + expect(bypassInstance.fixture.requests).toHaveLength(1) + expect(safeInstance.fixture.requests[0]?.body.messages) + .not.toEqual(bypassInstance.fixture.requests[0]?.body.messages) + expect(spawnSpecs.map(spec => spec.env?.CLAUDE_CONFIG_DIR).sort()) + .toEqual([ + safeInstance.env.CLAUDE_CONFIG_DIR, + bypassInstance.env.CLAUDE_CONFIG_DIR, + ].sort()) + await expectQuiescent(handles) + await bypassFiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }) + it('maps a real CLI process failure to error', async () => { const { harness, fixture } = await realHarness({ kind: 'hold' }) const run = await startRequest(harness, 'Exercise the failure path.') diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index b5be0987ca..df5ae38088 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -312,7 +312,7 @@ describe('task admission and package contracts', () => { .toThrow('must not be empty') }) - it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => { + it('registers the default descriptor, validates config, and unregisters on HMR', async () => { const ctx = new Context() await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) @@ -343,7 +343,126 @@ describe('task admission and package contracts', () => { await ctx.fiber.dispose() }) + it('keeps named instances, runs, and HMR ownership isolated', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + const safeChild = fakeChild() + const bypassChild = fakeChild() + const spawnSpecs: SubprocessSpawnSpec[] = [] + vi.spyOn(ctx.subprocess, 'resolveExecutable') + .mockResolvedValue('/native/claude') + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + spawnSpecs.push(spec) + return spec.env?.DSH_CLAUDE_INSTANCE === 'safe' + ? safeChild.handle + : bypassChild.handle + }) + const queryOptions: Options[] = [] + queryMock.mockImplementation(({ options }) => { + queryOptions.push(options) + options.spawnClaudeCodeProcess!(sdkSpawnOptions({ + command: options.pathToClaudeCodeExecutable!, + cwd: options.cwd!, + env: options.env!, + signal: options.abortController!.signal, + })) + return options.permissionMode === 'dontAsk' + ? waitingQuery(options.abortController!.signal) + : queryFrom([success('bypass answer')]) + }) + + const added: string[] = [] + const started: string[] = [] + const ended: string[] = [] + const removed: string[] = [] + ctx.on('subagent/provider-added', provider => void added.push(provider.name)) + ctx.on('subagent/start', info => void started.push(info.provider)) + ctx.on('subagent/end', info => void ended.push(info.provider)) + ctx.on('subagent/provider-removed', providerName => void removed.push(providerName)) + const safeFiber = await ctx.plugin(claudeCode, { + providerName: 'claude-safe', + env: { DSH_CLAUDE_INSTANCE: 'safe' }, + permissionMode: 'dontAsk', + disposeGraceMs: 11, + }) + const bypassFiber = await ctx.plugin(claudeCode, { + providerName: 'claude-bypass', + env: { DSH_CLAUDE_INSTANCE: 'bypass' }, + permissionMode: 'bypassPermissions', + disposeGraceMs: 29, + }) + expect(ctx.subagents.list()).toEqual(['claude-safe', 'claude-bypass']) + expect(added).toEqual(['claude-safe', 'claude-bypass']) + + const safeController = new AbortController() + const [safeRun, bypassRun] = await Promise.all([ + ctx.subagents.start('claude-safe', request(undefined, safeController.signal)), + ctx.subagents.start('claude-bypass', request()), + ]) + await safeFiber.dispose() + expect(ctx.subagents.list()).toEqual(['claude-bypass']) + expect(removed).toEqual(['claude-safe']) + await expect(ctx.subagents.start('claude-safe', request())) + .rejects.toMatchObject({ code: 'NO_PROVIDER' }) + + await expect(bypassRun.result).resolves.toEqual({ + output: [{ type: 'text', text: 'bypass answer' }], + stopReason: 'completed', + }) + safeController.abort(new Error('stop only the safe instance')) + await expect(safeRun.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + expect(queryOptions.map(options => ({ + instance: options.env?.DSH_CLAUDE_INSTANCE, + permissionMode: options.permissionMode, + }))).toEqual([ + { instance: 'safe', permissionMode: 'dontAsk' }, + { instance: 'bypass', permissionMode: 'bypassPermissions' }, + ]) + expect(spawnSpecs.map(spec => ({ + instance: spec.env?.DSH_CLAUDE_INSTANCE, + graceMs: spec.graceMs, + }))).toEqual([ + { instance: 'safe', graceMs: 11 }, + { instance: 'bypass', graceMs: 29 }, + ]) + + await Promise.all([safeRun.dispose(), bypassRun.dispose()]) + expect([...started].sort()).toEqual(['claude-bypass', 'claude-safe']) + expect([...ended].sort()).toEqual(['claude-bypass', 'claude-safe']) + expect(safeChild.terminate).toHaveBeenCalledOnce() + expect(bypassChild.terminate).toHaveBeenCalledOnce() + await bypassFiber.dispose() + expect(removed).toEqual(['claude-safe', 'claude-bypass']) + await ctx.fiber.dispose() + }) + + it('rejects duplicate provider names without replacing the first instance', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + const firstFiber = await ctx.plugin(claudeCode, { + providerName: 'claude-duplicate', + }) + const first = ctx.subagents.getProvider('claude-duplicate') + await expect(ctx.plugin(claudeCode, { + providerName: 'claude-duplicate', + permissionMode: 'bypassPermissions', + })).rejects.toMatchObject({ code: 'DUPLICATE_PROVIDER' }) + expect(ctx.subagents.getProvider('claude-duplicate')).toBe(first) + expect(ctx.subagents.list()).toEqual(['claude-duplicate']) + await firstFiber.dispose() + await ctx.fiber.dispose() + }) + it('accepts only the five fixed non-interactive permission modes', () => { + expect(claudeCode.Config({}).providerName).toBe('claude-code') + expect(claudeCode.Config({ providerName: 'claude-safe' }).providerName) + .toBe('claude-safe') + expect(() => claudeCode.Config({ providerName: '' })).toThrow() expect(claudeCode.Config({}).permissionMode) .toBe(DEFAULT_CLAUDE_CODE_PERMISSION_MODE) for (const permissionMode of CLAUDE_CODE_PERMISSION_MODES) { @@ -361,6 +480,13 @@ describe('task admission and package contracts', () => { await ctx.plugin(LocalSubprocessRuntime) claudeCode.apply(ctx, { env: {}, disposeGraceMs: 3_000 }) expect(ctx.subagents.getProvider('claude-code')).toBeDefined() + expect(() => { + claudeCode.apply(ctx, { + providerName: '', + env: {}, + disposeGraceMs: 3_000, + }) + }).toThrow('providerName must be non-empty') await ctx.fiber.dispose() }) @@ -375,6 +501,7 @@ describe('task admission and package contracts', () => { .mockResolvedValue('/native/claude') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) await ctx.plugin(claudeCode, { + providerName: 'claude-diagnostic', env: { ANTHROPIC_API_KEY: 'provider-fake-key', CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config', @@ -384,7 +511,7 @@ describe('task admission and package contracts', () => { disposeGraceMs: 29, }) - await expect(ctx.subagents.start('claude-code', { + await expect(ctx.subagents.start('claude-diagnostic', { ...request(), parent: { id: 'parent-without-cwd', @@ -396,11 +523,11 @@ describe('task admission and package contracts', () => { expect(queryMock).not.toHaveBeenCalled() resolveExecutable.mockRejectedValueOnce(new Error('claude missing from PATH')) - await expect(ctx.subagents.start('claude-code', request())) + await expect(ctx.subagents.start('claude-diagnostic', request())) .rejects.toThrow('claude missing from PATH') expect(queryMock).not.toHaveBeenCalled() - const run = await ctx.subagents.start('claude-code', request()) + const run = await ctx.subagents.start('claude-diagnostic', request()) child.settle({ exitCode: 9, signal: null }) child.stdout.end() await expect(run.result).resolves.toEqual({ @@ -408,7 +535,7 @@ describe('task admission and package contracts', () => { stopReason: 'error', }) expect(warn).toHaveBeenCalledWith(expect.stringContaining( - 'subagent-claude-code: child run failed (error):', + 'subagent-claude-code "claude-diagnostic": child run failed (error):', )) expect(resolveExecutable).toHaveBeenCalledWith( 'claude', From 044f65e46b6e62b38c3f2e181575d905a423e0ce Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 02:50:42 +0800 Subject: [PATCH 16/34] fix(subagent): tighten named Claude instance evidence --- ...ubagent-providers-in-shared-host.i18n.yaml | 2 +- ...oduct-subagent-providers-in-shared-host.md | 2 +- .../subagent/subagent-claude-code/cordis.yml | 26 +++++++------------ .../subagent/subagent-claude-code/driver.ts | 6 ++--- .../subagent-claude-code/src/index.ts | 3 --- .../tests/loader-composition.e2e.ts | 10 +++---- .../tests/subagent-claude-code.spec.ts | 7 ----- 7 files changed, 20 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml index 9559eaa59a..89a0fd7590 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.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 .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: 2798431709307e50a1ee16c7fc595bcead223f59 +2026-08-10-product-subagent-providers-in-shared-host.md: 34c286821a245a659b668a8ba6676c4e3b1ba5e9 2026-08-10-product-subagent-providers-in-shared-host.zh.md: 981b1e2cd305c1410dcd744e3aea5028eb283806 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md index 2798431709..34c286821a 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md @@ -26,7 +26,7 @@ The base bundle test proves production `dsh-base` contains neither product provi ## Alternatives considered -**Keep product providers opt-in at the Profile layer.** This preserves a smaller default dependency closure but requires the user to edit both a Profile and a Preset. The production-install exclusion decision accepts that installation trade-off; this note retains the requirement that any selected provider is mounted once on the host plane rather than inside the preset. +**Keep product providers opt-in at the Profile layer.** This preserves a smaller default dependency closure but requires the user to edit both a Profile and a Preset. The production-install exclusion decision accepts that installation trade-off; this note retains the requirement that selected provider instances are mounted on the host plane rather than inside the preset. **Store global or per-Profile product enable switches.** A process switch competes with the Preset as owner of model-visible tools and cannot express two sessions using different combinations. Availability and authentication are deployment facts, not another persisted product state. diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml index c9a4f71848..fb2d08678b 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml @@ -12,21 +12,15 @@ - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' -- id: subagent-claude-safe +- id: subagent-claude-primary name: '@deepseek-ai/dsh-subagent-claude-code' config: - providerName: claude-safe - permissionMode: dontAsk - env: - DSH_CLAUDE_INSTANCE: safe + providerName: claude-primary -- id: subagent-claude-bypass +- id: subagent-claude-secondary name: '@deepseek-ai/dsh-subagent-claude-code' config: - providerName: claude-bypass - permissionMode: bypassPermissions - env: - DSH_CLAUDE_INSTANCE: bypass + providerName: claude-secondary - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' @@ -36,19 +30,19 @@ backgroundMode: one-shot maxDepth: 'provider-managed' -- id: tool-subagent-claude-safe +- id: tool-subagent-claude-primary name: '@deepseek-ai/dsh-tool-subagent' config: - provider: claude-safe - toolName: subagent_claude_safe + provider: claude-primary + toolName: subagent_claude_primary backgroundMode: one-shot maxDepth: 'provider-managed' -- id: tool-subagent-claude-bypass +- id: tool-subagent-claude-secondary name: '@deepseek-ai/dsh-tool-subagent' config: - provider: claude-bypass - toolName: subagent_claude_bypass + provider: claude-secondary + toolName: subagent_claude_secondary backgroundMode: one-shot maxDepth: 'provider-managed' diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts index 92dd44fca1..018550468b 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts @@ -23,11 +23,11 @@ const ctx = await boot( ) try { - const providerNames = ['codex', 'claude-safe', 'claude-bypass'] as const + const providerNames = ['codex', 'claude-primary', 'claude-secondary'] as const const toolNames = [ 'subagent_codex', - 'subagent_claude_safe', - 'subagent_claude_bypass', + 'subagent_claude_primary', + 'subagent_claude_secondary', ] as const const providers = providerNames.map((providerName) => { const provider = ctx.subagents.getProvider(providerName) diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index 806e51873a..3cd6de36d1 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -131,9 +131,6 @@ export function apply(ctx: Context, config: Config): void { `subagent-claude-code: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`, ) } - if (resolved.providerName.length === 0) { - throw new TypeError('subagent-claude-code providerName must be non-empty') - } ctx.subagents.registerProvider(new ClaudeCodeProvider( resolved.providerName, ctx, diff --git a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts index c63612154d..acdf98c1e9 100644 --- a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts @@ -31,7 +31,7 @@ describe('product-provider public Loader composition', () => { expect(stderr).toBe('') expect(JSON.parse(stdout)).toEqual({ - registeredProviders: ['codex', 'claude-safe', 'claude-bypass'], + registeredProviders: ['codex', 'claude-primary', 'claude-secondary'], providers: [ { name: 'codex', @@ -44,7 +44,7 @@ describe('product-provider public Loader composition', () => { inheritsParentContext: false, }, { - name: 'claude-safe', + name: 'claude-primary', capabilities: { outputSchema: false, depthLimit: false, @@ -54,7 +54,7 @@ describe('product-provider public Loader composition', () => { inheritsParentContext: false, }, { - name: 'claude-bypass', + name: 'claude-secondary', capabilities: { outputSchema: false, depthLimit: false, @@ -71,12 +71,12 @@ describe('product-provider public Loader composition', () => { required: ['description', 'prompt'], }, { - name: 'subagent_claude_safe', + name: 'subagent_claude_primary', parameterNames: ['description', 'prompt', 'run_in_background'], required: ['description', 'prompt'], }, { - name: 'subagent_claude_bypass', + name: 'subagent_claude_secondary', parameterNames: ['description', 'prompt', 'run_in_background'], required: ['description', 'prompt'], }, diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index df5ae38088..3ebdf4f0d0 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -480,13 +480,6 @@ describe('task admission and package contracts', () => { await ctx.plugin(LocalSubprocessRuntime) claudeCode.apply(ctx, { env: {}, disposeGraceMs: 3_000 }) expect(ctx.subagents.getProvider('claude-code')).toBeDefined() - expect(() => { - claudeCode.apply(ctx, { - providerName: '', - env: {}, - disposeGraceMs: 3_000, - }) - }).toThrow('providerName must be non-empty') await ctx.fiber.dispose() }) From db52686a96611f5c987ea9ecfe05b56444720c74 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 02:56:44 +0800 Subject: [PATCH 17/34] feat(subagent): support named Codex provider instances --- ...ubagent-providers-in-shared-host.i18n.yaml | 4 +- ...oduct-subagent-providers-in-shared-host.md | 4 +- ...ct-subagent-providers-in-shared-host.zh.md | 4 +- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 6 +- ...ude-code-and-codex-subagent-backends.zh.md | 6 +- ...product-subagent-named-instances.i18n.yaml | 4 +- ...-08-18-product-subagent-named-instances.md | 10 +- ...-18-product-subagent-named-instances.zh.md | 10 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- .../product-subagent-both.cordis.snapshot.yml | 31 +++- .../product-subagent-both.cordis.yml | 31 +++- ...product-subagent-codex.cordis.snapshot.yml | 31 +++- .../product-subagent-codex.cordis.yml | 33 +++-- .../subagent/subagent-codex/cordis.yml | 25 +++- .../subagent/subagent-codex/driver.ts | 50 ++++--- .../tool-schemas.expected.json | 27 +++- .../tool-schemas.expected.json | 27 +++- .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 39 +++-- packages/subagent/subagent-codex/README.zh.md | 39 +++-- packages/subagent/subagent-codex/src/index.ts | 26 ++-- .../tests/loader-composition.e2e.ts | 51 ++++--- .../subagent-codex/tests/real-product.spec.ts | 135 ++++++++++++++++-- .../tests/subagent-codex.spec.ts | 126 +++++++++++++++- 27 files changed, 595 insertions(+), 144 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml index 9559eaa59a..1193fc347c 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.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 .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: 2798431709307e50a1ee16c7fc595bcead223f59 -2026-08-10-product-subagent-providers-in-shared-host.zh.md: 981b1e2cd305c1410dcd744e3aea5028eb283806 +2026-08-10-product-subagent-providers-in-shared-host.md: 78d2bb675446030acfa3d69ee40c6d2db6302f2c +2026-08-10-product-subagent-providers-in-shared-host.zh.md: dcca082e04087250608ddf85f72f0419c7d77769 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md index 2798431709..78d2bb6754 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md @@ -12,7 +12,7 @@ The placement decision must preserve two independent facts. Loading a provider m ## Decision -Product providers remain process-scoped host-plane registrations. The [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) supersedes only this note's former base-bundle installation choice: production `dsh-base` neither depends on nor mounts them. A Profile that opts in installs the selected provider package and mounts the required instances on the host plane. The [named-instance decision](../feature/2026-08-18-product-subagent-named-instances.md) owns each row's registry identity: Claude Code accepts multiple unique `providerName` values while preserving `claude-code` as its default; Codex still registers only its `codex` default. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows whose `provider` and `toolName` values expose exactly the configured instances needed by one agent without changing the Host registry. +Product providers remain process-scoped host-plane registrations. The [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) supersedes only this note's former base-bundle installation choice: production `dsh-base` neither depends on nor mounts them. A Profile that opts in installs the selected provider package and mounts the required instances on the host plane. The [named-instance decision](../feature/2026-08-18-product-subagent-named-instances.md) owns each row's registry identity: both products accept multiple unique `providerName` values while preserving `codex` and `claude-code` as their defaults. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows whose `provider` and `toolName` values expose exactly the configured instances needed by one agent without changing the Host registry. This note continues to own why a mounted product provider belongs on the host plane while its model-facing tool belongs to an Agent Preset. The production-install exclusion decision owns which Profiles install those optional packages. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. @@ -22,7 +22,7 @@ Only a Profile that selects the Claude Code provider carries the Claude Agent SD ## Verification -The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition explicitly mounts both optional providers and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove the Codex-only path and a Host containing the default Codex instance plus two named Claude instances register without starting a product process. Keyless ACP snapshots pin the model-visible tool schemas for one product and for independently named product tools, while provider tests separately prove native executable resolution, configuration isolation, failure, cancellation, and process-tree quiescence. +The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition explicitly mounts both optional providers and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove two named instances of each product register without starting a product process. Keyless ACP snapshots pin each product's two-tool roster and the final four-tool combination, while provider tests separately prove native executable resolution, configuration isolation, failure, cancellation, and process-tree quiescence. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md index 981b1e2cd3..dcca082e04 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -产品提供方仍是进程级的 host plane(宿主平面)注册。[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只取代本说明原先由 base bundle 安装提供方的选择:生产 `dsh-base` 既不依赖也不挂载它们。选择产品集成的 Profile 会安装目标提供方包,并在 host plane 挂载所需实例。[命名实例决策](../feature/2026-08-18-product-subagent-named-instances.md)负责每个配置项的注册身份:Claude Code 接受多个唯一的 `providerName`,同时保留 `claude-code` 作为默认值;Codex 仍只注册默认的 `codex`。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 通过普通 `dsh-tool-subagent` 配置项的 `provider` 与 `toolName` 准确公开单个 agent 所需的已配置实例,而无需更改 Host 注册表。 +产品提供方仍是进程级的 host plane(宿主平面)注册。[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只取代本说明原先由 base bundle 安装提供方的选择:生产 `dsh-base` 既不依赖也不挂载它们。选择产品集成的 Profile 会安装目标提供方包,并在 host plane 挂载所需实例。[命名实例决策](../feature/2026-08-18-product-subagent-named-instances.md)负责每个配置项的注册身份:两个产品都接受多个唯一的 `providerName`,同时保留 `codex` 与 `claude-code` 作为默认值。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 通过普通 `dsh-tool-subagent` 配置项的 `provider` 与 `toolName` 准确公开单个 agent 所需的已配置实例,而无需更改 Host 注册表。 本说明继续负责解释为什么已经挂载的产品提供方属于 host plane,而面向模型的工具属于 Agent Preset。生产安装排除决策负责哪些 Profile 安装这些可选包。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 @@ -22,7 +22,7 @@ Status: implemented ## 验证 -base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装显式挂载两个可选提供方,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明 Codex-only 路径以及包含默认 Codex 实例与两个命名 Claude 实例的 Host 会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定单个产品与独立命名产品工具的模型可见 schema,提供方测试则另行证明原生可执行文件解析、配置隔离、失败、取消和进程树完全停稳。 +base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装显式挂载两个可选提供方,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明每个产品的两个命名实例都会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定每个产品的双工具集合与最终四工具组合,提供方测试则另行证明原生可执行文件解析、配置隔离、失败、取消和进程树完全停稳。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 9e3d221765..3efcd1588d 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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 .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: fb672f5c326ad240964e1e6c051a4f18d8d1552e -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 11f5c8f1c47be9e80386832efbe0b8b5675b3437 +2026-08-04-claude-code-and-codex-subagent-backends.md: 547eebd931d90bc373cd6a0798347744078bd1d6 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 7519fa6952fdca5cccb9031a31b552c6ab665929 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index fb672f5c32..547eebd931 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance ## Decision -The harness publishes two sibling one-shot provider packages whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity and static tool binding, the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and diagnostic production. Claude Code accepts multiple named instances; Codex still registers its single default name. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. +The harness publishes two sibling one-shot provider packages whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity and static tool binding, the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and diagnostic production. Both packages accept multiple named instances. Loading either provider starts no product process, and each tool accepts only a standalone text task; product and instance selection remain deployment configuration. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation. @@ -34,7 +34,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro ## Codex provider -`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and starts `codex app-server --stdio` from `PATH`. Its public configuration contains an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a three-value native `permissionMode` that defaults to `never`. Installation, login, `CODEX_HOME`, model selection, base URL, and product-session settings remain native Codex or deployment responsibilities; the selected mode owns only the thread approval/reviewer/sandbox fields described by the non-interactive permissions decision. +`@deepseek-ai/dsh-subagent-codex` registers a Profile-selected provider name that defaults to `codex` and starts `codex app-server --stdio` from `PATH`. Its public configuration contains a non-empty `providerName`, an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a three-value native `permissionMode` that defaults to `never`. Each named instance retains those resolved values for its own runs. Installation, login, `CODEX_HOME`, model selection, base URL, and product-session settings remain native Codex or deployment responsibilities; the selected mode owns only the thread approval/reviewer/sandbox fields described by the non-interactive permissions decision. Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, maps the resolved mode into official `thread/start` fields, and creates an `ephemeral: true` thread. The fixed app-server argv contains no mode or task text. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. @@ -60,7 +60,7 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract ## Distribution and evidence -Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped explicit Profile configuration, verifies the default Codex instance and two named Claude Code instances expose independent one-shot tools alongside generic Job controls, and starts neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. +Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped explicit Profile configuration, verifies two named instances of each product expose four independent one-shot tools alongside generic Job controls, and starts neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, thread-level `never` overriding ambient `on-request`, automatic-review startup, unattended command rejection with safe diagnostic and no file side effect, explicit dangerous-bypass writing in suite-owned temporary storage, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 11f5c8f1c4..7519fa6952 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责 Profile 选择的提供方身份与静态工具绑定,[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责各产品提供方的 Profile 模式选择与诊断生产。Claude Code 接受多个命名实例;Codex 仍只注册单个默认名称。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 +harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责 Profile 选择的提供方身份与静态工具绑定,[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责各产品提供方的 Profile 模式选择与诊断生产。两个包都接受多个命名实例。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品与实例选择仍属于部署配置。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。 @@ -34,7 +34,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro ## Codex 提供方 -`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置包含显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `never` 的三值原生 `permissionMode`。安装、登录、`CODEX_HOME`、模型选择、基础 URL 和产品会话设置仍由 Codex 原生机制或部署环境负责;所选模式只拥有非交互权限决策中描述的线程 approval/reviewer/sandbox 字段。 +`@deepseek-ai/dsh-subagent-codex` 注册由 Profile 选择、默认值为 `codex` 的提供方名称,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置包含非空的 `providerName`、显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `never` 的三值原生 `permissionMode`。每个命名实例会为自己的运行保留这些已解析值。安装、登录、`CODEX_HOME`、模型选择、基础 URL 和产品会话设置仍由 Codex 原生机制或部署环境负责;所选模式只拥有非交互权限决策中描述的线程 approval/reviewer/sandbox 字段。 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,把已解析模式映射为官方 `thread/start` 字段,并创建一个 `ephemeral: true` 线程。固定 app-server argv 不包含模式或任务文本。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 @@ -60,7 +60,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 ## 分发与证据 -每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示的显式 Profile 配置,验证默认 Codex 实例与两个命名 Claude Code 实例会和通用 Job 控制工具一起公开彼此独立的一次性工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 +每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示的显式 Profile 配置,验证两个产品各自的两个命名实例会和通用 Job 控制工具一起公开四个彼此独立的一次性工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、带安全诊断且不产生文件副作用的无人值守命令拒绝、测试拥有临时存储中的显式危险绕过写入、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.i18n.yaml index 57c1c7ef60..614f036a6a 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.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 .agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md -2026-08-18-product-subagent-named-instances.md: 6b069727ebba7ebcf34444f9ebdb287c00bf315d -2026-08-18-product-subagent-named-instances.zh.md: dffa009296afde44126725fd65a2fc58977377fc +2026-08-18-product-subagent-named-instances.md: 759d3941ff8404138954c409f0fd4949e357200e +2026-08-18-product-subagent-named-instances.zh.md: 6faf0e70f639cbc6528e27b800b8e5f99f0d6c86 diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md index 6b069727eb..759d3941ff 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md @@ -6,15 +6,15 @@ English | [中文](2026-08-18-product-subagent-named-instances.zh.md) ## Problem -A Profile can mount one Cordis plugin package in multiple rows, but the Claude Code product provider previously registered every row as `claude-code`. A second row therefore failed as a duplicate before its distinct permission mode, environment, or process-release settings could become usable. Deriving an implicit name from those settings would create a second identity rule, while choosing a provider during a tool call would let model input select deployment authority. +A Profile can mount one Cordis plugin package in multiple rows, but the Codex and Claude Code product providers previously registered every row under one fixed product name. A second row therefore failed as a duplicate before its distinct permission mode, environment, or process-release settings could become usable. Deriving an implicit name from those settings would create a second identity rule, while choosing a provider during a tool call would let model input select deployment authority. The existing subagent registry already owns unique provider names, reversible registration, lifecycle events, and holder-owned published runs. The existing `dsh-tool-subagent` configuration already binds one provider name to one model-visible tool name. Product providers need to expose the missing Profile-owned identity without adding another registry or selection protocol. ## Decision -The Claude Code provider Config owns a non-empty `providerName` whose default remains `claude-code`. The resolved name is fixed when the plugin row loads and becomes the Provider object's `name`; registration, lookup, lifecycle events, run logs, and HMR removal therefore use the same value. Each mounted row retains its own `permissionMode`, `env`, `disposeGraceMs`, and run resources. The Codex provider still registers its single `codex` default name. +Each product provider Config owns a non-empty `providerName`; the defaults remain `codex` and `claude-code`. The resolved name is fixed when the plugin row loads and becomes the Provider object's `name`; registration, lookup, lifecycle events, run logs, and HMR removal therefore use the same value. Each mounted row retains its own `permissionMode`, `env`, `disposeGraceMs`, and run resources. -Profiles may mount multiple Claude Code rows when every row uses a distinct `providerName`. Each `dsh-tool-subagent` row continues to bind its existing `provider` field to that exact name and exposes an independently configured `toolName`. Tool calls carry no provider selector, alias, or permission input. A duplicate provider name fails through the existing `DUPLICATE_PROVIDER` path and leaves the first registration intact. +Profiles may mount multiple Codex or Claude Code rows when every row uses a distinct `providerName`. Each `dsh-tool-subagent` row continues to bind its existing `provider` field to that exact name and exposes an independently configured `toolName`. Tool calls carry no provider selector, alias, or permission input. A duplicate provider name fails through the existing `DUPLICATE_PROVIDER` path and leaves the first registration intact. Removing one provider row blocks new starts and removes only tools bound to that name. Runs already published by the removed instance remain owned by their holders and settle or dispose independently. Sibling instances remain registered and keep their own environment, native permission mode, cancellation controller, product process, and cleanup grace. @@ -29,7 +29,7 @@ Removing one provider row blocks new starts and removes only tools bound to that ## Verification -Claude Code package tests pin the default and custom names, empty-name rejection, duplicate rollback, actual-name diagnostics, two concurrent instances with different permission modes, environments, and cleanup grace, cancellation isolation, and removal of one instance while its published run remains valid. The official SDK/CLI loopback test runs two named instances in one Host against separate model fixtures and proves independent unload and process-tree quiescence. The public Loader composition mounts two Claude Code rows and two distinct tools without starting either product, while the keyless ACP snapshot pins both static tool schemas and the absence of a dynamic provider parameter. +Both product packages pin their default and custom names, empty-name rejection, duplicate rollback, actual-name diagnostics, two concurrent instances with different permission modes, environments, and cleanup grace, cancellation isolation, and removal of one instance while its published run remains valid. The official product loopback tests run two named instances in one Host against separate model fixtures and prove independent unload and process-tree quiescence. Public Loader compositions mount two rows and two distinct tools for each product without starting either product, while keyless ACP snapshots pin the four-tool combined roster and the absence of a dynamic provider parameter. ## Alternatives considered @@ -43,6 +43,6 @@ Claude Code package tests pin the default and custom names, empty-name rejection ## Consequences -A Profile can expose several Claude Code tools backed by separate native permission modes and environments while existing configurations continue to resolve `claude-code`. Provider and tool names remain independent configuration facts, so changing one requires updating the binding that refers to it. +A Profile can expose several Codex and Claude Code tools backed by separate native permission modes and environments while existing configurations continue to resolve `codex` and `claude-code`. Provider and tool names remain independent configuration facts, so changing one requires updating the binding that refers to it. The design adds no runtime renaming, model-visible selector, generated tool name, persistent instance directory, shared process pool, or compatibility alias. Correct multi-instance configurations require unique provider names and unique tool names; duplicate tool-name waiting remains a separate limitation. diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md index dffa009296..6faf0e70f6 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -Profile 可以用多个配置项挂载同一个 Cordis 插件包,但 Claude Code 产品提供方此前会把每个配置项都注册为 `claude-code`。因此,第二个配置项会在其独立权限模式、环境或进程释放设置可用前因名称重复而失败。根据这些设置隐式派生名称会建立第二套身份规则,而在工具调用期间选择提供方会让模型输入决定部署权限。 +Profile 可以用多个配置项挂载同一个 Cordis 插件包,但 Codex 与 Claude Code 产品提供方此前会把每个配置项都注册到一个固定产品名称下。因此,第二个配置项会在其独立权限模式、环境或进程释放设置可用前因名称重复而失败。根据这些设置隐式派生名称会建立第二套身份规则,而在工具调用期间选择提供方会让模型输入决定部署权限。 现有 subagent 注册表已经拥有提供方名称唯一性、可逆注册、生命周期事件和由持有方拥有的已发布运行。现有 `dsh-tool-subagent` 配置也已经把一个提供方名称绑定到一个模型可见工具名称。产品提供方只需公开缺失的 Profile 所有身份,无需增加另一套注册表或选择协议。 ## 决策 -Claude Code 提供方 Config 拥有非空的 `providerName`,其默认值仍为 `claude-code`。插件配置项加载时会固定解析后的名称,并把它作为 Provider 对象的 `name`;注册、查找、生命周期事件、运行日志和 HMR(热模块替换)移除因此使用同一个值。每个已挂载配置项保留自己的 `permissionMode`、`env`、`disposeGraceMs` 和运行资源。Codex 提供方仍只注册默认名称 `codex`。 +每个产品提供方 Config 都拥有非空的 `providerName`;默认值仍分别为 `codex` 与 `claude-code`。插件配置项加载时会固定解析后的名称,并把它作为 Provider 对象的 `name`;注册、查找、生命周期事件、运行日志和 HMR(热模块替换)移除因此使用同一个值。每个已挂载配置项保留自己的 `permissionMode`、`env`、`disposeGraceMs` 和运行资源。 -当每个配置项使用不同的 `providerName` 时,Profile 可以挂载多个 Claude Code 配置项。每个 `dsh-tool-subagent` 配置项继续用已有的 `provider` 字段绑定这个准确名称,并公开独立配置的 `toolName`。工具调用不携带提供方选择器、别名或权限输入。重复提供方名称沿用现有 `DUPLICATE_PROVIDER` 路径失败,而且不会替换第一个注册项。 +当每个配置项使用不同的 `providerName` 时,Profile 可以挂载多个 Codex 或 Claude Code 配置项。每个 `dsh-tool-subagent` 配置项继续用已有的 `provider` 字段绑定这个准确名称,并公开独立配置的 `toolName`。工具调用不携带提供方选择器、别名或权限输入。重复提供方名称沿用现有 `DUPLICATE_PROVIDER` 路径失败,而且不会替换第一个注册项。 移除一个提供方配置项会阻止新的启动,并且只移除绑定到该名称的工具。该实例已经发布的运行仍由其持有方拥有,并会独立结算或 dispose(资源释放)。兄弟实例继续保持注册,并保留各自的环境、原生权限模式、取消控制器、产品进程和清理宽限期。 @@ -29,7 +29,7 @@ Claude Code 提供方 Config 拥有非空的 `providerName`,其默认值仍为 ## 验证 -Claude Code 包测试固定默认与自定义名称、空名称拒绝、重复注册回滚、实际名称诊断、使用不同权限模式、环境与清理宽限期的两个并发实例、取消隔离,以及移除一个实例后其已发布运行仍然有效。官方 SDK/CLI 回环测试会在同一个 Host 中针对独立模型 fixture(测试前置数据)运行两个命名实例,并证明独立卸载与进程树完全停稳。公共 Loader 组合会挂载两个 Claude Code 配置项与两个不同工具,而且不启动任一产品;无密钥 ACP 快照固定两个静态工具 schema,并证明没有动态提供方参数。 +两个产品包测试都会固定默认与自定义名称、空名称拒绝、重复注册回滚、实际名称诊断、使用不同权限模式、环境与清理宽限期的两个并发实例、取消隔离,以及移除一个实例后其已发布运行仍然有效。官方产品回环测试会在同一个 Host 中针对独立模型 fixture(测试前置数据)运行两个命名实例,并证明独立卸载与进程树完全停稳。公共 Loader 组合会为每个产品挂载两个配置项与两个不同工具,而且不启动任一产品;无密钥 ACP 快照固定最终四工具组合,并证明没有动态提供方参数。 ## 考虑过的替代方案 @@ -43,6 +43,6 @@ Claude Code 包测试固定默认与自定义名称、空名称拒绝、重复 ## 结果 -Profile 可以公开多个由不同原生权限模式与环境支持的 Claude Code 工具,而现有配置仍会解析为 `claude-code`。提供方名称与工具名称继续是彼此独立的配置事实,因此修改其中一项时必须同时更新引用它的绑定。 +Profile 可以公开多个由不同原生权限模式与环境支持的 Codex 与 Claude Code 工具,而现有配置仍会解析为 `codex` 与 `claude-code`。提供方名称与工具名称继续是彼此独立的配置事实,因此修改其中一项时必须同时更新引用它的绑定。 本设计不增加运行时改名、模型可见选择器、自动生成的工具名称、持久实例目录、共享进程池或兼容别名。正确的多实例配置要求提供方名称与工具名称都保持唯一;重复工具名称的等待问题仍是独立限制。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 8767173399..8cbe6463c1 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 33fc261e971f9055f666e5005080e01b31c6d708 -config-catalog.zh.md: 24ad1fdb5d0d2eb7470785de7b913d7b33f6c9aa +config-catalog.md: 59f5009e44bb826bc3301b8f5e313efd7032f666 +config-catalog.zh.md: 85f7152bb39d8f8b8bdcbecb27da9a7c41df494a diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 33fc261e97..59f5009e44 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2116,6 +2116,8 @@ Requires: `subagents` · `subprocess` ```ts config-catalog /** Deployment-owned permission, environment, and process-release settings. */ export interface Config { + /** Provider name on `ctx.subagents` (default `codex`). */ + providerName?: string /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. @@ -2134,7 +2136,7 @@ export type CodexPermissionMode = | 'dangerously-bypass-approvals-and-sandbox' ``` -Source: [`packages/subagent/subagent-codex/src/index.ts:33`](../packages/subagent/subagent-codex/src/index.ts) +Source: [`packages/subagent/subagent-codex/src/index.ts:35`](../packages/subagent/subagent-codex/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 24ad1fdb5d..85f7152bb3 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2118,6 +2118,8 @@ export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[numbe ```ts config-catalog /** Deployment-owned permission, environment, and process-release settings. */ export interface Config { + /** Provider name on `ctx.subagents` (default `codex`). */ + providerName?: string /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. @@ -2136,7 +2138,7 @@ export type CodexPermissionMode = | 'dangerously-bypass-approvals-and-sandbox' ``` -来源:[`packages/subagent/subagent-codex/src/index.ts:33`](../packages/subagent/subagent-codex/src/index.ts) +来源:[`packages/subagent/subagent-codex/src/index.ts:35`](../packages/subagent/subagent-codex/src/index.ts) diff --git a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml index d69d35fa22..3c8acf86cd 100644 --- a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml +++ b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml @@ -1,5 +1,5 @@ -# Keyless twin of product-subagent-both.cordis.yml: preserve all named product -# tools while replacing only the external model adapter. +# Keyless twin of product-subagent-both.cordis.yml: preserve all four named +# product tools while replacing only the external model adapter. - id: base name: '@deepseek-ai/cordis-plugin-include' config: @@ -18,10 +18,20 @@ models: - id: deepseek-v4-flash - id: deepseek-v4-pro - - id: subagent-codex + - id: subagent-codex-safe name: '@deepseek-ai/dsh-subagent-codex' config: - permissionMode: approve-for-me + providerName: codex-safe + permissionMode: never + env: + DSH_CODEX_INSTANCE: safe + - id: subagent-codex-bypass + name: '@deepseek-ai/dsh-subagent-codex' + config: + providerName: codex-bypass + permissionMode: dangerously-bypass-approvals-and-sandbox + env: + DSH_CODEX_INSTANCE: bypass - id: subagent-claude-safe name: '@deepseek-ai/dsh-subagent-claude-code' config: @@ -36,11 +46,18 @@ permissionMode: bypassPermissions env: DSH_CLAUDE_INSTANCE: bypass - - id: tool-subagent-codex + - id: tool-subagent-codex-safe name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex - toolName: subagent_codex + provider: codex-safe + toolName: subagent_codex_safe + backgroundMode: one-shot + maxDepth: provider-managed + - id: tool-subagent-codex-bypass + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex-bypass + toolName: subagent_codex_bypass backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-safe diff --git a/examples/acp-agent/product-subagent-both.cordis.yml b/examples/acp-agent/product-subagent-both.cordis.yml index 710dfc7ad7..f81fc8b032 100644 --- a/examples/acp-agent/product-subagent-both.cordis.yml +++ b/examples/acp-agent/product-subagent-both.cordis.yml @@ -1,16 +1,26 @@ -# Add the native Codex provider, two named Claude Code instances, and the +# Add two named Codex providers, two named Claude Code providers, and the # independent one-shot tool rows an Agent Preset may contribute. Loading the -# composition starts neither product; the scenario pins all three schemas. +# composition starts neither product; the scenario pins all four schemas. - id: base name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: - insert: - - id: subagent-codex + - id: subagent-codex-safe name: '@deepseek-ai/dsh-subagent-codex' config: - permissionMode: approve-for-me + providerName: codex-safe + permissionMode: never + env: + DSH_CODEX_INSTANCE: safe + - id: subagent-codex-bypass + name: '@deepseek-ai/dsh-subagent-codex' + config: + providerName: codex-bypass + permissionMode: dangerously-bypass-approvals-and-sandbox + env: + DSH_CODEX_INSTANCE: bypass - id: subagent-claude-safe name: '@deepseek-ai/dsh-subagent-claude-code' config: @@ -25,11 +35,18 @@ permissionMode: bypassPermissions env: DSH_CLAUDE_INSTANCE: bypass - - id: tool-subagent-codex + - id: tool-subagent-codex-safe name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex - toolName: subagent_codex + provider: codex-safe + toolName: subagent_codex_safe + backgroundMode: one-shot + maxDepth: provider-managed + - id: tool-subagent-codex-bypass + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex-bypass + toolName: subagent_codex_bypass backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-safe diff --git a/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml index 83383814c9..e7b1dfeea2 100644 --- a/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml +++ b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml @@ -1,5 +1,5 @@ -# Keyless twin of product-subagent-codex.cordis.yml: keep the same product -# provider/tool composition and replace only the external model adapter. +# Keyless twin of product-subagent-codex.cordis.yml: keep both named product +# providers and tools while replacing only the external model adapter. - id: base name: '@deepseek-ai/cordis-plugin-include' config: @@ -18,14 +18,31 @@ models: - id: deepseek-v4-flash - id: deepseek-v4-pro - - id: subagent-codex + - id: subagent-codex-safe name: '@deepseek-ai/dsh-subagent-codex' config: - permissionMode: approve-for-me - - id: tool-subagent-codex + providerName: codex-safe + permissionMode: never + env: + DSH_CODEX_INSTANCE: safe + - id: subagent-codex-bypass + name: '@deepseek-ai/dsh-subagent-codex' + config: + providerName: codex-bypass + permissionMode: dangerously-bypass-approvals-and-sandbox + env: + DSH_CODEX_INSTANCE: bypass + - id: tool-subagent-codex-safe name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex - toolName: subagent_codex + provider: codex-safe + toolName: subagent_codex_safe + backgroundMode: one-shot + maxDepth: provider-managed + - id: tool-subagent-codex-bypass + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex-bypass + toolName: subagent_codex_bypass backgroundMode: one-shot maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-codex.cordis.yml b/examples/acp-agent/product-subagent-codex.cordis.yml index be399023b0..a27d4636e3 100644 --- a/examples/acp-agent/product-subagent-codex.cordis.yml +++ b/examples/acp-agent/product-subagent-codex.cordis.yml @@ -1,20 +1,37 @@ -# Add the native Codex product provider and its preset-shaped one-shot tool to -# the real ACP composition. The model is told not to call it; the scenario pins -# the assembled request schema without starting Codex. +# Add two named Codex product providers and their preset-shaped one-shot tools +# to the real ACP composition. The model is told not to call them; the scenario +# pins both assembled request schemas without starting Codex. - id: base name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: - insert: - - id: subagent-codex + - id: subagent-codex-safe name: '@deepseek-ai/dsh-subagent-codex' config: - permissionMode: approve-for-me - - id: tool-subagent-codex + providerName: codex-safe + permissionMode: never + env: + DSH_CODEX_INSTANCE: safe + - id: subagent-codex-bypass + name: '@deepseek-ai/dsh-subagent-codex' + config: + providerName: codex-bypass + permissionMode: dangerously-bypass-approvals-and-sandbox + env: + DSH_CODEX_INSTANCE: bypass + - id: tool-subagent-codex-safe name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex - toolName: subagent_codex + provider: codex-safe + toolName: subagent_codex_safe + backgroundMode: one-shot + maxDepth: provider-managed + - id: tool-subagent-codex-bypass + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex-bypass + toolName: subagent_codex_bypass backgroundMode: one-shot maxDepth: provider-managed diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml index fd015839ff..e2286135dd 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml @@ -1,4 +1,4 @@ -# Test-only composition of the public opt-in provider and one-shot task tool. +# Test-only composition of two named Codex instances and their one-shot tools. # The owning e2e boots this tree but never invokes the model or Codex. - id: fixture name: './fixture.ts' @@ -9,16 +9,29 @@ - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' -- id: subagent-codex +- id: subagent-codex-primary name: '@deepseek-ai/dsh-subagent-codex' config: - permissionMode: approve-for-me + providerName: codex-primary -- id: tool-subagent-codex +- id: subagent-codex-secondary + name: '@deepseek-ai/dsh-subagent-codex' + config: + providerName: codex-secondary + +- id: tool-subagent-codex-primary name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex - toolName: subagent_codex + provider: codex-primary + toolName: subagent_codex_primary + backgroundMode: one-shot + maxDepth: 'provider-managed' + +- id: tool-subagent-codex-secondary + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex-secondary + toolName: subagent_codex_secondary backgroundMode: one-shot maxDepth: 'provider-managed' diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts index 51cd5eaa6f..8bc8e74279 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts @@ -23,14 +23,36 @@ const ctx = await boot( ) try { - const provider = ctx.subagents.getProvider('codex') - if (provider === undefined) throw new Error('Codex provider was not registered') - const tool = ctx.tools.schemas().find(schema => schema.name === 'subagent_codex') - if (tool === undefined) throw new Error('subagent_codex tool was not registered') - const properties = tool.parameters.properties - if (typeof properties !== 'object' || properties === null || Array.isArray(properties)) { - throw new Error('subagent_codex tool has invalid parameter properties') - } + const providerNames = ['codex-primary', 'codex-secondary'] as const + const toolNames = ['subagent_codex_primary', 'subagent_codex_secondary'] as const + const providers = providerNames.map((providerName) => { + const provider = ctx.subagents.getProvider(providerName) + if (provider === undefined) { + throw new Error(`${providerName} provider was not registered`) + } + return { + name: provider.name, + capabilities: provider.capabilities, + inheritsParentContext: provider.inheritsParentContext, + } + }) + const tools = toolNames.map((toolName) => { + const tool = ctx.tools.schemas().find(schema => schema.name === toolName) + if (tool === undefined) throw new Error(`${toolName} tool was not registered`) + const properties = tool.parameters.properties + if ( + typeof properties !== 'object' + || properties === null + || Array.isArray(properties) + ) { + throw new Error(`${toolName} has invalid parameter properties`) + } + return { + name: tool.name, + parameterNames: Object.keys(properties).sort(), + required: tool.parameters.required, + } + }) const jobTools = ctx.tools.schemas() .map(schema => schema.name) .filter(name => name === 'job_kill' || name === 'job_list' || name === 'job_output') @@ -38,16 +60,8 @@ try { process.stdout.write(`${JSON.stringify({ providers: ctx.subagents.list(), - provider: { - name: provider.name, - capabilities: provider.capabilities, - inheritsParentContext: provider.inheritsParentContext, - }, - tool: { - name: tool.name, - parameterNames: Object.keys(properties).sort(), - required: tool.parameters.required, - }, + providerDetails: providers, + tools, jobTools, starts, })}\n`) diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json index 74690c0165..434e896fee 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json @@ -357,7 +357,32 @@ } }, { - "name": "subagent_codex", + "name": "subagent_codex_bypass", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_codex_safe", "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.", "parameters": { "type": "object", diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json index 29a85eb6b3..cfb805d2ec 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json @@ -307,7 +307,32 @@ } }, { - "name": "subagent_codex", + "name": "subagent_codex_bypass", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_codex_safe", "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.", "parameters": { "type": "object", diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 22f8e3c291..d76f955da2 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/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/subagent/subagent-codex/README.md -README.md: 645479474599eb4cb72c0bf73838a6341c98adb7 -README.zh.md: 1e9d21882b4c84312ea60eff3510bd2295d5334e +README.md: 85358a3fbbab216bccccb3340be47a1c5b1433ef +README.zh.md: 03b74233f18d55c7fa81b327e2de96cf85b816cc diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 6454794745..85358a3fbb 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This package registers the fixed `codex` subagent provider. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, submits one self-contained text task, and returns either the selected final answer or safe failure detail through the shared [`dsh-subagent`](../subagent/README.md) result contract. +This package registers a Profile-named Codex subagent provider whose default name is `codex`. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, submits one self-contained text task, and returns either the selected final answer or safe failure detail through the shared [`dsh-subagent`](../subagent/README.md) result contract. ## Start and ownership @@ -22,6 +22,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | Key | Default | Meaning | |---|---|---| +| `providerName` | `codex` | Non-empty registry name on `ctx.subagents`; each mounted instance needs a unique value. | | `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | | `permissionMode` | `never` | Native non-interactive approval and sandbox mode fixed for every thread from this Provider instance. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | @@ -34,15 +35,24 @@ The provider advertises no optional start-time capabilities and reports `inherit Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The Provider overrides only the selected thread approval/reviewer/sandbox fields; all other `CODEX_HOME`, project, model, provider, MCP, hook, skill, and account settings remain native. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. -Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-codex` and mount it once on the host plane; loading the provider starts no Codex process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. +Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-codex` and may mount one or more host-plane rows with distinct `providerName`, `permissionMode`, and `env` values; omitting `providerName` keeps the `codex` default. Loading an instance starts no Codex process until a bound tool calls it. Each `dsh-tool-subagent` row names one provider and needs its own `toolName`, so the model sees static tools rather than a dynamic provider selector. Full Agent Presets carry a matching default product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. -The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider row, and enables the preset tool row instead of mounting duplicate Job services. +The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider and tool rows, and does not mount duplicate Job services. ```yaml -- id: subagent-codex +- id: subagent-codex-safe name: '@deepseek-ai/dsh-subagent-codex' config: - permissionMode: approve-for-me + providerName: codex-safe + permissionMode: never + env: + OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY + +- id: subagent-codex-bypass + name: '@deepseek-ai/dsh-subagent-codex' + config: + providerName: codex-bypass + permissionMode: dangerously-bypass-approvals-and-sandbox env: OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY @@ -52,18 +62,26 @@ The standalone composition below shows the complete explicit capability. A Profi - id: tool-jobs name: '@deepseek-ai/dsh-tool-jobs' -- id: tool-subagent-codex +- id: tool-subagent-codex-safe name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex - toolName: subagent_codex + provider: codex-safe + toolName: subagent_codex_safe + backgroundMode: one-shot + maxDepth: provider-managed + +- id: tool-subagent-codex-bypass + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex-bypass + toolName: subagent_codex_bypass backgroundMode: one-shot maxDepth: provider-managed ``` ## Product compatibility and evidence -The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`. Real-product coverage proves that thread-level `never` overrides an ambient `on-request`, automatic review starts through the official app-server, dangerous bypass writes only in suite-owned temporary storage, safe diagnostics exclude raw commands and paths, and every wrapper/native process exits. +The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`. Real-product coverage proves that two named instances retain separate environments and native modes, thread-level `never` overrides an ambient `on-request`, automatic review starts through the official app-server, dangerous bypass writes only in suite-owned temporary storage, safe diagnostics exclude raw commands and paths, and every wrapper/native process exits. ## Model Experience @@ -71,7 +89,7 @@ The production wire intentionally implements only the app-server methods require #### What the model sees -The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd; its model, system instructions, tools, and authentication come from the native Codex installation and configuration, while the Provider's Profile configuration fixes the thread's non-interactive approval and sandbox mode. +The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd; its model, system instructions, tools, and authentication come from the native Codex installation and configuration, while the selected Provider instance's Profile configuration fixes the thread's environment, non-interactive approval policy, and sandbox mode. #### Token effect @@ -98,6 +116,7 @@ Append-only: foreground adds one result after the reusable parent prefix, while ## Known Limitations and Deferred Work - **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. +- **Static instance selection** — Profile rows fix provider names and tool bindings; calls cannot choose a provider dynamically, and every exposed tool needs a unique `toolName`. - **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. - **Compatibility is pinned by development evidence** — upgrading from the verified 0.147.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. - **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; the three Profile modes never create a DSH interaction channel or per-call allow policy. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 1e9d21882b..03b74233f1 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本包注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回选定的最终答案或安全失败说明。 +本包注册由 Profile 命名、默认名称为 `codex` 的 Codex subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回选定的最终答案或安全失败说明。 ## 启动与所有权 @@ -22,6 +22,7 @@ | 配置键 | 默认值 | 含义 | |---|---|---| +| `providerName` | `codex` | `ctx.subagents` 中的非空注册名称;每个已挂载实例都需要唯一值。 | | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | | `permissionMode` | `never` | 为该提供方实例的每个线程固定原生非交互审批与沙箱模式。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | @@ -34,15 +35,24 @@ 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。提供方只覆盖选定线程的 approval/reviewer/sandbox 字段;其他 `CODEX_HOME`、项目、模型、provider、MCP、hook、skill 与账户设置仍由原生机制负责。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 -生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-codex`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Codex 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 +生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-codex`,并可在 host plane(宿主平面)挂载一个或多个具有不同 `providerName`、`permissionMode` 与 `env` 的配置项;省略 `providerName` 时仍使用默认的 `codex`。加载实例本身不会在绑定工具调用前启动 Codex 进程。每个 `dsh-tool-subagent` 配置项指定一个提供方,并需要独立的 `toolName`,因此模型看到的是静态工具,而不是动态提供方选择器。完整 Agent Preset 携带对应的默认产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 -下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 行,只新增产品提供方行并启用 preset 工具行,禁止重复挂载 Job 服务。 +下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 配置项,新增产品提供方与工具配置项,而且不重复挂载 Job 服务。 ```yaml -- id: subagent-codex +- id: subagent-codex-safe name: '@deepseek-ai/dsh-subagent-codex' config: - permissionMode: approve-for-me + providerName: codex-safe + permissionMode: never + env: + OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY + +- id: subagent-codex-bypass + name: '@deepseek-ai/dsh-subagent-codex' + config: + providerName: codex-bypass + permissionMode: dangerously-bypass-approvals-and-sandbox env: OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY @@ -52,18 +62,26 @@ - id: tool-jobs name: '@deepseek-ai/dsh-tool-jobs' -- id: tool-subagent-codex +- id: tool-subagent-codex-safe name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex - toolName: subagent_codex + provider: codex-safe + toolName: subagent_codex_safe + backgroundMode: one-shot + maxDepth: provider-managed + +- id: tool-subagent-codex-bypass + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex-bypass + toolName: subagent_codex_bypass backgroundMode: one-shot maxDepth: provider-managed ``` ## 产品兼容性与证据 -生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。真实产品覆盖会证明线程级 `never` 覆盖环境中的 `on-request`,自动评审通过官方 app-server 启动,危险绕过只在测试拥有的临时存储中写入,安全诊断不包含原始命令与路径,而且所有 wrapper/native 进程都会退出。 +生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。真实产品覆盖会证明两个命名实例保留彼此独立的环境与原生模式,线程级 `never` 覆盖环境中的 `on-request`,自动评审通过官方 app-server 启动,危险绕过只在测试拥有的临时存储中写入,安全诊断不包含原始命令与路径,而且所有 wrapper/native 进程都会退出。 ## 模型体验 @@ -71,7 +89,7 @@ #### 模型看到的内容 -Codex 子级会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具和身份验证来自原生 Codex 安装与配置,而提供方的 Profile 配置会固定该线程的非交互审批与沙箱模式。 +Codex 子级会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具和身份验证来自原生 Codex 安装与配置,而所选提供方实例的 Profile 配置会固定该线程的环境、非交互审批策略与沙箱模式。 #### 对 token 的影响 @@ -98,6 +116,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 ## 已知限制与后续工作 - **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。 +- **静态选择实例**:Profile 配置项固定提供方名称与工具绑定;调用无法动态选择提供方,而且每个公开工具都需要唯一的 `toolName`。 - **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。 - **兼容性由开发证据锁定**:若要从已验证的 0.147.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 - **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;三种 Profile 模式都不会创建 DSH 交互通道或逐次调用 allow 策略。 diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index 9624824791..9e67f9659f 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -1,7 +1,7 @@ /** - * Fixed Codex one-shot subagent provider. Every accepted run starts a fresh - * official `codex app-server --stdio` process in the delegating Session's - * workspace and publishes only after an ephemeral thread exists. + * Profile-named Codex one-shot subagent provider. Every accepted run starts a + * fresh official `codex app-server --stdio` process in the delegating + * Session's workspace and publishes only after an ephemeral thread exists. * * @module @deepseek-ai/dsh-subagent-codex */ @@ -29,8 +29,12 @@ import { export const name = 'subagent-codex' export const inject = ['subagents', 'subprocess'] +const DEFAULT_PROVIDER_NAME = 'codex' + /** Deployment-owned permission, environment, and process-release settings. */ export interface Config { + /** Provider name on `ctx.subagents` (default `codex`). */ + providerName?: string /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. @@ -43,6 +47,7 @@ export interface Config { } export const Config: z = z.object({ + providerName: z.string().min(1).default(DEFAULT_PROVIDER_NAME), env: z.dict(z.string()).default({}), permissionMode: z.union([...CODEX_PERMISSION_MODES]) .default(DEFAULT_CODEX_PERMISSION_MODE), @@ -52,11 +57,11 @@ export const Config: z = z.object({ type ResolvedConfig = Required class CodexProvider implements SubagentProvider { - readonly name = 'codex' readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES readonly inheritsParentContext = false constructor( + readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig, ) {} @@ -80,7 +85,7 @@ class CodexProvider implements SubagentProvider { spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), onError: (error, stopReason) => { this.ctx.logger.warn( - `subagent-codex: child run failed (${stopReason}): ${error.message}`, + `subagent-codex "${this.name}": child run failed (${stopReason}): ${error.message}`, ) }, } @@ -89,12 +94,13 @@ class CodexProvider implements SubagentProvider { } /** - * Register the fixed `codex` provider. + * Register one Profile-named Codex provider. * @param ctx - context carrying shared subagent and subprocess services. - * @param config - permission mode, child environment, and disposal grace. + * @param config - registry name, permission mode, child environment, and disposal grace. */ export function apply(ctx: Context, config: Config): void { const resolved: ResolvedConfig = { + providerName: config.providerName ?? DEFAULT_PROVIDER_NAME, env: config.env as Record, permissionMode: config.permissionMode ?? DEFAULT_CODEX_PERMISSION_MODE, disposeGraceMs: config.disposeGraceMs as number, @@ -109,5 +115,9 @@ export function apply(ctx: Context, config: Config): void { `subagent-codex: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`, ) } - ctx.subagents.registerProvider(new CodexProvider(ctx, resolved)) + ctx.subagents.registerProvider(new CodexProvider( + resolved.providerName, + ctx, + resolved, + )) } diff --git a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts index 8e265c3207..d404c6cdeb 100644 --- a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts @@ -15,7 +15,7 @@ const configPath = join(fixtureDir, 'cordis.yml') const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) describe('Codex provider public Loader composition', () => { - it('loads the opt-in package, one-shot task tool, and job controls without starting Codex', async () => { + it('loads two named instances, their tools, and job controls without starting Codex', async () => { const { stdout, stderr } = await runLoaderSmoke({ label: 'subagent-codex Loader composition', tempDirPrefix: 'dsh-subagent-codex-loader-', @@ -31,22 +31,41 @@ describe('Codex provider public Loader composition', () => { expect(stderr).toBe('') expect(JSON.parse(stdout)).toEqual({ - providers: ['codex'], - provider: { - name: 'codex', - capabilities: { - outputSchema: false, - depthLimit: false, - toolFilter: false, - persona: false, + providers: ['codex-primary', 'codex-secondary'], + providerDetails: [ + { + name: 'codex-primary', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, }, - inheritsParentContext: false, - }, - tool: { - name: 'subagent_codex', - parameterNames: ['description', 'prompt', 'run_in_background'], - required: ['description', 'prompt'], - }, + { + name: 'codex-secondary', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, + ], + tools: [ + { + name: 'subagent_codex_primary', + parameterNames: ['description', 'prompt', 'run_in_background'], + required: ['description', 'prompt'], + }, + { + name: 'subagent_codex_secondary', + parameterNames: ['description', 'prompt', 'run_in_background'], + required: ['description', 'prompt'], + }, + ], jobTools: ['job_kill', 'job_list', 'job_output'], starts: 0, }) diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index a060d1555d..1af3b46ce9 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -15,7 +15,10 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' -import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import type { + SubprocessHandle, + SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' import type { CodexPermissionMode } from '../src/run.ts' @@ -50,17 +53,20 @@ interface RealHarness { readonly ctx: Context readonly handles: SubprocessHandle[] readonly parent: Agent + readonly providerName: string readonly env: Record readonly workspace: string } -async function realHarness( - script: readonly ResponsesBehavior[], - permissionMode?: CodexPermissionMode, -): Promise<{ - readonly harness: RealHarness +interface RealInstanceFixture { readonly fixture: ResponsesFixture -}> { + readonly env: Record + readonly workspace: string +} + +async function realInstanceFixture( + script: readonly ResponsesBehavior[], +): Promise { const root = mkdtempSync(join(tmpdir(), 'dsh-codex-real-')) roots.push(root) const workspace = join(root, 'workspace') @@ -99,27 +105,63 @@ async function realHarness( ALL_PROXY: '', NO_PROXY: '127.0.0.1,localhost', } + return { fixture, env, workspace } +} + +interface RealRuntime { + readonly ctx: Context + readonly handles: SubprocessHandle[] + readonly spawnSpecs: SubprocessSpawnSpec[] +} + +async function realRuntime(): Promise { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const handles: SubprocessHandle[] = [] + const spawnSpecs: SubprocessSpawnSpec[] = [] const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + spawnSpecs.push(spec) const handle = spawn(spec) handles.push(handle) return handle }) + return { ctx, handles, spawnSpecs } +} + +async function realHarness( + script: readonly ResponsesBehavior[], + permissionMode?: CodexPermissionMode, + providerName = 'codex', +): Promise<{ + readonly harness: RealHarness + readonly fixture: ResponsesFixture +}> { + const instance = await realInstanceFixture(script) + const { ctx, handles } = await realRuntime() await ctx.plugin(codex, { - env, + providerName, + env: instance.env, ...permissionMode === undefined ? {} : { permissionMode }, disposeGraceMs: 2_000, }) const parent = { id: 'real-parent', - session: { header: { cwd: workspace } }, + session: { header: { cwd: instance.workspace } }, } as unknown as Agent - return { harness: { ctx, handles, parent, env, workspace }, fixture } + return { + harness: { + ctx, + handles, + parent, + providerName, + env: instance.env, + workspace: instance.workspace, + }, + fixture: instance.fixture, + } } async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise { @@ -181,6 +223,79 @@ describe('real @openai/codex 0.147.0 product', () => { await expectQuiescent(harness.handles) }, 60_000) + it('runs two named instances concurrently and unloads one without revoking its run', async () => { + const safeInstance = await realInstanceFixture([{ kind: 'hold' }]) + const bypassInstance = await realInstanceFixture([{ + kind: 'complete', + text: 'NAMED_CODEX_BYPASS_RESULT', + }]) + const { ctx, handles, spawnSpecs } = await realRuntime() + const safeFiber = await ctx.plugin(codex, { + providerName: 'codex-safe', + env: safeInstance.env, + permissionMode: 'never', + disposeGraceMs: 2_000, + }) + const bypassFiber = await ctx.plugin(codex, { + providerName: 'codex-bypass', + env: bypassInstance.env, + permissionMode: 'dangerously-bypass-approvals-and-sandbox', + disposeGraceMs: 2_000, + }) + const safeParent = { + id: 'safe-parent', + session: { header: { cwd: safeInstance.workspace } }, + } as unknown as Agent + const bypassParent = { + id: 'bypass-parent', + session: { header: { cwd: bypassInstance.workspace } }, + } as unknown as Agent + const safeController = new AbortController() + + const [safeRun, bypassRun] = await Promise.all([ + ctx.subagents.start('codex-safe', { + prompt: [{ type: 'text', text: 'Hold the safe instance.' }], + parent: safeParent, + signal: safeController.signal, + }), + ctx.subagents.start('codex-bypass', { + prompt: [{ type: 'text', text: 'Complete the bypass instance.' }], + parent: bypassParent, + signal: new AbortController().signal, + }), + ]) + await safeInstance.fixture.requestStarted + await safeFiber.dispose() + expect(ctx.subagents.list()).toEqual(['codex-bypass']) + await expect(ctx.subagents.start('codex-safe', { + prompt: [{ type: 'text', text: 'This start must fail.' }], + parent: safeParent, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'NO_PROVIDER' }) + + await expect(bypassRun.result).resolves.toEqual({ + output: [{ type: 'text', text: 'NAMED_CODEX_BYPASS_RESULT' }], + stopReason: 'completed', + }) + safeController.abort(new Error('cancel only the published safe run')) + await expect(safeRun.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + await Promise.all([safeRun.dispose(), bypassRun.dispose()]) + expect(safeInstance.fixture.requests).toHaveLength(1) + expect(bypassInstance.fixture.requests).toHaveLength(1) + expect(safeInstance.fixture.requests[0]?.body.input) + .not.toEqual(bypassInstance.fixture.requests[0]?.body.input) + expect(spawnSpecs.map(spec => spec.env?.CODEX_HOME).sort()).toEqual([ + safeInstance.env.CODEX_HOME, + bypassInstance.env.CODEX_HOME, + ].sort()) + await expectQuiescent(handles) + await bypassFiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }, 60_000) + it('overrides on-request with never and reports a denied command safely', async () => { const command = process.platform === 'win32' ? 'cmd /c type nul > approval-side-effect' diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 497303237a..e4bafed1df 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -10,6 +10,7 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SubprocessHandle, SubprocessOutcome, + SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' @@ -333,7 +334,7 @@ describe('task admission and package contracts', () => { .toThrow('must not be empty') }) - it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => { + it('registers the default descriptor, validates config, and unregisters on HMR', async () => { const ctx = new Context() await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) @@ -362,7 +363,125 @@ describe('task admission and package contracts', () => { await ctx.fiber.dispose() }) + it('keeps named instances, runs, and HMR ownership isolated', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + const safeChild = fakeChild() + const bypassChild = fakeChild() + const spawnSpecs: SubprocessSpawnSpec[] = [] + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + spawnSpecs.push(spec) + return spec.env?.DSH_CODEX_INSTANCE === 'safe' + ? safeChild.handle + : bypassChild.handle + }) + const added: string[] = [] + const started: string[] = [] + const ended: string[] = [] + const removed: string[] = [] + ctx.on('subagent/provider-added', provider => void added.push(provider.name)) + ctx.on('subagent/start', info => void started.push(info.provider)) + ctx.on('subagent/end', info => void ended.push(info.provider)) + ctx.on('subagent/provider-removed', providerName => void removed.push(providerName)) + const safeFiber = await ctx.plugin(codex, { + providerName: 'codex-safe', + env: { DSH_CODEX_INSTANCE: 'safe' }, + permissionMode: 'never', + disposeGraceMs: 11, + }) + const bypassFiber = await ctx.plugin(codex, { + providerName: 'codex-bypass', + env: { DSH_CODEX_INSTANCE: 'bypass' }, + permissionMode: 'dangerously-bypass-approvals-and-sandbox', + disposeGraceMs: 29, + }) + expect(ctx.subagents.list()).toEqual(['codex-safe', 'codex-bypass']) + expect(added).toEqual(['codex-safe', 'codex-bypass']) + + const safeController = new AbortController() + const safeStarting = ctx.subagents.start( + 'codex-safe', + request(undefined, safeController.signal), + ) + const bypassStarting = ctx.subagents.start('codex-bypass', request()) + for (const child of [safeChild, bypassChild]) { + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { + thread: { id: 'thread-1', ephemeral: true }, + }) + } + const [safeRun, bypassRun] = await Promise.all([ + safeStarting, + bypassStarting, + ]) + await safeFiber.dispose() + expect(ctx.subagents.list()).toEqual(['codex-bypass']) + expect(removed).toEqual(['codex-safe']) + await expect(ctx.subagents.start('codex-safe', request())) + .rejects.toMatchObject({ code: 'NO_PROVIDER' }) + + const safeTurn = await safeChild.peer.nextMethod('turn/start') + const bypassTurn = await bypassChild.peer.nextMethod('turn/start') + safeChild.peer.respond(safeTurn, { turn: { id: 'turn-safe' } }) + bypassChild.peer.send( + { id: bypassTurn.id, result: { turn: { id: 'turn-bypass' } } }, + agentMessage('bypass answer', 'final_answer', 'turn-bypass'), + turnCompleted('completed', 'turn-bypass'), + ) + await expect(bypassRun.result).resolves.toEqual({ + output: [{ type: 'text', text: 'bypass answer' }], + stopReason: 'completed', + }) + safeController.abort(new Error('stop only the safe instance')) + await expect(safeRun.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + expect(spawnSpecs.map(spec => ({ + instance: spec.env?.DSH_CODEX_INSTANCE, + graceMs: spec.graceMs, + }))).toEqual([ + { instance: 'safe', graceMs: 11 }, + { instance: 'bypass', graceMs: 29 }, + ]) + + await Promise.all([safeRun.dispose(), bypassRun.dispose()]) + expect([...started].sort()).toEqual(['codex-bypass', 'codex-safe']) + expect([...ended].sort()).toEqual(['codex-bypass', 'codex-safe']) + expect(safeChild.terminate).toHaveBeenCalledOnce() + expect(bypassChild.terminate).toHaveBeenCalledOnce() + await bypassFiber.dispose() + expect(removed).toEqual(['codex-safe', 'codex-bypass']) + await ctx.fiber.dispose() + }) + + it('rejects duplicate provider names without replacing the first instance', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + const firstFiber = await ctx.plugin(codex, { + providerName: 'codex-duplicate', + }) + const first = ctx.subagents.getProvider('codex-duplicate') + await expect(ctx.plugin(codex, { + providerName: 'codex-duplicate', + permissionMode: 'dangerously-bypass-approvals-and-sandbox', + })).rejects.toMatchObject({ code: 'DUPLICATE_PROVIDER' }) + expect(ctx.subagents.getProvider('codex-duplicate')).toBe(first) + expect(ctx.subagents.list()).toEqual(['codex-duplicate']) + await firstFiber.dispose() + await ctx.fiber.dispose() + }) + it('accepts only the three fixed non-interactive permission modes', () => { + expect(codex.Config({}).providerName).toBe('codex') + expect(codex.Config({ providerName: 'codex-safe' }).providerName) + .toBe('codex-safe') + expect(() => codex.Config({ providerName: '' })).toThrow() expect(codex.Config({}).permissionMode).toBe(DEFAULT_CODEX_PERMISSION_MODE) for (const permissionMode of CODEX_PERMISSION_MODES) { expect(codex.Config({ permissionMode }).permissionMode).toBe(permissionMode) @@ -1586,11 +1705,12 @@ describe('run lifecycle and quiescence', () => { warnings.push(String(message)) }) as typeof ctx.logger.warn await ctx.plugin(codex, { + providerName: 'codex-diagnostic', env: { OPENAI_API_KEY: 'fake' }, permissionMode: 'approve-for-me', disposeGraceMs: 25, }) - const starting = ctx.subagents.start('codex', { + const starting = ctx.subagents.start('codex-diagnostic', { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent, signal: new AbortController().signal, @@ -1638,7 +1758,7 @@ describe('run lifecycle and quiescence', () => { cwd: process.cwd(), })) expect(warnings).toEqual([ - expect.stringContaining('subagent-codex: child run failed (error): subagent-codex: Codex turn ended with status failed: error'), + expect.stringContaining('subagent-codex "codex-diagnostic": child run failed (error): subagent-codex: Codex turn ended with status failed: error'), ]) expect(warnings.join('\n')).not.toContain('SECRET_TOKEN') expect(warnings.join('\n')).not.toContain('/private/secret.txt') From a5ea2c46a7c96f1437d6615eb9bffd187e4f5090 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 04:08:58 +0800 Subject: [PATCH 18/34] test(acp): isolate product diagnostic header pin --- examples/acp-agent/tests/acp.snapshot.ts | 4 +- .../tool-schemas.expected.json | 548 ++++++++++++++++++ 2 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/tool-schemas.expected.json diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index bfcdb8ed1a..c28ea72777 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -165,7 +165,9 @@ const SCENARIOS: Scenario[] = [ hasModelTurn: true, recorded: false, overridden: true, - headerClass: 'product-subagent-codex', + pinsHeader: true, + headerClass: 'product-subagent-result-diagnostic', + systemPromptSource: 'product-subagent-codex', configPath: PRODUCT_SUBAGENT_RESULT_DIAGNOSTIC_CONFIG, }, { diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/tool-schemas.expected.json new file mode 100644 index 0000000000..29a85eb6b3 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/tool-schemas.expected.json @@ -0,0 +1,548 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "job_kill", + "description": "Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the job." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "job_list", + "description": "List your background jobs (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "job_output", + "description": "Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_codex", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} From b85cb981eff636250676b13b2b6a98a46e292c57 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 04:13:14 +0800 Subject: [PATCH 19/34] docs(subagent): allow multiple product provider instances --- ...-12-product-subagent-one-shot-background-tasks.i18n.yaml | 4 ++-- ...2026-08-12-product-subagent-one-shot-background-tasks.md | 6 +++--- ...6-08-12-product-subagent-one-shot-background-tasks.zh.md | 6 +++--- .../cordis/skills/editing-cordis-compositions/SKILL.md | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml index cec2cc269a..5cba925b5f 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.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 .agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md -2026-08-12-product-subagent-one-shot-background-tasks.md: 248bb943f8ee46a7050c373b6b7c3f7dec65d566 -2026-08-12-product-subagent-one-shot-background-tasks.zh.md: d6867a97561e7efbe2b152b6c45991553393b4e7 +2026-08-12-product-subagent-one-shot-background-tasks.md: 9c382dc5bb9d98a1ca7252a66b0b2ea447a7d044 +2026-08-12-product-subagent-one-shot-background-tasks.zh.md: bb1967d2fc6887e1f9d8dab7415c2a1e5ac25cef diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md index 248bb943f8..9c382dc5bb 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md @@ -12,7 +12,7 @@ Exposing background execution must not add a product session, product-specific j ## Decision -Production `dsh` does not install the optional product providers. A Profile that opts in installs and mounts `dsh-subagent-codex`, `dsh-subagent-claude-code`, or both once on the host plane. The `standard`, `code`, and `cordis` Agent Presets configure the corresponding dormant tool rows with `backgroundMode: one-shot`; removing a row's `disabled` field exposes the existing optional `run_in_background` argument to agents composed from that preset. Omission or `false` waits in the foreground; explicit `true` returns a parent-owned Job id after synchronous Job preflight and registration, without waiting for provider startup or completion. +Production `dsh` does not install the optional product providers. A Profile that opts in installs the needed `dsh-subagent-codex` or `dsh-subagent-claude-code` packages and mounts the required provider instances on the host plane. The `standard`, `code`, and `cordis` Agent Presets configure the corresponding dormant tool rows with `backgroundMode: one-shot`; removing a row's `disabled` field exposes the existing optional `run_in_background` argument to agents composed from that preset. Omission or `false` waits in the foreground; explicit `true` returns a parent-owned Job id after synchronous Job preflight and registration, without waiting for provider startup or completion. The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile configuration and diagnostic production. @@ -33,7 +33,7 @@ product tool call | Fact or resource | Owner | Product-tool responsibility | Observable result | | --- | --- | --- | --- | -| Product provider installation and registration | Explicit Profile | Install the optional provider package and mount it once on the host plane | The provider name is available without adding its package to every production `dsh` install | +| Product provider installation and registration | Explicit Profile | Install the optional provider package and mount the required named instances on the host plane | The provider names are available without adding the package to every production `dsh` install | | Product selection and exposure | Agent Preset | Bind one fixed tool name to one fixed provider | Enabling one row exposes only that product tool | | Foreground or background choice | `dsh-tool-subagent` | Resolve `run_in_background` under `one-shot` policy | Omission is foreground; explicit `true` returns a Job id | | Job id, state, output, cancellation, and notice | `ctx.jobs` and `dsh-tool-jobs` | Register and present the existing one-shot run | Generic job tools collect or stop the run for the exact parent | @@ -41,7 +41,7 @@ product tool call ## Published composition -The production base keeps both optional product providers out of its dependency closure. An opting-in Profile installs and mounts either or both providers once on the host plane. Each full preset keeps both product-tool rows disabled and contributes the generic Job controls to its own agent scope, while the base host owns the shared Job registry. A user copies a preset and removes `disabled` from the matching product rows after the Profile provider is present; no product process starts during composition. +The production base keeps both optional product providers out of its dependency closure. An opting-in Profile installs the needed packages and mounts the required provider instances on the host plane. Each full preset keeps both product-tool rows disabled and contributes the generic Job controls to its own agent scope, while the base host owns the shared Job registry. A user copies a preset and removes `disabled` from the matching product rows after the Profile providers are present; no product process starts during composition. A standalone custom composition that enables one-shot background execution must provide the product provider plus the complete generic Job capability: `dsh-jobs-local` as the Job provider and `dsh-tool-jobs` as the model-facing consumer. A Profile based on `dsh-base` already has the Job capability and adds only the optional product provider before enabling the preset tool row. A product tool without the Job runtime can still execute in the foreground, but an explicit background request fails the existing Job preflight instead of publishing an uncollectable id. diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md index d6867a9756..bb1967d2fc 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md @@ -12,7 +12,7 @@ Codex 与 Claude Code 提供方已经能够运行一项自包含任务并返回 ## 决策 -生产 `dsh` 不安装可选产品提供方。选择启用产品集成的 Profile 会安装 `dsh-subagent-codex`、`dsh-subagent-claude-code` 或两者,并在 host plane(宿主平面)各挂载一次。`standard`、`code` 与 `cordis` Agent Preset 使用 `backgroundMode: one-shot` 配置相应的休眠工具行;删除某一行的 `disabled` 字段后,现有可选参数 `run_in_background` 会向由该 preset 组装的 agent 公开。省略该参数或传入 `false` 时会在前台等待;显式传入 `true` 时会在同步完成 Job 预检与登记后返回由父级拥有的 Job id,而不会等待提供方启动或完成。 +生产 `dsh` 不安装可选产品提供方。选择启用产品集成的 Profile 会安装所需的 `dsh-subagent-codex` 或 `dsh-subagent-claude-code` 包,并在 host plane(宿主平面)挂载所需的提供方实例。`standard`、`code` 与 `cordis` Agent Preset 使用 `backgroundMode: one-shot` 配置相应的休眠工具行;删除某一行的 `disabled` 字段后,现有可选参数 `run_in_background` 会向由该 preset 组装的 agent 公开。省略该参数或传入 `false` 时会在前台等待;显式传入 `true` 时会在同步完成 Job 预检与登记后返回由父级拥有的 Job id,而不会等待提供方启动或完成。 [通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责各产品提供方的 Profile 配置与诊断生产。 @@ -33,7 +33,7 @@ product tool call | 事实或资源 | 责任方 | 产品工具职责 | 可观察结果 | | --- | --- | --- | --- | -| 产品提供方安装与登记 | 显式 Profile | 安装可选提供方包,并在 host plane 挂载一次 | 提供方名称可用,但不会让每次生产 `dsh` 安装都包含该包 | +| 产品提供方安装与登记 | 显式 Profile | 安装可选提供方包,并在 host plane 挂载所需的命名实例 | 提供方名称可用,但不会让每次生产 `dsh` 安装都包含该包 | | 产品选择与公开 | Agent Preset | 把一个固定工具名绑定到一个固定提供方 | 启用一行只会公开对应产品工具 | | 前台或后台选择 | `dsh-tool-subagent` | 按 `one-shot` 策略解析 `run_in_background` | 省略参数时在前台运行;显式传入 `true` 时返回 Job id | | Job id、状态、输出、取消与通知 | `ctx.jobs` 与 `dsh-tool-jobs` | 登记并展示现有 one-shot 运行 | 通用作业工具为准确父级收集或停止运行 | @@ -41,7 +41,7 @@ product tool call ## 发布组装 -生产 base 不让两个可选产品提供方进入依赖闭包。选择启用产品集成的 Profile 会在 host plane 安装并挂载任一或两个提供方。每个完整 preset 让两个产品工具行保持禁用,并把通用 Job 控制工具贡献到自身 agent 作用域;base host 负责共享 Job 注册表。Profile 提供方存在后,用户复制一个 preset,再从对应产品行删除 `disabled`;组装期间不会启动产品进程。 +生产 base 不让两个可选产品提供方进入依赖闭包。选择启用产品集成的 Profile 会安装所需包,并在 host plane 挂载所需的提供方实例。每个完整 preset 让两个产品工具行保持禁用,并把通用 Job 控制工具贡献到自身 agent 作用域;base host 负责共享 Job 注册表。Profile 提供方实例存在后,用户复制一个 preset,再从对应产品行删除 `disabled`;组装期间不会启动产品进程。 独立自定义组装若启用 one-shot 后台执行,就必须同时提供产品提供方与完整通用 Job 能力:由 `dsh-jobs-local` 充当 Job 提供方,由 `dsh-tool-jobs` 充当面向模型的消费方。基于 `dsh-base` 的 Profile 已具备 Job 能力,只需在启用 preset 工具行前新增可选产品提供方。没有 Job 运行时的产品工具仍可在前台执行,但显式后台请求会在现有 Job 预检中失败,不会发布无法收集的 id。 diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index 8214e88654..304d03f8ae 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -147,7 +147,7 @@ Copy these disabled templates from a shipped full preset and remove `disabled` o maxDepth: provider-managed ``` -The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install or mount either optional provider: before enabling a row, the Profile must install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` package and mount it once on the host plane. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. The host must also provide `codex` or `claude` on `PATH`; the preset does not install, authenticate, select a model for, or probe either product. +The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install or mount either optional provider: before enabling a row, the Profile must install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` package and mount the required provider instances on the host plane. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. The host must also provide `codex` or `claude` on `PATH`; the preset does not install, authenticate, select a model for, or probe either product. ## What not to move into a preset From cde7ffe6e3c3be1c3f602efd92318c5d2711f4d5 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 04:29:48 +0800 Subject: [PATCH 20/34] fix(subagent): align named instance guidance and evidence --- ...-12-product-subagent-one-shot-background-tasks.i18n.yaml | 4 ++-- ...2026-08-12-product-subagent-one-shot-background-tasks.md | 2 ++ ...6-08-12-product-subagent-one-shot-background-tasks.zh.md | 2 ++ .../cordis/skills/editing-cordis-compositions/SKILL.md | 2 ++ .../subagent-claude-code/tests/real-product.spec.ts | 6 +----- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml index 5cba925b5f..3af0b22f0a 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.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 .agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md -2026-08-12-product-subagent-one-shot-background-tasks.md: 9c382dc5bb9d98a1ca7252a66b0b2ea447a7d044 -2026-08-12-product-subagent-one-shot-background-tasks.zh.md: bb1967d2fc6887e1f9d8dab7415c2a1e5ac25cef +2026-08-12-product-subagent-one-shot-background-tasks.md: fd197098f5e21e45ac5f4f94cfd8c1d014ffa4a7 +2026-08-12-product-subagent-one-shot-background-tasks.zh.md: 60a472996672e512616f8fd89e43a6d33f3ccd88 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md index 9c382dc5bb..fd197098f5 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md @@ -14,6 +14,8 @@ Exposing background execution must not add a product session, product-specific j Production `dsh` does not install the optional product providers. A Profile that opts in installs the needed `dsh-subagent-codex` or `dsh-subagent-claude-code` packages and mounts the required provider instances on the host plane. The `standard`, `code`, and `cordis` Agent Presets configure the corresponding dormant tool rows with `backgroundMode: one-shot`; removing a row's `disabled` field exposes the existing optional `run_in_background` argument to agents composed from that preset. Omission or `false` waits in the foreground; explicit `true` returns a parent-owned Job id after synchronous Job preflight and registration, without waiting for provider startup or completion. +The [named-instance decision](2026-08-18-product-subagent-named-instances.md) allows multiple rows for the same product. Each additional host provider row has its own `providerName`, and each exposed preset tool row binds that exact name through `provider` while keeping a unique `toolName`; the foreground/background scheduling choice does not constrain the number of instances. + The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile configuration and diagnostic production. This scheduling decision adds no provider configuration, service interface, event, wire field, persistence format, or product identifier. A Provider may define its own Profile configuration independently; foreground and background still differ only in which existing consumer waits for the same one-shot run. diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md index bb1967d2fc..60a4729966 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md @@ -14,6 +14,8 @@ Codex 与 Claude Code 提供方已经能够运行一项自包含任务并返回 生产 `dsh` 不安装可选产品提供方。选择启用产品集成的 Profile 会安装所需的 `dsh-subagent-codex` 或 `dsh-subagent-claude-code` 包,并在 host plane(宿主平面)挂载所需的提供方实例。`standard`、`code` 与 `cordis` Agent Preset 使用 `backgroundMode: one-shot` 配置相应的休眠工具行;删除某一行的 `disabled` 字段后,现有可选参数 `run_in_background` 会向由该 preset 组装的 agent 公开。省略该参数或传入 `false` 时会在前台等待;显式传入 `true` 时会在同步完成 Job 预检与登记后返回由父级拥有的 Job id,而不会等待提供方启动或完成。 +[命名实例决策](2026-08-18-product-subagent-named-instances.md)允许同一产品拥有多个配置项。每个新增宿主提供方配置项都有独立的 `providerName`,每个公开的 preset 工具配置项都通过 `provider` 绑定该名称并保持唯一的 `toolName`;前台或后台调度选择不会限制实例数量。 + [通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责各产品提供方的 Profile 配置与诊断生产。 本调度决策不新增提供方配置、服务接口、事件、协议字段、持久化格式或产品标识符。提供方可以独立定义自己的 Profile 配置;前台与后台的区别仍然只在于由哪个现有消费方等待同一个 one-shot 运行。 diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index 304d03f8ae..fed51e9f81 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -147,6 +147,8 @@ Copy these disabled templates from a shipped full preset and remove `disabled` o maxDepth: provider-managed ``` +For additional named instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings. + The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install or mount either optional provider: before enabling a row, the Profile must install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` package and mount the required provider instances on the host plane. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. The host must also provide `codex` or `claude` on `PATH`; the preset does not install, authenticate, select a model for, or probe either product. ## What not to move into a preset diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index e552ebddbc..b97bf4a3c0 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -119,7 +119,6 @@ interface RealHarness { readonly handles: SubprocessHandle[] readonly spawnSpecs: SubprocessSpawnSpec[] readonly parent: Agent - readonly providerName: string readonly workspace: string readonly env: Record readonly executable: string @@ -210,7 +209,6 @@ async function realHarness( behavior: MessagesBehavior, permissionMode?: ClaudeCodePermissionMode, nativeAllow: readonly string[] = [], - providerName = 'claude-code', ): Promise<{ readonly harness: RealHarness readonly fixture: MessagesFixture @@ -218,7 +216,6 @@ async function realHarness( const instance = await realInstanceFixture(behavior, nativeAllow) const { ctx, handles, spawnSpecs } = await realRuntime() await ctx.plugin(claudeCode, { - providerName, env: instance.env, ...permissionMode === undefined ? {} : { permissionMode }, disposeGraceMs: 3_000, @@ -233,7 +230,6 @@ async function realHarness( handles, spawnSpecs, parent, - providerName, workspace: instance.workspace, env: instance.env, executable: instance.executable, @@ -259,7 +255,7 @@ function startRequest( prompt: string, signal = new AbortController().signal, ) { - return harness.ctx.subagents.start(harness.providerName, { + return harness.ctx.subagents.start('claude-code', { prompt: [{ type: 'text', text: prompt }], parent: harness.parent, signal, From cf16ee41bfdec31cf338a8954eb9e60514c6a6bf Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 04:34:24 +0800 Subject: [PATCH 21/34] test(subagent): narrow named instance evidence --- .../product-subagent-both.cordis.snapshot.yml | 52 +++++++------------ .../product-subagent-both.cordis.yml | 52 +++++++------------ ...product-subagent-codex.cordis.snapshot.yml | 26 ++++------ .../product-subagent-codex.cordis.yml | 26 ++++------ .../tool-schemas.expected.json | 8 +-- .../tool-schemas.expected.json | 4 +- .../subagent-codex/tests/real-product.spec.ts | 4 -- 7 files changed, 66 insertions(+), 106 deletions(-) diff --git a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml index 3c8acf86cd..3bed92ab57 100644 --- a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml +++ b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml @@ -18,59 +18,47 @@ models: - id: deepseek-v4-flash - id: deepseek-v4-pro - - id: subagent-codex-safe + - id: subagent-codex-primary name: '@deepseek-ai/dsh-subagent-codex' config: - providerName: codex-safe - permissionMode: never - env: - DSH_CODEX_INSTANCE: safe - - id: subagent-codex-bypass + providerName: codex-primary + - id: subagent-codex-secondary name: '@deepseek-ai/dsh-subagent-codex' config: - providerName: codex-bypass - permissionMode: dangerously-bypass-approvals-and-sandbox - env: - DSH_CODEX_INSTANCE: bypass - - id: subagent-claude-safe + providerName: codex-secondary + - id: subagent-claude-primary name: '@deepseek-ai/dsh-subagent-claude-code' config: - providerName: claude-safe - permissionMode: dontAsk - env: - DSH_CLAUDE_INSTANCE: safe - - id: subagent-claude-bypass + providerName: claude-primary + - id: subagent-claude-secondary name: '@deepseek-ai/dsh-subagent-claude-code' config: - providerName: claude-bypass - permissionMode: bypassPermissions - env: - DSH_CLAUDE_INSTANCE: bypass - - id: tool-subagent-codex-safe + providerName: claude-secondary + - id: tool-subagent-codex-primary name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex-safe - toolName: subagent_codex_safe + provider: codex-primary + toolName: subagent_codex_primary backgroundMode: one-shot maxDepth: provider-managed - - id: tool-subagent-codex-bypass + - id: tool-subagent-codex-secondary name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex-bypass - toolName: subagent_codex_bypass + provider: codex-secondary + toolName: subagent_codex_secondary backgroundMode: one-shot maxDepth: provider-managed - - id: tool-subagent-claude-safe + - id: tool-subagent-claude-primary name: '@deepseek-ai/dsh-tool-subagent' config: - provider: claude-safe - toolName: subagent_claude_safe + provider: claude-primary + toolName: subagent_claude_primary backgroundMode: one-shot maxDepth: provider-managed - - id: tool-subagent-claude-bypass + - id: tool-subagent-claude-secondary name: '@deepseek-ai/dsh-tool-subagent' config: - provider: claude-bypass - toolName: subagent_claude_bypass + provider: claude-secondary + toolName: subagent_claude_secondary backgroundMode: one-shot maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-both.cordis.yml b/examples/acp-agent/product-subagent-both.cordis.yml index f81fc8b032..dfde2ead48 100644 --- a/examples/acp-agent/product-subagent-both.cordis.yml +++ b/examples/acp-agent/product-subagent-both.cordis.yml @@ -7,59 +7,47 @@ path: ./cordis.yml patches: - insert: - - id: subagent-codex-safe + - id: subagent-codex-primary name: '@deepseek-ai/dsh-subagent-codex' config: - providerName: codex-safe - permissionMode: never - env: - DSH_CODEX_INSTANCE: safe - - id: subagent-codex-bypass + providerName: codex-primary + - id: subagent-codex-secondary name: '@deepseek-ai/dsh-subagent-codex' config: - providerName: codex-bypass - permissionMode: dangerously-bypass-approvals-and-sandbox - env: - DSH_CODEX_INSTANCE: bypass - - id: subagent-claude-safe + providerName: codex-secondary + - id: subagent-claude-primary name: '@deepseek-ai/dsh-subagent-claude-code' config: - providerName: claude-safe - permissionMode: dontAsk - env: - DSH_CLAUDE_INSTANCE: safe - - id: subagent-claude-bypass + providerName: claude-primary + - id: subagent-claude-secondary name: '@deepseek-ai/dsh-subagent-claude-code' config: - providerName: claude-bypass - permissionMode: bypassPermissions - env: - DSH_CLAUDE_INSTANCE: bypass - - id: tool-subagent-codex-safe + providerName: claude-secondary + - id: tool-subagent-codex-primary name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex-safe - toolName: subagent_codex_safe + provider: codex-primary + toolName: subagent_codex_primary backgroundMode: one-shot maxDepth: provider-managed - - id: tool-subagent-codex-bypass + - id: tool-subagent-codex-secondary name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex-bypass - toolName: subagent_codex_bypass + provider: codex-secondary + toolName: subagent_codex_secondary backgroundMode: one-shot maxDepth: provider-managed - - id: tool-subagent-claude-safe + - id: tool-subagent-claude-primary name: '@deepseek-ai/dsh-tool-subagent' config: - provider: claude-safe - toolName: subagent_claude_safe + provider: claude-primary + toolName: subagent_claude_primary backgroundMode: one-shot maxDepth: provider-managed - - id: tool-subagent-claude-bypass + - id: tool-subagent-claude-secondary name: '@deepseek-ai/dsh-tool-subagent' config: - provider: claude-bypass - toolName: subagent_claude_bypass + provider: claude-secondary + toolName: subagent_claude_secondary backgroundMode: one-shot maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml index e7b1dfeea2..811b775087 100644 --- a/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml +++ b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml @@ -18,31 +18,25 @@ models: - id: deepseek-v4-flash - id: deepseek-v4-pro - - id: subagent-codex-safe + - id: subagent-codex-primary name: '@deepseek-ai/dsh-subagent-codex' config: - providerName: codex-safe - permissionMode: never - env: - DSH_CODEX_INSTANCE: safe - - id: subagent-codex-bypass + providerName: codex-primary + - id: subagent-codex-secondary name: '@deepseek-ai/dsh-subagent-codex' config: - providerName: codex-bypass - permissionMode: dangerously-bypass-approvals-and-sandbox - env: - DSH_CODEX_INSTANCE: bypass - - id: tool-subagent-codex-safe + providerName: codex-secondary + - id: tool-subagent-codex-primary name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex-safe - toolName: subagent_codex_safe + provider: codex-primary + toolName: subagent_codex_primary backgroundMode: one-shot maxDepth: provider-managed - - id: tool-subagent-codex-bypass + - id: tool-subagent-codex-secondary name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex-bypass - toolName: subagent_codex_bypass + provider: codex-secondary + toolName: subagent_codex_secondary backgroundMode: one-shot maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-codex.cordis.yml b/examples/acp-agent/product-subagent-codex.cordis.yml index a27d4636e3..1ca4cf297e 100644 --- a/examples/acp-agent/product-subagent-codex.cordis.yml +++ b/examples/acp-agent/product-subagent-codex.cordis.yml @@ -7,31 +7,25 @@ path: ./cordis.yml patches: - insert: - - id: subagent-codex-safe + - id: subagent-codex-primary name: '@deepseek-ai/dsh-subagent-codex' config: - providerName: codex-safe - permissionMode: never - env: - DSH_CODEX_INSTANCE: safe - - id: subagent-codex-bypass + providerName: codex-primary + - id: subagent-codex-secondary name: '@deepseek-ai/dsh-subagent-codex' config: - providerName: codex-bypass - permissionMode: dangerously-bypass-approvals-and-sandbox - env: - DSH_CODEX_INSTANCE: bypass - - id: tool-subagent-codex-safe + providerName: codex-secondary + - id: tool-subagent-codex-primary name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex-safe - toolName: subagent_codex_safe + provider: codex-primary + toolName: subagent_codex_primary backgroundMode: one-shot maxDepth: provider-managed - - id: tool-subagent-codex-bypass + - id: tool-subagent-codex-secondary name: '@deepseek-ai/dsh-tool-subagent' config: - provider: codex-bypass - toolName: subagent_codex_bypass + provider: codex-secondary + toolName: subagent_codex_secondary backgroundMode: one-shot maxDepth: provider-managed diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json index 434e896fee..9d7cfdc26d 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json @@ -307,7 +307,7 @@ } }, { - "name": "subagent_claude_bypass", + "name": "subagent_claude_primary", "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.", "parameters": { "type": "object", @@ -332,7 +332,7 @@ } }, { - "name": "subagent_claude_safe", + "name": "subagent_claude_secondary", "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.", "parameters": { "type": "object", @@ -357,7 +357,7 @@ } }, { - "name": "subagent_codex_bypass", + "name": "subagent_codex_primary", "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.", "parameters": { "type": "object", @@ -382,7 +382,7 @@ } }, { - "name": "subagent_codex_safe", + "name": "subagent_codex_secondary", "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.", "parameters": { "type": "object", diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json index cfb805d2ec..6fdb3bf877 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json @@ -307,7 +307,7 @@ } }, { - "name": "subagent_codex_bypass", + "name": "subagent_codex_primary", "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.", "parameters": { "type": "object", @@ -332,7 +332,7 @@ } }, { - "name": "subagent_codex_safe", + "name": "subagent_codex_secondary", "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.", "parameters": { "type": "object", diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 1af3b46ce9..781ac8d041 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -53,7 +53,6 @@ interface RealHarness { readonly ctx: Context readonly handles: SubprocessHandle[] readonly parent: Agent - readonly providerName: string readonly env: Record readonly workspace: string } @@ -134,7 +133,6 @@ async function realRuntime(): Promise { async function realHarness( script: readonly ResponsesBehavior[], permissionMode?: CodexPermissionMode, - providerName = 'codex', ): Promise<{ readonly harness: RealHarness readonly fixture: ResponsesFixture @@ -142,7 +140,6 @@ async function realHarness( const instance = await realInstanceFixture(script) const { ctx, handles } = await realRuntime() await ctx.plugin(codex, { - providerName, env: instance.env, ...permissionMode === undefined ? {} : { permissionMode }, disposeGraceMs: 2_000, @@ -156,7 +153,6 @@ async function realHarness( ctx, handles, parent, - providerName, env: instance.env, workspace: instance.workspace, }, From 7dd6436d52aa59f9b58c927f38a1076a12cde85c Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 04:44:42 +0800 Subject: [PATCH 22/34] docs(subagent): scope named guidance to Claude layer --- ...08-12-product-subagent-one-shot-background-tasks.i18n.yaml | 4 ++-- .../2026-08-12-product-subagent-one-shot-background-tasks.md | 2 +- ...026-08-12-product-subagent-one-shot-background-tasks.zh.md | 2 +- .../cordis/skills/editing-cordis-compositions/SKILL.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml index 3af0b22f0a..243e1defe8 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.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 .agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md -2026-08-12-product-subagent-one-shot-background-tasks.md: fd197098f5e21e45ac5f4f94cfd8c1d014ffa4a7 -2026-08-12-product-subagent-one-shot-background-tasks.zh.md: 60a472996672e512616f8fd89e43a6d33f3ccd88 +2026-08-12-product-subagent-one-shot-background-tasks.md: 8c8a076d862aa618c036440b169a84522d969984 +2026-08-12-product-subagent-one-shot-background-tasks.zh.md: 0ecd3ffef475d4b7923a765dcb875aa93ea697b9 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md index fd197098f5..8c8a076d86 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md @@ -14,7 +14,7 @@ Exposing background execution must not add a product session, product-specific j Production `dsh` does not install the optional product providers. A Profile that opts in installs the needed `dsh-subagent-codex` or `dsh-subagent-claude-code` packages and mounts the required provider instances on the host plane. The `standard`, `code`, and `cordis` Agent Presets configure the corresponding dormant tool rows with `backgroundMode: one-shot`; removing a row's `disabled` field exposes the existing optional `run_in_background` argument to agents composed from that preset. Omission or `false` waits in the foreground; explicit `true` returns a parent-owned Job id after synchronous Job preflight and registration, without waiting for provider startup or completion. -The [named-instance decision](2026-08-18-product-subagent-named-instances.md) allows multiple rows for the same product. Each additional host provider row has its own `providerName`, and each exposed preset tool row binds that exact name through `provider` while keeping a unique `toolName`; the foreground/background scheduling choice does not constrain the number of instances. +The [named-instance decision](2026-08-18-product-subagent-named-instances.md) currently allows multiple Claude Code rows, while Codex retains its single default name. Each additional Claude Code host provider row has its own `providerName`, and each exposed preset tool row binds that exact name through `provider` while keeping a unique `toolName`; the foreground/background scheduling choice does not constrain the number of supported instances. The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile configuration and diagnostic production. diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md index 60a4729966..0ecd3ffef4 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md @@ -14,7 +14,7 @@ Codex 与 Claude Code 提供方已经能够运行一项自包含任务并返回 生产 `dsh` 不安装可选产品提供方。选择启用产品集成的 Profile 会安装所需的 `dsh-subagent-codex` 或 `dsh-subagent-claude-code` 包,并在 host plane(宿主平面)挂载所需的提供方实例。`standard`、`code` 与 `cordis` Agent Preset 使用 `backgroundMode: one-shot` 配置相应的休眠工具行;删除某一行的 `disabled` 字段后,现有可选参数 `run_in_background` 会向由该 preset 组装的 agent 公开。省略该参数或传入 `false` 时会在前台等待;显式传入 `true` 时会在同步完成 Job 预检与登记后返回由父级拥有的 Job id,而不会等待提供方启动或完成。 -[命名实例决策](2026-08-18-product-subagent-named-instances.md)允许同一产品拥有多个配置项。每个新增宿主提供方配置项都有独立的 `providerName`,每个公开的 preset 工具配置项都通过 `provider` 绑定该名称并保持唯一的 `toolName`;前台或后台调度选择不会限制实例数量。 +[命名实例决策](2026-08-18-product-subagent-named-instances.md)目前允许 Claude Code 拥有多个配置项,而 Codex 仍保留单一默认名称。每个新增 Claude Code 宿主提供方配置项都有独立的 `providerName`,每个公开的 preset 工具配置项都通过 `provider` 绑定该名称并保持唯一的 `toolName`;前台或后台调度选择不会限制已支持的实例数量。 [通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责各产品提供方的 Profile 配置与诊断生产。 diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index fed51e9f81..0c3972d6c4 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -147,7 +147,7 @@ Copy these disabled templates from a shipped full preset and remove `disabled` o maxDepth: provider-managed ``` -For additional named instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings. +For additional named Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. The Codex provider still exposes only its default `codex` name here, so do not duplicate or retarget its shipped row. Do not reuse one tool row for several providers or derive either name from permission or environment settings. The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install or mount either optional provider: before enabling a row, the Profile must install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` package and mount the required provider instances on the host plane. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. The host must also provide `codex` or `claude` on `PATH`; the preset does not install, authenticate, select a model for, or probe either product. From 75cece4b926862105412eb85af395534ab877fb9 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 04:48:07 +0800 Subject: [PATCH 23/34] docs(subagent): finalize named instance guidance --- ...08-12-product-subagent-one-shot-background-tasks.i18n.yaml | 4 ++-- .../2026-08-12-product-subagent-one-shot-background-tasks.md | 2 +- ...026-08-12-product-subagent-one-shot-background-tasks.zh.md | 2 +- .../cordis/skills/editing-cordis-compositions/SKILL.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml index 243e1defe8..88bed4688c 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.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 .agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md -2026-08-12-product-subagent-one-shot-background-tasks.md: 8c8a076d862aa618c036440b169a84522d969984 -2026-08-12-product-subagent-one-shot-background-tasks.zh.md: 0ecd3ffef475d4b7923a765dcb875aa93ea697b9 +2026-08-12-product-subagent-one-shot-background-tasks.md: 5e9522f6fac6eadb874ba2d1d4f45100f962b9e2 +2026-08-12-product-subagent-one-shot-background-tasks.zh.md: 9685701cbe07df226357e9830a875e3928ae039d diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md index 8c8a076d86..5e9522f6fa 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md @@ -14,7 +14,7 @@ Exposing background execution must not add a product session, product-specific j Production `dsh` does not install the optional product providers. A Profile that opts in installs the needed `dsh-subagent-codex` or `dsh-subagent-claude-code` packages and mounts the required provider instances on the host plane. The `standard`, `code`, and `cordis` Agent Presets configure the corresponding dormant tool rows with `backgroundMode: one-shot`; removing a row's `disabled` field exposes the existing optional `run_in_background` argument to agents composed from that preset. Omission or `false` waits in the foreground; explicit `true` returns a parent-owned Job id after synchronous Job preflight and registration, without waiting for provider startup or completion. -The [named-instance decision](2026-08-18-product-subagent-named-instances.md) currently allows multiple Claude Code rows, while Codex retains its single default name. Each additional Claude Code host provider row has its own `providerName`, and each exposed preset tool row binds that exact name through `provider` while keeping a unique `toolName`; the foreground/background scheduling choice does not constrain the number of supported instances. +The [named-instance decision](2026-08-18-product-subagent-named-instances.md) allows multiple rows for either product. Each additional host provider row has its own `providerName`, and each exposed preset tool row binds that exact name through `provider` while keeping a unique `toolName`; the foreground/background scheduling choice does not constrain the number of instances. The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile configuration and diagnostic production. diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md index 0ecd3ffef4..9685701cbe 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md @@ -14,7 +14,7 @@ Codex 与 Claude Code 提供方已经能够运行一项自包含任务并返回 生产 `dsh` 不安装可选产品提供方。选择启用产品集成的 Profile 会安装所需的 `dsh-subagent-codex` 或 `dsh-subagent-claude-code` 包,并在 host plane(宿主平面)挂载所需的提供方实例。`standard`、`code` 与 `cordis` Agent Preset 使用 `backgroundMode: one-shot` 配置相应的休眠工具行;删除某一行的 `disabled` 字段后,现有可选参数 `run_in_background` 会向由该 preset 组装的 agent 公开。省略该参数或传入 `false` 时会在前台等待;显式传入 `true` 时会在同步完成 Job 预检与登记后返回由父级拥有的 Job id,而不会等待提供方启动或完成。 -[命名实例决策](2026-08-18-product-subagent-named-instances.md)目前允许 Claude Code 拥有多个配置项,而 Codex 仍保留单一默认名称。每个新增 Claude Code 宿主提供方配置项都有独立的 `providerName`,每个公开的 preset 工具配置项都通过 `provider` 绑定该名称并保持唯一的 `toolName`;前台或后台调度选择不会限制已支持的实例数量。 +[命名实例决策](2026-08-18-product-subagent-named-instances.md)允许两个产品分别拥有多个配置项。每个新增宿主提供方配置项都有独立的 `providerName`,每个公开的 preset 工具配置项都通过 `provider` 绑定该名称并保持唯一的 `toolName`;前台或后台调度选择不会限制实例数量。 [通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责各产品提供方的 Profile 配置与诊断生产。 diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index 0c3972d6c4..9cd6d70109 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -147,7 +147,7 @@ Copy these disabled templates from a shipped full preset and remove `disabled` o maxDepth: provider-managed ``` -For additional named Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. The Codex provider still exposes only its default `codex` name here, so do not duplicate or retarget its shipped row. Do not reuse one tool row for several providers or derive either name from permission or environment settings. +For additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings. The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install or mount either optional provider: before enabling a row, the Profile must install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` package and mount the required provider instances on the host plane. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. The host must also provide `codex` or `claude` on `PATH`; the preset does not install, authenticate, select a model for, or probe either product. From 9b83852dccea45c4de8946cd84d9713c2c4db068 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 05:23:40 +0800 Subject: [PATCH 24/34] docs(subagent): align named instance evidence notes --- ...-08-10-product-subagent-providers-in-shared-host.i18n.yaml | 4 ++-- .../2026-08-10-product-subagent-providers-in-shared-host.md | 2 +- ...2026-08-10-product-subagent-providers-in-shared-host.zh.md | 2 +- ...26-08-04-claude-code-and-codex-subagent-backends.i18n.yaml | 4 ++-- .../2026-08-04-claude-code-and-codex-subagent-backends.md | 2 +- .../2026-08-04-claude-code-and-codex-subagent-backends.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml index b041b28db6..a5f5f28166 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.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 .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: b4ae9e93634df123e8eab463d6675421c2a85bc9 -2026-08-10-product-subagent-providers-in-shared-host.zh.md: dcca082e04087250608ddf85f72f0419c7d77769 +2026-08-10-product-subagent-providers-in-shared-host.md: 8bc08ddb57f07b76d3f90ce7c375e79c666ce86d +2026-08-10-product-subagent-providers-in-shared-host.zh.md: f3d2053c78f52deda5130eee908e7c2eff98b89a diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md index b4ae9e9363..8bc08ddb57 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md @@ -22,7 +22,7 @@ Only a Profile that selects the Claude Code provider carries the Claude Agent SD ## Verification -The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition explicitly mounts both optional providers and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove two named instances of each product register without starting a product process. Keyless ACP snapshots pin each product's two-tool roster and the final four-tool combination, while provider tests separately prove native executable resolution, configuration isolation, failure, cancellation, and process-tree quiescence. +The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition explicitly mounts both optional providers and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove two named instances of each product register without starting a product process. Keyless ACP snapshots pin the Codex two-tool roster and the final four-tool combination, while provider tests separately prove native executable resolution, configuration isolation, failure, cancellation, and process-tree quiescence. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md index dcca082e04..f3d2053c78 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md @@ -22,7 +22,7 @@ Status: implemented ## 验证 -base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装显式挂载两个可选提供方,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明每个产品的两个命名实例都会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定每个产品的双工具集合与最终四工具组合,提供方测试则另行证明原生可执行文件解析、配置隔离、失败、取消和进程树完全停稳。 +base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装显式挂载两个可选提供方,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明每个产品的两个命名实例都会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定 Codex 双工具集合与最终四工具组合,提供方测试则另行证明原生可执行文件解析、配置隔离、失败、取消和进程树完全停稳。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 3efcd1588d..e5bf1cc2eb 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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 .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 547eebd931d90bc373cd6a0798347744078bd1d6 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 7519fa6952fdca5cccb9031a31b552c6ab665929 +2026-08-04-claude-code-and-codex-subagent-backends.md: b478a97d78cc7aaa9dad452bc5cd4cbb5fdf361e +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 761fe5df5a87a2c87af2e8a8dd6cb593af0d5ba2 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 547eebd931..b478a97d78 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -60,7 +60,7 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract ## Distribution and evidence -Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped explicit Profile configuration, verifies two named instances of each product expose four independent one-shot tools alongside generic Job controls, and starts neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. +Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Codex Loader fixture exposes two named Codex instances and tools; the Claude Code Loader fixture exposes the default Codex tool plus two named Claude Code instances and tools. Both fixtures include generic Job controls and start neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, thread-level `never` overriding ambient `on-request`, automatic-review startup, unattended command rejection with safe diagnostic and no file side effect, explicit dangerous-bypass writing in suite-owned temporary storage, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 7519fa6952..761fe5df5a 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -60,7 +60,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 ## 分发与证据 -每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示的显式 Profile 配置,验证两个产品各自的两个命名实例会和通用 Job 控制工具一起公开四个彼此独立的一次性工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 +每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Codex Loader fixture 会公开两个命名 Codex 实例与工具;Claude Code Loader fixture 会公开默认 Codex 工具以及两个命名 Claude Code 实例与工具。两个 fixture 都包含通用 Job 控制工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、带安全诊断且不产生文件副作用的无人值守命令拒绝、测试拥有临时存储中的显式危险绕过写入、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 From bf4cb507f1e42c9f48f1d06dbd3a821d04612bc3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 11:25:39 +0800 Subject: [PATCH 25/34] fix(locale): track the active locale in The document language attribute was a static value in the served markup, so it reported zh-CN for an English UI and would have reported en for a Chinese one once the resolved default changed. Set it from the active locale at plugin activation and on every switch, carrying a BCP 47 tag (zh-CN / en). Drop the now-unused dsh-client-test-runtime devDependency from ui-settings-general: removing its dead browser-language pin left the package with no remaining use of it, which knip reports as an error. --- ...1-browser-derived-initial-locale.i18n.yaml | 4 +-- ...26-07-31-browser-derived-initial-locale.md | 4 +++ ...07-31-browser-derived-initial-locale.zh.md | 4 +++ packages/client/locale/src/client/index.ts | 27 +++++++++++++++++++ .../tests/document-language.client.spec.ts | 5 ++-- .../client/ui-settings-general/package.json | 1 - pnpm-lock.yaml | 3 --- 7 files changed, 40 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml index c1ac4af1e9..f1168d973c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.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 .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md -2026-07-31-browser-derived-initial-locale.md: 94f32b136f20c7ab7fb8241a0ac9adf6249a4380 -2026-07-31-browser-derived-initial-locale.zh.md: 8d879b9b11ad42feed9ffd2ec3e5a16d1dcd9b8c +2026-07-31-browser-derived-initial-locale.md: 6fcd799b9c3e6ec898725e0ef72106b63f613bee +2026-07-31-browser-derived-initial-locale.zh.md: 73f2c825e11bb0380fe172b4b4522295d419e9a5 diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md index 94f32b136f..6fcd799b9c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md @@ -22,6 +22,8 @@ Reading the browser fixed the readers whose browser names a language this app sh **An explicit choice is durable.** `setLocale` writes through the Host settings API, so a user who picked a language keeps it across browser origins and system languages that share the same DSH home. Nothing writes the detected locale back: detection is re-derived every boot and stays invisible to the “has the user chosen?” question. +**`` follows the resolved locale, and the served markup cannot.** `apps/web/index.html` is one static file serving every visitor, so whatever it declares is wrong for somebody: resolution happens in the client, after the document is parsed. The locale plugin therefore sets `document.documentElement.lang` from the active locale — once at activation, because detection or an adopted Host preference may already disagree with the markup, and again on every switch. The markup declares the product default (`en`) so the pre-boot document is not actively misleading. Assistive technology and browser features (pronunciation rules, translation offers, font fallback, spell check) read this attribute, so a stale value misreports the document language rather than merely looking untidy. The attribute carries a BCP 47 tag rather than the app's locale id: `zh` alone leaves the script ambiguous, so the shipped Chinese copy declares `zh-CN`. + **The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale twice: an `en-US` browser and an `fr-FR` one both reach an English surface. The `fr-FR` scenario is the one that pins the fallback — an `en-US` browser would land on English under detection or fallback alike, so only an unshipped language distinguishes them, and the zh scenarios prove detection still overrides the fallback. ## Alternatives considered @@ -33,10 +35,12 @@ Reading the browser fixed the readers whose browser names a language this app sh - **Two constants, one for the opening locale and one for the dictionary fallback**: it separates two genuinely different questions, and would be required if the answers differed. They do not: the dictionaries are symmetric, so both are `en`, and a second constant would be two names for one value plus a rule nothing enforces. The symmetry itself is worth enforcing, so it is gated directly instead. - **Keeping `zh` as the dictionary fallback while opening in `en`**: it reads as the conservative choice, but with symmetric dictionaries it never resolves a key that `en` would not, so it buys nothing; and where it would matter — a key present only in `zh` — rendering Chinese text inside an otherwise English UI is worse than the bare key a reviewer would notice. - **Keeping the e2e lane's zh scenarios on storage pinning (`dsh.locale=zh`)**: it would keep the suite green while removing the only place the browser-derived path runs in an assembled app; pinning the browser language instead exercises the new resolution end to end. +- **Serving `` per request, or leaving the static attribute alone**: computing it server-side would need the request's `Accept-Language` to re-derive what the client resolves anyway, duplicating the rule in two places and still losing to a stored preference the server does not read. Leaving it static is what made the attribute permanently wrong for one language or the other. Setting it from the resolved locale keeps one source of truth. ## Consequences - A first visit from an English browser lands in English, a Chinese browser in Chinese, and a browser naming neither lands in English rather than Chinese. The Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction. - Dictionary resolution reverses direction: a key missing from the active locale now falls to `en`, not `zh`. With symmetric dictionaries no shipped key changes behavior, which is why the parity gate exists — it is the assumption that reversal rests on. +- `` now reports the language on screen in both directions, which closes [#2160](https://github.com/deepseek-harness/deepseek-harness/issues/2160). A client that never activates the locale plugin keeps the served default, so the attribute degrades to the old static behavior rather than to a blank value. - Non-browser runs of the client tree (node boots, the non-jsdom unit lane) now open in `en`. Specs that assert shipped Chinese copy must set `setLocale('zh')` explicitly on the runtime they construct; a suite-level `usePinnedBrowserLanguages('zh-CN')` only works in files that also declare `@vitest-environment jsdom`, because without a `window` the detection path never reads `navigator` at all. Seven `*.client.spec.ts` files carried such a dead pin and were relying on the old `zh` fallback instead. - Detection cost is one array walk per service construction and no implicit settings write; an explicit Host preference may cause one live convergence after plugin activation. diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md index 8d879b9b11..73f2c825e1 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md @@ -22,6 +22,8 @@ Status: implemented **显式选择具有持久性。** `setLocale` 通过 Host settings API 写入,因此选过语言的用户可在共享同一 DSH home 的不同浏览器 origin 与系统语言之间保留原选择。没有任何代码把探测到的 locale 写回:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。 +**`` 跟随解析出的 locale,而所服务的 markup 做不到这一点。** `apps/web/index.html` 是一份静态文件,服务所有访问者,因此它声明什么都必然对某些人是错的:解析发生在客户端,在文档被解析之后。于是由 locale 插件依据当前 locale 设置 `document.documentElement.lang`——激活时设置一次,因为探测结果或已采纳的 Host 偏好可能已与 markup 不一致;此后每次切换再设置一次。markup 声明产品默认值(`en`),使启动前的文档不至于主动误导。无障碍技术与浏览器功能(发音规则、翻译提示、字体回退、拼写检查)都读取该属性,因此陈旧的值是在误报文档语言,而不只是看起来不整齐。该属性承载 BCP 47 标签而非应用内部的 locale id:单独的 `zh` 会使文字(script)含义不明,因此已提供的中文文案声明 `zh-CN`。 + **浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 两次使用没有显式 locale 的全新 Host home:`en-US` 浏览器与 `fr-FR` 浏览器都会抵达英文界面。真正钉住回落值的是 `fr-FR` 那个场景——`en-US` 浏览器无论走探测还是走回落都会落在英文,因此只有本应用不提供的语言才能区分二者,而中文场景则证明探测仍然覆盖回落值。 ## Alternatives considered @@ -33,10 +35,12 @@ Status: implemented - **拆成两个常量,一个管开场 locale、一个管字典回落**:它区分了两个确实不同的问题,若两个答案不同也确有必要。但它们并不不同:字典是对称的,因此两者都是 `en`,第二个常量只会是同一个值的两个名字,外加一条无人强制的规则。对称性本身值得强制,所以直接为它设门禁。 - **开场用 `en`、字典回落仍保留 `zh`**:这看起来是保守选择,但在字典对称的前提下,它能解析的 key 与 `en` 完全相同,因此毫无收益;而在它真正会起作用的情形——某个 key 只存在于 `zh`——在整体英文的界面里渲染出中文文本,比让 reviewer 一眼看见裸 key 更糟。 - **让 e2e 车道的中文场景继续钉存储项(`dsh.locale=zh`)**:那会让套件保持绿色,却抹掉浏览器推导路径在组装后应用中唯一的运行处;改钉浏览器语言才能端到端地演练新的解析过程。 +- **按请求服务 ``,或干脆不管这个静态属性**:在服务端计算它需要用请求的 `Accept-Language` 去重新推导客户端本就会解析的结果,使同一条规则在两处重复,而且仍会输给服务端并不读取的存储偏好。放任其保持静态,正是该属性对某一种语言永远错误的原因。依据解析出的 locale 来设置,可保持单一真源。 ## Consequences - 来自英文浏览器的首访落在英文界面,中文浏览器落在中文界面,而两者皆未声明的浏览器落在英文而非中文界面。语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。 - 字典解析方向发生反转:当前 locale 缺失的 key 现在回落到 `en` 而非 `zh`。在字典对称的前提下,没有任何已提供的 key 行为发生变化——这正是那道对称性门禁存在的原因:它是这次反转所依赖的前提。 +- `` 现在在两个方向上都如实报告屏幕上的语言,这也关闭了 [#2160](https://github.com/deepseek-harness/deepseek-harness/issues/2160)。若某个客户端从未激活 locale 插件,则保留所服务的默认值,因此该属性退化为旧的静态行为,而不会退化为空值。 - 客户端树的非浏览器运行(node 启动、非 jsdom 单测车道)现在以 `en` 开场。断言已提供中文文案的用例必须在其构造的 runtime 上显式调用 `setLocale('zh')`;套件级的 `usePinnedBrowserLanguages('zh-CN')` 仅在同时声明了 `@vitest-environment jsdom` 的文件中生效,因为没有 `window` 时探测路径根本不会读取 `navigator`。此前有七个 `*.client.spec.ts` 文件带着这样一条失效的固定语句,实际依赖的是旧的 `zh` 回落值。 - 探测的代价是每次服务构造遍历一次数组,且不会隐式写入 settings;插件激活后,显式 Host 偏好可能引发一次实时收敛。 diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index abea65ac9e..ab84f054ad 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -107,6 +107,28 @@ const LOCALES: readonly LocaleDefinition[] = Object.freeze([ { id: 'en', label: 'English' }, ]) +/** + * `` tag per shipped locale. The locale id is the app's own + * vocabulary (primary subtag); the document attribute wants a BCP 47 tag, + * which assistive technology and browser features (pronunciation rules, + * translation offers, font fallback, spell check) read to pick their own + * behavior. `zh` alone leaves the script ambiguous, so the shipped Chinese + * copy names the variant it actually is. + */ +const DOCUMENT_LANGUAGE: Record = { zh: 'zh-CN', en: 'en' } + +/** + * Point `` at the active locale. Called on every locale change, + * so the attribute tracks the UI instead of standing at whatever the served + * markup happened to declare. + * @param active - the active locale id. + */ +function syncDocumentLanguage(active: LocaleId): void { + // Non-browser runs (node boots of the client tree) have no document. + if (typeof document === 'undefined') return + document.documentElement.lang = DOCUMENT_LANGUAGE[active] +} + /** * Dictionary registry plus locale preference. Lookup chain per key: the * entry's namespace in the active locale -> that namespace's en fallback -> @@ -371,6 +393,7 @@ export function apply(ctx: ClientContext): void { const store = createLanguageRowStore() let bound: BoundActions | undefined const sync = (snapshot: LocaleSnapshot): void => { + syncDocumentLanguage(snapshot.active) bound?.sync( snapshot.active, snapshot.locales.map(l => ({ id: l.id, label: l.label })), @@ -378,6 +401,10 @@ export function apply(ctx: ClientContext): void { ) } ctx.on('locale/change', sync) + // The served markup declares one language; the resolved locale may differ + // (browser detection, or a stored preference adopted after activation), so + // state it once at activation rather than waiting for the first change. + syncDocumentLanguage(locale.getLocale().active) const injected = (actions: BoundActions): LanguageRowInjected => { bound = actions // Re-sync from the getter so no event is lost between registration and diff --git a/packages/client/locale/tests/document-language.client.spec.ts b/packages/client/locale/tests/document-language.client.spec.ts index 891e22e5f5..b10a3e8e69 100644 --- a/packages/client/locale/tests/document-language.client.spec.ts +++ b/packages/client/locale/tests/document-language.client.spec.ts @@ -11,7 +11,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' +import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' +import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-locale/client' import type { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' @@ -43,7 +44,7 @@ async function bench(preference?: string) { ctx.provide('connection', { api: { settings: { describe: describeRpc, mutate } }, isLoopback: true } as never) // The settings transport and the forwarded-event port the plugin injects. new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await() await ctx.plugin({ inject: [...inject], apply }).await() return { ctx, locale: ctx.get('locale') as LocaleRuntime } } diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 59a7694633..14e826630e 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -67,7 +67,6 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce67604757..154a76c92f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2490,9 +2490,6 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives From 9de06952ab68ccd4fd6e0ae6f04b57cc307e278a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 12:18:03 +0800 Subject: [PATCH 26/34] fix(locale): persist an explicit pick of the provisional locale setLocale returned early when the id already matched the active locale, so choosing the language already on screen wrote nothing. That value may be a provisional browser-derived or fallback resolution nothing has stored, so a different browser sharing the DSH home still resolved on its own. Write unconditionally; keep the render publish conditional. Broaden the dictionary parity gate to every workspace package, pair zh/en across sibling files and inline registrations, and fail when a dictionary has no counterpart. It previously scanned only packages/client and packages/ extensions, compared within a single module, and silently skipped unpaired dictionaries -- so the split locales/zh.ts + en.ts common pair, the inline directory-picker-browse dictionary, and session-log-export were unchecked. Normalize paths at ingestion so the sweep does not narrow on Windows. Regenerate the client API catalog and update the locale README pair: both described the old zh fallback direction. Add the English fallback dialog golden, and drop a dead afterEach plus the blank lines left where the dead browser-language pins were removed. --- apps/web/tests/settings-chrome.e2e.ts | 9 +- .../settings-chrome/dialog-en.expected.md | 45 ++++ packages/client/locale/README.i18n.yaml | 4 +- packages/client/locale/README.md | 2 +- packages/client/locale/README.zh.md | 2 +- packages/client/locale/src/client/index.ts | 11 +- .../client/locale/tests/apply.client.spec.ts | 6 +- .../client/locale/tests/locale.client.spec.ts | 22 +- .../tests/apply.client.spec.ts | 1 - .../tests/apply.client.spec.ts | 1 - .../tests/apply.client.spec.ts | 1 - .../tests/apply.client.spec.ts | 1 - .../tests/apply.client.spec.ts | 2 - .../ui-theme/tests/apply.client.spec.ts | 1 - .../ui-workspace/tests/apply.client.spec.ts | 1 - .../src/client/api-catalog.ts | 2 +- scripts/locale-dictionary-parity.spec.ts | 243 +++++++++++++----- 17 files changed, 265 insertions(+), 89 deletions(-) create mode 100644 apps/web/tests/snapshots/settings-chrome/dialog-en.expected.md diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index f3cf4b3bbe..216dae4dbb 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -24,6 +24,8 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url)) const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md') const PLUGINS_EXPECTED = join(SNAPSHOT_DIR, 'plugins.expected.md') +// The English fallback surface: a browser naming no shipped language. +const DIALOG_EN_EXPECTED = join(SNAPSHOT_DIR, 'dialog-en.expected.md') const PLUGIN_ROW_SELECTOR = '[data-plugin-entry$="ui-settings"]' const MODE = webSnapshotMode() @@ -496,6 +498,11 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = frPage.getByRole('dialog', { name: 'Settings' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) + // Golden of the English fallback dialog — the visible output this change + // produces. The zh golden above covers the detected-locale surface, so + // the pair pins both directions of the resolution. + const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd) + await compareOrRefreshGolden(DIALOG_EN_EXPECTED, snapshot, MODE) expect(frTripwire.pageErrors).toEqual([]) expect(frTripwire.warnings).toEqual([]) } finally { @@ -506,6 +513,6 @@ describe('web e2e: settings modal and General preferences', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md', 'plugins.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['dialog-en.expected.md', 'dialog.expected.md', 'plugins.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/settings-chrome/dialog-en.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog-en.expected.md new file mode 100644 index 0000000000..605e2fe328 --- /dev/null +++ b/apps/web/tests/snapshots/settings-chrome/dialog-en.expected.md @@ -0,0 +1,45 @@ +- dialog "Settings": + - navigation: + - text: Settings + - button "General": + - img + - text: General + - button "Models": + - img + - text: Models + - button "Plugins": + - img + - text: Plugins + - button "Agent presets": + - img + - text: Agent presets + - button "Open configuration file" + - button "Close": + - img + - text: Close + - text: Agent preset Applies to sessions you start from now on. Running sessions keep the preset they began with. + - button "Standard mode": + - text: Standard mode + - img + - text: Permission Choose the default permission mode for new sessions + - button "Workspace Write": + - text: Workspace Write + - img + - text: Language + - button "English": + - text: English + - img + - text: Appearance + - button "Light": + - img + - text: Light + - button "Dark": + - img + - text: Dark + - button "System" [pressed]: + - img + - text: System + - text: Enter behavior while busy Busy only; Cmd/Ctrl+Enter uses the other behavior + - button "Queue": + - text: Queue + - img diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 126c4ee685..e1cc2a1c88 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/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/locale/README.md -README.md: a63807f093dc12831a41196e61008151868e3205 -README.zh.md: b302e6055ba30d2db0948c0a6f1551c4a9dcb24c +README.md: 3fb5cce334e59b36c30f22a863f8e91d260f2ac9 +README.zh.md: 4f08344d6f030e408e570ff0ad31d0b0d4de3ecc diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index a63807f093..3fb5cce334 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `zh` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → zh → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. +Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches, and the plugin points `` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. ## Model Experience diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index b302e6055b..4f08344d6f 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `zh`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → zh → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 +locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `` 指向当前 locale(`zh-CN`/`en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 ## 模型体验 diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index ab84f054ad..3f14acf216 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -197,13 +197,20 @@ export class LocaleRuntime { /** * Switch the active locale — the only user preference write entry. + * + * The durable write happens even when the id already matches the active + * locale, because the active value may be a provisional browser-derived or + * fallback resolution that nothing has stored yet. Picking the language + * already on screen is still an explicit choice, and it must survive a + * different browser sharing the same DSH home. Only the render notification + * is conditional: republishing an unchanged locale would churn every + * subscriber for nothing. * @param id - a registered locale id; unknown ids throw. */ setLocale(id: string): void { const match = this.snapshot.locales.find(l => l.id === id) if (match === undefined) throw new Error(`locale "${id}" is not registered`) - if (this.snapshot.active === match.id) return - this.publish(match.id, true) + if (this.snapshot.active !== match.id) this.publish(match.id, true) void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id) } diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index a5c70ed624..4f4d08a951 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -2,7 +2,7 @@ * Language row registration, snapshot projection into the row store, and * recovery after an HMR collapse of the declaring entry. */ import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts' @@ -78,10 +78,6 @@ describe('locale apply', () => { // localized copy sets its locale explicitly via setLocale/Host preference // rather than leaning on FALLBACK_LOCALE. This file has no jsdom environment, // so there is no `window` and no browser-language detection to stub. - afterEach(() => { - vi.unstubAllGlobals() - }) - it('declares the slot service', () => { expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope']) }) diff --git a/packages/client/locale/tests/locale.client.spec.ts b/packages/client/locale/tests/locale.client.spec.ts index eb279f1295..a945ccd07e 100644 --- a/packages/client/locale/tests/locale.client.spec.ts +++ b/packages/client/locale/tests/locale.client.spec.ts @@ -138,7 +138,7 @@ describe('LocaleRuntime', () => { expect(svc.getSnapshot().revision).toBe(before + 1) }) - it('setLocale writes through the scope, republishes an immutable snapshot, and no-ops on same value', () => { + it('setLocale writes through the scope and republishes only on a real change', () => { const host = stubSettingsScope() const { svc, events } = make(host) svc.setLocale('en') @@ -147,9 +147,27 @@ describe('LocaleRuntime', () => { expect(events).toHaveLength(1) expect(events[0]).toBe(svc.getLocale()) expect(events[0]!.revision).toBe(1) + // Re-selecting the active locale publishes nothing (no subscriber churn) + // but still writes: the active value may be a provisional browser-derived + // resolution nothing has stored, and picking it is an explicit choice that + // must outlive this browser. svc.setLocale('en') expect(events).toHaveLength(1) - expect(host.set).toHaveBeenCalledOnce() + expect(host.set).toHaveBeenCalledTimes(2) + expect(host.set).toHaveBeenLastCalledWith('preference', 'en') + }) + + it('persists an explicit pick of the provisional locale, so a shared DSH home agrees', () => { + // A browser naming no shipped language opens at FALLBACK_LOCALE with + // nothing stored. Choosing that same language in the menu must become + // durable, or a Chinese browser sharing the home still opens Chinese. + stubLanguages('fr-FR') + const host = stubSettingsScope() + const { svc } = make(host) + expect(svc.getLocale().active).toBe('en') + expect(host.set).not.toHaveBeenCalled() + svc.setLocale('en') + expect(host.set).toHaveBeenCalledWith('preference', 'en') }) it('setLocale without a host scope stays process-local', () => { 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 7d998c7e11..e0d5c69e4e 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -21,7 +21,6 @@ import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSectio import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx' - const ROSTER_ONE = { rpcId: 'r', result: { diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index f5d04fcfd6..e66fb73262 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -12,7 +12,6 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-input-trigger/client' - const sid = (k: string): SessionId => k as SessionId async function bench() { 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 79d597f9bc..f8c7407e5a 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -10,7 +10,6 @@ import { GeneralSection } from '../src/client/GeneralSection.tsx' import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx' - /** The seats this plugin fills for a loopback browser (slot name → expected component). */ const SEATS = [ ['settings.trigger', TriggerContent], 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 d73950a6f7..385f726d36 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -11,7 +11,6 @@ import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' - async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() 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 d8022887fa..c3987e38a5 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -13,7 +13,6 @@ import type { ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' - /** * @param served - namespaces the Host describes; omitted answers a failed read, * which is what most of these specs want (no card has anything to render). @@ -87,7 +86,6 @@ describe('ui-settings-plugins apply', () => { expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' }) }) - it('injects a live tab projection, the card directory, and one business face per card', async () => { const { ctx, slots } = await bench() declareRoot(slots) diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index 0ac3dc32b8..fa20e0dd3b 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -14,7 +14,6 @@ import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-sett import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { createAppearanceRowStore } from '../src/client/settings-store.ts' - const SLOT = 'settings.general.item' function deferred() { diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 4a819c8587..abba4371c2 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -7,7 +7,6 @@ import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepsee import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' - async function bench() { const ctx = new Context() await ctx.plugin(SlotRegistry).await() diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 58bbbdee3f..c6539aa46a 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -106,7 +106,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'locale', summary: 'Dictionary registry plus locale preference.', - description: 'Dictionary registry plus locale preference. Lookup chain per key: the entry\'s namespace in the active locale -> that namespace\'s zh fallback -> the shared common namespace (active, then zh) -> the key itself (missing text stays visible, fail loud in the UI rather than blank). Reads go through getLocale; writes only through setLocale; continuous sync through the `locale/change` event, or through the LocaleFace getSnapshot/subscribe pair the render machinery consumes (installed via `ctx.slots.installLocale`).', + description: 'Dictionary registry plus locale preference. Lookup chain per key: the entry\'s namespace in the active locale -> that namespace\'s en fallback -> the shared common namespace (active, then en) -> the key itself (missing text stays visible, fail loud in the UI rather than blank). Reads go through getLocale; writes only through setLocale; continuous sync through the `locale/change` event, or through the LocaleFace getSnapshot/subscribe pair the render machinery consumes (installed via `ctx.slots.installLocale`).', methods: [ { signature: 'getLocale(): LocaleSnapshot', diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts index d48fc3e808..ea40919fd9 100644 --- a/scripts/locale-dictionary-parity.spec.ts +++ b/scripts/locale-dictionary-parity.spec.ts @@ -9,83 +9,144 @@ * only one side breaks that: a reader of the other language sees a bare key * such as `list.aria` instead of text. This gate fails on the asymmetry rather * than waiting for the bare key to reach a UI. + * + * Discovery is deliberately broad, because a gate that silently narrows is + * worse than no gate. It sweeps every workspace package (not just + * `packages/client`), reads dictionaries wherever they are declared — + * `locales.ts`, a `locales/` directory, or inline in the plugin body — and + * pairs `zh`/`en` across sibling files as well as within one module. A `zh` + * dictionary whose `en` counterpart cannot be found anywhere is an error, not + * a skip. */ import type { Dirent } from 'node:fs' -import { readdirSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import ts from 'typescript' import { describe, expect, it } from 'vitest' const root = fileURLToPath(new URL('..', import.meta.url)) -/** Every `locales*.ts` module under a client package's `src/`. */ -function dictionaryModules(): string[] { +/** Repo-relative path with `/` separators, so messages and suffix tests match on every OS. */ +function relative(file: string): string { + return file.slice(root.length).replaceAll('\\', '/') +} + +/** Every `.ts` source file under each workspace package's `src`, excluding declarations. */ +function sourceFiles(): string[] { const files: string[] = [] - for (const group of ['client', 'extensions']) { - const groupRoot = resolve(root, 'packages', group) - let packages: string[] - try { - packages = readdirSync(groupRoot, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .map(entry => entry.name) - } catch { - continue - } - for (const pkg of packages) { - const srcRoot = resolve(groupRoot, pkg, 'src') - walk(srcRoot, files) + const packagesRoot = resolve(root, 'packages') + for (const group of directories(packagesRoot)) { + for (const pkg of directories(resolve(packagesRoot, group))) { + walk(resolve(packagesRoot, group, pkg, 'src'), files) } } return files.sort() } -function walk(dir: string, out: string[]): void { +/** Immediate subdirectory names, or none when the path is not a directory. */ +function directories(dir: string): string[] { + if (!existsSync(dir)) return [] let entries: Dirent[] try { entries = readdirSync(dir, { withFileTypes: true }) } catch { + // Swallows only the race between existsSync and readdirSync (a package + // directory removed mid-sweep); readdirSync is the sole statement in the + // try, so no other failure can reach here. + return [] + } + return entries.filter(entry => entry.isDirectory()).map(entry => entry.name) +} + +function walk(dir: string, out: string[]): void { + if (!existsSync(dir)) return + let entries: Dirent[] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + // Same narrow race as `directories`: readdirSync is the only statement + // guarded, so this cannot mask a parse or assertion failure. return } for (const entry of entries) { const full = resolve(dir, entry.name) - if (entry.isDirectory()) { - walk(full, out) - } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { - if (/^locales?(\.[\w-]+)?\.ts$/.test(entry.name) || dir.endsWith('/locales')) out.push(full) - } + if (entry.isDirectory()) walk(full, out) + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) out.push(full) } } +/** One discovered dictionary: which file and export name declared it. */ +interface Dictionary { + /** Repo-relative declaring file. */ + file: string + /** Export name, or the registration site for an inline literal. */ + name: string + /** Declared keys, sorted. */ + keys: string[] +} + /** - * Keys of every top-level `export const ...= { ... }` object literal, - * read from the AST so the gate never executes package code. - * @param file - absolute path of the dictionary module. - * @returns exported dictionary name mapped to its declared keys. + * Keys of every top-level `export const = { ... }` object literal whose + * name identifies a locale dictionary, plus inline `register(ns, locale, {...})` + * literals. Read from the AST so the gate never executes package code. + * @param file - absolute path of a candidate module. + * @returns discovered dictionaries, keyed by locale-bearing name. */ -function exportedDictionaries(file: string): Map { - const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.ESNext, true) - const found = new Map() +function dictionariesIn(file: string): Dictionary[] { + const text = readFileSync(file, 'utf8') + // Cheap pre-filter: parsing every package source is wasteful, and a file + // with no locale token cannot declare a dictionary under any shape below. + if (!/\b(zh|en)\b/.test(text)) return [] + const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true) + const found: Dictionary[] = [] + const rel = relative(file) + for (const statement of source.statements) { if (!ts.isVariableStatement(statement)) continue - const exported = statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) === true - if (!exported) continue + if (statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) !== true) continue for (const decl of statement.declarationList.declarations) { if (!ts.isIdentifier(decl.name)) continue - const initializer = unwrap(decl.initializer) - if (initializer === undefined || !ts.isObjectLiteralExpression(initializer)) continue - const keys: string[] = [] - for (const prop of initializer.properties) { - if (!ts.isPropertyAssignment(prop)) continue - if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text) - } - found.set(decl.name.text, keys.sort()) + const literal = unwrap(decl.initializer) + if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue + if (localeOf(decl.name.text) === undefined) continue + found.push({ file: rel, name: decl.name.text, keys: keysOf(literal) }) } } + + // Inline registrations: a `[['zh', {...}], ['en', {...}]]` pair handed to a + // registration loop in the plugin body. Both halves key off the enclosing + // array's line so they pair with each other and not across sites. + const visit = (node: ts.Node): void => { + if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) { + const site = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1 + for (const element of node.elements) { + if (!ts.isArrayLiteralExpression(element) || element.elements.length !== 2) continue + const [tag, dict] = element.elements + const literal = unwrap(dict) + if (tag === undefined || !ts.isStringLiteral(tag)) continue + if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue + if (tag.text !== 'zh' && tag.text !== 'en') continue + found.push({ file: rel, name: `${tag.text}@inline:${site}`, keys: keysOf(literal) }) + } + } + ts.forEachChild(node, visit) + } + visit(source) return found } +/** Declared property names of an object literal, sorted. */ +function keysOf(literal: ts.ObjectLiteralExpression): string[] { + const keys: string[] = [] + for (const prop of literal.properties) { + if (!ts.isPropertyAssignment(prop)) continue + if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text) + } + return keys.sort() +} + /** Look through `satisfies`/`as`/parenthesized wrappers to the literal. */ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined { let current = node @@ -98,40 +159,90 @@ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined { return current } -/** Pair a `zh` export with the `en` export covering the same namespace. */ -function counterpart(name: string): string | undefined { - if (name === 'zh') return 'en' - if (name.startsWith('zh') && name.length > 2) return `en${name.slice(2)}` - if (name.endsWith('Zh')) return `${name.slice(0, -2)}En` +/** + * The locale a dictionary name declares, and the namespace-ish remainder that + * identifies which pair it belongs to. `zh`/`en`, `zhSettings`/`enSettings`, + * and `settingsZh`/`settingsEn` are the shapes this repo uses. + * @param name - export name or synthetic inline name. + * @returns locale plus pair key, or undefined when the name names no locale. + */ +function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefined { + for (const locale of ['zh', 'en'] as const) { + const other = locale === 'zh' ? 'Zh' : 'En' + if (name === locale) return { locale, pair: '' } + if (name.startsWith(`${locale}@inline:`)) return { locale, pair: name.slice(name.indexOf(':')) } + if (name.startsWith(locale) && name.length > 2 && name[2] === name[2]?.toUpperCase()) { + return { locale, pair: name.slice(2) } + } + if (name.endsWith(other)) return { locale, pair: name.slice(0, -2) } + } return undefined } describe('shipped locale dictionaries', () => { it('declares the same keys in zh and en, so the single fallback locale always resolves', () => { - const modules = dictionaryModules() - // Guard the discovery itself: an empty sweep would pass every assertion - // below while checking nothing. - expect(modules.length).toBeGreaterThan(20) + const files = sourceFiles() + // Guard the discovery itself: an empty or narrowed sweep would pass every + // assertion below while checking nothing. + expect(files.length).toBeGreaterThan(500) - const mismatches: string[] = [] - let comparedPairs = 0 - for (const file of modules) { - const dicts = exportedDictionaries(file) - for (const [name, zhKeys] of dicts) { - const enName = counterpart(name) - if (enName === undefined) continue - const enKeys = dicts.get(enName) - if (enKeys === undefined) continue - comparedPairs++ - const rel = file.slice(root.length) - const zhOnly = zhKeys.filter(key => !enKeys.includes(key)) - const enOnly = enKeys.filter(key => !zhKeys.includes(key)) - if (zhOnly.length > 0) mismatches.push(`${rel} ${name} has keys absent from ${enName}: ${zhOnly.join(', ')}`) - if (enOnly.length > 0) mismatches.push(`${rel} ${enName} has keys absent from ${name}: ${enOnly.join(', ')}`) + // Pair within a file first; a dictionary whose counterpart is not in the + // same module then pairs with a sibling in the same directory. Both shapes + // ship here: `locales/settings.ts` exports zh+en together, while + // `locales/zh.ts` + `locales/en.ts` split the common pair across files. + const perFile = new Map() + for (const file of files) { + const dicts = dictionariesIn(file) + if (dicts.length > 0) perFile.set(relative(file), dicts) + } + + const groups = new Map>() + const place = (key: string, locale: 'zh' | 'en', dict: Dictionary): void => { + const slot = groups.get(key) ?? new Map<'zh' | 'en', Dictionary>() + if (slot.has(locale)) { + throw new Error(`two ${locale} dictionaries claim pair ${key}: ${slot.get(locale)?.file} and ${dict.file}`) + } + slot.set(locale, dict) + groups.set(key, slot) + } + + for (const [rel, dicts] of perFile) { + for (const dict of dicts) { + const parsed = localeOf(dict.name) + if (parsed === undefined) continue + const sameFileCounterpart = dicts.some((other) => { + const otherParsed = localeOf(other.name) + return otherParsed !== undefined + && otherParsed.pair === parsed.pair + && otherParsed.locale !== parsed.locale + }) + // Same-file pairs key by file so two pairs in one directory stay + // distinct; split pairs key by directory so siblings meet. + const key = sameFileCounterpart ? `${rel}::${parsed.pair}` : `${dirname(rel)}::${parsed.pair}` + place(key, parsed.locale, dict) } } - expect(comparedPairs).toBeGreaterThan(20) - expect(mismatches).toEqual([]) + const problems: string[] = [] + let comparedPairs = 0 + for (const [key, slot] of [...groups].sort()) { + const zh = slot.get('zh') + const en = slot.get('en') + if (zh === undefined || en === undefined) { + const present = zh ?? en + problems.push(`${present?.file} declares ${present?.name} with no counterpart for pair ${key}`) + continue + } + comparedPairs++ + const zhOnly = zh.keys.filter(k => !en.keys.includes(k)) + const enOnly = en.keys.filter(k => !zh.keys.includes(k)) + if (zhOnly.length > 0) problems.push(`${zh.file} ${zh.name} has keys absent from ${en.name}: ${zhOnly.join(', ')}`) + if (enOnly.length > 0) problems.push(`${en.file} ${en.name} has keys absent from ${zh.name}: ${enOnly.join(', ')}`) + } + + // The shipped dictionary count only grows; a collapse means discovery or + // pairing broke, which would hide real asymmetry. + expect(comparedPairs).toBeGreaterThan(25) + expect(problems).toEqual([]) }) }) From 6e9b2560a334f4df9d96d46e6bc14d385f0ad602 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 12:25:09 +0800 Subject: [PATCH 27/34] fix(locale): keep the test-runtime devDependency and narrow the parity gate catch Restore @deepseek-ai/dsh-client-test-runtime in ui-settings-general: the package still imports bindSnapshotSelector from it in tests/components.client.spec.tsx, so removing it was manifest drift. The earlier knip report predated that file arriving on this branch. Swallow only ENOENT when reading a directory in the parity gate. A broad catch treated EACCES or an I/O failure as "absent", which would narrow the sweep and let the gate pass while checking less. --- .../client/ui-settings-general/package.json | 1 + pnpm-lock.yaml | 3 ++ scripts/locale-dictionary-parity.spec.ts | 37 +++++++++---------- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 14e826630e..59a7694633 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -67,6 +67,7 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 154a76c92f..ce67604757 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2490,6 +2490,9 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts index ea40919fd9..5284801ae4 100644 --- a/scripts/locale-dictionary-parity.spec.ts +++ b/scripts/locale-dictionary-parity.spec.ts @@ -20,7 +20,7 @@ */ import type { Dirent } from 'node:fs' -import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { readdirSync, readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import ts from 'typescript' @@ -47,30 +47,27 @@ function sourceFiles(): string[] { /** Immediate subdirectory names, or none when the path is not a directory. */ function directories(dir: string): string[] { - if (!existsSync(dir)) return [] - let entries: Dirent[] + return readEntries(dir).filter(entry => entry.isDirectory()).map(entry => entry.name) +} + +/** + * Directory entries, treating only a genuinely absent directory as empty. + * Any other failure (`EACCES`, I/O) rethrows: silently reading it as "absent" + * would narrow the sweep and let the gate pass while checking less. + * @param dir - absolute directory path. + * @returns entries, or none when the directory does not exist. + */ +function readEntries(dir: string): Dirent[] { try { - entries = readdirSync(dir, { withFileTypes: true }) - } catch { - // Swallows only the race between existsSync and readdirSync (a package - // directory removed mid-sweep); readdirSync is the sole statement in the - // try, so no other failure can reach here. - return [] + return readdirSync(dir, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error } - return entries.filter(entry => entry.isDirectory()).map(entry => entry.name) } function walk(dir: string, out: string[]): void { - if (!existsSync(dir)) return - let entries: Dirent[] - try { - entries = readdirSync(dir, { withFileTypes: true }) - } catch { - // Same narrow race as `directories`: readdirSync is the only statement - // guarded, so this cannot mask a parse or assertion failure. - return - } - for (const entry of entries) { + for (const entry of readEntries(dir)) { const full = resolve(dir, entry.name) if (entry.isDirectory()) walk(full, out) else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) out.push(full) From 8e2785d9eb4f8ca3181a781fbdfb25437b23cf0f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 13:28:18 +0800 Subject: [PATCH 28/34] fix(locale): cover direct register() dictionaries and assert assembled The parity gate recognized only a `[['zh',{...}],['en',{...}]]` array, so the two separate ctx.locale.register(NS, 'zh'|'en', {...}) calls in ui-permission-presets were unchecked: deleting a key from one side left the gate green. Pair those calls by their namespace argument. Widen the pre-filter to admit zhSettings/accessZh spellings, which a bare \b(zh|en)\b misses and would have skipped before parsing. Assert document.documentElement.lang in the assembled app. The served markup already ships lang="en", so the fr-FR scenario passes whether or not the sync runs; the zh scenario is the discriminating half and now asserts zh-CN before the switch and en after it. Drop the dead vi.unstubAllGlobals() from the document-language spec, which manages navigator with defineProperty and never calls vi.stubGlobal. --- apps/web/tests/settings-chrome.e2e.ts | 11 ++++++ .../tests/document-language.client.spec.ts | 3 +- scripts/locale-dictionary-parity.spec.ts | 38 +++++++++++++++---- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 216dae4dbb..61f5af88c5 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -400,6 +400,11 @@ describe('web e2e: settings modal and General preferences', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const zhDialog = page.getByRole('dialog', { name: '设置' }) await zhDialog.waitFor({ timeout: 10_000 }) + // The document language follows the active locale in the assembled app, not + // only on a directly-mounted plugin. This is a zh browser, so the served + // markup's `en` must already have been replaced — asserting it here (rather + // than only in an English scenario) is what makes the check discriminating. + expect(await page.evaluate(() => document.documentElement.lang)).toBe('zh-CN') // The Language selector pill shows the active locale's own name. const selector = zhDialog.getByRole('button', { name: '中文' }) expect(await selector.getAttribute('aria-haspopup')).toBe('menu') @@ -410,6 +415,8 @@ describe('web e2e: settings modal and General preferences', () => { // the rest of the app's copy is intentionally out of this row's scope.) const enDialog = page.getByRole('dialog', { name: 'Settings' }) await enDialog.waitFor({ timeout: 10_000 }) + // ...and the attribute follows that switch, in the assembled app. + await expect.poll(() => page.evaluate(() => document.documentElement.lang), { timeout: 5_000 }).toBe('en') expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true') await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1) expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() @@ -498,6 +505,10 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = frPage.getByRole('dialog', { name: 'Settings' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) + // The markup already ships `en`, so this alone cannot prove the sync ran + // — the zh scenario above is the discriminating half. Asserted here too + // so a future change that resolves en but writes the wrong tag is caught. + expect(await frPage.evaluate(() => document.documentElement.lang)).toBe('en') // Golden of the English fallback dialog — the visible output this change // produces. The zh golden above covers the detected-locale surface, so // the pair pins both directions of the resolution. diff --git a/packages/client/locale/tests/document-language.client.spec.ts b/packages/client/locale/tests/document-language.client.spec.ts index b10a3e8e69..dc375ec4ce 100644 --- a/packages/client/locale/tests/document-language.client.spec.ts +++ b/packages/client/locale/tests/document-language.client.spec.ts @@ -61,7 +61,8 @@ describe('document language', () => { }) afterEach(() => { - vi.unstubAllGlobals() + // navigator properties are installed with defineProperty above, so they + // are removed the same way; nothing here goes through vi.stubGlobal. const own = navigator as unknown as Record delete own.languages delete own.language diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts index 5284801ae4..b240c564ae 100644 --- a/scripts/locale-dictionary-parity.spec.ts +++ b/scripts/locale-dictionary-parity.spec.ts @@ -93,9 +93,12 @@ interface Dictionary { */ function dictionariesIn(file: string): Dictionary[] { const text = readFileSync(file, 'utf8') - // Cheap pre-filter: parsing every package source is wasteful, and a file - // with no locale token cannot declare a dictionary under any shape below. - if (!/\b(zh|en)\b/.test(text)) return [] + // Cheap pre-filter: parsing every package source is wasteful. The pattern + // must admit every shape `localeOf` accepts, or a file would be skipped + // before parsing — the silent narrowing this gate exists to prevent. A bare + // `\b(zh|en)\b` misses `zhSettings`/`accessZh`, because `\b` does not hold + // between `h` and an uppercase letter. + if (!/\b(zh|en)\b|\b(zh|en)[A-Z]|(Zh|En)\b/.test(text)) return [] const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true) const found: Dictionary[] = [] const rel = relative(file) @@ -112,10 +115,29 @@ function dictionariesIn(file: string): Dictionary[] { } } - // Inline registrations: a `[['zh', {...}], ['en', {...}]]` pair handed to a - // registration loop in the plugin body. Both halves key off the enclosing - // array's line so they pair with each other and not across sites. + // Inline registrations, two shapes. A `[['zh', {...}], ['en', {...}]]` pair + // handed to a registration loop keys off the enclosing array; separate + // `register(NS, 'zh', {...})` / `register(NS, 'en', {...})` calls key off the + // namespace argument, so the two calls pair with each other. const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = node.expression + const name = ts.isPropertyAccessExpression(callee) ? callee.name.text : undefined + if (name === 'register' && node.arguments.length >= 3) { + const [ns, tag, dict] = node.arguments + const literal = unwrap(dict) + if ( + ns !== undefined && tag !== undefined && ts.isStringLiteral(tag) + && (tag.text === 'zh' || tag.text === 'en') + && literal !== undefined && ts.isObjectLiteralExpression(literal) + ) { + // The namespace expression's source text identifies the pair, so the + // zh and en calls for one namespace meet and calls for different + // namespaces stay apart. + found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(literal) }) + } + } + } if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) { const site = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1 for (const element of node.elements) { @@ -167,7 +189,9 @@ function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefin for (const locale of ['zh', 'en'] as const) { const other = locale === 'zh' ? 'Zh' : 'En' if (name === locale) return { locale, pair: '' } - if (name.startsWith(`${locale}@inline:`)) return { locale, pair: name.slice(name.indexOf(':')) } + // Synthetic names for inline shapes carry their own pair key after the + // first ':' (the enclosing array's line, or the namespace expression). + if (name.startsWith(`${locale}@`)) return { locale, pair: name.slice(name.indexOf(':')) } if (name.startsWith(locale) && name.length > 2 && name[2] === name[2]?.toUpperCase()) { return { locale, pair: name.slice(2) } } From cf7d485b5ecd0cecc98e5b0534ef939d12b1e3b3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 15:25:08 +0800 Subject: [PATCH 29/34] fix(client): harden settings describe mirror --- ...12-plugin-owned-settings-surface.i18n.yaml | 4 +- ...026-08-12-plugin-owned-settings-surface.md | 4 +- ...-08-12-plugin-owned-settings-surface.zh.md | 4 +- ...6-08-17-settings-describe-mirror.i18n.yaml | 4 +- .../2026-08-17-settings-describe-mirror.md | 6 +- .../2026-08-17-settings-describe-mirror.zh.md | 6 +- ...8-06-host-backed-web-preferences.i18n.yaml | 4 +- .../2026-08-06-host-backed-web-preferences.md | 4 +- ...26-08-06-host-backed-web-preferences.zh.md | 4 +- ...seek-onboarding-credential-setup.i18n.yaml | 4 +- ...30-deepseek-onboarding-credential-setup.md | 2 +- ...deepseek-onboarding-credential-setup.zh.md | 2 +- apps/web/tests/startup-rpc-budget.e2e.ts | 4 +- .../src/client/settings-store.ts | 2 +- .../permission-presets-row.client.spec.tsx | 10 +-- .../tests/settings-store.client.spec.ts | 17 +++- .../src/client/settings-document-store.ts | 2 +- .../tests/components.client.spec.tsx | 4 +- .../settings-document-store.client.spec.ts | 12 +-- .../ui-settings-models/src/client/index.ts | 9 +- .../ui-settings-models/src/client/store.ts | 11 +-- .../src/client/welcome-store.ts | 20 ++++- .../tests/apply.client.spec.ts | 52 +++++++++++- .../tests/store.client.spec.ts | 39 ++++++++- .../src/client/tab-store.ts | 2 +- .../ui-settings/src/client/settings-mirror.ts | 29 +++++-- .../tests/settings-mirror.client.spec.ts | 82 ++++++++++++++++--- .../tests/settings-scope.client.spec.ts | 22 +++++ 28 files changed, 285 insertions(+), 80 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml index d77b2c75c7..ce1925c821 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.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 .agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md -2026-08-12-plugin-owned-settings-surface.md: 3137cfe81ef3cb78a940f085c559ab4a7b62cce3 -2026-08-12-plugin-owned-settings-surface.zh.md: 8dd5e5ccebf1cfb80b55a615f6049dd391943bd7 +2026-08-12-plugin-owned-settings-surface.md: 722e6cfbe890418e8305f89790e76976027d7775 +2026-08-12-plugin-owned-settings-surface.zh.md: 93e5227d5f6a629fd32f5a2fe22e9882c7f7c5ac diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md index 3137cfe81e..722e6cfbe8 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md @@ -22,7 +22,7 @@ Together the two meant a user-authored plugin was configurable only by hand-edit **`settings.plugin.item` is keyed on the settings namespace.** The slot moved from `list` to `keyed`, the key being the namespace the card edits, following the `tool.call.toolview` precedent where each tool plugin registers its renderer under the tool name. A card declares `key`, not `id`/`order`. The slot is declared by the Plugins section's `configurable` tab, which owns the card list. -**The tab drives dispatch from the served namespaces.** It reads `settings.describe` once, subscribes to the settings-document invalidation and to connection resets, and dispatches one key per served namespace. What renders is the intersection of two ledgers — namespaces a live Host plugin registered, and cards registered under those keys — computed in the tab's controller from the slot ledger (`ctx.slots.entries`, `ctx.slots.subscribe`) and the wire answer. +**The tab drives dispatch from the served namespaces.** It derives the current served set from `ctx.settingsScope.describe()` and follows that shared settings mirror, while its own listener follows the card slot ledger. It dispatches one key per served namespace. What renders is the intersection of two ledgers — namespaces a live Host plugin registered, and cards registered under those keys — computed in the tab's controller from the slot ledger (`ctx.slots.entries`, `ctx.slots.subscribe`) and the mirror answer. The later [settings describe mirror decision](2026-08-17-settings-describe-mirror.md) owns the browser-wide read and invalidation lifecycle. Keying makes absence the signal, and that is what removes the bookkeeping the previous shape needed. A namespace another surface owns (`ui-theme`, `permission`, `llm-*`, `agent-presets`) has no card under its key, so it renders nothing without declaring anything anywhere. A card whose namespace this deployment does not serve is never dispatched, which also fixes the old empty-state defect: the tab counted registered cards, including ones rendering nothing, so a deployment exposing none showed an empty list instead of its empty line. @@ -56,6 +56,6 @@ A plugin distributed outside this repository is configurable from the settings p Deferred, and larger than this change: the redactor returns a `role('secret')` reachable only through a union, intersection, or transform verbatim (its own `TODO(settings-wire-redaction)`), and `schema.toJSON()` carries a secret's default. That gap predates this change, but serving every registered namespace widens its blast radius from schemas audited in this repository to any third-party schema, so the wire should refuse a namespace it cannot prove it can redact. Also deferred: an assembled-composition test of the headline capability — an overlay-mounted fixture plugin whose Host half registers a namespace and whose `dsh.client` half registers a card, asserted end-to-end. The current coverage proves each half separately; the shipped cards' unchanged output cannot prove the new path. -The wire read the section adds is one `settings.describe` beside the per-scope reads the cards already make. Its invalidation is imprecise in one direction: the wire announces document commits and connection resets, not registrations, so a namespace registered after the section's read joins on the next commit or reconnect. +The section and its cards add no `settings.describe` reads: both derive from the browser-wide mirror. Its invalidation is imprecise in one direction: the wire announces document commits and connection resets, not registrations, so a namespace registered after the mirror's current answer joins on the next commit or reconnect. Two frictions remain for an author outside this repository, both recorded in the section's README. The browser half must be a `dsh.client` package built in the client module system's lazy-CJS factory format, and the `clientBundle` preset that emits it lives in `packages/client/tsdown.client.ts` rather than a published package. The bundle-purity gate forbids importing this package's card chrome or staged-form model as values, so such a card reimplements staging and revision fencing. Sharing them would mean either publishing the preset or declaring a child slot inside the card so the section supplies the chrome; neither is built. diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md index 8dd5e5cceb..93e5227d5f 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md @@ -22,7 +22,7 @@ Status: implemented **`settings.plugin.item` 以 settings 命名空间为键。** 该 slot 从 `list` 改为 `keyed`,键就是卡片所编辑的命名空间,沿用 `tool.call.toolview` 的先例——每个工具插件把自己的渲染器注册在工具名这个键上。卡片声明 `key`,不再声明 `id`/`order`。该 slot 由「插件」分区的 `configurable` 标签页声明,卡片列表归它所有。 -**标签页以被服务的命名空间驱动派发。** 它读取一次 `settings.describe`,订阅 settings 文档失效通知与连接重置,并为每个被服务的命名空间派发一个键。渲染出来的是两份账本的交集——存活 Host 插件注册的命名空间,以及注册在这些键上的卡片——由标签页的 controller 从 slot 账本(`ctx.slots.entries`、`ctx.slots.subscribe`)与协议答复算出。 +**标签页以被服务的命名空间驱动派发。** 它从 `ctx.settingsScope.describe()` 派生当前被服务的集合并跟随该共享 settings 镜像,自身的监听器只跟随卡片 slot 账本;随后为每个被服务的命名空间派发一个键。渲染出来的是两份账本的交集——存活 Host 插件注册的命名空间,以及注册在这些键上的卡片——由标签页的 controller 从 slot 账本(`ctx.slots.entries`、`ctx.slots.subscribe`)与镜像应答算出。后续的 [settings describe 镜像决策](2026-08-17-settings-describe-mirror.md)持有浏览器全局的读取与失效生命周期。 以命名空间为键,让「缺席」本身成为信号,而这正是它消掉旧形态所需簿记的原因。归别的界面所有的命名空间(`ui-theme`、`permission`、`llm-*`、`agent-presets`)在其键上没有卡片,于是什么都不渲染,且无需在任何地方声明任何东西。命名空间未被本部署服务的卡片根本不会被派发,这同时修掉了旧的空态缺陷:标签页数的是已注册卡片,其中包含那些什么都不渲染的,因此一个都不暴露的部署看到的是空列表,而不是它那行空态文案。 @@ -56,6 +56,6 @@ Status: implemented 以下延后,且都大于本次改动:脱敏器对只能经由 union、intersection 或 transform 抵达的 `role('secret')` 原样返回(其自身的 `TODO(settings-wire-redaction)`),而 `schema.toJSON()` 会携带 secret 的默认值。该缺口早于本次改动,但服务每一个已注册命名空间,把它的影响面从本仓库内经审计的 schema 扩大到任意第三方 schema,因此协议应当拒绝服务它无法证明可安全脱敏的命名空间。同样延后的还有:对本次头号能力的组装态测试——用 overlay 挂载一个 fixture 插件(Host 半注册命名空间、`dsh.client` 半注册卡片)并在端到端断言。当前覆盖分别证明了两个半侧;已发卡片输出未变这一点,证明不了新路径。 -分区新增的协议读取是一次 `settings.describe`,与卡片各自已有的 per-scope 读取并列。它的失效通知在一个方向上不精确:协议通告的是文档提交与连接重置,而非注册行为,因此在分区读取之后才被注册的命名空间,要等下一次提交或重连才会加入。 +分区与其中的卡片都不再新增 `settings.describe` 读取:两者都从浏览器全局的镜像派生。它的失效通知在一个方向上不精确:协议通告的是文档提交与连接重置,而非注册行为,因此在镜像当前应答之后才被注册的命名空间,要等下一次提交或重连才会加入。 对仓库之外的作者仍留有两处摩擦,均记在该分区的 README 里。浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `packages/client/tsdown.client.ts`,并非已发布的包。bundle 纯净度门禁禁止以值的形式导入本包的卡片外观与暂存表单模型,因此这样的卡片要重新实现暂存与 revision 设栅。要共享它们,要么发布该预设,要么在卡片内部声明一层子 slot 让分区提供外观;两者都尚未构建。 diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml index 245a6770c7..35b8cb3aa3 100644 --- a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.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 .agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md -2026-08-17-settings-describe-mirror.md: c845c8ab65c526f27d09d1efdfd584a757ef00ba -2026-08-17-settings-describe-mirror.zh.md: 1229aaf9900178b6a86d7f3cdedeebc2ac99663c +2026-08-17-settings-describe-mirror.md: a3774699ff328a44aed192a16dea0fa19d03c83c +2026-08-17-settings-describe-mirror.zh.md: c57f5630b4bee0cd77a29a6f5458cb439c7f0585 diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md index c845c8ab65..a3774699ff 100644 --- a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md @@ -10,9 +10,11 @@ A cold web boot issued `settings.describe` fifteen times inside ~200ms, and the ## Decision -**One reader, many derivations.** `dsh-client-ui-settings` owns `SettingsDescribeMirror`, the single `settings.describe` reader in the browser: one snapshot store holding the whole answer, refreshed by the owning plugin's two subscriptions (`settings/document-updated`, `connection/reset`). Concurrent `load()` calls fold into the in-flight read plus at most one rerun — the in-flight slot clears inside the run's own try/finally, in the same synchronous segment that observes the rerun flag, because a `.finally()` on the returned promise runs one microtask later and a `load()` landing in that gap marked a rerun nobody read. +**One reader, many derivations.** `dsh-client-ui-settings` owns `SettingsDescribeMirror`, the single `settings.describe` reader in the browser: one snapshot store holding the whole answer, refreshed by the owning plugin's two subscriptions (`settings/document-updated`, `connection/reset`). Concurrent `load()` calls fold into the in-flight read plus at most one rerun. The in-flight slot owns a run before its loading publication can synchronously reenter `load()`, then clears inside the run's own try/finally in the same synchronous segment that observes the rerun flag; a `.finally()` on the returned promise would run one microtask later and let a refresh landing in that gap mark a rerun nobody reads. -`bind()` still returns the unchanged `SettingsScope` face, but the controller is now a selector over the mirror: no read path of its own, the same decode rules, and the write queue kept. A committed write folds its answered view back into the mirror (`acceptView`), so sibling scopes see the new revision with no re-read; a failed latest write triggers one mirror recovery read. Cross-namespace surfaces — the plugin-directory tab, the permission row (its dynamic enum lives in the namespace SCHEMA, which scopes deliberately do not carry), the models join, the agent-preset row's writability, and `hasDocument` — consume `ctx.settingsScope.describe()`, a read-only face (`getSnapshot`/`subscribe`/`ensure`/`acceptView`). +`bind()` still returns the unchanged `SettingsScope` face, but the controller is now a selector over the mirror: no read path of its own, the same decode rules, and the write queue kept. A committed write folds its answered view back into the mirror (`acceptView`), so sibling scopes see the new revision with no re-read; the fold invalidates any older in-flight answer, and a write before the first held document reruns that read instead of publishing a partial document. A failed latest write triggers one mirror recovery read. Cross-namespace surfaces — the plugin-directory tab, the permission row (its dynamic enum lives in the namespace schema, which scopes deliberately do not carry), the models join, the agent-preset row's writability, and `hasDocument` — consume `ctx.settingsScope.describe()`, the shared read/fold face (`getSnapshot`/`subscribe`/`ensure`/`acceptView`). + +This decision updates the browser read and invalidation mechanics recorded by [Host-backed Web preferences](../bug-fix/2026-08-06-host-backed-web-preferences.md) and [plugin-owned settings surface](2026-08-12-plugin-owned-settings-surface.md), while preserving their preference-ownership and namespace-exposure decisions. It also replaces the direct settings-read description in [official DeepSeek first-run credential setup](../feature/2026-07-30-deepseek-onboarding-credential-setup.md); that join now derives its settings half from this mirror. The cold-boot budget is pinned at two reads by `apps/web/tests/startup-rpc-budget.e2e.ts`: the mirror's eager bind-time read, plus the first-connection reset read, which is kept deliberately — it closes the window where a document commit lands between the eager HTTP read and the SSE subscription and its invalidation is lost. The plan's original target of one read is unreachable without either accepting that lost-invalidation window or delaying the first read until after the SSE stream opens. diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md index 1229aaf990..c57f5630b4 100644 --- a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md @@ -10,9 +10,11 @@ Status: implemented ## 决定 -**一个读取方,多个派生面。**`dsh-client-ui-settings` 持有 `SettingsDescribeMirror`——浏览器中唯一的 `settings.describe` 读取方:一个持有完整应答的快照 store,由所属插件的两个订阅(`settings/document-updated`、`connection/reset`)负责刷新。并发的 `load()` 调用折叠进在飞读取加至多一次尾随重读——在飞槽位在 run 自身 try/finally 内、与读取 rerun 标志相同的同步段中清空,因为挂在返回 promise 上的 `.finally()` 要晚一个微任务执行,落入该间隙的 `load()` 会标记一个无人读取的 rerun。 +**一个读取方,多个派生面。**`dsh-client-ui-settings` 持有 `SettingsDescribeMirror`——浏览器中唯一的 `settings.describe` 读取方:一个持有完整应答的快照 store,由所属插件的两个订阅(`settings/document-updated`、`connection/reset`)负责刷新。并发的 `load()` 调用折叠进在飞读取加至多一次尾随重读。在飞槽位会在 loading 发布同步重入 `load()` 之前先取得 run 的所有权,随后在 run 自身 try/finally 内、与读取 rerun 标志相同的同步段中清空;若把清理挂在返回 promise 的 `.finally()` 上,它要晚一个微任务执行,落入该间隙的刷新会标记一个无人读取的 rerun。 -`bind()` 返回的 `SettingsScope` 面保持不变,但 controller 现在是镜像上的 selector:自身没有读路径,decode 规则不变,写队列保留。提交成功的写入把应答的 view 折回镜像(`acceptView`),兄弟 scope 无需重读即可看到新 revision;失败的最新写入触发一次镜像恢复读取。跨命名空间的表面——插件目录 tab、permission 行(其动态枚举位于命名空间 **schema** 中,而 scope 有意不携带 schema)、models join、agent-preset 行的可写性、以及 `hasDocument`——消费 `ctx.settingsScope.describe()` 只读面(`getSnapshot`/`subscribe`/`ensure`/`acceptView`)。 +`bind()` 返回的 `SettingsScope` 面保持不变,但 controller 现在是镜像上的 selector:自身没有读路径,decode 规则不变,写队列保留。提交成功的写入把应答的 view 折回镜像(`acceptView`),兄弟 scope 无需重读即可看到新 revision;这次折叠会废弃更早发出的在飞应答,而首次完整文档尚未建立时到达的写入会让该读取重跑,不会把单个 namespace 发布成残缺文档。失败的最新写入触发一次镜像恢复读取。跨命名空间的表面——插件目录 tab、permission 行(其动态枚举位于命名空间 schema 中,而 scope 有意不携带 schema)、models join、agent-preset 行的可写性、以及 `hasDocument`——消费 `ctx.settingsScope.describe()` 提供的共享读/折叠面(`getSnapshot`/`subscribe`/`ensure`/`acceptView`)。 + +本决策更新了[通过 Host settings 持久化 Web 用户偏好](../bug-fix/2026-08-06-host-backed-web-preferences.md)和[由插件自己拥有的设置表层](2026-08-12-plugin-owned-settings-surface.md)所记录的浏览器读取与失效机制,同时保留其中关于偏好所有权与命名空间暴露的决策。它也取代了 [DeepSeek 官方首次使用凭据配置](../feature/2026-07-30-deepseek-onboarding-credential-setup.md)中的设置直读描述;该联接的 settings 部分现在从本镜像派生。 冷启动预算由 `apps/web/tests/startup-rpc-budget.e2e.ts` 钉在两次读取:镜像在绑定时的急切读取,加上首连 reset 触发的读取——后者是有意保留的:它关闭了「文档提交落在急切 HTTP 读取与 SSE 订阅之间、其失效通知丢失」的窗口。方案最初的一次读取目标,若不接受该失效丢失窗口、或不把首次读取推迟到 SSE 流建立之后,无法达成。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml index 87101e680a..10e09d4a49 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.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 .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md -2026-08-06-host-backed-web-preferences.md: 5d90f2be7c8b4030e9bdc00eed2769491ec009e5 -2026-08-06-host-backed-web-preferences.zh.md: c861c45bff299e06841165a2b36d0781e8f54d99 +2026-08-06-host-backed-web-preferences.md: 2e33d05417bf6c347a57b5c0b6c7281ff1392b5b +2026-08-06-host-backed-web-preferences.zh.md: 1d3518bb33d334d89408916a5f5210a45b938a01 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md index 5d90f2be7c..2e33d05417 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md @@ -12,9 +12,9 @@ The first theme implementation moved only Appearance to Host settings but awaite ## Decision -The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy explicitly exposes all three namespaces beside the other Web settings; registration alone never crosses that configuration boundary. +The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy serves every registered namespace to a loopback client; field roles still redact secrets. -`dsh-client-ui-settings` provides `ctx.settingsScope.bind(spec)`, which owns one lifecycle per namespace as the browser mirror of the Host-side settings owner seam. It installs `settings/document-updated` and `connection/reset` listeners before starting a background initial read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap, and it publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through the colocated `ctx.settingsSchema` service, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local. +`dsh-client-ui-settings` owns one browser-wide settings describe mirror and provides `ctx.settingsScope.bind(spec)` as a per-namespace selector over it. The mirror installs `settings/document-updated` and `connection/reset` listeners before starting its background read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap. Each bound scope publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to, without adding a wire read or listener of its own. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through the colocated `ctx.settingsSchema` service, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local. The shared read and invalidation lifecycle is specified by the later [settings describe mirror decision](../architecture/2026-08-17-settings-describe-mirror.md). User changes update the live service synchronously and queue a `settings.mutate` path operation through `scope.set`. The scope serializes gestures, sends the latest known namespace revision as `expectedRevision`, records every successful revision, and lets only the latest write settlement republish live state. A rejected or failed latest write reloads Host state. Disposal rejects new work, skips queued operations, suppresses publication by the in-flight operation, and waits for that operation to settle before the plugin reaches quiescence. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md index c861c45bff..1d3518bb33 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md @@ -12,9 +12,9 @@ Web 的 Appearance、Language 和繁忙态 Enter 偏好原本存在浏览器 `lo ## 决策 -各领域所属的 Host half 注册三份 schema:可选的 `locale.preference`(`zh` 或 `en`,缺失时交由浏览器决定)、`ui-theme.preference`(`light`、`dark` 或 `system`,默认为 `system`),以及 `ui-conversation.busyEnter`(`queue` 或 `steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会显式暴露这三个 namespace,与其他 Web settings 并列;仅注册它们,绝不会跨越该配置边界。 +各领域所属的 Host half 注册三份 schema:可选的 `locale.preference`(`zh` 或 `en`,缺失时交由浏览器决定)、`ui-theme.preference`(`light`、`dark` 或 `system`,默认为 `system`),以及 `ui-conversation.busyEnter`(`queue` 或 `steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会向回环客户端服务每一个已注册的 namespace;字段角色仍会脱敏机密值。 -`dsh-client-ui-settings` 提供 `ctx.settingsScope.bind(spec)`,为每个 namespace 持有一份生命周期,作为 Host 侧 settings owner seam 的浏览器镜像。它在开始后台初始读取之前安装 `settings/document-updated` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档;它还会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式)。默认解码器会对照该 namespace 自身的序列化 wire schema(经同包的 `ctx.settingsSchema` 服务还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。 +`dsh-client-ui-settings` 持有一个浏览器全局的 settings describe 镜像,并提供 `ctx.settingsScope.bind(spec)` 作为该镜像上的逐 namespace selector。镜像在开始后台读取之前安装 `settings/document-updated` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档。每个绑定的 scope 会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式),自身不再增加协议读取或监听器。默认解码器会对照该 namespace 自身的序列化 wire schema(经同包的 `ctx.settingsSchema` 服务还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。共享读取与失效生命周期由后续的 [settings describe 镜像决策](../architecture/2026-08-17-settings-describe-mirror.md)规定。 用户变更会同步更新实时服务,并经 `scope.set` 将一项 `settings.mutate` 路径操作排入队列。scope 会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,scope 会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。 diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml index 5ef2099535..d48293405c 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.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 .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md -2026-07-30-deepseek-onboarding-credential-setup.md: 823d10a723af70ec4ff51018b8b86198db0f5c29 -2026-07-30-deepseek-onboarding-credential-setup.zh.md: 7e8d79c23c4b1489bfd818c90558f36509635486 +2026-07-30-deepseek-onboarding-credential-setup.md: 87533e7a55f9b1f05f6a4ba58c3c9888780c158c +2026-07-30-deepseek-onboarding-credential-setup.zh.md: 575d8232b4837b57d9508ed613530606c05db1f7 diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md index 823d10a723..87533e7a55 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md @@ -10,7 +10,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma ## Decision -**One readiness projection owns both Models and onboarding facts.** `ui-settings-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only. +**One readiness projection owns both Models and onboarding facts.** `ui-settings-models` keeps a single store that joins `llm.providers({})`, the redacted namespace views held by the shared settings describe mirror, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only. The later [settings describe mirror decision](../architecture/2026-08-17-settings-describe-mirror.md) owns that settings read and its invalidation ordering. **The settings shell contributes ordering, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-settings-models` registers the DeepSeek step, the preceding welcome notice, and its Models section through `slots.inject()`, so every contribution follows one client Cordis plugin's lifecycle and the dialogs cannot stack. Their common presentation is owned by the [shared-modal onboarding decision](2026-08-13-shared-modal-product-onboarding.md). diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md index 7e8d79c23c..575d8232b4 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -**Models 与首次使用引导共享同一个就绪状态投影。**`ui-settings-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。 +**Models 与首次使用引导共享同一个就绪状态投影。**`ui-settings-models` 维护一个 store,把 `llm.providers({})`、共享 settings describe 镜像持有的已脱敏 namespace views 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。后续的 [settings describe 镜像决策](../architecture/2026-08-17-settings-describe-mirror.md)持有这次 settings 读取及其失效顺序。 **设置外壳只贡献排序,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-settings-models` 通过 `slots.inject()` 注册 DeepSeek 步骤、排在它之前的欢迎声明及 Models 分区,因此所有贡献都跟随同一个 client Cordis 插件的生命周期,两个弹窗也无法堆叠。它们的共用展示由[共用弹窗引导决策](2026-08-13-shared-modal-product-onboarding.md)持有。 diff --git a/apps/web/tests/startup-rpc-budget.e2e.ts b/apps/web/tests/startup-rpc-budget.e2e.ts index 22d109d1d9..135ff15e23 100644 --- a/apps/web/tests/startup-rpc-budget.e2e.ts +++ b/apps/web/tests/startup-rpc-budget.e2e.ts @@ -35,7 +35,7 @@ afterAll(async () => { }) describe('startup RPC budget', () => { - it('keeps cold-boot settings.describe within the mirror budget', async () => { + it('keeps cold-boot settings.describe at the mirror count', async () => { page = await newEnglishPage(browser) watchConsole(page) const calls: string[] = [] @@ -49,6 +49,6 @@ describe('startup RPC budget', () => { await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 }) await page.waitForTimeout(3000) const describeCount = calls.filter(method => method === 'settings.describe').length - expect(describeCount, `startup /api calls:\n${calls.join('\n')}`).toBeLessThanOrEqual(DESCRIBE_BUDGET) + expect(describeCount, `startup /api calls:\n${calls.join('\n')}`).toBe(DESCRIBE_BUDGET) }) }) 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 e2762f0a59..cf770151bd 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -194,7 +194,7 @@ export class AgentPresetSettingsController { /** * @param api - the agent-preset and settings wire faces (roster and default write). - * @param describeFace - the shared mirror's read-only face (writability source). + * @param describeFace - the shared mirror's describe face (writability source). */ constructor( private readonly api: IApiClient, 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 70816f483f..84a9065792 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 @@ -73,7 +73,7 @@ describe('PermissionRow', () => { settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate, - } as never, + }, }) mount(controller) const button = await screen.findByRole('button', { name: 'Read Only' }) @@ -100,7 +100,7 @@ describe('PermissionRow', () => { settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate, - } as never, + }, }) mount(controller) fireEvent.click(await screen.findByRole('button', { name: 'Read Only' })) @@ -124,7 +124,7 @@ describe('PermissionRow', () => { settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })), mutate: vi.fn(), - } as never, + }, }) const rendered = mount(absent) await waitFor(() => { expect(rendered.container.textContent).toBe('') }) @@ -134,7 +134,7 @@ describe('PermissionRow', () => { settings: { describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })), mutate: vi.fn(), - } as never, + }, }) mount(readonly) expect((await screen.findByRole('button', { name: 'Read Only' })).hasAttribute('disabled')).toBe(true) @@ -155,7 +155,7 @@ describe('PermissionRow', () => { error: { code: 'settings-conflict', message: 'changed elsewhere', details: {} }, }, }), - } as never, + }, }) mount(controller) expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true) 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 ee8c342769..4e37cbb65b 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 @@ -182,15 +182,26 @@ describe('permission settings store', () => { expect(mutate).not.toHaveBeenCalled() const thrown = permissionController({ - // Promise consumers must contain unknown rejection values from a - // transport implementation, including non-Error legacy clients. - describe: () => Promise.reject('disconnected' as never), + describe: async () => { throw 'disconnected' }, mutate, }).controller await thrown.load() expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' }) }) + it('hides the row in a remote browser instead of loading forever', async () => { + const describeCall = vi.fn() + const mutate = vi.fn() + const wire = { settings: { describe: describeCall, mutate } } as never + const mirror = new SettingsDescribeMirror(wire, 'memory') + const controller = new PermissionPresetSettingsController(mirror, wire, schema) + await controller.load() + expect(controller.store.getSnapshot().status).toBe('unavailable') + await controller.select('workspace-write') + expect(describeCall).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() + }) + it('follows a mirror refresh without an own read once loaded', async () => { const describe = vi.fn() .mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [view('read-only', 1)] })) diff --git a/packages/client/ui-settings-general/src/client/settings-document-store.ts b/packages/client/ui-settings-general/src/client/settings-document-store.ts index 2e8c8eb192..c6e2109f4d 100644 --- a/packages/client/ui-settings-general/src/client/settings-document-store.ts +++ b/packages/client/ui-settings-general/src/client/settings-document-store.ts @@ -29,7 +29,7 @@ export class SettingsDocumentStore { /** * @param api - loopback settings wire face that opens the provider document. - * @param describeFace - the shared mirror's read-only face (`hasDocument` source). + * @param describeFace - the shared mirror's describe face (`hasDocument` source). */ constructor( private readonly api: Pick, 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 94b1f953c4..9dc6043004 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -82,7 +82,7 @@ describe('SettingsDocumentAction', () => { })), openDocument, }, - } as never) + }) render( { result: { ok: false as const, error: { code: 'internal' as const, message: 'xdg-open missing', details: {} } }, })), }, - } as never) + }) render( { it('loads provider metadata and asks the settings domain to open its document', async () => { const describe = vi.fn(() => Promise.resolve(response(true))) const openDocument = vi.fn(() => Promise.resolve(opened())) - const controller = derivedDocumentStore({ settings: { describe, openDocument } } as never) + const controller = derivedDocumentStore({ settings: { describe, openDocument } }) await controller.load() expect(controller.store.getSnapshot()).toEqual({ status: 'ready', opening: false, error: null, @@ -54,7 +54,7 @@ describe('SettingsDocumentStore', () => { const openDocument = vi.fn(() => Promise.resolve(opened())) const absent = derivedDocumentStore({ settings: { describe: () => Promise.resolve(response()), openDocument }, - } as never) + }) await absent.load() await absent.open() expect(absent.store.getSnapshot().status).toBe('unavailable') @@ -62,13 +62,13 @@ describe('SettingsDocumentStore', () => { const failed = derivedDocumentStore({ settings: { describe: () => Promise.reject(new Error('offline')), openDocument }, - } as never) + }) await failed.load() expect(failed.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' }) const rejected = derivedDocumentStore({ settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openDocument }, - } as never) + }) await rejected.load() expect(rejected.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'provider failed', @@ -80,7 +80,7 @@ describe('SettingsDocumentStore', () => { const openDocument = vi.fn(() => new Promise>((resolve) => { resolveOpen = resolve })) const controller = derivedDocumentStore({ settings: { describe: () => Promise.resolve(response(true)), openDocument }, - } as never) + }) await controller.load() const first = controller.open() const second = controller.open() @@ -102,7 +102,7 @@ describe('SettingsDocumentStore', () => { describe: vi.fn(() => Promise.resolve(response(true))), openDocument: () => new Promise((_, reject) => { rejectOpen = reject }), }, - } as never) + }) await controller.load() expect(controller.store.getSnapshot().status).toBe('ready') const opening = controller.open() diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index 1ffd457ee8..6bb403a90f 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -99,10 +99,11 @@ export function apply(ctx: ClientContext): void { t, }) - // Pushed invalidations converge every open surface without polling: any - // settings/credentials/topology change refetches once the page loaded. The - // welcome notice follows its settings scope, so the shared mirror already - // keeps it fresh without a subscription here. + // Pushed invalidations converge every open surface without polling. The + // settingsScope injection makes ui-settings activate first, and remote + // dispatch preserves listener order; its listener therefore starts the + // mirror refresh before this store joins that refresh. The welcome notice + // follows its settings scope, so it needs no subscription here. ctx.effect(() => { const refreshModels = (): void => { refreshIfLoaded(controller) } const disposers = [ diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 59fbe5458b..5b688db919 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -1,6 +1,6 @@ /** * Models settings page store: one snapshot joining the configurable-provider - * directory (`llm.providers`), the settings namespaces (`settings.describe`), + * directory (`llm.providers`), the settings namespaces (shared settings mirror), * 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. @@ -116,7 +116,7 @@ export class ModelsSettingsStore { /** * @param api - the wire face (credentials/llm domains, and settings writes). - * @param describeFace - the shared mirror's read-only face (namespace views and writability). + * @param describeFace - the shared mirror's describe face (namespace views and writability). */ constructor( private readonly api: Pick, @@ -127,8 +127,9 @@ export class ModelsSettingsStore { /** * Refresh the whole page snapshot: the provider directory and the mirror's * settings answer in parallel, then one batched credential describe over - * every referenced ref. A failure keeps the last good rows and surfaces the - * error. + * every referenced ref. Provider failure or absence of an initial settings + * answer keeps the last good rows and surfaces an error; a failed settings + * refresh reuses the mirror's held view. * @returns nothing; the snapshot carries the outcome. */ async load(): Promise { @@ -145,7 +146,7 @@ export class ModelsSettingsStore { if (!providersResponse.result.ok) throw new Error(providersResponse.result.error.message) const mirrored = this.describeFace.getSnapshot() if (mirrored.view === undefined) { - throw new Error(mirrored.error ?? 'settings have not answered yet') + throw new Error(mirrored.error ?? 'settings are unavailable in this browser') } providers = providersResponse.result.value.providers writable = mirrored.view.writable diff --git a/packages/client/ui-settings-models/src/client/welcome-store.ts b/packages/client/ui-settings-models/src/client/welcome-store.ts index 9edd54a9cb..82b8e0a662 100644 --- a/packages/client/ui-settings-models/src/client/welcome-store.ts +++ b/packages/client/ui-settings-models/src/client/welcome-store.ts @@ -34,11 +34,16 @@ export function decodeWelcomeSection(section: unknown): WelcomeSection { : {} } +/* v8 ignore next 3 -- closed-union default only defends future source widening */ +function assertNever(_value: never): never { + throw new Error('unexpected welcome settings status') +} + /** Coordinates durable Host acknowledgement or a process-local remote fallback. */ export class WelcomeNoticeStore { /** uSES-safe state source shared by the registered welcome step. */ - readonly store: SnapshotStore = createSnapshotStore({ - status: 'idle' as const, acknowledged: false, error: null, + readonly store: SnapshotStore = createSnapshotStore({ + status: 'idle', acknowledged: false, error: null, }) private localAcknowledged = false @@ -51,10 +56,14 @@ export class WelcomeNoticeStore { */ constructor(private readonly scope: SettingsScope) {} - /** Begin following the bound scope (idempotent) and publish its current answer. */ - async load(): Promise { + /** + * Begin following the bound scope (idempotent) and publish its current answer. + * @returns settlement after the current answer is published. + */ + load(): Promise { this.following ??= this.scope.subscribe(() => { this.derive() }) this.derive() + return Promise.resolve() } /** @@ -122,7 +131,10 @@ export class WelcomeNoticeStore { state.acknowledged = acknowledged state.error = null }) + return } + /* v8 ignore next -- every current settings scope status is handled above */ + default: return assertNever(scope.status) } } } 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 65c33dd0ca..3fd154d8a0 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -18,7 +18,7 @@ import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') -async function bench(isLoopback = true, settings?: object) { +async function bench(isLoopback = true, settings?: object, services: object = {}) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) @@ -29,7 +29,10 @@ async function bench(isLoopback = true, settings?: object) { // 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 ? {} : { settings }, isLoopback } as never) + ctx.provide('connection', { + api: settings === undefined ? services : { ...services, settings }, + isLoopback, + } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale } } @@ -253,4 +256,49 @@ describe('pushed invalidations', () => { expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) }) }) + + 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, + }], + }, + }, + })) + const providers = vi.fn(() => Promise.resolve({ + rpcId: 'apply-models-providers' as never, + result: { ok: true as const, value: { providers: [] } }, + })) + const b = await bench(true, { describe }, { llm: { providers } }) + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries('settings.section') + .find(candidate => candidate.options.id === 'models')! + const injected = ( + entry.inject as unknown as + () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected + )() + await injected.controller.load() + expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(1) + + revision = 2 + b.ctx.remote.$dispatch('settings/document-updated', ['llm-test', revision]) + + await vi.waitFor(() => { + expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(2) + }) + expect(describe).toHaveBeenCalledTimes(2) + }) }) 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 3a2164fd1c..c71457e474 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -125,8 +125,7 @@ describe('ModelsSettingsStore', () => { it('stringifies a non-Error credential transport rejection', async () => { const { face, mirror } = api({ - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario - describeCredentials: () => Promise.reject('credential transport refusal'), + describeCredentials: async () => { throw 'credential transport refusal' }, }) const store = new ModelsSettingsStore(face, settingsSchema, mirror) await expect(store.load()).resolves.toBeUndefined() @@ -223,10 +222,42 @@ describe('edge joins', () => { expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' }) }) + it('reports a terminally unavailable settings mirror precisely', async () => { + const { face } = api() + const store = new ModelsSettingsStore( + face, + settingsSchema, + new SettingsDescribeMirror(face, 'memory'), + ) + await store.load() + expect(store.store.getSnapshot()).toMatchObject({ + status: 'error', + error: 'settings are unavailable in this browser', + }) + }) + + it('reuses a held settings view after its refresh fails', async () => { + let settingsCall = 0 + const { face, mirror } = api({ + describeSettings: () => { + settingsCall += 1 + return Promise.resolve(settingsCall === 1 + ? ok({ writable: true, hasDocument: false, namespaces: NAMESPACES }) + : fail('settings refresh down')) + }, + }) + const store = new ModelsSettingsStore(face, settingsSchema, mirror) + await store.load() + await mirror.load() + expect(mirror.getSnapshot().error).toBe('settings refresh down') + await store.load() + expect(store.store.getSnapshot()).toMatchObject({ status: 'ready', error: null }) + expect(store.store.getSnapshot().rows).toHaveLength(4) + }) + it('stringifies a non-Error load failure', async () => { // The wire can surface non-Error throwables; the store must stringify them. - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario - const { face, mirror } = api({ providers: () => Promise.reject('plain refusal') }) + const { face, mirror } = api({ providers: async () => { throw 'plain refusal' } }) const store = new ModelsSettingsStore(face, settingsSchema, mirror) await store.load() expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' }) diff --git a/packages/client/ui-settings-plugins/src/client/tab-store.ts b/packages/client/ui-settings-plugins/src/client/tab-store.ts index ff9d7b4b74..5cd26c69fc 100644 --- a/packages/client/ui-settings-plugins/src/client/tab-store.ts +++ b/packages/client/ui-settings-plugins/src/client/tab-store.ts @@ -49,7 +49,7 @@ export class ConfigurablePluginsTabController { private readonly unsubscribe: () => void /** - * @param describeFace - the shared mirror's read-only face; its refreshes + * @param describeFace - the shared mirror's describe face; its refreshes * (document commits, reconnects) are what keep the served set current. * @param entries - reads the cards currently registered into the section's slot. */ diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts index 61dc21e287..8e570b6512 100644 --- a/packages/client/ui-settings/src/client/settings-mirror.ts +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -2,7 +2,7 @@ * Client mirror of the Host settings document: the one `settings.describe` * reader in the browser. Every settings consumer derives from this store — * per-namespace scopes through `SettingsScopeBinder.bind`, cross-namespace - * surfaces through the binder's read-only describe face — so startup cost and + * surfaces through the binder's shared describe face — so startup cost and * freshness are properties of this class, not of how many features own a * preference. The Host stays the fact source: the mirror re-reads on the * invalidations its owning plugin subscribes to and folds write answers in @@ -60,7 +60,7 @@ export interface SettingsDescribeFace { ensure(): Promise /** * Fold one write answer's namespace view into the held view without a wire - * read. + * read, invalidating any older read still in flight. * @param view - the namespace view a settings write answered with. */ acceptView(view: SettingsNamespaceView): void @@ -117,7 +117,8 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { this.rerun = true return this.inFlight } - const run = this.run() + // Own the slot before the loading publication can synchronously reenter load(). + const run = Promise.resolve().then(() => this.run()) this.inFlight = run return run } @@ -137,12 +138,15 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { /** * Fold one write answer's namespace view into the held view without a wire - * read. A no-op until a first answer exists — a write cannot precede the - * read that supplied its `expectedRevision`. + * read, and invalidate any read still in flight. With no held document, the + * answer is not published as a partial document; an in-flight read reruns so + * it cannot publish a document fetched before the write committed. * @param view - the namespace view a settings write answered with. */ acceptView(view: SettingsNamespaceView): void { const before = this.store.getSnapshot() + this.generation += 1 + if (this.inFlight !== undefined) this.rerun = true if (before.view === undefined) return const namespaces = before.view.namespaces.some(row => row.ns === view.ns) ? before.view.namespaces.map(row => row.ns === view.ns ? view : row) @@ -166,10 +170,14 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { // that gap would mark a rerun nobody reads, losing the read. try { do { - this.rerun = false - const generation = ++this.generation const before = this.store.getSnapshot() if (before.status === 'idle') this.store.set({ ...before, status: 'loading' }) + // Cleared immediately before the wire read goes out: a load() marked + // earlier (including one reentering from the loading publish above) + // is covered by this very read, while one landing after needs the + // rerun. + this.rerun = false + const generation = ++this.generation let outcome: { view: SettingsDescribeView } | { failure: string } try { const response = await this.api.settings.describe({}) @@ -179,6 +187,7 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { } catch (error) { outcome = { failure: error instanceof Error ? error.message : String(error) } } + // A write answer invalidates a document read before that write committed. if (generation !== this.generation) continue if ('view' in outcome) { this.store.set({ status: 'ready', view: outcome.view, error: null }) @@ -192,9 +201,13 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { error: outcome.failure, }) } - } while (this.rerun) + } while (this.shouldRerun()) } finally { this.inFlight = undefined } } + + private shouldRerun(): boolean { + return this.rerun + } } 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 972b3cea73..6439a3000e 100644 --- a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts @@ -33,17 +33,22 @@ function deferred() { } describe('SettingsDescribeMirror', () => { - it('folds concurrent load calls into the in-flight read plus one rerun', async () => { + it('folds loads before the wire read into it, and mid-flight loads into one rerun', async () => { const gate = deferred>() const describeCall = vi.fn() .mockReturnValueOnce(gate.promise) .mockResolvedValue(described([view('theme', 1)])) const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) const first = mirror.load() - const second = mirror.load() - const third = mirror.load() + // Issued before the wire read goes out: covered by that read, no rerun. + const early = mirror.load() + await Promise.resolve() + expect(describeCall).toHaveBeenCalledTimes(1) + // Issued while the read is on the wire: exactly one rerun, however many. + const mid = mirror.load() + const midToo = mirror.load() gate.resolve(described([view('theme', 0)])) - await Promise.all([first, second, third]) + await Promise.all([first, early, mid, midToo]) expect(describeCall).toHaveBeenCalledTimes(2) expect(mirror.getSnapshot().status).toBe('ready') expect(mirror.namespace('theme')?.revision).toBe(1) @@ -139,16 +144,73 @@ describe('SettingsDescribeMirror', () => { await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) }) }) - it('suppresses a stale answer that lost to a newer generation', async () => { + it('starts no second run for a load issued inside the loading publish', async () => { + const gate = deferred>() + const describeCall = vi.fn().mockReturnValue(gate.promise) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + let reentered = false + const unsubscribe = mirror.subscribe(() => { + if (reentered) return + reentered = true + void mirror.load() + }) + const loading = mirror.load() + await Promise.resolve() + expect(describeCall).toHaveBeenCalledTimes(1) + gate.resolve(described([view('theme', 1)])) + await loading + unsubscribe() + // The reentrant load folded into the first run rather than racing it. + expect(describeCall).toHaveBeenCalledTimes(1) + expect(mirror.getSnapshot().status).toBe('ready') + }) + + it('lets the first read cover a write folded inside the loading publish', async () => { + const describeCall = vi.fn().mockResolvedValue(described([view('theme', 2)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const unsubscribe = mirror.subscribe(() => { + unsubscribe() + mirror.acceptView(view('theme', 2)) + }) + + await mirror.load() + + expect(describeCall).toHaveBeenCalledTimes(1) + expect(mirror.getSnapshot().status).toBe('ready') + expect(mirror.namespace('theme')?.revision).toBe(2) + }) + + it('re-reads after a folded write invalidates an in-flight document', async () => { + const slow = deferred>() + const describeCall = vi.fn() + .mockResolvedValueOnce(described([view('theme', 4), view('locale', 1)])) + .mockReturnValueOnce(slow.promise) + .mockResolvedValueOnce(described([view('theme', 5), view('locale', 2)])) + const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + await mirror.load() + expect(describeCall).toHaveBeenCalledTimes(1) + const stale = mirror.load() + await Promise.resolve() + mirror.acceptView(view('theme', 5)) + slow.resolve(described([view('theme', 4), view('locale', 2)])) + await stale + expect(describeCall).toHaveBeenCalledTimes(3) + expect(mirror.namespace('theme')?.revision).toBe(5) + expect(mirror.namespace('locale')?.revision).toBe(2) + }) + + it('re-reads after a pre-answer write invalidates the in-flight document', async () => { const slow = deferred>() const describeCall = vi.fn() .mockReturnValueOnce(slow.promise) - .mockResolvedValue(described([view('theme', 8)])) + .mockResolvedValueOnce(described([view('theme', 2)])) const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) - const first = mirror.load() - const second = mirror.load() + const loading = mirror.load() + await Promise.resolve() + mirror.acceptView(view('theme', 2)) slow.resolve(described([view('theme', 1)])) - await Promise.all([first, second]) - expect(mirror.namespace('theme')?.revision).toBe(8) + await loading + expect(describeCall).toHaveBeenCalledTimes(2) + expect(mirror.namespace('theme')?.revision).toBe(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 c5fb104e04..0437f602b5 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -188,6 +188,28 @@ describe('SettingsScopeController', () => { expect(sibling.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 5 }) }) + it('re-reads after a revisionless first write lands during the initial read', async () => { + const initial = deferred>() + const describeCall = vi.fn() + .mockReturnValueOnce(initial.promise) + .mockResolvedValueOnce(described({ preference: 'dark' }, 2)) + const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2))) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + const loading = mirror.load() + await Promise.resolve() + + await scope.set('preference', 'dark') + initial.resolve(described({ preference: 'system' }, 1)) + await loading + + expect(mutate).toHaveBeenCalledWith({ + ns: 'ui-test', + ops: [{ op: 'set', path: ['preference'], value: 'dark' }], + }) + expect(describeCall).toHaveBeenCalledTimes(2) + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 2 }) + }) + it('recovers the latest rejected or thrown write from Host state', async () => { const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'system' }, 2)) From d415b63c1972ab846f2093c71edee91728b4a16f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 16:54:16 +0800 Subject: [PATCH 30/34] test(client): cover settings lifecycle guards --- .../tests/settings-store.client.spec.ts | 24 +++++++++ .../tests/stores.client.spec.ts | 42 ++++++++++++++- .../tests/settings-scope.client.spec.ts | 54 +++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) 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 4e37cbb65b..8ddc1033ea 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 @@ -187,6 +187,23 @@ describe('permission settings store', () => { }).controller await thrown.load() expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' }) + + const wire = { + settings: { + describe: () => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [view('read-only')], + })), + mutate, + }, + } as never + const mirror = new SettingsDescribeMirror(wire) + const malformed = new PermissionPresetSettingsController(mirror, wire, { + rehydrate: () => { throw 'schema disconnected' }, + } as never) + await malformed.load() + expect(malformed.store.getSnapshot()).toMatchObject({ + status: 'error', error: 'schema disconnected', + }) }) it('hides the row in a remote browser instead of loading forever', async () => { @@ -216,6 +233,13 @@ describe('permission settings store', () => { }) it('disposal stops deriving and suppresses in-flight writes', async () => { + const neverRead = vi.fn() + const { controller: neverLoaded } = permissionController({ describe: neverRead, mutate: vi.fn() }) + neverLoaded.dispose() + await neverLoaded.load() + expect(neverLoaded.store.getSnapshot().status).toBe('idle') + expect(neverRead).not.toHaveBeenCalled() + const read = Promise.withResolvers { expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([]) }) + it('ignores a mirror notification already queued when disposal starts', () => { + let notify = (): void => {} + let snapshot: SettingsMirrorSnapshot = { + status: 'ready' as const, + view: { writable: true, hasDocument: true, namespaces: [] }, + error: null, + } + const describeFace = { + getSnapshot: () => snapshot, + subscribe: (listener: () => void) => { + notify = listener + return () => {} + }, + ensure: () => Promise.resolve(), + acceptView: vi.fn(), + } as never + const controller = new ConfigurablePluginsTabController(describeFace, () => ledger('bash')) + expect(controller.inject().hooks.configurablePlugins.getSnapshot()) + .toEqual({ loaded: true, namespaces: [] }) + + controller.dispose() + snapshot = { + status: 'ready', + view: { + writable: true, + hasDocument: true, + namespaces: [{ + ns: 'bash', schema: {}, value: {}, applies: 'live', secrets: [], revision: 1, + }], + }, + error: null, + } + notify() + + expect(controller.inject().hooks.configurablePlugins.getSnapshot()) + .toEqual({ loaded: true, namespaces: [] }) + }) + it('reports the Host answered even when it serves nothing this tab shows', async () => { const settings = settingsApi(['ui-theme']) const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash')) 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 0437f602b5..ddbb6784d1 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -259,6 +259,27 @@ describe('SettingsScopeController', () => { expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 }) }) + it('keeps the write queue usable when a write publication listener throws', async () => { + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 1)) + const mutate = vi.fn() + .mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2))) + .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() + let shouldThrow = true + mirror.subscribe(() => { + if (!shouldThrow) return + shouldThrow = false + throw new Error('write subscriber failed') + }) + + await expect(scope.set('preference', 'dark')).rejects.toThrow('write subscriber failed') + await expect(scope.set('preference', 'light')).resolves.toBeUndefined() + + expect(mutate).toHaveBeenCalledTimes(2) + 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 mutate = vi.fn().mockReturnValue(first.promise) @@ -292,6 +313,38 @@ describe('SettingsScopeController', () => { expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 }) }) + it('ignores a mirror notification already queued when disposal starts', async () => { + let notify = (): void => {} + let snapshot = { + status: 'ready' as const, + view: { + writable: true, hasDocument: true, + namespaces: [view({ preference: 'dark' }, 1)], + }, + error: null, + } + const mirror = { + getSnapshot: () => snapshot, + subscribe: (listener: () => void) => { + notify = listener + return () => {} + }, + } as never + const wire = { settings: {} } as never + const scope = new SettingsScopeController( + wire, { namespace: 'ui-test' }, mirror, 'host', settingsSchema) + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 }) + + await scope.dispose() + snapshot = { + ...snapshot, + view: { ...snapshot.view, namespaces: [view({ preference: 'light' }, 2)] }, + } + notify() + + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 }) + }) + it('keeps a remote browser in memory mode without Host calls', async () => { const describeCall = vi.fn() const mutate = vi.fn() @@ -380,6 +433,7 @@ describe('SettingsScopeBinder.bind', () => { let locale!: SettingsScope new TestRemote(ctx) await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema }).await() + expect(ctx.settingsScope.describe()).toBe(mirror) const fiber = ctx.plugin({ inject: ['connection', 'remote', 'settingsScope'], apply: (plugin: Context) => { From 2b3a8d1845f424693f50d65f2c77af85db69ce20 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 17:18:39 +0800 Subject: [PATCH 31/34] ci: refresh pull request checks From 0fe31f11a33661fd52fdca2d1d7b80cf8e06da79 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 17:39:06 +0800 Subject: [PATCH 32/34] fix(locale): correct two stale product-default-Chinese comments Chinese is no longer the product default since FALLBACK_LOCALE moved to en in this branch. connectFreshWorkspaceZh and the access-confirmation scenario both reach the Chinese surface by advertising ZH_BROWSER_LOCALE, not by inheriting a default, so their comments must say so. --- apps/web/tests/access-confirmation.e2e.ts | 5 +++-- apps/web/tests/support.ts | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index aea33f14f1..a173f8399e 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -30,8 +30,9 @@ describe('web e2e: Full access confirmation', () => { // is temporarily unavailable. const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH browser = await chromium.launch(executablePath === undefined ? {} : { executablePath }) - // Keep the product default Chinese locale: the golden pins the actual - // registered dictionary rather than a test-local translation callback. + // Keep the Chinese surface via {@link ZH_BROWSER_LOCALE}: the golden pins + // the actual registered dictionary rather than a test-local translation + // callback. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 38d0849784..3a1cd94782 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -88,9 +88,10 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo } /** - * {@link connectFreshWorkspace} over the product default Chinese locale: the - * English helper's anchors assume the locale every other scenario boots, so a - * scenario that deliberately keeps zh needs the localized picker copy. + * {@link connectFreshWorkspace} over a page that advertises + * {@link ZH_BROWSER_LOCALE}: the English helper's anchors assume the locale + * most other scenarios boot, so a scenario that deliberately keeps zh needs + * the localized picker copy. * @param page - the browser page under test. * @param root - workspace parent directory. * @param name - directory created under `root` and connected. From 9301def7ebd2ba0b457cfce95299053f38b61d91 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 17:55:10 +0800 Subject: [PATCH 33/34] fix(locale): tighten parity gate and correct copy-source wording Address the three open review threads on the dictionary parity gate. Regex/TEXT: localeOf now requires an uppercase ASCII [A-Z] flat-letter at the third position of a name-prefix shape, so zh2Foo/zh_probe are no longer treated as dictionaries in localeOf while the admission pre-filter skips them. The two now agree exactly. register detection now also admits a bare register identifier callee in addition to a property access, covering a future destructured register(NS, 'zh'|'en', dict) call instead of silently dropping it. A 3-arg register whose dictionary argument is a local variable is resolved through module-scope const initializers; one that cannot be resolved to an object literal makes the gate refuse with a named error rather than skipping the registration and narrowing the sweep. Also restate the FALLBACK_LOCALE rationale: the residual case points at English because a browser naming neither shipped language is the reader least likely to read Chinese, not because English is the copy's source language (Chinese is; packages/client/AGENTS.md). Sync the identical claim in the bilingual Agent Note and re-record its .i18n.yaml pairing. --- ...1-browser-derived-initial-locale.i18n.yaml | 4 +- ...26-07-31-browser-derived-initial-locale.md | 2 +- ...07-31-browser-derived-initial-locale.zh.md | 2 +- packages/client/locale/src/client/index.ts | 4 +- scripts/locale-dictionary-parity.spec.ts | 65 +++++++++++++++---- 5 files changed, 59 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml index f1168d973c..9aa0372a26 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.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 .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md -2026-07-31-browser-derived-initial-locale.md: 6fcd799b9c3e6ec898725e0ef72106b63f613bee -2026-07-31-browser-derived-initial-locale.zh.md: 73f2c825e11bb0380fe172b4b4522295d419e9a5 +2026-07-31-browser-derived-initial-locale.md: 66fd56327aeb4463bfb8f6426ce7f7962d339782 +2026-07-31-browser-derived-initial-locale.zh.md: 721a785aa476951e7254c50230ddc092b9f8b211 diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md index 6fcd799b9c..66fd56327a 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md @@ -14,7 +14,7 @@ Reading the browser fixed the readers whose browser names a language this app sh **The provisional locale resolves through the browser, then `FALLBACK_LOCALE` (`en`); an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active. -**One constant serves both the opening locale and the dictionary fallback, because the dictionaries are symmetric.** `FALLBACK_LOCALE` answers both "which language does the UI open in when the browser names none we ship" and "which dictionary backs a key the active locale misses". Those are different questions, and splitting them into two constants would be right if either answer had to differ — but every shipped `zh`/`en` pair declares identical key sets, so the fallback step always resolves and both answers are `en`, the source language of the copy. `scripts/locale-dictionary-parity.spec.ts` gates the symmetry the shared constant depends on: a key added to one side only fails that spec by name, instead of surfacing later as a bare key such as `list.aria` in a running UI. +**One constant serves both the opening locale and the dictionary fallback, because the dictionaries are symmetric.** `FALLBACK_LOCALE` answers both "which language does the UI open in when the browser names none we ship" and "which dictionary backs a key the active locale misses". Those are different questions, and splitting them into two constants would be right if either answer had to differ — but every shipped `zh`/`en` pair declares identical key sets, so the fallback step always resolves and both answers are `en`. The residual case points at English rather than zh because a browser naming neither shipped language is the reader least likely to read Chinese. `scripts/locale-dictionary-parity.spec.ts` gates the symmetry the shared constant depends on: a key added to one side only fails that spec by name, instead of surfacing later as a bare key such as `list.aria` in a running UI. **Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express. diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md index 73f2c825e1..721a785aa4 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md @@ -14,7 +14,7 @@ Status: implemented **暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE`(`en`)解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。 -**开场 locale 与字典回落值共用一个常量,因为两侧字典是对称的。** `FALLBACK_LOCALE` 同时回答「浏览器未声明任何本应用提供的语言时,界面以哪种语言开场」与「当前 locale 的字典缺失某个 key 时由哪本字典兜住」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的——但每一对已提供的 `zh`/`en` 字典都声明了完全相同的 key 集合,因此回落这一步总能解析成功,两个答案都是 `en`,也就是文案的源语言。`scripts/locale-dictionary-parity.spec.ts` 为这个共用常量所依赖的对称性设了门禁:只加在一侧的 key 会让该用例指名失败,而不是日后在运行中的界面里显现为形如 `list.aria` 的裸 key。 +**开场 locale 与字典回落值共用一个常量,因为两侧字典是对称的。** `FALLBACK_LOCALE` 同时回答「浏览器未声明任何本应用提供的语言时,界面以哪种语言开场」与「当前 locale 的字典缺失某个 key 时由哪本字典兜住」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的——但每一对已提供的 `zh`/`en` 字典都声明了完全相同的 key 集合,因此回落这一步总能解析成功,两个答案都是 `en`。残余情形指向英文而非 `zh`,是因为一个声明了本应用都不支持的语言的浏览器,其读者最不可能读中文。`scripts/locale-dictionary-parity.spec.ts` 为这个共用常量所依赖的对称性设了门禁:只加在一侧的 key 会让该用例指名失败,而不是日后在运行中的界面里显现为形如 `list.aria` 的裸 key。 **浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN` 与 `zh-TW` 同归 `zh`、`en-GB` 归 `en`;而只请求本应用不提供的语言(`fr`、`de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主——DOM 库把它标注为必然存在,所以这份容忍带一条窄口径 lint 例外,与 `localStorage` 守卫表达的环境边界不信任同源。 diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 3f14acf216..5b1d6c72b4 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -91,7 +91,9 @@ declare module '@deepseek-ai/cordis' { * language (and for non-browser runs), and the dictionary consulted after the * active locale misses a key. One constant serves both because the shipped * `zh`/`en` dictionaries carry identical key sets, so neither direction can - * leave a key unresolved; English is the source language of the copy. + * leave a key unresolved; the residual case points at English rather than + * zh because a browser naming neither shipped language is the reader least + * likely to read Chinese. */ export const FALLBACK_LOCALE: LocaleId = 'en' diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts index b240c564ae..b51630f105 100644 --- a/scripts/locale-dictionary-parity.spec.ts +++ b/scripts/locale-dictionary-parity.spec.ts @@ -103,6 +103,20 @@ function dictionariesIn(file: string): Dictionary[] { const found: Dictionary[] = [] const rel = relative(file) + // Module-scope variable declarations, keyed by name. A 3-arg + // `register(NS, 'zh'|'en', dict)` whose third argument is an identifier — + // e.g. a local dictionary variable rather than an inline literal — resolves + // through here so the gate still verifies its symmetry. + const moduleConsts = new Map() + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue + for (const decl of statement.declarationList.declarations) { + if (ts.isIdentifier(decl.name) && decl.initializer !== undefined) { + moduleConsts.set(decl.name.text, decl.initializer) + } + } + } + for (const statement of source.statements) { if (!ts.isVariableStatement(statement)) continue if (statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) !== true) continue @@ -115,6 +129,14 @@ function dictionariesIn(file: string): Dictionary[] { } } + // A 3-arg `register(ns, 'zh'|'en', dict)` call whose dictionary argument we + // cannot turn into an object literal. We refuse instead of skipping: a + // registration we cannot measure is exactly the silent narrowing this gate + // exists to catch. + const refuse = (ns: string, tag: string, why: string): never => { + throw new Error(`cannot verify register('${ns}', '${tag}', ...) in ${rel}: ${why}`) + } + // Inline registrations, two shapes. A `[['zh', {...}], ['en', {...}]]` pair // handed to a registration loop keys off the enclosing array; separate // `register(NS, 'zh', {...})` / `register(NS, 'en', {...})` calls key off the @@ -122,20 +144,34 @@ function dictionariesIn(file: string): Dictionary[] { const visit = (node: ts.Node): void => { if (ts.isCallExpression(node)) { const callee = node.expression - const name = ts.isPropertyAccessExpression(callee) ? callee.name.text : undefined + const name = ts.isPropertyAccessExpression(callee) + ? callee.name.text + : ts.isIdentifier(callee) && callee.text === 'register' ? 'register' : undefined if (name === 'register' && node.arguments.length >= 3) { const [ns, tag, dict] = node.arguments - const literal = unwrap(dict) - if ( - ns !== undefined && tag !== undefined && ts.isStringLiteral(tag) - && (tag.text === 'zh' || tag.text === 'en') - && literal !== undefined && ts.isObjectLiteralExpression(literal) - ) { - // The namespace expression's source text identifies the pair, so the - // zh and en calls for one namespace meet and calls for different - // namespaces stay apart. - found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(literal) }) + if (ns === undefined || tag === undefined || !ts.isStringLiteral(tag)) return + if (tag.text !== 'zh' && tag.text !== 'en') return + const raw = unwrap(dict) + const literal = raw !== undefined && ts.isIdentifier(raw) + ? (() => { + const resolved = moduleConsts.get(raw.text) + return resolved === undefined ? undefined : unwrap(resolved) + })() + : raw + const why = raw !== undefined && ts.isIdentifier(raw) + ? `third argument ${raw.text} does not resolve to an inline or module-scope object literal` + : 'third argument is neither an object literal nor a resolvable dictionary variable' + if (literal === undefined || !ts.isObjectLiteralExpression(literal)) { + // The dictionary argument must resolve to an object literal; the + // gate refuses rather than skips, so the symmetry it verifies never + // silently narrows. + refuse(ns.getText(source), tag.text, why) } + const dictionary: ts.ObjectLiteralExpression = literal as ts.ObjectLiteralExpression + // The namespace expression's source text identifies the pair, so the + // zh and en calls for one namespace meet and calls for different + // namespaces stay apart. + found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(dictionary) }) } } if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) { @@ -181,7 +217,10 @@ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined { /** * The locale a dictionary name declares, and the namespace-ish remainder that * identifies which pair it belongs to. `zh`/`en`, `zhSettings`/`enSettings`, - * and `settingsZh`/`settingsEn` are the shapes this repo uses. + * and `settingsZh`/`settingsEn` are the shapes this repo uses. A name-prefix + * shape requires an uppercase ASCII letter at the third position (`[A-Z]`), + * matching the admission of the cheap pre-filter, so `zh2Foo`/`zh_probe` + * cannot be treated as dictionaries in one place and skipped in another. * @param name - export name or synthetic inline name. * @returns locale plus pair key, or undefined when the name names no locale. */ @@ -192,7 +231,7 @@ function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefin // Synthetic names for inline shapes carry their own pair key after the // first ':' (the enclosing array's line, or the namespace expression). if (name.startsWith(`${locale}@`)) return { locale, pair: name.slice(name.indexOf(':')) } - if (name.startsWith(locale) && name.length > 2 && name[2] === name[2]?.toUpperCase()) { + if (name.startsWith(locale) && name.length > 2 && /[A-Z]/.test(name[2] ?? '')) { return { locale, pair: name.slice(2) } } if (name.endsWith(other)) return { locale, pair: name.slice(0, -2) } From 3967df95f78061de815f9e62630a85bdafb8436b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 18:19:30 +0800 Subject: [PATCH 34/34] docs(locale): clarify full-rollout note on the en fallback default The Consequences bullet named the zh copy surface the 'zh default', which could be read as the product's opening locale. It covers component-copy coverage only; the opening/fallback locale (browser naming no shipped language, or a non-browser run) is en since FALLBACK_LOCALE moved. State that explicitly and cross-link the browser-derived-initial-locale note, in both languages, and re-record the .i18n.yaml pairing. --- .../2026-07-30-client-locale-full-rollout.i18n.yaml | 4 ++-- .../architecture/2026-07-30-client-locale-full-rollout.md | 2 +- .../architecture/2026-07-30-client-locale-full-rollout.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index ecca8ab2b0..007b9c0d73 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.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 .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: c6c5a8f2faffd3e03462eaad159ae94c53c735ce -2026-07-30-client-locale-full-rollout.zh.md: 8d6220784104944f5d07b533e4f107ceffdfcfea +2026-07-30-client-locale-full-rollout.md: 6701aefa451786d3ca6ac27d7214824a6d903bab +2026-07-30-client-locale-full-rollout.zh.md: 0c05ec9699700d88d786bc661f7013f2ed09ebb2 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index c6c5a8f2fa..6701aefa45 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -42,4 +42,4 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels - A language switch refreshes the whole UI instantly with zero re-registration; adopting a new package is three steps (dictionary + declare-merge + `locale: NS`), no hand-written glue. - Cost: list-label consumers must know `resolveSlotLabel` (a raw `options.label` read can now hold a function); the `SlotLabel` type catches most misuse statically. - ui-primitives' Chinese defaults still render Chinese under the English locale **until a consumer passes labels** — the unmigrated JsonTree consumer (ui-trajectory) showing its English defaults happens to match that package's all-English status quo. -- Pinning e2e to English means the zh default is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. +- Pinning e2e to English means the zh copy surface is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. The opening/fallback locale (a browser naming no shipped language, or a non-browser run) is `en`, not zh — see [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md). diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index 8d62207841..0c05ec9699 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -42,4 +42,4 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` - 语言切换全 UI 即时刷新且零重注册;新包接入 = 字典 + declare-merge + `locale: NS` 三步,无手写胶水。 - 代价:list label 的消费方必须知道 `resolveSlotLabel`(裸读 `options.label` 现在可能拿到函数);类型上 `SlotLabel` 已挡住多数误用。 - ui-primitives 的中文默认值在英文语言下依旧是中文,**直到消费方传入 labels**——未迁移的 JsonTree 消费方(ui-trajectory)显示其英文默认值,恰好符合其整包英文现状。 -- e2e 英文钉死意味着 zh 默认态主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。 +- e2e 英文钉死意味着 zh 文案面主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。开场/回落 locale(声明了本应用都不支持语言的浏览器,或非浏览器运行)是 `en` 而非 `zh`,见 [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)。