feat(remote): deliver allowlisted Host events through ctx.remote.$on

api/remotes owns the allowlist and its type projection; type-meta owns the shape
predicate, the selection seat, and the internal remote/host-event carrier
signal; api/gateway's Client half turns that signal into $on callbacks through a
private dispatch. apiproxy forwards each allowlisted emission verbatim in one
host/remote-event frame, registered ahead of the derived invalidation frames so
frame order is unchanged, and drops the three per-event variants it replaces.
Owner packages move their Events declarations into client-safe ./types exports,
so a consumer's listener signature is the Host's own declaration.
This commit is contained in:
imccyu
2026-08-10 21:32:50 +08:00
parent b64da061a8
commit d88f771e19
59 changed files with 956 additions and 257 deletions

View File

@@ -152,14 +152,14 @@ describe('connection client apply', () => {
sockets[1]!.receive(JSON.stringify({
type: 'server-request',
rpcId: 'host-browser',
method: 'host/commands-changed',
payload: { type: 'host/commands-changed' },
method: 'host/remote-event',
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
}))
expect(await muxFrame).toMatchObject({
value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } },
})
expect(await hostFrame).toMatchObject({
value: { rpcId: 'host-browser', payload: { type: 'host/commands-changed' } },
value: { rpcId: 'host-browser', payload: { type: 'host/remote-event', event: 'commands/change' } },
})
expect(errors).toHaveBeenCalledTimes(2)
await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) })

View File

@@ -93,7 +93,7 @@ describe('WebSocket downlinks', () => {
},
async function * (signal) {
try {
yield { rpcId: RpcId('host-1'), payload: { type: 'host/commands-changed' } }
yield { rpcId: RpcId('host-1'), payload: { type: 'host/remote-event', event: 'commands/change', args: [] } }
await untilAbort(signal)
} finally {
hostAborted = true
@@ -116,8 +116,8 @@ describe('WebSocket downlinks', () => {
expect(await hostFrame).toEqual({
type: 'server-request',
rpcId: 'host-1',
method: 'host/commands-changed',
payload: { type: 'host/commands-changed' },
method: 'host/remote-event',
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
})
const muxClosed = once(mux, 'close')

View File

@@ -150,29 +150,6 @@ declare module '@deepseek-ai/cordis' {
* @param key - the mutated SlotMap key.
*/
'slots/changed'(key: string): void
/**
* The host command registry changed (host/commands-changed passthrough).
* Pure invalidation signal: subscribers refetch `command.list` in the
* background rather than diffing.
* @mode emit
*/
'commands/changed'(): void
/**
* One settings namespace's resolved value changed on the host
* (host/settings-changed passthrough). Subscribers refetch
* `settings.describe`; the frame carries no values.
* @mode emit
* @param ns - the namespace whose resolved value changed.
*/
'settings/changed'(ns: string): void
/**
* One credential reference's state changed on the host
* (host/credentials-changed passthrough). The ref is an
* environment-variable NAME — never a value.
* @mode emit
* @param ref - the reference whose configured state changed.
*/
'credentials/changed'(ref: string): void
/**
* The host provider topology changed (host/models-changed passthrough).
* Subscribers refetch `llm.providers`/`llm.models`/`session.models`.
@@ -241,16 +218,15 @@ export function apply(ctx: Context): void {
onHostEnvelope: (envelope) => {
sessions.handleHostEnvelope(envelope)
workspaces.handleHostEnvelope(envelope)
// Typed-event bridge: the session layer ignores registry frames (no
// session routing); consumers (command directory caches, the settings
// and model services) subscribe on ctx.
// Forwarded-event bridge: the session layer ignores registry frames (no
// session routing). This plugin only carries the frame onto the internal
// `remote/host-event` plumbing event; the Remote service subscribes there
// and fans out to `ctx.remote.$on`, so no consumer reads a frame.
const frame = envelope.payload
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
if (frame.type === 'host/remote-event') ctx.emit('remote/host-event', frame.event, frame.args)
else if (frame.type === 'host/session-preset-changed') {
ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset)
}
else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
},
onConnected: () => {

View File

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

View File

@@ -39,6 +39,7 @@ export { FixtureSession, TestSessions } from './sessions.ts'
export { stubSettingsScope } from './settings-scope.ts'
export type { StubSettingsScope } from './settings-scope.ts'
export { TestWorkspaces } from './workspaces.ts'
export { TestRemote } from './remote.ts'
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
export { makeTranslate } from './translate.ts'

View File

@@ -0,0 +1,55 @@
/** Test-owned Remote face: `$on` subscriptions driven by the internal forwarded-event plumbing. */
import type { Context } from '@deepseek-ai/cordis'
/**
* Remote service test double for the forwarded-event path. Feature specs need
* `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded
* host events to reach those subscribers, but not the generated namespaces or
* the wire — so this double implements subscription and dispatch only.
*
* Dispatch is driven the same way production drives it: by the internal
* `remote/host-event` event the connection sink emits. A spec therefore
* exercises its refresh chains with `ctx.emit('remote/host-event', name, args)`,
* the exact signal `client/runtime` republishes from a `host/remote-event`
* frame, rather than reaching into this double.
*
* `$mount` rejects: a spec that reaches a generated namespace through this
* double has outgrown it and needs the real Client Remote service.
*/
export class TestRemote {
private readonly subscriptions = new Map<string, Set<(...args: never[]) => void>>()
/**
* Register the double as `ctx.remote` and bind its dispatch to the plumbing event.
* @param ctx - the spec's root Context.
*/
constructor(ctx: Context) {
ctx.provide('remote', this)
ctx.on('remote/host-event', (event, args) => {
const listeners = this.subscriptions.get(event)
if (listeners === undefined) return
for (const listener of [...listeners]) listener(...args as never[])
})
}
/**
* Subscribe to one forwarded host event.
* @param event - forwarded host event name.
* @param listener - receives the Host argument list verbatim.
* @returns disposer removing this subscription.
*/
$on(event: string, listener: (...args: never[]) => void): () => void {
const listeners = this.subscriptions.get(event) ?? new Set()
this.subscriptions.set(event, listeners)
listeners.add(listener)
return () => { listeners.delete(listener) }
}
/**
* Generated-namespace mount, unsupported by this double.
* @returns never; always rejects.
*/
$mount(): Promise<() => Promise<void>> {
return Promise.reject(new Error('TestRemote: $mount needs the real Client Remote service'))
}
}

View File

@@ -36,7 +36,8 @@
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-settings"
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web"
}
@@ -47,6 +48,8 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
@@ -56,10 +59,10 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",

View File

@@ -14,6 +14,9 @@
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
// (the settings invalidation rides the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
@@ -43,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']
export const inject = ['slots', 'locale', 'connection', 'remote']
/**
* Mount the General-settings row.
@@ -71,15 +74,17 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => {
// The roster is a live directory and the default is a settings field, so
// both an external settings edit and a reconnect can move this row.
const refresh = (ns?: string): void => {
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
const refresh = (): void => {
void controller.load()
// The section reads the same roster and marks the same default, so a
// change made from either surface converges both.
if (section.store.getSnapshot().status !== 'idle') void section.load()
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.remote.$on('settings/document-updated', (ns) => {
if (ns !== AGENT_PRESET_SETTINGS_NS) return
refresh()
}),
ctx.on('connection/reset', () => { refresh() }),
]
return () => { for (const dispose of disposers) dispose() }
@@ -132,8 +137,8 @@ export function apply(ctx: ClientContext): void {
// the next session keeps offering the previous default until a reload,
// which is exactly the session the setting claims to govern. A staged
// pick survives: `load()` prefers it over the refreshed fallback.
const settingsMoved = scope.on('settings/changed', (ns?: string) => {
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
const settingsMoved = scope.remote.$on('settings/document-updated', (ns) => {
if (ns !== AGENT_PRESET_SETTINGS_NS) return
void seat.load()
})
// Authoring writes a FILE, not a setting, so nothing on the wire

View File

@@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } 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, 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'
@@ -78,6 +78,9 @@ async function bench() {
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
// The plugins inject `remote`; forwarded events reach them through the
// same `remote/host-event` signal the connection sink republishes.
new TestRemote(ctx)
const calls: string[] = []
ctx.provide('connection', {
api: {
@@ -169,7 +172,7 @@ function sessionsDouble(state: {
describe('ui-agent-preset apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection'])
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
})
it('registers the General row and the settings section', async () => {
@@ -250,11 +253,11 @@ describe('ui-agent-preset apply', () => {
await section.load()
const before = calls.length
ctx.emit('settings/changed', 'agent-presets')
ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1])
await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
const afterRelevant = calls.length
ctx.emit('settings/changed', 'llm-deepseek')
ctx.emit('remote/host-event', 'settings/document-updated', ['llm-deepseek', 1])
await Promise.resolve()
// Both surfaces re-read on their own namespace; an unrelated one moves
@@ -282,7 +285,7 @@ describe('ui-agent-preset apply', () => {
await ctx.plugin({ inject: [...inject], apply }).await()
const before = calls.length
ctx.emit('settings/changed', 'agent-presets')
ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1])
await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) })
// Only the General row reloads: a section nobody opened has nothing to
@@ -333,11 +336,11 @@ describe('ui-agent-preset apply', () => {
// An unrelated namespace moves nothing: the chip re-reads on its own
// setting, not on every settings write in the process.
moveDefault()
ctx.emit('settings/changed', 'llm-deepseek')
ctx.emit('remote/host-event', 'settings/document-updated', ['llm-deepseek', 1])
await Promise.resolve()
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
ctx.emit('settings/changed', 'agent-presets')
ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1])
await vi.waitFor(() => {
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal')
})

View File

@@ -40,6 +40,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../api/remotes/tsconfig.client.json"
}
]
}

View File

@@ -35,7 +35,8 @@
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slash",
"@deepseek-ai/dsh-client-ui-conversation"
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web"
}
@@ -49,6 +50,8 @@
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
@@ -57,10 +60,10 @@
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",

View File

@@ -45,7 +45,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
const NS = 'command'
/** Required services: the '/' source registry plus the scope + wire faces the service reads, and the copy's locale registry. */
export const inject = ['slash', 'sessions', 'connection', 'locale']
export const inject = ['slash', 'sessions', 'connection', 'locale', 'remote']
/**
* Client plugin body: mount the service, then register the popupSelect shell

View File

@@ -11,6 +11,9 @@ import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
// (`commands/change` rides the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
SubmitOutcome,
@@ -93,7 +96,7 @@ function fuzzyCandidates(candidates: readonly SlashCandidate[], rawQuery: string
/** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
export class CommandService extends Service implements CommandServiceContract {
static inject = ['slash', 'sessions', 'connection']
static inject = ['slash', 'sessions', 'connection', 'remote']
private readonly directory: CommandDirectory
private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
@@ -123,7 +126,7 @@ export class CommandService extends Service implements CommandServiceContract {
matchEnter: (session, line, signal) => this.matchEnter(session, line, signal),
warm: (session) => { this.directory.warm(session.sessionId) },
}), 'command: slash source')
ctx.on('commands/changed', () => { this.directory.invalidateAll() })
ctx.remote.$on('commands/change', () => { this.directory.invalidateAll() })
// A preset switch changes which commands one session's agent resolves and
// registers nothing globally, so the registry-wide signal above never
// fires for it: repull that key alone, soft, so the old snapshot serves

View File

@@ -14,6 +14,7 @@ import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandServiceContract } from '../src/client/contract.ts'
import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, CommandService, inject } from '../src/client/index.ts'
const sid = (k: string): SessionId => k as SessionId
@@ -38,6 +39,8 @@ async function bench() {
name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } },
} as never, (() => null) as never)
ctx.provide('locale', new LocaleService(ctx))
// CommandService injects `remote` for the forwarded directory invalidation.
new TestRemote(ctx)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const mint = (key: string) => {
@@ -50,7 +53,7 @@ async function bench() {
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale'])
expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale', 'remote'])
})
it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => {

View File

@@ -10,6 +10,7 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
@@ -78,6 +79,9 @@ async function bench(opts: BenchOptions = {}) {
: undefined,
})
ctx.provide('connection', { api })
// CommandService injects `remote`; the directory invalidation arrives on the
// same `remote/host-event` signal the connection sink republishes.
new TestRemote(ctx)
/** Notices the fake conversation face collected (runDetached routing). */
const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
ctx.provide('conversation', {
@@ -611,7 +615,7 @@ describe('directory invalidation events', () => {
},
})
await warm(proj('s1'))
ctx.emit('commands/changed')
ctx.emit('remote/host-event', 'commands/change', [])
await new Promise(resolve => setTimeout(resolve, 0))
expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()

View File

@@ -34,6 +34,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../api/remotes/tsconfig.client.json"
}
]
}

View File

@@ -34,7 +34,8 @@
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale"
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web"
}
@@ -45,6 +46,8 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
@@ -52,10 +55,10 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",

View File

@@ -12,6 +12,9 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
// (settings/credentials invalidations ride the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import { ModelsSection } from './ModelsSection.tsx'
import type { ModelsSectionInjected } from './ModelsSection.tsx'
import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx'
@@ -48,7 +51,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']
export const inject = ['slots', 'locale', 'connection', 'remote']
/**
* Register the Models section once the `settings.section` declaration is on
@@ -82,8 +85,8 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => {
const refresh = (): void => { refreshIfLoaded(controller) }
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('credentials/changed', refresh),
ctx.remote.$on('settings/document-updated', refresh),
ctx.remote.$on('credentials/updated', refresh),
ctx.on('models/changed', refresh),
ctx.on('connection/reset', refresh),
]

View File

@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } 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, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-models/client'
import { ModelsSection } from '../src/client/ModelsSection.tsx'
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
@@ -18,6 +18,9 @@ async function bench() {
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
// The plugins inject `remote`; forwarded events reach them through the
// same `remote/host-event` signal the connection sink republishes.
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: {} } as never)
@@ -39,7 +42,7 @@ function declare(slots: SlotsService): () => void {
describe('ui-models apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection'])
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
})
it('registers the models nav entry for declarations before or after apply', async () => {
@@ -135,8 +138,8 @@ describe('pushed invalidations', () => {
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
// The fake wire face has no methods: a fetch attempt would throw.
b.ctx.emit('settings/changed', 'llm-pi-ai')
b.ctx.emit('credentials/changed', 'OPENAI_API_KEY')
b.ctx.emit('remote/host-event', 'settings/document-updated', ['llm-pi-ai', 1])
b.ctx.emit('remote/host-event', 'credentials/updated', ['OPENAI_API_KEY'])
b.ctx.emit('models/changed')
b.ctx.emit('connection/reset')
})
@@ -167,7 +170,7 @@ describe('pushed invalidations', () => {
)()
injected.controller.store.update((state) => { state.status = 'ready' })
const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
b.ctx.emit('credentials/changed', 'DEEPSEEK_API_KEY')
b.ctx.emit('remote/host-event', 'credentials/updated', ['DEEPSEEK_API_KEY'])
expect(load).toHaveBeenCalledTimes(1)
})
})

View File

@@ -37,6 +37,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../api/remotes/tsconfig.client.json"
}
]
}

View File

@@ -35,7 +35,8 @@
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-command"
"@deepseek-ai/dsh-client-ui-command",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web"
}
@@ -46,6 +47,8 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
@@ -56,14 +59,15 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",

View File

@@ -16,6 +16,9 @@
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
// (the settings invalidation rides the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
@@ -38,7 +41,7 @@ export type {
} from './settings-store.ts'
/** Required services (cordis fiber inject). */
export const inject = ['command', 'sessions', 'slots', 'locale', 'connection']
export const inject = ['command', 'sessions', 'slots', 'locale', 'connection', 'remote']
const ACCESS_NS = 'permission.access'
@@ -118,12 +121,12 @@ export function apply(ctx: ClientContext): void {
})
ctx.effect(() => {
const refresh = (ns?: string): void => {
if (ns !== undefined && ns !== PERMISSION_SETTINGS_NS) return
refreshPermissionIfLoaded(controller)
}
const refresh = (): void => { refreshPermissionIfLoaded(controller) }
const disposers = [
ctx.on('settings/changed', refresh),
ctx.remote.$on('settings/document-updated', (ns) => {
if (ns !== PERMISSION_SETTINGS_NS) return
refresh()
}),
ctx.on('connection/reset', () => { refresh() }),
]
return () => {

View File

@@ -12,6 +12,7 @@ import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-command/client'
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
import {
@@ -37,6 +38,9 @@ async function bench() {
const locale = new LocaleService(ctx)
locale.setLocale('en')
ctx.provide('locale', locale)
// The plugin injects `remote`; forwarded events reach it through the same
// `remote/host-event` signal the connection sink republishes.
new TestRemote(ctx)
ctx.slots.register({
name: 'root',
children: {
@@ -158,8 +162,8 @@ describe('ui-permission browser plugin', () => {
it('disposal removes the decoration (HMR safety)', async () => {
const b = await bench()
expect(b.decoration()).toBeDefined()
b.ctx.emit('settings/changed', 'another')
b.ctx.emit('settings/changed', 'permission')
b.ctx.emit('remote/host-event', 'settings/document-updated', ['another', 1])
b.ctx.emit('remote/host-event', 'settings/document-updated', ['permission', 1])
b.ctx.emit('connection/reset')
await b.fiber.dispose()
expect(b.decoration()).toBeUndefined()

View File

@@ -43,6 +43,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../api/remotes/tsconfig.client.json"
}
]
}

View File

@@ -35,7 +35,8 @@
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-connection"
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web"
}
@@ -50,6 +51,8 @@
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
@@ -58,10 +61,10 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",

View File

@@ -12,6 +12,9 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
// Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge.
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
// (the settings invalidation rides the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
import { GeneralSection } from './GeneralSection.tsx'
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
@@ -51,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', 'remote']
/**
* Register the `settings` dictionaries, the chrome content, and the General
@@ -83,12 +86,12 @@ export function apply(ctx: ClientContext): void {
})
ctx.effect(() => {
const refresh = (ns?: string): void => {
if (ns !== undefined && ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return
refreshWelcomeIfLoaded(welcomeController)
}
const refresh = (): void => { refreshWelcomeIfLoaded(welcomeController) }
const disposers = [
ctx.on('settings/changed', refresh),
ctx.remote.$on('settings/document-updated', (ns) => {
if (ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return
refresh()
}),
ctx.on('connection/reset', () => {
refresh()
refreshDocumentIfLoaded(documentController)

View File

@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } 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, 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'
@@ -33,6 +33,9 @@ async function bench(isLoopback = true) {
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
// The plugins inject `remote`; forwarded events reach them through the
// same `remote/host-event` signal the connection sink republishes.
new TestRemote(ctx)
const settingsDescribe = vi.fn(() => Promise.resolve({
rpcId: 'settings-general' as never,
result: {
@@ -86,7 +89,7 @@ function generalEntry(slots: SlotsService) {
describe('ui-settings-general apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection'])
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
})
it('fills all six seats for declarations before or after apply', async () => {
@@ -167,9 +170,9 @@ describe('ui-settings-general apply', () => {
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
await controller.load()
expect(b.settingsDescribe).toHaveBeenCalledOnce()
b.ctx.emit('settings/changed', 'unrelated')
b.ctx.emit('remote/host-event', 'settings/document-updated', ['unrelated', 1])
expect(b.settingsDescribe).toHaveBeenCalledOnce()
b.ctx.emit('settings/changed', WELCOME_NOTICE_SETTINGS_NAMESPACE)
b.ctx.emit('remote/host-event', 'settings/document-updated', [WELCOME_NOTICE_SETTINGS_NAMESPACE, 1])
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
b.ctx.emit('connection/reset')
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })

View File

@@ -37,6 +37,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../api/remotes/tsconfig.client.json"
}
]
}